Merge branch 'master' into harness-single-node-default
diff --git a/Cargo.lock b/Cargo.lock
index 9111fc8..0cf2039 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -9548,6 +9548,7 @@
  "bytes",
  "compio",
  "consensus",
+ "futures",
  "iggy_binary_protocol",
  "iggy_common",
  "journal",
diff --git a/core/bench/Cargo.toml b/core/bench/Cargo.toml
index 9842a44..faa3269 100644
--- a/core/bench/Cargo.toml
+++ b/core/bench/Cargo.toml
@@ -31,6 +31,18 @@
 name = "iggy-bench"
 path = "src/main.rs"
 
+[features]
+# Switches the SDK to the vsr Register-handshake framing spoken by server-ng
+# clusters. The framing is chosen at compile time, so a default-features bench
+# binary cannot talk to a vsr cluster at all (the first request never frames).
+# TRAP: `cargo test -p integration --features vsr` does NOT rebuild this
+# binary -- the harness spawns whatever the last build produced, and a
+# default-featured leftover trips the bench timeout in
+# `run_bench_and_wait_for_finish` (one per restart-matrix case). Build the
+# workspace (or this crate with --features vsr) first; `just nextest-vsr`
+# does.
+vsr = ["iggy/vsr"]
+
 [dependencies]
 async-trait = { workspace = true }
 bench-report = { workspace = true }
diff --git a/core/bench/src/main.rs b/core/bench/src/main.rs
index 6556a5a..70e06be 100644
--- a/core/bench/src/main.rs
+++ b/core/bench/src/main.rs
@@ -33,11 +33,25 @@
 use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
 use utils::cpu_name::append_cpu_name_lowercase;
 
+/// Which SDK framing this binary was compiled with.
+///
+/// One binary name, two wire dialects, and the mismatch is asymmetric: a
+/// default-features bench against a vsr server HANGS rather than fails, because
+/// the server reads a full 256-byte header before validating anything and the
+/// client's own response timeout is itself vsr-gated. Printing the flavor in the
+/// always-on banner turns that from a silent hang into a one-second diagnosis.
+const SDK_FRAMING: &str = if cfg!(feature = "vsr") {
+    "vsr (server-ng Register handshake)"
+} else {
+    "legacy (classic server framing)"
+};
+
 #[tokio::main]
 async fn main() -> Result<(), IggyError> {
     let standard_font = FIGlet::standard().unwrap();
     let figure = standard_font.convert("Iggy Bench");
     println!("{}", figure.unwrap());
+    println!("SDK framing: {SDK_FRAMING}");
 
     let mut args = IggyBenchArgs::parse();
     args.validate();
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index 52b9fb6..a4661cd 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -1415,7 +1415,40 @@
     pub commit_op: u64,
     pub namespace: u64,
     pub available: u8,
-    pub reserved: [u8; 95],
+    /// Set on an `available == 0` refusal that means "not right now" rather than
+    /// "this node is broken".
+    ///
+    /// PARTITION arm only: it is the only side with a consecutive-failure count
+    /// to charge. The requester then re-arms on a flat interval instead of
+    /// charging that count, whose exponential backoff climbs to 1024x the retry
+    /// interval and is reset only by a completed install. A serving primary
+    /// momentarily behind its own frontier is the common case under produce
+    /// load.
+    ///
+    /// This and `commit_max` below claim the HEAD of what used to be the
+    /// reserved tail, so every pre-existing field keeps its published offset.
+    /// Layout compatibility only: the size assert cannot catch an equal-size
+    /// reshuffle, so a mid-struct insertion would silently move every field
+    /// after it. It says nothing about the semantics of these two -- an older
+    /// peer presents zeros here and serves no partition transfers at all.
+    pub unavailable_transient: u8,
+    /// Explicit padding so `commit_max` sits 8-aligned without the implicit
+    /// padding `NoUninit` forbids.
+    pub reserved_alignment: [u8; 6],
+    /// Serving replica's `commit_max` when the descriptor was built.
+    ///
+    /// Read by the PARTITION receiver only; the metadata arm branches on
+    /// `available` and falls back to journal repair without a refusal.
+    ///
+    /// A partition receiver refuses an offer from a replica that knows LESS
+    /// than it does:
+    /// without this the descriptor carried no proof of the sender's own
+    /// progress, and a phantom view-0 primary (a group whose directory vanished
+    /// boots `init()` rather than `init_as_backup()`, comes up Normal at view 0,
+    /// 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],
 }
 const _: () = {
     assert!(size_of::<StateTransferTargetHeader>() == HEADER_SIZE);
@@ -1423,7 +1456,14 @@
         offset_of!(StateTransferTargetHeader, nonce)
             == offset_of!(StateTransferTargetHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(StateTransferTargetHeader, reserved) + size_of::<[u8; 95]>() == HEADER_SIZE);
+    // The pre-existing published offsets. New fields grow into the reserved
+    // tail only; a change that moves one of these is a wire break.
+    assert!(offset_of!(StateTransferTargetHeader, commit_op) == 144);
+    assert!(offset_of!(StateTransferTargetHeader, namespace) == 152);
+    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);
 };
 
 impl ConsensusHeader for StateTransferTargetHeader {
@@ -1450,6 +1490,20 @@
                 "available must be 0 or 1".to_string(),
             ));
         }
+        if self.unavailable_transient > 1 {
+            return Err(ConsensusError::InvalidField(
+                "unavailable_transient must be 0 or 1".to_string(),
+            ));
+        }
+        // The flag qualifies a refusal, so it is meaningless on an offer. Inert
+        // today (the receiver reads it only inside the `available == 0` arm),
+        // rejected anyway because a self-contradictory descriptor says the
+        // sender is not the build this field was designed for.
+        if self.available == 1 && self.unavailable_transient == 1 {
+            return Err(ConsensusError::InvalidField(
+                "unavailable_transient must be 0 on an available offer".to_string(),
+            ));
+        }
         // Unavailable is a bare refusal; a manifest body on it would be
         // ambiguous (which offer would the chunks belong to?). An
         // `available == 1` body is left unbounded here on purpose: it carries
diff --git a/core/binary_protocol/src/consensus/operation.rs b/core/binary_protocol/src/consensus/operation.rs
index 11d15a1..026ff9b 100644
--- a/core/binary_protocol/src/consensus/operation.rs
+++ b/core/binary_protocol/src/consensus/operation.rs
@@ -186,6 +186,21 @@
         (*self as u8) >= Self::PARTITION_START
     }
 
+    /// Operations that replicate through the METADATA consensus group and live
+    /// in its WAL.
+    ///
+    /// Wider than [`Self::is_metadata`]: the session ops replicate on the
+    /// metadata plane without being metadata mutations. The single source of
+    /// truth for "does the metadata plane own this op", shared by the plane's
+    /// own applicability predicate and the repair router's legacy-stamp
+    /// acceptance -- the two drifting is how a metadata op ends up offered to
+    /// the partition arm.
+    #[must_use]
+    #[inline]
+    pub const fn is_metadata_plane(&self) -> bool {
+        self.is_metadata() || matches!(self, Self::Register | Self::Logout)
+    }
+
     /// Operations clients are allowed to send directly.
     #[must_use]
     #[inline]
diff --git a/core/configs/src/server_ng_config/defaults.rs b/core/configs/src/server_ng_config/defaults.rs
index e68f088..40695a6 100644
--- a/core/configs/src/server_ng_config/defaults.rs
+++ b/core/configs/src/server_ng_config/defaults.rs
@@ -176,6 +176,11 @@
             prepare_queue_depth: partition.prepare_queue_depth as usize,
             evicted_ring_capacity: partition.evicted_ring_capacity as usize,
             evicted_ring_bytes_max: partition.evicted_ring_bytes_max.parse().unwrap(),
+            transfer_served_cache_bytes_max: partition
+                .transfer_served_cache_bytes_max
+                .parse()
+                .unwrap(),
+            transfer_artifact_bytes_max: partition.transfer_artifact_bytes_max.parse().unwrap(),
         }
     }
 }
diff --git a/core/configs/src/server_ng_config/displays.rs b/core/configs/src/server_ng_config/displays.rs
index 2d40475..ab48712 100644
--- a/core/configs/src/server_ng_config/displays.rs
+++ b/core/configs/src/server_ng_config/displays.rs
@@ -24,6 +24,7 @@
 
 use super::message_bus::MessageBusConfig;
 use super::metadata::MetadataConfig;
+use super::partition::PartitionConfig;
 use super::quic::{QuicCertificateConfig, QuicConfig, QuicSocketConfig};
 use super::server_ng::{ExtraConfig, NamespaceConfig, ServerNgConfig};
 use super::tcp::{TcpConfig, TcpSocketConfig, TcpTlsConfig};
@@ -35,7 +36,7 @@
             f,
             "{{ consumer_group: {}, data_maintenance: {}, extra: {}, message_saver: {}, \
              heartbeat: {}, system: {}, quic: {}, tcp: {}, http: {}, telemetry: {}, \
-             metadata: {}, message_bus: {} }}",
+             metadata: {}, message_bus: {}, partition: {} }}",
             self.consumer_group,
             self.data_maintenance,
             self.extra,
@@ -48,6 +49,23 @@
             self.telemetry,
             self.metadata,
             self.message_bus,
+            self.partition,
+        )
+    }
+}
+
+impl Display for PartitionConfig {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{{ prepare_queue_depth: {}, evicted_ring_capacity: {}, \
+             evicted_ring_bytes_max: {}, transfer_served_cache_bytes_max: {}, \
+             transfer_artifact_bytes_max: {} }}",
+            self.prepare_queue_depth,
+            self.evicted_ring_capacity,
+            self.evicted_ring_bytes_max,
+            self.transfer_served_cache_bytes_max,
+            self.transfer_artifact_bytes_max,
         )
     }
 }
diff --git a/core/configs/src/server_ng_config/partition.rs b/core/configs/src/server_ng_config/partition.rs
index 580668e..011e82a 100644
--- a/core/configs/src/server_ng_config/partition.rs
+++ b/core/configs/src/server_ng_config/partition.rs
@@ -55,6 +55,22 @@
 /// sizing endorsement.
 pub const MAX_PARTITION_PREPARE_QUEUE_DEPTH: usize = 256;
 
+/// Mirrors the free const `shard::PARTITION_ARTIFACT_LEN_DEFAULT` (segment
+/// ceiling plus the one whole batch a segment may close past it).
+pub const DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX: u64 = 1024 * 1024 * 1024 + 64 * 1024 * 1024;
+
+/// Mirrors the free const `shard::SERVED_SEGMENT_CACHE_BYTES_DEFAULT`: room for
+/// two concurrently served segments at the size a SEALED one actually reaches,
+/// which is the artifact ceiling above, not the configured segment target. Sized
+/// off the target instead, two admitted pulls would not both fit and would evict
+/// each other on every chunk.
+pub const DEFAULT_TRANSFER_SERVED_CACHE_BYTES_MAX: u64 = 2 * DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX;
+
+/// Upper bound on the two state-transfer byte knobs. A typo guard, not a sizing
+/// endorsement: both are PER SHARD, so a slipped digit multiplies by the core
+/// count.
+pub const MAX_TRANSFER_BYTES: u64 = 64 * 1024 * 1024 * 1024;
+
 /// Mirrors `partitions::EVICTED_RING_CAPACITY`.
 pub const DEFAULT_EVICTED_RING_CAPACITY: usize = 4096;
 
@@ -95,6 +111,27 @@
     /// [`MAX_EVICTED_RING_BYTES`].
     #[config_env(leaf)]
     pub evicted_ring_bytes_max: IggyByteSize,
+
+    /// Byte budget for segment payloads a SERVING shard keeps resident to
+    /// answer state-transfer chunk requests, per shard (so the process-wide
+    /// bound is this times the shard count).
+    ///
+    /// Sized for concurrent pulls, not one: at exactly one maximum segment a
+    /// single receiver arming several transfers thrashes the cache by itself,
+    /// and every miss re-reads and re-hashes a whole segment to serve one
+    /// chunk. Must be > 0.
+    #[config_env(leaf)]
+    pub transfer_served_cache_bytes_max: IggyByteSize,
+
+    /// Alloc ceiling for ONE received state-transfer artifact, per shard.
+    ///
+    /// A receiver holds the whole artifact resident through verify, walk and
+    /// staging write, so the in-flight cap multiplies this. It must stay above
+    /// the largest legal segment (`segment.size` plus the one batch a segment
+    /// may overshoot it by) or legal segments are rejected deterministically.
+    /// Must be > 0.
+    #[config_env(leaf)]
+    pub transfer_artifact_bytes_max: IggyByteSize,
 }
 
 impl Validatable<ConfigurationError> for PartitionConfig {
@@ -121,6 +158,26 @@
             );
             return Err(ConfigurationError::InvalidConfigurationValue);
         }
+        // The FLOOR on `transfer_artifact_bytes_max` cannot live here (it needs
+        // `system.segment.size` and the bus cap); it is enforced in the
+        // `ServerNgConfig` validator, which is what turns that misconfiguration
+        // into a boot error instead of a silent per-partition rejoin livelock.
+        let served_cache = self.transfer_served_cache_bytes_max.as_bytes_u64();
+        if served_cache == 0 || served_cache > MAX_TRANSFER_BYTES {
+            eprintln!(
+                "{COMPONENT_NG} partition.transfer_served_cache_bytes_max ({served_cache} bytes) \
+                 must be > 0 and <= {MAX_TRANSFER_BYTES} bytes"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        let artifact_bytes = self.transfer_artifact_bytes_max.as_bytes_u64();
+        if artifact_bytes == 0 || artifact_bytes > MAX_TRANSFER_BYTES {
+            eprintln!(
+                "{COMPONENT_NG} partition.transfer_artifact_bytes_max ({artifact_bytes} bytes) \
+                 must be > 0 and <= {MAX_TRANSFER_BYTES} bytes"
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
         let ring_bytes = self.evicted_ring_bytes_max.as_bytes_u64();
         if ring_bytes == 0 {
             eprintln!("{COMPONENT_NG} partition.evicted_ring_bytes_max must be > 0");
@@ -147,6 +204,25 @@
         assert!(PartitionConfig::default().validate().is_ok());
     }
 
+    /// The shipped TOML strings are the only thing an operator sees, and nothing
+    /// else ties them to the constants the code sizes itself against -- a
+    /// decimal/binary slip ("1088 MB" for 1088 MiB) parses fine and ships a cap
+    /// BELOW the largest legal segment, which livelocks a rejoin per partition.
+    #[test]
+    fn shipped_transfer_defaults_match_the_runtime_constants() {
+        let config = PartitionConfig::default();
+        assert_eq!(
+            config.transfer_artifact_bytes_max.as_bytes_u64(),
+            DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX,
+            "config.toml transfer_artifact_bytes_max drifted from the runtime default"
+        );
+        assert_eq!(
+            config.transfer_served_cache_bytes_max.as_bytes_u64(),
+            DEFAULT_TRANSFER_SERVED_CACHE_BYTES_MAX,
+            "config.toml transfer_served_cache_bytes_max drifted from the runtime default"
+        );
+    }
+
     #[test]
     fn rejects_zero_prepare_queue_depth() {
         let config = PartitionConfig {
diff --git a/core/configs/src/server_ng_config/validators.rs b/core/configs/src/server_ng_config/validators.rs
index 8c6941f..f7408b7 100644
--- a/core/configs/src/server_ng_config/validators.rs
+++ b/core/configs/src/server_ng_config/validators.rs
@@ -183,6 +183,34 @@
             return Err(ConfigurationError::InvalidConfigurationValue);
         }
 
+        // A received segment artifact can be one whole batch larger than the
+        // segment cap (rotation checks the cap AFTER appending), and the real
+        // batch bound is the BUS frame cap -- server-ng never enforces
+        // `MAX_PAYLOAD_SIZE`. An artifact ceiling under that floor refuses a
+        // legal segment, and the manifest check is all-or-nothing, so the
+        // partition livelocks re-requesting the same segment from every peer at
+        // the backoff ceiling. Caught here so it is a boot error rather than one
+        // partition that silently never rejoins.
+        let artifact_floor = self
+            .system
+            .segment
+            .size
+            .as_bytes_u64()
+            .saturating_add(self.message_bus.max_message_size.as_bytes_u64());
+        if self.partition.transfer_artifact_bytes_max.as_bytes_u64() < artifact_floor {
+            eprintln!(
+                "{COMPONENT_NG} partition.transfer_artifact_bytes_max ({} B) must be at least \
+                 system.segment.size ({} B) + message_bus.max_message_size ({} B) = \
+                 {artifact_floor} B: a segment may close one whole batch past its cap, and an \
+                 artifact ceiling below that refuses a legal segment and livelocks the \
+                 partition's rejoin",
+                self.partition.transfer_artifact_bytes_max.as_bytes_u64(),
+                self.system.segment.size.as_bytes_u64(),
+                self.message_bus.max_message_size.as_bytes_u64(),
+            );
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+
         self.message_bus
             .validate()
             .error(|e: &ConfigurationError| {
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 63beade..d23378f 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -1527,6 +1527,9 @@
             commit_max: self.commit_max.get(),
             checkpoint_op,
             checkpoint_checksum,
+            // Consensus mints no message offsets: the PARTITION plane stamps
+            // this in before it writes (`IggyPartition::write_superblock`).
+            offset_frontier: 0,
         }
     }
 
@@ -3046,7 +3049,11 @@
         // never re-stamped (`restamp_prepare_view` patches only `view`), so this
         // survives view-change retransmits. The header `checksum` and its `parent`
         // chain stay `0`: activating them needs the retransmit path to re-seal a
-        // re-stamped header, a separate change.
+        // re-stamped header, a separate change. Whoever activates it must also
+        // audit every `set_last_prepare_checksum` caller for cross-plane carry --
+        // the repair router in `shard` drops metadata-plane frames it cannot
+        // journal precisely so one cannot stamp a PARTITION consensus, which is
+        // inert only while these values are structurally zero.
         //
         // Metadata plane only. A partition produce prepare already carries a verified
         // `batch_checksum` over the same bytes, so a second full-payload pass is pure
@@ -3079,7 +3086,12 @@
                 op,
                 timestamp,
                 operation: old.operation,
-                namespace: old.namespace,
+                // The GROUP's namespace, never the request's: a client
+                // RequestHeader carries namespace 0, and journaling that
+                // would make the stored prepare route to the wrong plane
+                // when repair later ships it verbatim (live replication
+                // masked this; repair replay is what broke).
+                namespace: consensus.namespace,
                 checksum_body,
                 // Copied verbatim: carries the stamped acting user for client
                 // ops (and the authenticated user on Register), so the in-apply
diff --git a/core/consensus/src/lib.rs b/core/consensus/src/lib.rs
index 7b5457a..4d7a53d 100644
--- a/core/consensus/src/lib.rs
+++ b/core/consensus/src/lib.rs
@@ -155,8 +155,13 @@
 };
 pub mod state_manifest;
 pub use state_manifest::{
-    StateArtifact, StateManifestError, artifact_kind, decode_state_manifest, encode_state_manifest,
-    state_artifact_checksum,
+    StateArtifact, StateArtifactHasher, StateManifestError, artifact_kind, decode_state_manifest,
+    encode_state_manifest, state_artifact_checksum,
+};
+pub mod state_transfer;
+pub use state_transfer::{
+    ArtifactProgress, ChunkProgress, STATE_TRANSFER_MAX_DECODE_RETRIES,
+    STATE_TRANSFER_MAX_STALL_RETRIES, append_chunk, next_pending_chunk, verify_state_artifact,
 };
 // One-shot per `PipelineEntry` for in-process commit awaiters.
 pub(crate) mod oneshot;
diff --git a/core/consensus/src/state_manifest.rs b/core/consensus/src/state_manifest.rs
index 2841c6c..be5a530 100644
--- a/core/consensus/src/state_manifest.rs
+++ b/core/consensus/src/state_manifest.rs
@@ -45,8 +45,14 @@
     pub const METADATA_SNAPSHOT: u8 = 0;
     /// Metadata plane: [`crate::ClientTable::encode`] bytes.
     pub const CLIENT_TABLE: u8 = 1;
-    // Partition plane (reserved, not served yet):
-    // SEGMENT_LOG = 2, CONSUMER_OFFSETS = 3.
+    /// Partition plane: one retained segment's `.log` bytes verbatim
+    /// (prepare-stripped `SendMessages2` records); `frontier` = the
+    /// segment's base offset.
+    pub const SEGMENT_LOG: u8 = 2;
+    /// Partition plane: the encoded consumer + consumer-group offset table
+    /// (plus the applied purge generation); `frontier` = the offer's
+    /// `commit_op`.
+    pub const CONSUMER_OFFSETS: u8 = 3;
 }
 
 /// One artifact a serving peer offers: what it is, where its receiver-side
@@ -88,6 +94,32 @@
     hasher.finish()
 }
 
+/// Streaming form of [`state_artifact_checksum`].
+///
+/// For payloads too large to hold resident (a serving primary hashing
+/// multi-GiB segment files in chunks between reactor yields). Feeding the
+/// same bytes in any chunking produces the same stamp as the one-shot form.
+#[derive(Default)]
+pub struct StateArtifactHasher {
+    inner: XxHash3_64,
+}
+
+impl StateArtifactHasher {
+    #[must_use]
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    pub fn update(&mut self, bytes: &[u8]) {
+        self.inner.write(bytes);
+    }
+
+    #[must_use]
+    pub fn finish(&self) -> u64 {
+        self.inner.finish()
+    }
+}
+
 /// Failure decoding an encoded manifest.
 #[derive(Debug)]
 pub enum StateManifestError {
diff --git a/core/consensus/src/state_transfer.rs b/core/consensus/src/state_transfer.rs
new file mode 100644
index 0000000..f5cf14e
--- /dev/null
+++ b/core/consensus/src/state_transfer.rs
@@ -0,0 +1,279 @@
+// 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.
+
+//! Receiver-side state-transfer session math shared by both planes.
+//!
+//! A transfer pulls the artifacts named by a [`crate::StateArtifact`]
+//! manifest in manifest order, lockstep with one chunk in flight; each
+//! artifact's `buf.len()` doubles as the next request offset. The functions
+//! here are the pure parts of that session -- chunk cursor arithmetic and the
+//! frame-acceptance guards -- so the metadata and partition receivers cannot
+//! drift on the invariants that were review findings on the metadata plane
+//! (sequential offsets, overrun refusal, the zero-byte-payload livelock).
+
+use crate::state_manifest::{StateArtifact, state_artifact_checksum};
+
+/// Stall rounds a receiver spends on ONE peer before abandoning the
+/// transfer and falling back to journal repair.
+///
+/// The retry has no peer re-selection, so this is what keeps a peer that
+/// died mid-transfer from wedging the rejoining node; repair then re-picks
+/// a target and re-arms a transfer if the gap is still below the new peer's
+/// retained floor.
+pub const STATE_TRANSFER_MAX_STALL_RETRIES: u32 = 5;
+
+/// Decode-failure rounds a receiver spends on ONE offered generation
+/// before refusing to pull it again.
+///
+/// Read by the METADATA arm only -- it lives here because the chunk cursor it
+/// pairs with is plane-agnostic, not because both planes use it.
+///
+/// Keyed on `snapshot_seq`: a peer whose snapshot
+/// generation advances resets the budget (new bytes are worth full
+/// retries), while a generation this build cannot decode costs one refused
+/// descriptor per repair round instead of a full pull. The partition plane
+/// deliberately does NOT use a generation-keyed budget -- a committing
+/// origin advances `commit_op` every round, which pinned such a count at
+/// 1 forever; it counts consecutive failures on the partition and backs
+/// its re-arm off instead.
+pub const STATE_TRANSFER_MAX_DECODE_RETRIES: u32 = 5;
+
+/// One artifact of an accepted transfer target: its manifest entry plus
+/// the bytes received so far.
+///
+/// Chunks are sequential, so `buf.len()` doubles as the next request offset. A receiver that spills completed artifacts to
+/// disk empties `buf` afterwards and tracks completion out of band.
+#[derive(Debug)]
+pub struct ArtifactProgress {
+    pub entry: StateArtifact,
+    pub buf: Vec<u8>,
+}
+
+/// What [`next_pending_chunk`] and [`append_chunk`] need from one slot.
+///
+/// Exists so a plane can track completion in richer shapes -- the
+/// partition receiver spills finished segments to disk and replaces the
+/// buffer with staged metadata -- while both planes share one chunk cursor:
+/// a spilled artifact simply reports itself complete and is skipped.
+pub trait ChunkProgress {
+    fn declared_len(&self) -> u64;
+    fn received_len(&self) -> u64;
+    /// Only called after the cursor checks `received + payload <= declared`,
+    /// so an impl whose slot cannot grow (already complete) never sees it.
+    fn extend_from_chunk(&mut self, payload: &[u8]);
+    /// Reserve room for the whole declared length, called once per artifact on
+    /// its FIRST chunk (see [`append_chunk`]). Default: nothing, for slots that
+    /// do not accumulate in memory. Reserving at accept time instead would
+    /// commit address space for every manifest entry at once.
+    fn reserve_declared(&mut self) {}
+    fn complete(&self) -> bool {
+        self.received_len() == self.declared_len()
+    }
+}
+
+impl ChunkProgress for ArtifactProgress {
+    fn declared_len(&self) -> u64 {
+        self.entry.len
+    }
+
+    fn received_len(&self) -> u64 {
+        self.buf.len() as u64
+    }
+
+    fn extend_from_chunk(&mut self, payload: &[u8]) {
+        self.buf.extend_from_slice(payload);
+    }
+
+    fn reserve_declared(&mut self) {
+        // Exact rather than geometric: `entry.len` already passed the caller's
+        // per-kind caps, and doubling to gigabyte sizes copies roughly twice the
+        // bytes at a ~1.5x transient peak.
+        #[allow(clippy::cast_possible_truncation)]
+        self.buf.reserve_exact(self.entry.len as usize);
+    }
+}
+
+/// Next `(artifact index, offset, len)` to request.
+///
+/// The first incomplete artifact in manifest order, asked from its current
+/// frontier, clamped to `chunk_len_max`. `None` when every artifact is complete (or the manifest
+/// is empty).
+#[must_use]
+pub fn next_pending_chunk<T: ChunkProgress>(
+    artifacts: &[T],
+    chunk_len_max: u64,
+) -> Option<(u32, u64, u32)> {
+    let (index, artifact) = artifacts
+        .iter()
+        .enumerate()
+        .find(|(_, artifact)| !artifact.complete())?;
+    let offset = artifact.received_len();
+    let remaining = artifact.declared_len() - offset;
+    #[allow(clippy::cast_possible_truncation)]
+    let len = remaining.min(chunk_len_max) as u32;
+    #[allow(clippy::cast_possible_truncation)]
+    Some((index as u32, offset, len))
+}
+
+/// Append one received chunk; `true` only when bytes actually landed, which
+/// is the caller's cue to reset its liveness counters and re-drive progress.
+///
+/// Everything else is dropped without side effects: an artifact that is not
+/// the FIRST incomplete one, a non-sequential offset (chunks are pulled
+/// lockstep, so anything else is a duplicate or reorder -- the stall retry
+/// re-requests from the current frontier), an overrun past the declared
+/// length, and a zero-byte payload. The first-incomplete restriction mirrors
+/// what [`next_pending_chunk`] would have requested, and bounds the
+/// reservation below to ONE artifact at a time: without it a peer that pushes
+/// one byte into every manifest entry would make each slot reserve its whole
+/// declared length, committing address space for the sum of the manifest, and
+/// a failed `Vec` reservation aborts the process rather than erroring. A
+/// zero-byte payload is not progress: it extends nothing and the same offset is re-requested
+/// immediately, and resetting liveness counters on one is what turned a
+/// short rebuilt offer into an unbounded empty-frame ping-pong on the
+/// metadata plane. The serving side refuses to produce these now; the guard
+/// stays because a peer running an older build still can.
+#[must_use]
+pub fn append_chunk<T: ChunkProgress>(
+    artifacts: &mut [T],
+    artifact_index: u32,
+    offset: u64,
+    payload: &[u8],
+) -> bool {
+    let first_incomplete = artifacts.iter().position(|artifact| !artifact.complete());
+    if first_incomplete != Some(artifact_index as usize) {
+        return false;
+    }
+    let Some(artifact) = artifacts.get_mut(artifact_index as usize) else {
+        return false;
+    };
+    if offset != artifact.received_len() {
+        return false;
+    }
+    if artifact.received_len() + payload.len() as u64 > artifact.declared_len() {
+        tracing::warn!(
+            artifact = artifact_index,
+            declared_len = artifact.declared_len(),
+            "state chunk overruns the declared artifact length; dropping frame"
+        );
+        return false;
+    }
+    if payload.is_empty() {
+        return false;
+    }
+    // First chunk of this artifact: give the slot its full declared length in
+    // one allocation, so a segment-sized artifact is not grown by doubling.
+    if artifact.received_len() == 0 {
+        artifact.reserve_declared();
+    }
+    artifact.extend_from_chunk(payload);
+    true
+}
+
+/// Whether `bytes` is exactly the artifact the manifest promised:
+/// declared length and `XxHash3_64` checksum.
+///
+/// This proves transit integrity only -- the payload still needs its own
+/// format validation, since the checksum does not prove the peer computed
+/// it over sane bytes.
+#[must_use]
+pub fn verify_state_artifact(entry: &StateArtifact, bytes: &[u8]) -> bool {
+    bytes.len() as u64 == entry.len && state_artifact_checksum(bytes) == entry.checksum
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn progress(kind: u8, len: u64) -> ArtifactProgress {
+        ArtifactProgress {
+            entry: StateArtifact {
+                kind,
+                frontier: 0,
+                len,
+                checksum: 0,
+            },
+            buf: Vec::new(),
+        }
+    }
+
+    #[test]
+    fn given_partial_artifacts_when_next_chunk_requested_should_resume_first_incomplete() {
+        let mut artifacts = vec![progress(0, 4), progress(1, 10)];
+        artifacts[0].buf = vec![0; 4];
+        artifacts[1].buf = vec![0; 3];
+
+        assert_eq!(next_pending_chunk(&artifacts, 5), Some((1, 3, 5)));
+    }
+
+    #[test]
+    fn given_short_tail_when_next_chunk_requested_should_clamp_to_remaining() {
+        let mut artifacts = vec![progress(0, 8)];
+        artifacts[0].buf = vec![0; 6];
+
+        assert_eq!(next_pending_chunk(&artifacts, 64), Some((0, 6, 2)));
+    }
+
+    #[test]
+    fn given_complete_or_empty_manifest_when_next_chunk_requested_should_yield_none() {
+        assert_eq!(next_pending_chunk::<ArtifactProgress>(&[], 64), None);
+        let mut artifacts = vec![progress(0, 2)];
+        artifacts[0].buf = vec![0; 2];
+        assert_eq!(next_pending_chunk(&artifacts, 64), None);
+    }
+
+    #[test]
+    fn given_zero_length_artifact_when_scanned_should_read_complete() {
+        // A zero-length artifact must never be asked for: `start >= len` is
+        // the empty-chunk exchange the serving side refuses.
+        let artifacts = vec![progress(0, 0), progress(1, 1)];
+        assert_eq!(next_pending_chunk(&artifacts, 64), Some((1, 0, 1)));
+    }
+
+    #[test]
+    fn given_sequential_chunks_when_appended_should_accumulate() {
+        let mut artifacts = vec![progress(0, 4)];
+        assert!(append_chunk(&mut artifacts, 0, 0, b"ab"));
+        assert!(append_chunk(&mut artifacts, 0, 2, b"cd"));
+        assert!(artifacts[0].complete());
+    }
+
+    #[test]
+    fn given_bad_frames_when_appended_should_drop_without_side_effects() {
+        let mut artifacts = vec![progress(0, 4)];
+        assert!(append_chunk(&mut artifacts, 0, 0, b"ab"));
+
+        assert!(!append_chunk(&mut artifacts, 1, 0, b"xx"), "index OOB");
+        assert!(!append_chunk(&mut artifacts, 0, 0, b"xx"), "stale offset");
+        assert!(!append_chunk(&mut artifacts, 0, 3, b"xx"), "future offset");
+        assert!(!append_chunk(&mut artifacts, 0, 2, b"xyz"), "overrun");
+        assert!(!append_chunk(&mut artifacts, 0, 2, b""), "empty payload");
+        assert_eq!(artifacts[0].buf, b"ab", "rejected frames must not mutate");
+    }
+
+    #[test]
+    fn given_artifact_bytes_when_verified_should_match_len_and_checksum() {
+        let bytes = b"state transfer artifact".to_vec();
+        let entry = StateArtifact::for_bytes(2, 7, &bytes);
+        assert!(verify_state_artifact(&entry, &bytes));
+
+        let mut flipped = bytes.clone();
+        flipped[0] ^= 1;
+        assert!(!verify_state_artifact(&entry, &flipped));
+        assert!(!verify_state_artifact(&entry, &bytes[1..]));
+    }
+}
diff --git a/core/consensus/src/vsr_state.rs b/core/consensus/src/vsr_state.rs
index 6523f38..b9e2701 100644
--- a/core/consensus/src/vsr_state.rs
+++ b/core/consensus/src/vsr_state.rs
@@ -28,11 +28,23 @@
 
 use std::fmt;
 
-/// Number of bytes [`VsrState::to_bytes`] produces and [`VsrState::try_from`]
-/// expects: `cluster`(16) + `replica_id`(1) + `replica_count`(1) + `view`(4)
-/// + `log_view`(4) + `commit_max`(8) + `checkpoint_op`(8)
-/// + `checkpoint_checksum`(16).
-pub const ENCODED_LEN: usize = 58;
+/// Number of bytes [`VsrState::to_bytes`] produces: `cluster`(16) +
+/// `replica_id`(1) + `replica_count`(1) + `view`(4) + `log_view`(4) +
+/// `commit_max`(8) + `checkpoint_op`(8) + `checkpoint_checksum`(16) +
+/// `offset_frontier`(8).
+pub const ENCODED_LEN: usize = 66;
+
+/// The layout before `offset_frontier` was appended.
+///
+/// [`VsrState::try_from`] still accepts records of this length and zero-fills
+/// the new field. Without it every superblock already on disk -- the metadata
+/// plane writes one on every view change and checkpoint, single-node included --
+/// would decode as [`VsrStateError::WrongLength`] and refuse boot as a
+/// durability violation. A version bump instead of this would not help on its
+/// own: `classify` compares the version for exact equality, so a v2 build turns
+/// every v1 record into `Unreadable`, which is the same refusal wearing a
+/// different name.
+pub const ENCODED_LEN_WITHOUT_FRONTIER: usize = 58;
 
 /// The durable consensus state of one replica for one consensus group.
 ///
@@ -69,6 +81,22 @@
     /// Integrity tag of the paired checkpoint, detecting a torn
     /// snapshot/superblock pairing across a crash.
     pub checkpoint_checksum: u128,
+    /// PARTITION plane: the next message offset this replica will mint, or `0`
+    /// for a group whose offset space is still empty.
+    ///
+    /// A durable LOWER BOUND, not a completeness claim: boot takes the max of
+    /// this and whatever the recovered segments prove. It exists because
+    /// nothing else durably names the frontier once the segments that carried
+    /// it are gone -- a state-transfer install of an all-GC'd origin, a crash
+    /// inside the install's swap window, and the fence-and-rebuild path all
+    /// leave a replica whose counter would otherwise restart at 0 while the
+    /// group is at N. That is not a lag: replicas re-stamp `base_offset` from
+    /// this counter and recompute `batch_checksum` over it, so the next
+    /// replicated prepare would persist different bytes here than on every
+    /// peer, silently.
+    ///
+    /// Always `0` on the metadata plane, which mints no message offsets.
+    pub offset_frontier: u64,
 }
 
 impl VsrState {
@@ -84,6 +112,7 @@
         out[26..34].copy_from_slice(&self.commit_max.to_le_bytes());
         out[34..42].copy_from_slice(&self.checkpoint_op.to_le_bytes());
         out[42..58].copy_from_slice(&self.checkpoint_checksum.to_le_bytes());
+        out[58..66].copy_from_slice(&self.offset_frontier.to_le_bytes());
         out
     }
 }
@@ -92,13 +121,25 @@
     type Error = VsrStateError;
 
     fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
-        // One length check up front puts every field slice below in bounds by
-        // construction, so the `try_into`s cannot fail.
-        let bytes: &[u8; ENCODED_LEN] =
-            bytes.try_into().map_err(|_| VsrStateError::WrongLength {
-                expected: ENCODED_LEN,
-                actual: bytes.len(),
-            })?;
+        // Length-tolerant: a pre-`offset_frontier` record is padded out and the
+        // new field reads as 0, which is exactly "no recorded frontier" (the
+        // read sites filter it). One length check up front then puts every
+        // field slice below in bounds by construction, so the `try_into`s
+        // cannot fail.
+        let mut padded = [0u8; ENCODED_LEN];
+        match bytes.len() {
+            ENCODED_LEN => padded.copy_from_slice(bytes),
+            ENCODED_LEN_WITHOUT_FRONTIER => {
+                padded[..ENCODED_LEN_WITHOUT_FRONTIER].copy_from_slice(bytes);
+            }
+            actual => {
+                return Err(VsrStateError::WrongLength {
+                    expected: ENCODED_LEN,
+                    actual,
+                });
+            }
+        }
+        let bytes = &padded;
         let state = Self {
             cluster: u128::from_le_bytes(field(bytes, 0)),
             replica_id: bytes[16],
@@ -108,6 +149,7 @@
             commit_max: u64::from_le_bytes(field(bytes, 26)),
             checkpoint_op: u64::from_le_bytes(field(bytes, 34)),
             checkpoint_checksum: u128::from_le_bytes(field(bytes, 42)),
+            offset_frontier: u64::from_le_bytes(field(bytes, 58)),
         };
         // A record violating `log_view <= view` decodes into a replica that looks
         // healthy locally while `DoViewChangeHeader::validate` makes every peer drop
@@ -146,7 +188,11 @@
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::WrongLength { expected, actual } => {
-                write!(f, "VsrState needs {expected} bytes, got {actual}")
+                write!(
+                    f,
+                    "VsrState needs {expected} bytes (or {ENCODED_LEN_WITHOUT_FRONTIER}, \
+                     the layout before the offset frontier), got {actual}"
+                )
             }
             Self::LogViewAheadOfView { view, log_view } => write!(
                 f,
@@ -178,6 +224,7 @@
             commit_max: 6,
             checkpoint_op: 7,
             checkpoint_checksum: 8,
+            offset_frontier: 0,
         };
         let bytes = state.to_bytes();
         assert_eq!(bytes.len(), ENCODED_LEN);
@@ -194,6 +241,41 @@
         assert!(VsrState::try_from(&bytes[..ENCODED_LEN - 1]).is_err());
     }
 
+    /// A superblock written before `offset_frontier` existed must still decode:
+    /// the metadata plane writes one on every view change, so an exact-length
+    /// decode turns an in-place upgrade into a boot refusal on every deployment
+    /// that ever ran.
+    #[test]
+    fn given_pre_frontier_record_when_decoded_should_accept_and_zero_fill() {
+        let full = VsrState {
+            cluster: 3,
+            replica_id: 1,
+            replica_count: 3,
+            view: 9,
+            log_view: 8,
+            commit_max: 41,
+            checkpoint_op: 7,
+            checkpoint_checksum: 5,
+            offset_frontier: 77,
+        }
+        .to_bytes();
+
+        let legacy = &full[..ENCODED_LEN_WITHOUT_FRONTIER];
+        let decoded = VsrState::try_from(legacy).expect("a pre-frontier record must decode");
+        assert_eq!(decoded.offset_frontier, 0, "the new field zero-fills");
+        assert_eq!(decoded.view, 9);
+        assert_eq!(decoded.log_view, 8);
+        assert_eq!(decoded.commit_max, 41);
+        assert_eq!(decoded.checkpoint_op, 7);
+        assert_eq!(decoded.checkpoint_checksum, 5);
+
+        // Anything that is neither layout is still refused.
+        assert!(matches!(
+            VsrState::try_from(&full[..40]),
+            Err(VsrStateError::WrongLength { .. })
+        ));
+    }
+
     #[test]
     fn given_log_view_past_view_when_decoded_should_reject() {
         // Corruption inside the checksummed region can produce a length-valid record
@@ -209,8 +291,12 @@
             commit_max: 0,
             checkpoint_op: 0,
             checkpoint_checksum: 0,
+            // Distinct and nonzero: with 0 here a transposed write over the
+            // trailing field would still satisfy every assertion below.
+            offset_frontier: 9,
         }
         .to_bytes();
+        assert_eq!(bytes[58], 9, "offset_frontier must occupy bytes 58..66");
         bytes[22] = 5; // log_view = 5, view stays 4
 
         assert_eq!(
diff --git a/core/integration/src/bench_utils.rs b/core/integration/src/bench_utils.rs
index 0262420..de2de07 100644
--- a/core/integration/src/bench_utils.rs
+++ b/core/integration/src/bench_utils.rs
@@ -20,10 +20,10 @@
 use iggy::prelude::*;
 use iggy_common::TransportProtocol;
 use std::{
-    fs::{self, File, OpenOptions},
-    io::Write,
+    fs::{self, File},
     process::{Command, Stdio},
     thread::{self, panicking},
+    time::{Duration, Instant},
 };
 use uuid::Uuid;
 
@@ -31,6 +31,14 @@
 const MESSAGE_BATCHES: u64 = 100;
 const MESSAGES_PER_BATCH: u64 = 100;
 const DEFAULT_NUMBER_OF_STREAMS: u64 = 8;
+// Generous for a few MB of traffic even in debug builds, and deliberately
+// UNDER nextest's harness timeout (`.config/nextest.toml` sigkills at
+// 60s x 5): a longer wait here would never fire, taking the capture dump and
+// the `--features vsr` hint below with it. Exists because a protocol mismatch
+// (an SDK framing the server does not speak, e.g. a default-features
+// iggy-bench against a vsr cluster) hangs both sides silently instead of
+// erroring.
+const BENCH_WAIT_TIMEOUT: Duration = Duration::from_secs(240);
 
 pub fn run_bench_and_wait_for_finish(
     server_addr: &str,
@@ -102,48 +110,40 @@
     }
 
     let mut child = command.spawn().unwrap();
-    let result = child.wait().unwrap();
-
-    // Cleanup
-    if let Ok(output) = child.wait_with_output() {
-        let stderr = String::from_utf8_lossy(&output.stderr);
-        let stdout = String::from_utf8_lossy(&output.stdout);
-        if let Some(stderr_file_path) = &stderr_file_path {
-            OpenOptions::new()
-                .append(true)
-                .create(true)
-                .open(stderr_file_path)
-                .unwrap()
-                .write_all(stderr.as_bytes())
-                .unwrap();
+    let deadline = Instant::now() + BENCH_WAIT_TIMEOUT;
+    // A timeout does NOT panic here: doing so jumped over the capture dump and
+    // the temp-file cleanup below, so every timed-out run leaked both files and
+    // printed only stderr -- while iggy-bench writes its progress to stdout,
+    // the one capture that explains a hang. The verdict is the assert at the end.
+    let mut timed_out = false;
+    let status = loop {
+        match child.try_wait().unwrap() {
+            Some(status) => break Some(status),
+            None if Instant::now() >= deadline => {
+                let _ = child.kill();
+                let _ = child.wait();
+                timed_out = true;
+                break None;
+            }
+            None => thread::sleep(Duration::from_millis(200)),
         }
+    };
 
-        if let Some(stdout_file_path) = &stdout_file_path {
-            OpenOptions::new()
-                .append(true)
-                .create(true)
-                .open(stdout_file_path)
-                .unwrap()
-                .write_all(stdout.as_bytes())
-                .unwrap();
-        }
-    } else {
-        panic!("Failed to get output from iggy-bench");
-    }
-
-    if panicking() {
-        if let Some(stdout_file_path) = &stdout_file_path {
-            eprintln!(
-                "Iggy bench stdout:\n{}",
-                fs::read_to_string(stdout_file_path).unwrap()
-            );
-        }
-
-        if let Some(stderr_file_path) = &stderr_file_path {
-            eprintln!(
-                "Iggy bench stderr:\n{}",
-                fs::read_to_string(stderr_file_path).unwrap()
-            );
+    // Nothing to drain, by construction: both branches above redirect the
+    // child's stdout and stderr -- to files, or inherited under
+    // `IGGY_TEST_VERBOSE` -- so no pipe exists for the poll loop to deadlock
+    // against. The old `wait_with_output` capture here could only ever return
+    // empty buffers for the same reason; the captures the failure path prints
+    // are the redirect FILES.
+    let failed = timed_out || status.is_none_or(|status| !status.success());
+    if failed || panicking() {
+        for (stream, path) in [("stdout", &stdout_file_path), ("stderr", &stderr_file_path)] {
+            if let Some(path) = path {
+                eprintln!(
+                    "Iggy bench {stream}:\n{}",
+                    fs::read_to_string(path).unwrap_or_default()
+                );
+            }
         }
     }
 
@@ -154,7 +154,13 @@
         fs::remove_file(stderr_file_path).unwrap();
     }
 
-    assert!(result.success());
+    assert!(
+        !timed_out,
+        "iggy-bench did not finish within {BENCH_WAIT_TIMEOUT:?}; if the server \
+         runs in vsr mode, make sure iggy-bench was built with --features vsr \
+         (the SDK framing is chosen at compile time)"
+    );
+    assert!(status.is_some_and(|status| status.success()));
 }
 
 pub fn get_random_path() -> String {
diff --git a/core/integration/src/harness/orchestrator/harness.rs b/core/integration/src/harness/orchestrator/harness.rs
index 7f14cc4..4a66157 100644
--- a/core/integration/src/harness/orchestrator/harness.rs
+++ b/core/integration/src/harness/orchestrator/harness.rs
@@ -330,6 +330,20 @@
         Ok(())
     }
 
+    /// Restart node `index` with its data directory INTACT, so it rejoins the
+    /// still-live cluster from its own recovered state (superblock, segments,
+    /// offset files). The counterpart of
+    /// [`Self::restart_node_from_clean_slate`] for the crash-and-return
+    /// shape rather than the provisioned-replacement one.
+    pub fn restart_node(&mut self, index: usize) -> Result<(), TestBinaryError> {
+        let server = self
+            .servers
+            .get_mut(index)
+            .ok_or(TestBinaryError::MissingServer)?;
+        server.stop()?;
+        server.start()
+    }
+
     /// Restart node `index` with its data directory wiped, so it rejoins the
     /// still-live cluster as a fresh replica with no local history. The other
     /// nodes stay up throughout (they hold quorum and keep committing), which
@@ -504,6 +518,23 @@
             .await
     }
 
+    /// Root-authenticated TCP client bound to ONE node of a cluster, unlike
+    /// [`Self::root_client_for`], which always targets node 0.
+    ///
+    /// # Errors
+    ///
+    /// [`TestBinaryError::MissingServer`] when `index` is out of range, or the
+    /// underlying connect/login failure.
+    pub async fn root_client_for_node(&self, index: usize) -> Result<IggyClient, TestBinaryError> {
+        self.servers
+            .get(index)
+            .ok_or(TestBinaryError::MissingServer)?
+            .tcp_client()?
+            .with_root_login()
+            .connect()
+            .await
+    }
+
     /// Create a new client logged in as root for the specified transport.
     pub fn client_builder_for(
         &self,
diff --git a/core/integration/tests/cluster/mod.rs b/core/integration/tests/cluster/mod.rs
index 8e5f267..673d9a7 100644
--- a/core/integration/tests/cluster/mod.rs
+++ b/core/integration/tests/cluster/mod.rs
@@ -19,3 +19,4 @@
 mod metadata_checkpoint_restart;
 mod metadata_state_transfer;
 mod multi_shard_partition_convergence;
+mod partition_state_transfer;
diff --git a/core/integration/tests/cluster/partition_state_transfer.rs b/core/integration/tests/cluster/partition_state_transfer.rs
new file mode 100644
index 0000000..0d36479
--- /dev/null
+++ b/core/integration/tests/cluster/partition_state_transfer.rs
@@ -0,0 +1,599 @@
+// 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.
+
+//! Partition-plane state transfer: a rejoining replica whose journal repair
+//! cannot close the gap (the peer's evicted ring moved past it) pulls the
+//! partition's retained segments + consumer offsets from the caught-up
+//! primary, installs them, and hands the live tail to ordinary repair.
+//!
+//! Forcing function: `messages_required_to_save = 1` flushes (and ring-
+//! evicts) every committed batch, and `evicted_ring_capacity = 64` keeps the
+//! repair window shallow, so ~200 produced batches push `repair_retained_from`
+//! far past a rejoiner's durable end. Its gap-fill repair then gets
+//! `RangeEvicted`, the repaired window cannot connect to recovered state,
+//! and `complete_repair` returns the `FloorRefused` conversion trigger.
+
+#![cfg(feature = "vsr")]
+
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use tokio::time::sleep;
+
+const STREAM_NAME: &str = "partition-transfer-stream";
+const TOPIC_NAME: &str = "partition-transfer-topic";
+/// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+/// Enough batches to push the evicted ring (capacity 64) well past the
+/// window a rejoiner could repair from op 1.
+const MESSAGES_COUNT: u32 = 200;
+const STORED_CONSUMER_OFFSET: u64 = 17;
+
+/// Dead-peer spec sizing: enough 256 KiB payloads (64 MiB total) that the
+/// transfer spans hundreds of 256 KiB chunk round-trips, keeping the pull
+/// in flight long enough for a marker-gated kill to land mid-transfer (a
+/// 16 MiB pull completed in ~120ms on a release build, inside one marker
+/// poll). Also enough commits (> ring capacity 64) that the fresh
+/// rejoiner's floor refuses.
+const BULKY_MESSAGES_COUNT: u32 = 256;
+const BULKY_PAYLOAD_LEN: usize = 256 * 1024;
+/// Poll for the kill gate: the serving marker appears at descriptor-serve
+/// time and the kill must land within the pull, so the ordinary 200ms
+/// cadence is too coarse here.
+const KILL_GATE_POLL: Duration = Duration::from_millis(10);
+
+const CONVERSION_MARKER: &str = "partition repair floor unreachable; converting to state transfer";
+const INSTALL_MARKER: &str = "partition state transfer installed";
+/// Logged once per transfer, at the last byte of the last artifact. The
+/// descriptor-time "serving" line only proves a request ARRIVED; this proves the
+/// pull ran.
+const FULLY_SERVED_MARKER: &str = "partition state transfer fully served";
+const ABANDON_MARKER: &str =
+    "partition state transfer stalled past its retry budget; abandoning with a backed-off re-arm";
+
+/// Transfer end-to-end: adoption, repair round-trip, conversion, chunk pull,
+/// install, tail repair. CI runners are slow; bound without hanging the suite.
+const TRANSFER_BUDGET: Duration = Duration::from_secs(60);
+const MARKER_POLL: Duration = Duration::from_millis(200);
+
+#[iggy_harness(
+    cluster_nodes = 3,
+    server(
+        system.sharding.cpu_allocation = "0..1",
+        partition.evicted_ring_capacity = "64",
+        system.partition.messages_required_to_save = "1"
+    )
+)]
+async fn given_evicted_ring_when_fresh_node_joins_late_should_state_transfer_partition(
+    harness: &mut TestHarness,
+) {
+    let client = harness
+        .root_client_for_node(0)
+        .await
+        .expect("connect a root client to the node");
+    seed_partition(&client).await;
+    client
+        .store_consumer_offset(
+            &Consumer::default(),
+            &Identifier::named(STREAM_NAME).expect("stream identifier"),
+            &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+            Some(PARTITION_ID),
+            STORED_CONSUMER_OFFSET,
+        )
+        .await
+        .expect("store a consumer offset before the wipe");
+    sleep(Duration::from_secs(1)).await;
+    // Deliberately NOT dropped before the wipe: a disconnect commits a
+    // Logout, and a fresh (never-checkpointed) metadata rejoin currently
+    // wedges repairing session-scoped ops. Keeping the seed session alive
+    // caps the metadata window at ops a fresh joiner provably replays, so
+    // this spec isolates the PARTITION plane.
+    let _seed_client = client;
+
+    // Wipe node 2 and rejoin: no local history at all, so repair cannot
+    // connect any floor and the refusal converts to a transfer.
+    harness
+        .restart_node_from_clean_slate(2)
+        .expect("clean-slate restart of node 2");
+
+    await_marker(harness, 2, CONVERSION_MARKER).await;
+    await_marker(harness, 2, INSTALL_MARKER).await;
+
+    // Disk proof on the rejoined node: transferred segment bytes and a
+    // persisted consumer-offset file (a single LE u64).
+    let data_path = harness.node(2).data_path();
+    // Each transferred batch is at least its 256-byte header; anything below
+    // this floor is a truncated install, not the seeded 200 batches.
+    let transferred_floor = u64::from(MESSAGES_COUNT) * 256;
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    loop {
+        if total_partition_log_bytes(&data_path) >= transferred_floor {
+            break;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "node 2 never materialized the transferred segment bytes \
+             (expected at least {transferred_floor})"
+        );
+        sleep(MARKER_POLL).await;
+    }
+    let offsets_file = find_consumer_offset_file(&data_path)
+        .expect("transferred consumer offset file exists on node 2");
+    let bytes = std::fs::read(&offsets_file).expect("read transferred consumer offset");
+    assert_eq!(
+        u64::from_le_bytes(bytes.as_slice().try_into().expect("offset file is one u64")),
+        STORED_CONSUMER_OFFSET,
+        "the stored consumer offset must survive the transfer"
+    );
+
+    // Functional capstone: with node 1 down, quorum is node 0 + the
+    // transferred node 2, so one more produce+poll round-trip cannot commit
+    // unless node 2 PrepareOks from its transferred state.
+    harness.stop_node(1).expect("stop node 1");
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    loop {
+        if let Some(client) = connect_any(harness, &[2, 0]).await {
+            let mut extra = vec![IggyMessage::from_str("post-transfer").expect("message")];
+            if client
+                .send_messages(
+                    &Identifier::named(STREAM_NAME).expect("stream identifier"),
+                    &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+                    &Partitioning::partition_id(PARTITION_ID),
+                    &mut extra,
+                )
+                .await
+                .is_ok()
+                && poll_count(&client, MESSAGES_COUNT + 1).await == Ok(MESSAGES_COUNT + 1)
+            {
+                return;
+            }
+        }
+        assert!(
+            Instant::now() < deadline,
+            "the pre-wipe batch plus one post-transfer message never became pollable \
+             with only node 0 and the transferred node 2 alive"
+        );
+        sleep(MARKER_POLL).await;
+    }
+}
+
+#[iggy_harness(
+    cluster_nodes = 3,
+    server(
+        system.sharding.cpu_allocation = "0..1",
+        partition.evicted_ring_capacity = "64",
+        system.partition.messages_required_to_save = "1"
+    )
+)]
+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).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;
+}
+
+#[iggy_harness(
+    cluster_nodes = 3,
+    server(
+        system.sharding.cpu_allocation = "0..1",
+        partition.evicted_ring_capacity = "64",
+        system.partition.messages_required_to_save = "1",
+        system.segment.size = "8MiB"
+    )
+)]
+// The 8 MiB segment cap makes the 64 MiB bulky seed span several sealed
+// segments, so unlike the other specs (single-segment under the default 1 GiB
+// cap) this one installs from a MULTI-ARTIFACT manifest, one spill per artifact.
+// The installed segment count at the end is what asserts that. The re-armed
+// pull also runs the staged-segment reuse scan, but how much it can adopt
+// depends on how many artifacts completed before the kill, so nothing here
+// asserts on reuse.
+async fn given_transfer_peer_dies_when_stalled_should_abandon_and_recover_partition(
+    harness: &mut TestHarness,
+) {
+    let client = harness
+        .root_client_for_node(0)
+        .await
+        .expect("connect a root client to the node");
+    seed_topic(&client).await;
+    // Bulky payloads so the pull spans many 256 KiB chunks: the kill below
+    // must land while the transfer is provably in flight, and a small
+    // partition finishes inside the marker-poll latency, leaving the
+    // abandon path untested.
+    produce_bulky(&client, BULKY_MESSAGES_COUNT, BULKY_PAYLOAD_LEN).await;
+    sleep(Duration::from_secs(1)).await;
+    let _seed_client = client;
+
+    // Wipe node 2, wait until its rejoin CONVERTED to a transfer and the
+    // serving peer (the view-0 primary, node 0) proved it started serving
+    // the pull, then kill that peer mid-pull. Node 2 must not retry into
+    // the corpse forever: the stall budget abandons with a backed-off
+    // re-arm against the next peer, the survivors elect past node 0, and
+    // the transfer re-runs against the new primary (node 1).
+    harness
+        .restart_node_from_clean_slate(2)
+        .expect("clean-slate restart of node 2");
+    // Kill node 0 the moment node 2 CONVERTED: the transfer is then armed
+    // at node 0 but the 64 MiB pull cannot possibly finish inside the kill
+    // latency, so node 2 deterministically ends up stalling against a dead
+    // peer -- whether the descriptor made it out or not, both funnels land
+    // in the stall budget. (Gating on the serving marker instead raced the
+    // pull itself: a release-build pull finishes in a few hundred ms.)
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    while !harness.node(2).stdout_contains(CONVERSION_MARKER) {
+        assert!(
+            Instant::now() < deadline,
+            "node 2 never converted its refused repair floor to a transfer"
+        );
+        sleep(KILL_GATE_POLL).await;
+    }
+    // Baselines BEFORE the kill. Every marker check below counts occurrences
+    // against these instead of scanning the whole accumulated log: node 2 can
+    // have installed and node 1 can have fully served an earlier attempt while
+    // node 0 was still up, and a `contains` would call those the recovery.
+    let installs_before_kill = harness.node(2).stdout_occurrences(INSTALL_MARKER);
+    let served_before_kill = harness.node(1).stdout_occurrences(FULLY_SERVED_MARKER);
+    let abandons_before_kill = harness.node(2).stdout_occurrences(ABANDON_MARKER);
+    harness
+        .stop_node(0)
+        .expect("stop the serving peer (node 0)");
+
+    // The abandon is now deterministic: the pull was in flight against a
+    // peer that is gone, so the stall budget must exhaust.
+    await_new_marker(harness, 2, ABANDON_MARKER, abandons_before_kill).await;
+
+    // Recovery: the scheduled re-arm targets the surviving primary. No
+    // follow-up commit is asserted -- the cluster is quorum-marginal with
+    // one node down, and an unanswered read mid-election is not a verdict.
+    //
+    // Node 1 is named explicitly, not "any survivor": with node 0 dead it is
+    // the only replica left that can serve, so a marker from it is proof the
+    // re-arm found a new peer rather than proof of the pre-kill attempt.
+    await_new_marker(harness, 2, INSTALL_MARKER, installs_before_kill).await;
+    await_new_marker(harness, 1, FULLY_SERVED_MARKER, served_before_kill).await;
+
+    // The manifest the install consumed carried one artifact per sealed
+    // segment, and each was spilled and renamed separately: the seeded 64 MiB
+    // over an 8 MiB segment cap cannot land as one file.
+    let data_path = harness.node(2).data_path();
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    loop {
+        let installed = segment_log_count(&data_path);
+        if installed > 1 {
+            return;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "node 2 installed {installed} segment file(s); a 64 MiB transfer under an \
+             8 MiB segment cap must arrive as a multi-artifact manifest"
+        );
+        sleep(MARKER_POLL).await;
+    }
+}
+
+async fn connect_any(harness: &TestHarness, nodes: &[usize]) -> Option<IggyClient> {
+    for &node in nodes {
+        if let Ok(builder) = harness.node(node).tcp_client()
+            && let Ok(client) = builder.with_root_login().connect().await
+        {
+            return Some(client);
+        }
+    }
+    None
+}
+
+async fn seed_topic(client: &IggyClient) {
+    client
+        .create_stream(STREAM_NAME)
+        .await
+        .expect("create stream");
+    client
+        .create_topic(
+            &Identifier::named(STREAM_NAME).expect("stream identifier"),
+            TOPIC_NAME,
+            1,
+            CompressionAlgorithm::None,
+            None,
+            IggyExpiry::NeverExpire,
+            MaxTopicSize::ServerDefault,
+        )
+        .await
+        .expect("create topic with one partition");
+}
+
+async fn produce(client: &IggyClient, count: u32) {
+    // One message per send: each commit flushes (messages_required_to_save=1)
+    // and ring-evicts, which is what marches `repair_retained_from` forward.
+    for sequence in 0..count {
+        let mut messages =
+            vec![IggyMessage::from_str(&format!("message-{sequence}")).expect("message")];
+        client
+            .send_messages(
+                &Identifier::named(STREAM_NAME).expect("stream identifier"),
+                &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+                &Partitioning::partition_id(PARTITION_ID),
+                &mut messages,
+            )
+            .await
+            .expect("send message");
+    }
+}
+
+/// Like [`produce`], one commit per send, but with a payload of
+/// `payload_len` filler bytes so the on-disk segment grows fast.
+async fn produce_bulky(client: &IggyClient, count: u32, payload_len: usize) {
+    for sequence in 0..count {
+        let payload = format!("bulky-{sequence}-{}", "x".repeat(payload_len));
+        let mut messages = vec![IggyMessage::from_str(&payload).expect("message")];
+        client
+            .send_messages(
+                &Identifier::named(STREAM_NAME).expect("stream identifier"),
+                &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+                &Partitioning::partition_id(PARTITION_ID),
+                &mut messages,
+            )
+            .await
+            .expect("send bulky message");
+    }
+}
+
+async fn seed_partition(client: &IggyClient) {
+    seed_topic(client).await;
+    produce(client, MESSAGES_COUNT).await;
+    assert_eq!(
+        poll_count(client, MESSAGES_COUNT).await,
+        Ok(MESSAGES_COUNT),
+        "the seed batch must commit before the fault is injected"
+    );
+}
+
+async fn poll_count(client: &IggyClient, count: u32) -> Result<u32, IggyError> {
+    let polled = client
+        .poll_messages(
+            &Identifier::named(STREAM_NAME).expect("stream identifier"),
+            &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+            Some(PARTITION_ID),
+            &Consumer::default(),
+            &PollingStrategy::offset(0),
+            count,
+            false,
+        )
+        .await?;
+    #[allow(clippy::cast_possible_truncation)]
+    Ok(polled.messages.len() as u32)
+}
+
+/// Polls node `node`'s installed segment files until every payload in
+/// `expected` is present, in the order given.
+///
+/// The payloads are the oracle the markers are not: a short install is missing
+/// the tail, a torn one is missing a middle, and a reordered one fails the
+/// ascending-position check.
+async fn await_installed_payloads(harness: &TestHarness, node: usize, expected: &[String]) {
+    let data_path = harness.node(node).data_path();
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    loop {
+        if let Err(missing) = installed_payloads_complete(&data_path, expected) {
+            assert!(
+                Instant::now() < deadline,
+                "node {node}'s installed segments never held the whole produced batch: {missing}"
+            );
+            sleep(MARKER_POLL).await;
+            continue;
+        }
+        return;
+    }
+}
+
+/// `Ok(())` when every payload in `expected` appears in node-local segment
+/// bytes at a non-decreasing position, otherwise the first discrepancy.
+fn installed_payloads_complete(data_path: &Path, expected: &[String]) -> Result<(), String> {
+    let mut chain = Vec::new();
+    let mut paths = Vec::new();
+    let _ = walk(data_path, &mut |path| {
+        if is_segment_log(path) {
+            paths.push(path.to_path_buf());
+        }
+        false
+    });
+    // Segment files are named for their zero-padded base offset, so lexical
+    // order is offset order.
+    paths.sort();
+    for path in paths {
+        let Ok(bytes) = std::fs::read(&path) else {
+            return Err(format!("{} could not be read", path.display()));
+        };
+        chain.extend_from_slice(&bytes);
+    }
+    let mut searched_from = 0;
+    for payload in expected {
+        let found = chain[searched_from..]
+            .windows(payload.len())
+            .position(|window| window == payload.as_bytes())
+            // A bare find would match `message-1` inside `message-10`.
+            .map(|offset| searched_from + offset)
+            .filter(|start| {
+                chain
+                    .get(start + payload.len())
+                    .is_none_or(|byte| !byte.is_ascii_digit())
+            });
+        let Some(start) = found else {
+            return Err(format!(
+                "{payload:?} is absent from the {} installed bytes after position {searched_from}",
+                chain.len()
+            ));
+        };
+        searched_from = start;
+    }
+    Ok(())
+}
+
+/// [`await_marker`], but satisfied only by an occurrence beyond `baseline` -
+/// the whole-log scan cannot distinguish a line from before the fault.
+async fn await_new_marker(harness: &TestHarness, node: usize, marker: &str, baseline: usize) {
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    while harness.node(node).stdout_occurrences(marker) <= baseline {
+        assert!(
+            Instant::now() < deadline,
+            "node {node} never logged {marker:?} again after the fault \
+             (still at the pre-fault count of {baseline}) within {TRANSFER_BUDGET:?}"
+        );
+        sleep(MARKER_POLL).await;
+    }
+}
+
+/// [`await_marker`] over a set of nodes: satisfied by the first one to log it.
+async fn await_marker_any(harness: &TestHarness, nodes: &[usize], marker: &str) {
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    while !nodes
+        .iter()
+        .any(|&node| harness.node(node).stdout_contains(marker))
+    {
+        assert!(
+            Instant::now() < deadline,
+            "none of {nodes:?} logged {marker:?} within {TRANSFER_BUDGET:?}"
+        );
+        sleep(MARKER_POLL).await;
+    }
+}
+
+async fn await_marker(harness: &TestHarness, node: usize, marker: &str) {
+    let deadline = Instant::now() + TRANSFER_BUDGET;
+    while !harness.node(node).stdout_contains(marker) {
+        assert!(
+            Instant::now() < deadline,
+            "node {node} never logged {marker:?} within {TRANSFER_BUDGET:?}"
+        );
+        sleep(MARKER_POLL).await;
+    }
+}
+
+/// Total transferred segment payload under `root`. Walked rather than
+/// path-derived so the test does not hard-code the
+/// `streams/<s>/topics/<t>/partitions/<p>` layout.
+///
+/// Matches the segment file NAME shape, not the `.log` extension alone: the
+/// server's own text log sits under the same data root (~15 KiB on a local run,
+/// about a third of the floor this feeds), and the child inherits `RUST_LOG`, so
+/// an extension-only sum let a debug run satisfy the caller with zero
+/// transferred segment bytes.
+fn total_partition_log_bytes(root: &Path) -> u64 {
+    let mut total = 0;
+    let _ = walk(root, &mut |path| {
+        if is_segment_log(path)
+            && let Ok(metadata) = std::fs::metadata(path)
+        {
+            total += metadata.len();
+        }
+        false
+    });
+    total
+}
+
+/// Number of segment files under `root`; more than one proves the install came
+/// from a multi-artifact manifest, each artifact spilled and renamed in turn.
+fn segment_log_count(root: &Path) -> usize {
+    let mut count = 0;
+    let _ = walk(root, &mut |path| {
+        if is_segment_log(path) {
+            count += 1;
+        }
+        false
+    });
+    count
+}
+
+/// A segment `.log`, named for its 20-digit zero-padded base offset (see
+/// `partitions::state_transfer`'s path builders).
+fn is_segment_log(path: &Path) -> bool {
+    path.extension().is_some_and(|extension| extension == "log")
+        && path
+            .file_stem()
+            .and_then(|stem| stem.to_str())
+            .is_some_and(|stem| stem.len() == 20 && stem.bytes().all(|byte| byte.is_ascii_digit()))
+}
+
+fn find_consumer_offset_file(root: &Path) -> Option<PathBuf> {
+    walk(root, &mut |path| {
+        path.parent()
+            .and_then(Path::file_name)
+            .is_some_and(|name| name == "consumers")
+            && std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == 8)
+    })
+}
+
+fn walk(root: &Path, matches: &mut dyn FnMut(&Path) -> bool) -> Option<PathBuf> {
+    let mut pending = vec![root.to_path_buf()];
+    while let Some(dir) = pending.pop() {
+        if dir.file_name().is_some_and(|name| name == "metadata") {
+            continue;
+        }
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if path.is_dir() {
+                pending.push(path);
+            } else if matches(&path) {
+                return Some(path);
+            }
+        }
+    }
+    None
+}
diff --git a/core/integration/tests/data_integrity/verify_after_server_restart.rs b/core/integration/tests/data_integrity/verify_after_server_restart.rs
index 9d75af2..71a6164 100644
--- a/core/integration/tests/data_integrity/verify_after_server_restart.rs
+++ b/core/integration/tests/data_integrity/verify_after_server_restart.rs
@@ -16,65 +16,58 @@
 // under the License.
 
 use iggy::prelude::*;
-#[cfg(not(feature = "vsr"))]
 use integration::bench_utils::run_bench_and_wait_for_finish;
 use integration::harness::{TestHarness, TestServerConfig};
 use serial_test::parallel;
-#[cfg(not(feature = "vsr"))]
 use std::{collections::HashMap, str::FromStr};
-#[cfg(not(feature = "vsr"))]
 use test_case::test_matrix;
 
-#[cfg(not(feature = "vsr"))]
 fn cache_open_segment() -> &'static str {
     "open_segment"
 }
 
-#[cfg(not(feature = "vsr"))]
 fn cache_all() -> &'static str {
     "all"
 }
 
-#[cfg(not(feature = "vsr"))]
 fn cache_none() -> &'static str {
     "none"
 }
 
-#[cfg(not(feature = "vsr"))]
 fn build_server_config(cache_setting: &str) -> TestServerConfig {
     let mut extra_envs = HashMap::new();
     extra_envs.insert(
         "IGGY_SYSTEM_SEGMENT_CACHE_INDEXES".to_string(),
         cache_setting.to_string(),
     );
-    // Under vsr this config must ALSO set the eager-flush envs
-    // (`IGGY_SYSTEM_PARTITION_MESSAGES_REQUIRED_TO_SAVE=1`,
-    // `IGGY_SYSTEM_PARTITION_ENFORCE_FSYNC=true`, see
-    // `encryption_scenario::build_server_config`): server-ng serves no
-    // `flush_unsaved_buffer` (the command is slated for removal), so the
-    // flush calls below must be cfg'd out and replaced by eager persistence
-    // when the fill test is ungated.
+    // server-ng flushes on the journal thresholds (no flush primitive), so
+    // force every committed batch straight to disk: the restart asserts
+    // below need everything durable, and the explicit flush calls are
+    // cfg'd out under vsr (`flush_unsaved_buffer` answers
+    // FeatureUnavailable there and is slated for removal). Legacy keeps its
+    // shipped buffered defaults; the flush loops below are its barrier.
+    #[cfg(feature = "vsr")]
+    extra_envs.insert(
+        "IGGY_SYSTEM_PARTITION_MESSAGES_REQUIRED_TO_SAVE".to_string(),
+        "1".to_string(),
+    );
+    #[cfg(feature = "vsr")]
+    extra_envs.insert(
+        "IGGY_SYSTEM_PARTITION_ENFORCE_FSYNC".to_string(),
+        "true".to_string(),
+    );
     TestServerConfig::builder().extra_envs(extra_envs).build()
 }
 
 // TODO(numminex) - Move the message generation method from benchmark run to a special method.
 //
-// vsr-gated: requires PARTITION-plane state transfer. The 5 MB bench fill is
-// thousands of ops while the partition journal's evicted ring retains only
-// the last 4096, so a restarted replica's rejoin window exceeds what journal
-// repair can serve. The commit floor lets recovered segments stand in for
-// the evicted prefix, but the sub-floor stats/offset seeding this test
-// asserts (exact messages_count / size_bytes across the restart) needs the
-// partition-plane transfer to carry them; the metadata-plane transfer's
-// snapshot stats cannot be stitched to the partition repair window without
-// double-counting.
-//
-// NOT gated on `flush_unsaved_buffer`: this test only uses flush as a
-// durability barrier, and under vsr the calls are replaced by the eager-flush
-// server envs (see `build_server_config`). The other blocker to clear when
-// ungating is the bench harness (`run_bench_and_wait_for_finish`), which is
-// out of the vsr test pass today.
-#[cfg(not(feature = "vsr"))]
+// Under vsr this runs against a 3-node cluster and needs two adaptations:
+// the durability barrier is the eager-flush envs in `build_server_config`
+// (`flush_unsaved_buffer` answers FeatureUnavailable there, so the explicit
+// flush loops are cfg'd out), and `iggy-bench` must be built with
+// `--features vsr` because the SDK framing is chosen at compile time. A
+// default-features bench binary never completes a frame against server-ng
+// and the run trips the bench timeout in `run_bench_and_wait_for_finish`.
 #[test_matrix(
     [cache_all(), cache_open_segment(), cache_none()]
 )]
@@ -111,6 +104,9 @@
     let client = harness.tcp_root_client().await.unwrap();
 
     let topic_id = Identifier::numeric(0).unwrap();
+    // Durability barrier on the legacy server only; server-ng persists
+    // eagerly via the config envs and answers FeatureUnavailable here.
+    #[cfg(not(feature = "vsr"))]
     for i in 0..7 {
         let stream_id = Identifier::numeric(i).unwrap();
         client
@@ -224,14 +220,17 @@
     // Connect and login to server
     let client = harness.tcp_root_client().await.unwrap();
 
-    // Flush unsaved buffer
-    let topic_id = Identifier::numeric(0).unwrap();
-    for i in 0..7 {
-        let stream_id = Identifier::numeric(i).unwrap();
-        client
-            .flush_unsaved_buffer(&stream_id, &topic_id, 0, true)
-            .await
-            .unwrap();
+    // Durability barrier on the legacy server only (see the first loop).
+    #[cfg(not(feature = "vsr"))]
+    {
+        let topic_id = Identifier::numeric(0).unwrap();
+        for i in 0..7 {
+            let stream_id = Identifier::numeric(i).unwrap();
+            client
+                .flush_unsaved_buffer(&stream_id, &topic_id, 0, true)
+                .await
+                .unwrap();
+        }
     }
 
     // Save stats from the second server (should have double the data)
diff --git a/core/integration/tests/sdk/hello_world.rs b/core/integration/tests/sdk/hello_world.rs
index 7d6db41..c7ad212 100644
--- a/core/integration/tests/sdk/hello_world.rs
+++ b/core/integration/tests/sdk/hello_world.rs
@@ -18,7 +18,6 @@
 use iggy::prelude::*;
 use integration::iggy_harness;
 
-#[cfg(not(feature = "vsr"))]
 #[iggy_harness]
 async fn hello_world(harness: &TestHarness) {
     let client = harness.root_client().await.unwrap();
diff --git a/core/integration/tests/server/cluster_view_durability_vsr.rs b/core/integration/tests/server/cluster_view_durability_vsr.rs
index d69d673..a2cc801 100644
--- a/core/integration/tests/server/cluster_view_durability_vsr.rs
+++ b/core/integration/tests/server/cluster_view_durability_vsr.rs
@@ -73,7 +73,10 @@
     // the primary of view 0 is replica 0). Commit a stream through it, so the
     // metadata group has committed state to recover later and exactly one leader
     // is visible.
-    let client = connect(harness, 0).await;
+    let client = harness
+        .root_client_for_node(0)
+        .await
+        .expect("connect a root client to the node");
     client
         .create_stream(STREAM_NAME)
         .await
@@ -155,18 +158,6 @@
     );
 }
 
-/// Connect a root-authenticated TCP client to a specific node.
-async fn connect(harness: &TestHarness, node: usize) -> IggyClient {
-    harness
-        .node(node)
-        .tcp_client()
-        .expect("tcp client builder")
-        .with_root_login()
-        .connect()
-        .await
-        .unwrap_or_else(|e| panic!("connect to node {node}: {e}"))
-}
-
 /// Connect to the first node in `nodes` that accepts a connection, `None` when none
 /// do (mid-election, or a node still restarting).
 async fn connect_any(harness: &TestHarness, nodes: &[usize]) -> Option<IggyClient> {
diff --git a/core/integration/tests/server/mod.rs b/core/integration/tests/server/mod.rs
index 8c3f6e0..b93b005 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -48,6 +48,10 @@
 // across a replica restart.
 #[cfg(feature = "vsr")]
 mod cluster_view_durability_vsr;
+// A partition view change must persist the advanced view in that group's own
+// superblock and recover it from disk across a replica restart.
+#[cfg(feature = "vsr")]
+mod partition_view_durability_vsr;
 // 80-case race matrix with hardcoded HTTP variants (test_matrix bypasses
 // the harness transport filter).
 mod concurrent_addition;
diff --git a/core/integration/tests/server/partition_view_durability_vsr.rs b/core/integration/tests/server/partition_view_durability_vsr.rs
new file mode 100644
index 0000000..0071473
--- /dev/null
+++ b/core/integration/tests/server/partition_view_durability_vsr.rs
@@ -0,0 +1,330 @@
+// 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.
+
+//! Partition-plane view durability across a real view change and process restarts,
+//! over the per-partition `PingPongSuperblock` path.
+//!
+//! The partition-plane sibling of `cluster_view_durability_vsr`: every partition
+//! consensus group now records its `(view, log_view)` in a superblock inside its own
+//! partition directory, and every view-scoped send for that group is gated on the
+//! record being durable. This drives a genuine partition view change, crashing the
+//! view-0 primary so the survivors elect a new one, then proves the elected view is
+//! durable on the PARTITION group's own record: it lands in a survivor's on-disk
+//! `VsrState` as `view` / `log_view >= 1` and survives that survivor's own process
+//! restart. Messages committed before the crash still poll back at the end, so the
+//! partition data path rides along.
+//!
+//! vsr-only: partition consensus groups and their superblocks exist only on
+//! `iggy-server-ng`.
+
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+
+use consensus::VsrState;
+use iggy::prelude::*;
+use integration::harness::{TestBinary, TestHarness};
+use integration::iggy_harness;
+use journal::superblock::{SLOT_FILE_NAMES, SuperblockContents, decode_slots};
+use tokio::time::sleep;
+
+const STREAM_NAME: &str = "partition-view-durability-stream";
+const TOPIC_NAME: &str = "partition-view-durability-topic";
+/// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+const MESSAGES_COUNT: u32 = 10;
+
+/// A view change plus a rejoin is not instant (primary timeout, SVC, DVC, StartView,
+/// then the restarted node's probe and repair), and CI runners are slow. 60s bounds
+/// the worst case without hanging the suite.
+const CONVERGE_TIMEOUT: Duration = Duration::from_secs(60);
+/// Boot line reporting the `(view, log_view)` a partition group restored from its
+/// superblock: the recovered replica's own account of what it read back.
+const RESTORED_VIEW_MARKER: &str = "restored partition view from its superblock";
+const POLL_INTERVAL: Duration = Duration::from_millis(250);
+
+#[iggy_harness(cluster_nodes = 3, server(system.sharding.cpu_allocation = "0..1"))]
+async fn given_advanced_partition_view_when_survivor_restarts_should_recover_view_from_superblock(
+    harness: &mut TestHarness,
+) {
+    // Baseline: node 0 is the view-0 primary of every group (its replica id is 0).
+    // Commit a topic with ONE partition and a batch of messages through it, so
+    // exactly one partition consensus group exists and holds committed state.
+    let client = harness
+        .root_client_for_node(0)
+        .await
+        .expect("connect a root client to the node");
+    client
+        .create_stream(STREAM_NAME)
+        .await
+        .expect("create stream on the view-0 primary");
+    let stream_id = Identifier::named(STREAM_NAME).expect("stream identifier");
+    client
+        .create_topic(
+            &stream_id,
+            TOPIC_NAME,
+            1,
+            CompressionAlgorithm::None,
+            None,
+            IggyExpiry::NeverExpire,
+            MaxTopicSize::ServerDefault,
+        )
+        .await
+        .expect("create topic with one partition");
+    let topic_id = Identifier::named(TOPIC_NAME).expect("topic identifier");
+    let mut messages: Vec<IggyMessage> = (0..MESSAGES_COUNT)
+        .map(|sequence| IggyMessage::from_str(&format!("message-{sequence}")).expect("message"))
+        .collect();
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(PARTITION_ID),
+            &mut messages,
+        )
+        .await
+        .expect("send messages through the view-0 partition primary");
+    assert_eq!(
+        poll_all(&client).await.expect("poll on the view-0 primary"),
+        MESSAGES_COUNT,
+        "the pre-crash batch must commit before the view change"
+    );
+    drop(client);
+    // Let the committed prepares settle on the survivors before the primary dies.
+    sleep(Duration::from_secs(1)).await;
+
+    // Force a partition view change: crash the view-0 primary. The survivors'
+    // primary timeout trips, they run SVC/DVC for the partition group and elect a
+    // new primary at view >= 1, each persisting the advanced view through the
+    // per-partition superblock gate before it casts a view-scoped vote.
+    harness
+        .node_mut(0)
+        .stop()
+        .expect("crash the view-0 primary (node 0)");
+
+    // Wait for the advanced PARTITION view to become durable on a survivor's own
+    // disk. Node 2 has to take part in the election (quorum needs both survivors),
+    // so its partition superblock must reach log_view >= 1. Reading the durable
+    // record directly is the race-free signal, and `log_view >= 1` (not bare
+    // `view >= 1`) is the settled form: the gate persists `view` the moment a
+    // replica advances it to vote, before it adopts the new primary's log.
+    let view_before = wait_for_advanced_partition_view(harness, 2).await;
+
+    // Bring the crashed primary back so it rejoins from its own disk: partition
+    // recovery opens the superblock, restores its recorded view, and the boot
+    // probe adopts the cluster's advanced view from there.
+    harness
+        .node_mut(0)
+        .start()
+        .expect("restart the crashed primary (node 0)");
+    let rejoined = wait_for_advanced_partition_view(harness, 0).await;
+    assert!(
+        rejoined.view >= view_before.view,
+        "the rejoined replica must adopt the advanced partition view (>= {}), not \
+         resume the stale view 0, got {rejoined:?}",
+        view_before.view
+    );
+    // Let the 3/3 mesh settle so the survivor restart below keeps a live quorum.
+    sleep(Duration::from_secs(1)).await;
+
+    // Restart the survivor that advanced its view. It drops all in-memory
+    // consensus state and must recover the advanced partition view from its own
+    // superblock, not reset to a fresh 0.
+    harness
+        .node_mut(2)
+        .stop()
+        .expect("stop the survivor (node 2)");
+    harness
+        .node_mut(2)
+        .start()
+        .expect("restart the survivor (node 2)");
+
+    // NODE 2 specifically must serve the pre-crash batch: the helper returns on
+    // the first node that answers, and nodes 0 and 1 were not restarted, so
+    // asking the whole set would never contact the node under test.
+    wait_until_partition_serves(harness, &[2]).await;
+
+    // The recovered replica's own BEHAVIOR, not the file's contents: the
+    // superblock is read only at boot and written only when the persist gate
+    // fires, so re-reading node 2's own record here would return the
+    // pre-restart bytes even if recovery were broken and node 2 came back at
+    // view 0. Node 2's boot line reports the view it actually restored.
+    //
+    // The expectation is read from NODE 1, which was never restarted: comparing
+    // node 2's restored view against node 2's own file would be `x >= x` and
+    // would pass with the restore path deleted.
+    let expected = read_partition_superblock_state(&harness.node(1).data_path())
+        .expect("the survivor that never restarted holds the cluster's recorded view");
+    let restored = restored_partition_view(harness, 2)
+        .expect("node 2 must log the partition view it restored from its superblock");
+    assert!(
+        restored.0 >= expected.view && restored.1 >= expected.log_view,
+        "node 2 restored (view {}, log_view {}) but the untouched survivor's record is \
+         {expected:?}; a replica that resumes below the view it already acted in can \
+         re-enter it",
+        restored.0,
+        restored.1
+    );
+}
+
+/// `(view, log_view)` the node reports restoring at boot, parsed out of the
+/// structured fields of its restore line. `None` while the line is absent.
+///
+/// Reads the node's OWN log file as well as the harness stdout capture: under
+/// `IGGY_TEST_VERBOSE` the child inherits stdout and the capture is empty, and
+/// an oracle that silently skips in the mode someone debugging this would run
+/// is not an oracle.
+fn restored_partition_view(harness: &TestHarness, node: usize) -> Option<(u32, u32)> {
+    let mut log = harness.node(node).stdout_plain();
+    let own_logs = harness.node(node).data_path().join("logs");
+    if let Ok(entries) = std::fs::read_dir(own_logs) {
+        for entry in entries.flatten() {
+            if let Ok(contents) = std::fs::read_to_string(entry.path()) {
+                log.push_str(&contents);
+            }
+        }
+    }
+    log.lines()
+        .filter(|line| line.contains(RESTORED_VIEW_MARKER))
+        .filter_map(|line| {
+            // Leading space so the `view` key cannot match inside `log_view`.
+            let view = field(line, " view=")?;
+            let log_view = field(line, " log_view=")?;
+            Some((view, log_view))
+        })
+        .max()
+}
+
+/// Value of a space-prefixed `key=<u32>` tracing field.
+fn field(line: &str, key: &str) -> Option<u32> {
+    let start = line.find(key)? + key.len();
+    line[start..]
+        .split(|character: char| !character.is_ascii_digit())
+        .next()
+        .and_then(|digits| digits.parse().ok())
+}
+
+/// Poll the whole pre-crash batch from offset 0; `Ok(count)` of messages seen.
+async fn poll_all(client: &IggyClient) -> Result<u32, IggyError> {
+    let polled = client
+        .poll_messages(
+            &Identifier::named(STREAM_NAME).expect("stream identifier"),
+            &Identifier::named(TOPIC_NAME).expect("topic identifier"),
+            Some(PARTITION_ID),
+            &Consumer::default(),
+            &PollingStrategy::offset(0),
+            MESSAGES_COUNT,
+            false,
+        )
+        .await?;
+    Ok(polled.messages.len() as u32)
+}
+
+/// Find the single partition directory's superblock record on `node`, `None`
+/// while no record exists yet (a partition group that never left view 0 has an
+/// empty superblock, and its slot files may not exist at all).
+///
+/// Located by walking the node's data directory for `superblock.a`, skipping the
+/// metadata plane's record, so the test does not hard-code the
+/// `streams/<s>/topics/<t>/partitions/<p>` layout. Exactly one partition group
+/// exists in this test.
+///
+/// # Panics
+/// If a slot holds bytes that do not verify: no step here corrupts one, so that
+/// is a real durability bug, and reading it as "not written yet" would time the
+/// pollers out on a misleading message.
+fn read_partition_superblock_state(data_path: &Path) -> Option<VsrState> {
+    let dir = find_partition_superblock_dir(data_path)?;
+    let slot_a = std::fs::read(dir.join(SLOT_FILE_NAMES[0])).ok();
+    let slot_b = std::fs::read(dir.join(SLOT_FILE_NAMES[1])).ok();
+    match decode_slots(slot_a.as_deref(), slot_b.as_deref()) {
+        SuperblockContents::Present(payload) => VsrState::try_from(payload.as_slice()).ok(),
+        SuperblockContents::Empty => None,
+        SuperblockContents::Unreadable { version } => panic!(
+            "partition superblock at {} is unreadable (version {version:?}); \
+             no test step corrupts it",
+            dir.display()
+        ),
+    }
+}
+
+fn find_partition_superblock_dir(root: &Path) -> Option<PathBuf> {
+    let mut pending = vec![root.to_path_buf()];
+    while let Some(dir) = pending.pop() {
+        // The metadata plane keeps its own superblock under `<data>/metadata`;
+        // only partition records are under test here.
+        if dir.file_name().is_some_and(|name| name == "metadata") {
+            continue;
+        }
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if path.is_dir() {
+                pending.push(path);
+            } else if path
+                .file_name()
+                .is_some_and(|name| name == SLOT_FILE_NAMES[0])
+            {
+                return Some(dir);
+            }
+        }
+    }
+    None
+}
+
+/// Poll `node`'s partition superblock until it holds a settled advanced view
+/// (`log_view >= 1`, which implies `view >= 1`). Panics on timeout.
+async fn wait_for_advanced_partition_view(harness: &TestHarness, node: usize) -> VsrState {
+    let data_path = harness.node(node).data_path();
+    let deadline = Instant::now() + CONVERGE_TIMEOUT;
+    loop {
+        if let Some(state) = read_partition_superblock_state(&data_path)
+            && state.log_view >= 1
+        {
+            return state;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "node {node} did not persist a settled advanced partition view \
+             (log_view >= 1) within {CONVERGE_TIMEOUT:?}"
+        );
+        sleep(POLL_INTERVAL).await;
+    }
+}
+
+/// Poll until some node serves the whole pre-crash batch. Panics on timeout.
+async fn wait_until_partition_serves(harness: &TestHarness, nodes: &[usize]) {
+    let deadline = Instant::now() + CONVERGE_TIMEOUT;
+    loop {
+        for &node in nodes {
+            if let Ok(builder) = harness.node(node).tcp_client()
+                && let Ok(client) = builder.with_root_login().connect().await
+                && poll_all(&client).await == Ok(MESSAGES_COUNT)
+            {
+                return;
+            }
+        }
+        assert!(
+            Instant::now() < deadline,
+            "no node served the {MESSAGES_COUNT} pre-crash messages within \
+             {CONVERGE_TIMEOUT:?}"
+        );
+        sleep(POLL_INTERVAL).await;
+    }
+}
diff --git a/core/integration/tests/server/scenarios/purge_delete_scenario.rs b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
index 5abb681..8c65c3b 100644
--- a/core/integration/tests/server/scenarios/purge_delete_scenario.rs
+++ b/core/integration/tests/server/scenarios/purge_delete_scenario.rs
@@ -975,6 +975,11 @@
         .purge_topic(&stream_ident, &topic_ident)
         .await
         .unwrap();
+    // Sampled BEFORE the restart: if the purge already drained the offset
+    // directories, a restart may not resurrect them, and the assert below stays
+    // instant even in the restart cells. Only the kill-lands-mid-purge case
+    // earns a tolerance.
+    let drained_before_restart = is_dir_empty(&consumers_dir) && is_dir_empty(&groups_dir);
     maybe_restart(harness, restart_server).await;
 
     // server-ng purges asynchronously (metadata commit -> reconciler -> pump);
@@ -984,45 +989,57 @@
     #[cfg(feature = "vsr")]
     await_segment_layout(&partition_path, &[0]).await;
 
-    // --- Verify consumer offsets cleared ---
-    let consumer_offset = client
-        .get_consumer_offset(&consumer, &stream_ident, &topic_ident, Some(PARTITION_ID))
-        .await
-        .unwrap();
-    assert!(
-        consumer_offset.is_none(),
-        "Consumer offset must be cleared after purge"
-    );
-
-    let group_offset = client
-        .get_consumer_offset(
-            &group_consumer_ref,
-            &stream_ident,
-            &topic_ident,
-            Some(PARTITION_ID),
-        )
-        .await
-        .unwrap();
-    assert!(
-        group_offset.is_none(),
-        "Consumer group offset must be cleared after purge"
-    );
-
-    // --- Verify offset files deleted from disk ---
-    let consumer_files: Vec<_> = read_dir(&consumers_dir)
-        .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
-        .unwrap_or_default();
-    let group_files: Vec<_> = read_dir(&groups_dir)
-        .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
-        .unwrap_or_default();
-    assert!(
-        consumer_files.is_empty(),
-        "Consumer offset files must be deleted after purge, found: {consumer_files:?}"
-    );
-    assert!(
-        group_files.is_empty(),
-        "Consumer group offset files must be deleted after purge, found: {group_files:?}"
-    );
+    // --- Verify consumer offsets cleared (memory + disk) ---
+    // ZERO tolerance everywhere except one cell: vsr + restart where the kill
+    // landed mid-purge. There boot plants the [0] layout itself (fencing a torn
+    // chain, or recovering an already-drained directory) with the offset files
+    // still present, so the layout gate above is satisfied BEFORE the
+    // reconciler's re-purge clears them (the applied generation is not
+    // persisted, so a restart re-purges). Everywhere else the pump clears
+    // offsets and files in the SAME frame that plants the layout, and a poll
+    // would hide a regression that clears them one frame late. Kept short --
+    // a client-visible stale offset after purge-then-restart is a real
+    // (bounded) window, not something to paper over with a long tolerance.
+    let poll_window = if cfg!(feature = "vsr") && restart_server && !drained_before_restart {
+        std::time::Duration::from_secs(2)
+    } else {
+        std::time::Duration::ZERO
+    };
+    let offsets_deadline = std::time::Instant::now() + poll_window;
+    loop {
+        let consumer_offset = client
+            .get_consumer_offset(&consumer, &stream_ident, &topic_ident, Some(PARTITION_ID))
+            .await
+            .unwrap();
+        let group_offset = client
+            .get_consumer_offset(
+                &group_consumer_ref,
+                &stream_ident,
+                &topic_ident,
+                Some(PARTITION_ID),
+            )
+            .await
+            .unwrap();
+        let consumer_files: Vec<_> = read_dir(&consumers_dir)
+            .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
+            .unwrap_or_default();
+        let group_files: Vec<_> = read_dir(&groups_dir)
+            .map(|e| e.filter_map(|e| e.ok().map(|e| e.file_name())).collect())
+            .unwrap_or_default();
+        if consumer_offset.is_none()
+            && group_offset.is_none()
+            && consumer_files.is_empty()
+            && group_files.is_empty()
+        {
+            break;
+        }
+        assert!(
+            std::time::Instant::now() < offsets_deadline,
+            "consumer offsets must be cleared after purge: consumer={consumer_offset:?} \
+             group={group_offset:?} consumer_files={consumer_files:?} group_files={group_files:?}"
+        );
+        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
+    }
 
     // --- Verify partition reset: single empty segment at offset 0 ---
     assert_fresh_empty_partition(&partition_path);
diff --git a/core/journal/src/lib.rs b/core/journal/src/lib.rs
index 1159573..9de342b 100644
--- a/core/journal/src/lib.rs
+++ b/core/journal/src/lib.rs
@@ -20,6 +20,7 @@
 use std::rc::Rc;
 
 pub mod file_storage;
+pub mod local_gate;
 pub mod prepare_journal;
 pub mod superblock;
 
diff --git a/core/journal/src/local_gate.rs b/core/journal/src/local_gate.rs
new file mode 100644
index 0000000..ca9998f
--- /dev/null
+++ b/core/journal/src/local_gate.rs
@@ -0,0 +1,207 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Single-shard async gate for critical sections that hold across an `.await`.
+//!
+//! Many futures can drive the same shard-local resource concurrently (a pump
+//! loop, detached per-client tasks, repair drivers). This gate serializes them
+//! without atomics: the shard is never `Sync`, so a `Cell` flag provides the
+//! exclusion a `tokio::sync::Mutex` would buy with an atomic RMW per acquire.
+//!
+//! Not a general lock: single-threaded (`Cell`/`RefCell`, never `Sync`),
+//! release wakes every waiter and poll order re-races (arrival-order FIFO
+//! under `futures::join!`-style drivers), cancel-safe (dropping the guard
+//! releases; dropping a waiter leaves only a stale waker). Non-reentrant: a
+//! holder that re-acquires deadlocks itself.
+
+use std::cell::{Cell, RefCell};
+
+/// See the module docs. Callers hold the returned guard across the awaited
+/// critical section; dropping it releases the gate and wakes every waiter.
+///
+/// Its exclusion is load-bearing in RELEASE, not only under
+/// `debug_assertions`: this gate is the only enforcement of the superblock
+/// single-writer contract there. The `WritingGuard` tripwire is debug-only,
+/// `PingPongSuperblock::write` takes `&self`, and two overlapping writers
+/// collide on one fixed `.tmp` path and tear a slot while both return `Ok`.
+pub struct LocalGate {
+    busy: Cell<bool>,
+    waiters: RefCell<Vec<std::task::Waker>>,
+}
+
+impl LocalGate {
+    #[must_use]
+    pub const fn new() -> Self {
+        Self {
+            busy: Cell::new(false),
+            waiters: RefCell::new(Vec::new()),
+        }
+    }
+
+    // A manual `Future` impl does not inherit the async-fn unused lint, so
+    // without these `gate.acquire();` and `gate.acquire().await;` both
+    // compile clean as no-op exclusion (the guard drops at the semicolon).
+    #[must_use = "acquire does nothing until awaited"]
+    pub const fn acquire(&self) -> LocalGateAcquire<'_> {
+        LocalGateAcquire { gate: self }
+    }
+}
+
+impl Default for LocalGate {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[must_use = "the acquire future must be awaited to take the gate"]
+pub struct LocalGateAcquire<'a> {
+    gate: &'a LocalGate,
+}
+
+impl<'a> std::future::Future for LocalGateAcquire<'a> {
+    type Output = LocalGateGuard<'a>;
+
+    fn poll(
+        self: std::pin::Pin<&mut Self>,
+        cx: &mut std::task::Context<'_>,
+    ) -> std::task::Poll<Self::Output> {
+        if self.gate.busy.get() {
+            // Deduped, not appended: a waiter driven by a multi-source driver
+            // (`select!`, `join!`) is re-polled on every unrelated wake, and
+            // release wakes the whole list, so a bare push makes draining n
+            // contenders O(n^2) waker clones.
+            let mut waiters = self.gate.waiters.borrow_mut();
+            if !waiters.iter().any(|waiter| waiter.will_wake(cx.waker())) {
+                waiters.push(cx.waker().clone());
+            }
+            std::task::Poll::Pending
+        } else {
+            self.gate.busy.set(true);
+            std::task::Poll::Ready(LocalGateGuard { gate: self.gate })
+        }
+    }
+}
+
+#[must_use = "dropping the guard immediately releases the gate"]
+pub struct LocalGateGuard<'a> {
+    gate: &'a LocalGate,
+}
+
+impl Drop for LocalGateGuard<'_> {
+    fn drop(&mut self) {
+        self.gate.busy.set(false);
+        // Move the waiters out before waking: `wake()` only schedules under
+        // compio today, but a waker that ever polled a waiter inline would
+        // re-enter `acquire`'s `waiters.borrow_mut()` and panic the RefCell.
+        let waiters = std::mem::take(&mut *self.gate.waiters.borrow_mut());
+        for waker in waiters {
+            waker.wake();
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use futures::FutureExt;
+    use futures::future::join;
+    use std::rc::Rc;
+
+    /// Two writers queued on the same gate run one after the other, never
+    /// interleaved -- the property a torn superblock slot depends on.
+    #[compio::test]
+    async fn given_two_waiters_when_acquiring_should_serialize() {
+        let gate = LocalGate::new();
+        let gate = &gate;
+        let log: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
+
+        let first = {
+            let log = Rc::clone(&log);
+            async move {
+                let guard = gate.acquire().await;
+                log.borrow_mut().push("first:enter");
+                // A yield inside the critical section: without exclusion the
+                // second writer would slot in right here.
+                compio::time::sleep(std::time::Duration::from_millis(1)).await;
+                log.borrow_mut().push("first:exit");
+                drop(guard);
+            }
+        };
+        let second = {
+            let log = Rc::clone(&log);
+            async move {
+                let guard = gate.acquire().await;
+                log.borrow_mut().push("second:enter");
+                compio::time::sleep(std::time::Duration::from_millis(1)).await;
+                log.borrow_mut().push("second:exit");
+                drop(guard);
+            }
+        };
+        join(first, second).await;
+
+        let log = log.borrow();
+        assert_eq!(log.len(), 4, "both sections ran: {log:?}");
+        let first_exit = log.iter().position(|entry| *entry == "first:exit").unwrap();
+        let second_enter = log
+            .iter()
+            .position(|entry| *entry == "second:enter")
+            .unwrap();
+        assert!(
+            first_exit < second_enter,
+            "sections must not interleave: {log:?}"
+        );
+    }
+
+    /// Dropping the guard wakes a queued waiter, so the gate does not wedge
+    /// once contended.
+    #[compio::test]
+    async fn given_queued_waiter_when_guard_drops_should_wake_it() {
+        let gate = LocalGate::new();
+        let held = gate.acquire().await;
+
+        let mut waiter = Box::pin(gate.acquire());
+        assert!(
+            waiter.as_mut().now_or_never().is_none(),
+            "the gate is held, so the waiter must park"
+        );
+
+        drop(held);
+        assert!(
+            waiter.now_or_never().is_some(),
+            "dropping the guard must wake the queued waiter"
+        );
+    }
+
+    /// A waiter that goes away leaves only a stale waker behind: the next
+    /// acquirer still gets the gate. Cancel safety is load-bearing here, since
+    /// every acquire site sits in a future a shard can drop.
+    #[compio::test]
+    async fn given_dropped_waiter_when_guard_releases_should_not_wedge() {
+        let gate = LocalGate::new();
+        let held = gate.acquire().await;
+
+        let mut abandoned = Box::pin(gate.acquire());
+        assert!(abandoned.as_mut().now_or_never().is_none());
+        drop(abandoned);
+
+        drop(held);
+        assert!(
+            gate.acquire().now_or_never().is_some(),
+            "a dropped waiter must not keep the gate busy"
+        );
+    }
+}
diff --git a/core/journal/src/superblock.rs b/core/journal/src/superblock.rs
index 9cecb31..fd8b761 100644
--- a/core/journal/src/superblock.rs
+++ b/core/journal/src/superblock.rs
@@ -48,7 +48,6 @@
 use std::hash::Hasher;
 use std::io;
 use std::path::{Path, PathBuf};
-use std::pin::Pin;
 
 use compio::io::{AsyncReadAtExt, AsyncWriteAtExt};
 
@@ -71,14 +70,28 @@
 
 /// Ceiling on a record's payload, bounding every allocation this module makes from
 /// a length it read off disk (`PrepareJournal::MAX_ENTRY_SIZE` bounds the WAL for the
-/// same reason). The only payload today is a 58-byte [`VsrState`]; the headroom is
-/// for a payload that grows fields, not for bulk data. `read_slot` treats a longer
-/// file as corrupt WITHOUT reading it, and `build_record` refuses to write one, so a
-/// length this store could have produced is always in bounds.
+/// same reason). The only payload today is a [`consensus::VsrState`], 66 bytes now
+/// that it carries the offset frontier (58 before it, a length its decode still
+/// accepts); the headroom is for a payload that grows fields, not for bulk data.
+/// `read_slot` treats a longer file as corrupt WITHOUT reading it, and
+/// `build_record` refuses to write one, so a length this store could have
+/// produced is always in bounds.
 const MAX_PAYLOAD_LEN: usize = 4096;
 /// Largest record `read_slot` will read into memory.
 const MAX_RECORD_LEN: usize = HEADER_LEN + MAX_PAYLOAD_LEN + CHECKSUM_LEN;
 
+/// First retry delay after a failed superblock write, doubling per failure.
+///
+/// Starts near the 10 ms consensus tick, so a one-off failure costs no
+/// latency, and a persistent one stops re-running `atomic_replace` per tick.
+pub const SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS: u64 = 10_000;
+/// Ceiling on the retry delay. A replica fenced this long is not coming back without
+/// operator action, but the retry must stay frequent enough to recover on its own the
+/// moment the disk does.
+pub const SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS: u64 = 1_000_000;
+/// Caps the doubling so the shift cannot overflow before the ceiling clamps it.
+pub const SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT: u64 = 8;
+
 const FILE_A: &str = "superblock.a";
 const FILE_B: &str = "superblock.b";
 
@@ -129,33 +142,6 @@
     fn read_latest(&self) -> impl Future<Output = io::Result<SuperblockContents>>;
 }
 
-/// A boxed, lifetime-bound future, for the object-safe superblock adapter.
-type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
-
-/// Object-safe adapter over [`SuperblockStore`].
-///
-/// Lets a superblock be held as `Rc<dyn DynSuperblockStore>` without threading a
-/// generic through the consensus and metadata layers. Writes happen only on a
-/// view change or checkpoint, so the boxed future is off every hot path. The
-/// `dyn_` prefix avoids clashing with the inherent methods under the blanket impl.
-pub trait DynSuperblockStore {
-    /// See [`SuperblockStore::write`].
-    fn dyn_write<'a>(&'a self, payload: &'a [u8]) -> BoxFuture<'a, io::Result<()>>;
-
-    /// See [`SuperblockStore::read_latest`].
-    fn dyn_read_latest(&self) -> BoxFuture<'_, io::Result<SuperblockContents>>;
-}
-
-impl<T: SuperblockStore> DynSuperblockStore for T {
-    fn dyn_write<'a>(&'a self, payload: &'a [u8]) -> BoxFuture<'a, io::Result<()>> {
-        Box::pin(self.write(payload))
-    }
-
-    fn dyn_read_latest(&self) -> BoxFuture<'_, io::Result<SuperblockContents>> {
-        Box::pin(self.read_latest())
-    }
-}
-
 #[derive(Clone, Copy)]
 enum Slot {
     A,
@@ -211,6 +197,19 @@
     /// # Errors
     /// I/O error if a slot file exists but cannot be read.
     pub async fn open(dir: impl Into<PathBuf>) -> io::Result<Self> {
+        Ok(Self::open_with_latest(dir).await?.0)
+    }
+
+    /// [`Self::open`] plus the latest recorded contents, from the SAME slot
+    /// reads `open` already performs -- boot paths that would otherwise call
+    /// `read_latest` right after `open` pay four slot reads per group
+    /// instead of two.
+    ///
+    /// # Errors
+    /// Same as [`Self::open`]: any slot read failing at the io layer.
+    pub async fn open_with_latest(
+        dir: impl Into<PathBuf>,
+    ) -> io::Result<(Self, SuperblockContents)> {
         let dir = dir.into();
         let slot_a = read_slot(&dir.join(FILE_A)).await?;
         let slot_b = read_slot(&dir.join(FILE_B)).await?;
@@ -232,14 +231,15 @@
         };
         let next_slot = if a_is_newest { Slot::B } else { Slot::A };
 
-        Ok(Self {
+        let store = Self {
             dir,
             next_sequence: Cell::new(latest + 1),
             next_slot: Cell::new(next_slot),
             degraded: has_unreadable_sequence(&slot_a) || has_unreadable_sequence(&slot_b),
             #[cfg(debug_assertions)]
             writing: Cell::new(false),
-        })
+        };
+        Ok((store, combine_slots(slot_a, slot_b)))
     }
 }
 
diff --git a/core/metadata/src/impls/metadata.rs b/core/metadata/src/impls/metadata.rs
index 0290e5b..55d4fb0 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -50,7 +50,11 @@
 use iggy_common::UserId;
 use iggy_common::calculate_checksum;
 use iggy_common::variadic;
-use journal::superblock::DynSuperblockStore;
+use journal::local_gate::LocalGate;
+use journal::superblock::{
+    PingPongSuperblock, SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS, SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS,
+    SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT, SuperblockStore,
+};
 use journal::{Journal, JournalHandle};
 use message_bus::MessageBus;
 use server_common::Message;
@@ -202,17 +206,6 @@
     }
 }
 
-/// First retry delay after a failed superblock write, doubling per consecutive
-/// failure. Starts near the 10 ms consensus tick, so a one-off failure costs no
-/// latency, and a persistent one stops re-running `atomic_replace` per tick.
-const SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS: u64 = 10_000;
-/// Ceiling on the retry delay. A replica fenced this long is not coming back without
-/// operator action, but the retry must stay frequent enough to recover on its own the
-/// moment the disk does.
-const SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS: u64 = 1_000_000;
-/// Caps the doubling so the shift cannot overflow before the ceiling clamps it.
-const SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT: u64 = 8;
-
 /// Framing marker for the snapshot integrity trailer, "ISNP". Distinguishes a sealed
 /// snapshot from one written before the trailer existed, so a MISSING trailer can be
 /// accepted (unverified, loudly) while a PRESENT but mismatching one refuses boot. A
@@ -446,81 +439,6 @@
 const _: () =
     assert!(SnapshotCoordinator::<()>::CHECKPOINT_MARGIN >= consensus::PIPELINE_PREPARE_QUEUE_MAX);
 
-/// Single-shard async gate serializing the journal-mutation section of
-/// `on_replicate` (forced checkpoint + WAL append).
-///
-/// Many futures can drive `on_replicate` concurrently on one shard (the
-/// pump loop, detached per-client submit tasks, repair). Ungated they race
-/// `SnapshotCoordinator::checkpoint`: every driver crossing the
-/// `remaining_capacity <= CHECKPOINT_MARGIN` boundary runs a full
-/// checkpoint, and the concurrent `journal.drain()` calls collide on the
-/// WAL rewrite — shared `wal.tmp`, ENOENT for every rename that loses,
-/// short reads after the winner's reopen. Appends racing a drain are just
-/// as unsound: the drain's live-set partition misses an append landing
-/// mid-rewrite and the rewrite silently discards it.
-///
-/// Not a general lock: single-threaded (`Cell`/`RefCell`, never `Sync`),
-/// release wakes every waiter and poll order re-races (arrival-order FIFO
-/// under `futures::join!`-style drivers), cancel-safe (dropping the guard
-/// releases; dropping a waiter leaves only a stale waker).
-struct LocalGate {
-    busy: Cell<bool>,
-    waiters: RefCell<Vec<std::task::Waker>>,
-}
-
-impl LocalGate {
-    const fn new() -> Self {
-        Self {
-            busy: Cell::new(false),
-            waiters: RefCell::new(Vec::new()),
-        }
-    }
-
-    const fn acquire(&self) -> LocalGateAcquire<'_> {
-        LocalGateAcquire { gate: self }
-    }
-}
-
-struct LocalGateAcquire<'a> {
-    gate: &'a LocalGate,
-}
-
-impl<'a> std::future::Future for LocalGateAcquire<'a> {
-    type Output = LocalGateGuard<'a>;
-
-    fn poll(
-        self: std::pin::Pin<&mut Self>,
-        cx: &mut std::task::Context<'_>,
-    ) -> std::task::Poll<Self::Output> {
-        if self.gate.busy.get() {
-            // Re-polls while still busy push a duplicate waker; the extra
-            // wake is spurious and harmless at pipeline-queue scale.
-            self.gate.waiters.borrow_mut().push(cx.waker().clone());
-            std::task::Poll::Pending
-        } else {
-            self.gate.busy.set(true);
-            std::task::Poll::Ready(LocalGateGuard { gate: self.gate })
-        }
-    }
-}
-
-struct LocalGateGuard<'a> {
-    gate: &'a LocalGate,
-}
-
-impl Drop for LocalGateGuard<'_> {
-    fn drop(&mut self) {
-        self.gate.busy.set(false);
-        // Move the waiters out before waking: `wake()` only schedules under
-        // compio today, but a waker that ever polled a waiter inline would
-        // re-enter `acquire`'s `waiters.borrow_mut()` and panic the RefCell.
-        let waiters = std::mem::take(&mut *self.gate.waiters.borrow_mut());
-        for waker in waiters {
-            waker.wake();
-        }
-    }
-}
-
 /// Failures shared by the in-process metadata submit helpers.
 ///
 /// Returned by [`IggyMetadata::submit_register_in_process`],
@@ -718,7 +636,7 @@
 /// single-thread invariant keeps access safe without [`Sync`].
 pub type CommitNotifier = std::rc::Rc<dyn Fn(Operation)>;
 
-pub struct IggyMetadata<C, J, S, M> {
+pub struct IggyMetadata<C, J, S, M, SB = PingPongSuperblock> {
     /// `Some` on shard 0, `None` on other shards. Server-ng bootstrap
     /// holds the invariant: only shard 0 owns the metadata consensus
     /// replica; every other shard reconstructs `mux_stm` from the
@@ -736,10 +654,11 @@
     /// `Some` on shard 0, `None` on other shards.
     pub snapshot: Option<S>,
     /// Durable VSR-state record (`view`/`log_view`/`commit`). `Some` only on the
-    /// shard owning metadata consensus (shard 0); `None` on peer shards and in the
-    /// partition plane, which is not yet durable. Behind `Rc<dyn ...>` so the
-    /// simulator harness can keep a clone outliving a replica across a restart.
-    pub superblock: Option<Rc<dyn DynSuperblockStore>>,
+    /// shard owning metadata consensus (shard 0); `None` on peer shards. Generic
+    /// (`PingPongSuperblock` in production, `SimSuperblock` in the simulator,
+    /// recording doubles in tests) and behind `Rc` so the simulator harness can
+    /// keep a clone outliving a replica across a restart.
+    pub superblock: Option<Rc<SB>>,
     /// Serializes superblock writes on shard 0 so at most one is in flight.
     /// View-change persists ([`Self::persist_superblock_if_needed`]) and checkpoints
     /// ([`Self::checkpoint_if_needed`]) share the one ping-pong superblock, and
@@ -781,7 +700,15 @@
     /// Snapshot coordinator - present when persistent checkpointing is configured.
     pub coordinator: Option<SnapshotCoordinator<M>>,
     /// Serializes `on_replicate`'s journal-mutation section (forced
-    /// checkpoint + WAL append) across concurrent drivers. See [`LocalGate`].
+    /// checkpoint + WAL append) across concurrent drivers (the pump loop,
+    /// detached per-client submit tasks, repair). Ungated they race
+    /// `SnapshotCoordinator::checkpoint`: every driver crossing the
+    /// `remaining_capacity <= CHECKPOINT_MARGIN` boundary runs a full
+    /// checkpoint, and the concurrent `journal.drain()` calls collide on the
+    /// WAL rewrite -- shared `wal.tmp`, ENOENT for every rename that loses,
+    /// short reads after the winner's reopen. Appends racing a drain are just
+    /// as unsound: the drain's live-set partition misses an append landing
+    /// mid-rewrite and the rewrite silently discards it. See [`LocalGate`].
     journal_gate: LocalGate,
     /// Per-client session state (sessions, dedup, eviction). Metadata-only.
     pub client_table: RefCell<ClientTable>,
@@ -816,7 +743,7 @@
     transfer_offer_cache: RefCell<Option<Rc<StateTransferOffer>>>,
 }
 
-impl<C, J, S, M> IggyMetadata<C, J, S, M>
+impl<C, J, S, M, SB> IggyMetadata<C, J, S, M, SB>
 where
     M: StreamsFrontend + FillSnapshot<MetadataSnapshot>,
 {
@@ -829,7 +756,7 @@
         consensus: Option<C>,
         journal: Option<J>,
         snapshot: Option<S>,
-        superblock: Option<Rc<dyn DynSuperblockStore>>,
+        superblock: Option<Rc<SB>>,
         mux_stm: M,
         data_dir: Option<std::path::PathBuf>,
     ) -> Self {
@@ -859,7 +786,7 @@
     }
 }
 
-impl<C, J, S, M> IggyMetadata<C, J, S, M> {
+impl<C, J, S, M, SB> IggyMetadata<C, J, S, M, SB> {
     /// Slot capacity of the LIVE client table, i.e. the largest transferred
     /// table this replica can absorb.
     ///
@@ -996,9 +923,10 @@
 }
 
 #[allow(clippy::future_not_send)]
-impl<B, J, S, M> Plane<VsrConsensus<B>> for IggyMetadata<VsrConsensus<B>, J, S, M>
+impl<B, J, S, M, SB> Plane<VsrConsensus<B>> for IggyMetadata<VsrConsensus<B>, J, S, M, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
     J: JournalHandle,
     J::Target: Journal<J::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     M: StreamsFrontend
@@ -1343,7 +1271,8 @@
     }
 }
 
-impl<B, P, J, S, M> PlaneIdentity<VsrConsensus<B, P>> for IggyMetadata<VsrConsensus<B, P>, J, S, M>
+impl<B, P, J, S, M, SB> PlaneIdentity<VsrConsensus<B, P>>
+    for IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
 where
     B: MessageBus,
     P: Pipeline<Entry = PipelineEntry>,
@@ -1359,8 +1288,7 @@
             message.header().command(),
             Command2::Request | Command2::Prepare | Command2::PrepareOk
         ));
-        let op = message.header().operation();
-        op.is_metadata() || matches!(op, Operation::Register | Operation::Logout)
+        message.header().operation().is_metadata_plane()
     }
 }
 
@@ -1487,9 +1415,10 @@
     pub pairing_durable: bool,
 }
 
-impl<B, J, S, M> IggyMetadata<VsrConsensus<B>, J, S, M>
+impl<B, J, S, M, SB> IggyMetadata<VsrConsensus<B>, J, S, M, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
     J: JournalHandle,
     J::Target: Journal<J::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     M: StreamsFrontend
@@ -3004,10 +2933,11 @@
     }
 }
 
-impl<B, P, J, S, M> IggyMetadata<VsrConsensus<B, P>, J, S, M>
+impl<B, P, J, S, M, SB> IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
 where
     B: MessageBus,
     P: Pipeline<Entry = PipelineEntry>,
+    SB: SuperblockStore,
 {
     /// Persist the current VSR state to the superblock when the view changed since
     /// the last write. The split-brain gate: callers MUST invoke this before
@@ -3084,11 +3014,7 @@
     /// from logs. Fail-stopping the process is the TigerBeetle-style answer, but this
     /// layer holds no process-lifecycle handle; wire it where the shard owns shutdown.
     #[allow(clippy::future_not_send)]
-    async fn write_superblock(
-        &self,
-        consensus: &VsrConsensus<B, P>,
-        superblock: &dyn DynSuperblockStore,
-    ) -> bool {
+    async fn write_superblock(&self, consensus: &VsrConsensus<B, P>, superblock: &SB) -> bool {
         // Carry the last durable pairing forward so a view-change write never
         // regresses the `(checkpoint_op, checksum)` a checkpoint recorded. `(0, 0)`
         // with no checkpoint taken, or no coordinator (peer shards, the simulator).
@@ -3097,7 +3023,7 @@
             .as_ref()
             .map_or((0, 0), SnapshotCoordinator::last_checkpoint);
         let state = consensus.vsr_state(checkpoint_op, checkpoint_checksum);
-        match superblock.dyn_write(&state.to_bytes()).await {
+        match superblock.write(&state.to_bytes()).await {
             Ok(()) => {
                 consensus.mark_superblock_durable(state.view, state.log_view);
                 self.superblock_write_failures.set(0);
@@ -3141,10 +3067,11 @@
     }
 }
 
-impl<B, P, J, S, M> IggyMetadata<VsrConsensus<B, P>, J, S, M>
+impl<B, P, J, S, M, SB> IggyMetadata<VsrConsensus<B, P>, J, S, M, SB>
 where
     B: MessageBus,
     P: Pipeline<Entry = PipelineEntry>,
+    SB: SuperblockStore,
     J: JournalHandle,
     J::Target: Journal<J::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     M: StreamsFrontend
@@ -3829,7 +3756,9 @@
         op,
         timestamp,
         operation,
-        namespace: request.namespace,
+        // The group's namespace, never the request's: clients send 0, and a
+        // journaled 0 mis-routes the entry when repair replays it verbatim.
+        namespace: consensus.namespace(),
         // Carry the acting user id so the in-apply RBAC gate sees the same
         // identity on every replica. The default projection copies it (see
         // `Project::project`); this helper builds prepares for the ops it
diff --git a/core/metadata/src/impls/recovery.rs b/core/metadata/src/impls/recovery.rs
index 0ed4cfc..286d286 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -781,6 +781,7 @@
             commit_max: 100,
             checkpoint_op,
             checkpoint_checksum,
+            offset_frontier: 0,
         }
     }
 
diff --git a/core/metadata/src/lib.rs b/core/metadata/src/lib.rs
index 5c4ba39..4d4d240 100644
--- a/core/metadata/src/lib.rs
+++ b/core/metadata/src/lib.rs
@@ -27,5 +27,9 @@
     apply_committed_prepare,
 };
 
+// Recovery vocabulary other crates name in their own signatures and error
+// enums, so they do not have to spell the `impls::` path.
+pub use impls::recovery::{IdentityField, RecoveryError, ReplicaIdentity};
+
 // Re-export MuxStateMachine for use in other modules
 pub use stm::mux::MuxStateMachine;
diff --git a/core/partitions/Cargo.toml b/core/partitions/Cargo.toml
index 5524ecf..0acc918 100644
--- a/core/partitions/Cargo.toml
+++ b/core/partitions/Cargo.toml
@@ -43,6 +43,7 @@
 bytes = { workspace = true }
 compio = { workspace = true }
 consensus = { workspace = true }
+futures = { workspace = true }
 iggy_binary_protocol = { workspace = true }
 iggy_common = { workspace = true }
 journal = { workspace = true }
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index d7cc733..dad3f70 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -26,7 +26,8 @@
     PartitionDirResolution, PollPlan, PollTier, ResidentTailSnapshot,
 };
 use crate::segment::Segment;
-use crate::types::RepairSession;
+use crate::state_transfer::{PartitionTransferSession, PendingTransferRearm};
+use crate::types::{RepairConclusion, RepairSession};
 use crate::{
     AppendResult, Partition, PartitionOffsets, PartitionsConfig, PollQueryResult, PollingArgs,
     PollingConsumer,
@@ -55,6 +56,11 @@
     IggyByteSize, IggyError, IggyExpiry, IggyTimestamp, PartitionStats, PollingKind,
 };
 use journal::Journal as _;
+use journal::local_gate::LocalGate;
+use journal::superblock::{
+    PingPongSuperblock, SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS, SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS,
+    SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT, SuperblockStore,
+};
 use message_bus::{IggyMessageBus, MessageBus, is_auto_commit_client};
 use server_common::{
     MESSAGE_ALIGN, Message, SegmentStorage,
@@ -65,8 +71,9 @@
     },
     sharding::IggyNamespace,
 };
-use std::cell::RefCell;
-use std::collections::HashMap;
+use std::cell::{Cell, RefCell};
+use std::collections::{HashMap, HashSet};
+use std::fmt;
 use std::hash::Hash;
 use std::rc::Rc;
 use std::sync::Arc;
@@ -80,8 +87,7 @@
 // `SendMessages` retries are at-least-once and may commit multiple times.
 // Consumers handle duplicate messages via `server_common::MessageDeduplicator`
 // (message-id based) if they care.
-#[derive(Debug)]
-pub struct IggyPartition<B = IggyMessageBus>
+pub struct IggyPartition<B = IggyMessageBus, SB = PingPongSuperblock>
 where
     B: MessageBus,
 {
@@ -104,15 +110,15 @@
     pub revision_id: u64,
     pub should_increment_offset: bool,
     pub write_lock: Arc<TokioMutex<()>>,
-    consumer_offsets_path: Option<String>,
-    consumer_group_offsets_path: Option<String>,
+    pub(crate) consumer_offsets_path: Option<String>,
+    pub(crate) consumer_group_offsets_path: Option<String>,
     /// Canonical on-disk partition directory, set at construction by the
     /// server builder. Disk polls must not derive this from live writers:
     /// sealed segments drop their writer at rotation, so a writer-derived
     /// path transiently disappears and silently hides the disk tier.
     /// `None` only for in-memory (simulated) partitions.
-    partition_dir: Option<String>,
-    consumer_offset_enforce_fsync: bool,
+    pub(crate) partition_dir: Option<String>,
+    pub(crate) consumer_offset_enforce_fsync: bool,
     /// In-flight journal repair:
     /// set when the recovery handshake finds this replica behind the group's
     /// commit frontier, cleared when `RepairDone` completes the walk.
@@ -123,7 +129,16 @@
     /// re-persisting / re-counting them. Immutable after boot, so live
     /// traffic (always above it) is never affected.
     pub recovered_durable_offset: Option<u64>,
-    pending_consumer_offset_commits: HashMap<u64, PendingConsumerOffsetCommit>,
+    /// Where the group's offset space STARTS on this replica: everything
+    /// below it is represented by a completed state-transfer install (or by
+    /// the empty segment such an install planted at the frontier). Consulted
+    /// only by the repair floor-connect check, so an install with zero
+    /// staged segments does not force one wasted transfer round per rejoin.
+    /// Deliberately separate from [`Self::recovered_durable_offset`], which
+    /// also gates repaired-batch persistence -- overstating THAT field would
+    /// silently drop the `(commit_op, commit_max]` replay window.
+    pub installed_frontier: Option<u64>,
+    pub(crate) pending_consumer_offset_commits: HashMap<u64, PendingConsumerOffsetCommit>,
     /// Committed-only mirror of each consumer's persisted offset file: the
     /// last value this replica durably wrote per (kind, consumer id). Fed
     /// exclusively by the file-writing paths (replicated commit-apply, the
@@ -136,13 +151,125 @@
     /// the tracker rebuilds from disk lazily and deterministically.
     /// `RefCell`: mutated from `&self` paths on the single shard thread;
     /// borrows never cross an await.
-    persisted_offsets: RefCell<HashMap<(ConsumerKind, u32), u64>>,
-    observed_view: u32,
+    pub(crate) persisted_offsets: RefCell<HashMap<(ConsumerKind, u32), u64>>,
+    pub(crate) observed_view: u32,
     /// Highest `PurgeTopic` generation this replica has locally applied (reset
     /// the partition to empty). The reconciler compares the committed metadata
     /// generation against this and resets only when it advances, so a redundant
     /// reconcile pass never re-wipes a partition already at this generation.
-    applied_purge_generation: u64,
+    pub(crate) applied_purge_generation: 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
+    /// in-memory / simulated partitions, where the persist gate is a no-op
+    /// and views stay process-lifetime only. Behind `Rc` because the boot
+    /// path opens the store once and hands the same instance here:
+    /// re-opening would fork the ping-pong sequence counter.
+    superblock: Option<Rc<SB>>,
+    /// Serializes this partition's superblock writes so at most one is in
+    /// flight: `PingPongSuperblock::write` picks its slot before it awaits,
+    /// so two overlapping writers would target the same slot and could tear
+    /// it while both report success. Per partition, not per shard -- every
+    /// group owns its own two-file store, so writes to different partitions
+    /// never contend.
+    superblock_lock: LocalGate,
+    /// Consecutive failed superblock writes, and the clock reading after which
+    /// the next attempt may run. A persistent `ENOSPC` / `EIO` would otherwise
+    /// re-run a full `atomic_replace` on every 10 ms consensus tick. Reset on
+    /// the first success. See [`Self::persist_superblock_if_needed`] for the
+    /// terminal policy.
+    superblock_write_failures: Cell<u64>,
+    superblock_retry_after_micros: Cell<u64>,
+    /// A committed purge this replica accepted but could not apply, because it
+    /// could not record the frontier reset first. Withholds `PrepareOk` until
+    /// the purge lands: the counter still names the PRE-purge offset space, so
+    /// every op acked meanwhile would be stamped from a `base_offset` the peers
+    /// that already purged do not share.
+    ///
+    /// The superblock persist gate cannot cover this on its own -- it fires on
+    /// `(view, log_view)` changes, and a replica with a stable view and a full
+    /// disk attempts no write, observes no failure, and fences nothing.
+    pub(crate) purge_deferred: bool,
+    /// The `offset_frontier` the last successful superblock write recorded,
+    /// seeded at boot from the record that write left behind.
+    ///
+    /// The advance direction maxes against THIS as well as the live counter,
+    /// because the two diverge: a failed install leaves the counter at its
+    /// pre-install value while the record already names the incoming frontier,
+    /// and the fence that follows then persists the counter. Maxing against
+    /// the counter alone writes 0 over a recorded N and quarantines the
+    /// segments that were the only other witness, after which the rebuild
+    /// re-mints offsets the group already handed out.
+    durable_offset_frontier: Cell<u64>,
+    /// In-flight state transfer for this group (rejoin whose repair floor was
+    /// refused); tail repair takes over at install. See
+    /// [`PartitionTransferSession`].
+    pub transfer: Option<PartitionTransferSession>,
+    /// Consecutive transfer stall rounds WITHIN one recovery attempt. NOT in the
+    /// session: three of four metadata arming sites re-minted their session, so
+    /// a per-session counter bounded nothing and a permanent failure cycled
+    /// abandon -> repair -> refusal -> re-arm at zero forever. Reset by
+    /// [`Self::note_transfer_progress`] and by
+    /// [`Self::note_transfer_rearm_scheduled`]; livelock across attempts is
+    /// bounded by [`Self::transfer_failures`] and its exponential backoff.
+    transfer_attempts: 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
+    /// every round, and a generation-keyed count reset to 1 forever, so a
+    /// deterministic local failure (ENOSPC, an undecodable artifact) looped
+    /// at network round-trip rate. Reset only by
+    /// [`Self::note_transfer_installed`]; drives the re-arm backoff.
+    transfer_failures: u32,
+    /// CONSECUTIVE transient refusals (a peer that cannot serve right now).
+    /// Drives log escalation only -- never the backoff. See
+    /// [`Self::record_transfer_refusal`].
+    transfer_refusals: u32,
+    /// A scheduled transfer re-arm: try `peer` again once `after_ticks`
+    /// consensus ticks elapse. Owned by the shard tick sweep; while one is
+    /// pending, the repair-refusal trigger must not arm concurrently.
+    pub transfer_rearm: Option<PendingTransferRearm>,
+    /// Memoized segment-payload checksum state, keyed by segment base offset.
+    /// Sealed segments are immutable, so their stamp never changes; the active
+    /// segment extends its own hasher over the bytes it gained. Without this,
+    /// EVERY offer build re-reads and re-hashes all retained bytes on the pump
+    /// -- and a committing primary advances `commit_op` each round, so the offer
+    /// cache alone never saves the pass. Swept against the live chain at build
+    /// time; cleared wherever segment files are unlinked-and-recreated (purge,
+    /// install, converge).
+    pub(crate) segment_checksum_cache:
+        RefCell<std::collections::HashMap<u64, crate::state_transfer::SegmentChecksumMemo>>,
+    /// Receiving-side memo of the last staged-segment reuse scan, so a peer
+    /// rotation against the same segment set does not re-read and re-walk every
+    /// staged file. See [`crate::state_transfer::ReuseScanMemo`].
+    pub(crate) reuse_scan_memo: RefCell<Option<crate::state_transfer::ReuseScanMemo>>,
+    /// Serving-side offer cache, keyed by the `commit_op` it was built at, so
+    /// simultaneous rejoiners share one manifest instead of re-reading every
+    /// segment per requester. Invalidated by `purge` (same commit frontier,
+    /// different bytes) and released by the shard's offer-expiry sweep.
+    pub(crate) transfer_offer_cache:
+        RefCell<Option<Rc<crate::state_transfer::PartitionStateTransferOffer>>>,
+}
+
+impl<B, SB> fmt::Debug for IggyPartition<B, SB>
+where
+    B: MessageBus,
+{
+    // Hand-written because `SB` carries no `Debug` bound; the fields listed
+    // are the ones diagnostics actually key on.
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("IggyPartition")
+            .field("namespace", &self.consensus.namespace())
+            .field("offset", &self.offset)
+            .field("dirty_offset", &self.dirty_offset)
+            .field("should_increment_offset", &self.should_increment_offset)
+            .field("partition_dir", &self.partition_dir)
+            .field("repair", &self.repair)
+            .field("recovered_durable_offset", &self.recovered_durable_offset)
+            .field("observed_view", &self.observed_view)
+            .field("applied_purge_generation", &self.applied_purge_generation)
+            .finish_non_exhaustive()
+    }
 }
 
 /// Post-preflight dispatch in `on_request`: replicate via VSR or take the
@@ -158,8 +285,62 @@
     },
 }
 
+/// Why a purge did not complete, split by whether it had already mutated.
+///
+/// The two need opposite handling, and conflating them is a data-loss bug:
+/// fencing a partition whose purge failed before it touched anything
+/// quarantines a complete healthy chain while the live counter still names the
+/// pre-purge offset space, and the fence's own frontier write then stamps that
+/// stale counter as durable truth.
+#[derive(Debug)]
+pub enum PurgeError {
+    /// The frontier reset could not be recorded. NOTHING was mutated: the
+    /// segments, the counters and `applied_purge_generation` are all untouched,
+    /// so the reconciler's `committed > applied` gate re-issues this purge on
+    /// its next pass. Retry, do not fence.
+    ///
+    /// Sets [`Self::purge_deferred`], which withholds `PrepareOk` for this
+    /// group until the purge lands, so the replica goes quorum-invisible THERE
+    /// while every other partition on the node keeps serving. Without that
+    /// fence the counter would still name the pre-purge offset space and every
+    /// op this replica acked would be stamped from a `base_offset` its purged
+    /// peers do not share. The superblock persist gate does not cover it: that
+    /// fires on `(view, log_view)` changes, and a stable view attempts no write
+    /// and so observes no failure.
+    ///
+    /// Fencing the SEND rather than the whole partition is the point. The
+    /// alternative was fencing a partition whose chain is still whole, which
+    /// quarantines live data and rebuilds it at the pre-purge frontier.
+    ///
+    /// Carries no cause: the write path reports `bool`, and the underlying
+    /// `ENOSPC` / `EIO` is logged by the superblock writer on the first failure
+    /// and at every power-of-two thereafter.
+    FrontierNotRecorded,
+    /// A step after the drain failed, so the partition holds no serviceable
+    /// segment chain and its next append would panic on `active_segment()`.
+    /// The caller must fence this group for rebuild.
+    Unserviceable(IggyError),
+}
+
+impl fmt::Display for PurgeError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::FrontierNotRecorded => write!(
+                f,
+                "could not record the purge's offset-frontier reset; nothing was mutated"
+            ),
+            Self::Unserviceable(source) => write!(
+                f,
+                "purge left the partition without a serviceable chain: {source}"
+            ),
+        }
+    }
+}
+
+impl std::error::Error for PurgeError {}
+
 #[derive(Debug, Clone, Copy, PartialEq)]
-struct PendingConsumerOffsetCommit {
+pub struct PendingConsumerOffsetCommit {
     kind: ConsumerKind,
     consumer_id: u32,
     mutation: PendingConsumerOffsetMutation,
@@ -223,9 +404,10 @@
     }
 }
 
-impl<B> IggyPartition<B>
+impl<B, SB> IggyPartition<B, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     pub fn new(stats: Arc<PartitionStats>, consensus: VsrConsensus<B>) -> Self {
         let observed_view = consensus.view();
@@ -249,10 +431,25 @@
             consumer_offset_enforce_fsync: false,
             repair: None,
             recovered_durable_offset: None,
+            installed_frontier: None,
             pending_consumer_offset_commits: HashMap::new(),
             persisted_offsets: RefCell::new(HashMap::new()),
             observed_view,
             applied_purge_generation: 0,
+            superblock: None,
+            superblock_lock: LocalGate::new(),
+            superblock_write_failures: Cell::new(0),
+            superblock_retry_after_micros: Cell::new(0),
+            purge_deferred: false,
+            durable_offset_frontier: Cell::new(0),
+            transfer: None,
+            transfer_attempts: 0,
+            transfer_failures: 0,
+            transfer_refusals: 0,
+            transfer_rearm: None,
+            segment_checksum_cache: RefCell::new(std::collections::HashMap::new()),
+            reuse_scan_memo: RefCell::new(None),
+            transfer_offer_cache: RefCell::new(None),
         };
         if single_replica {
             partition.log.journal().inner.set_repair_retention(false);
@@ -298,6 +495,414 @@
         self.partition_dir = Some(partition_dir);
     }
 
+    /// Attach the durable superblock store the boot path opened for this
+    /// partition's group, along with the record it read back. Boot seeds
+    /// consensus with the recovered `(view, log_view)` and marks them durable
+    /// before attaching; from then on [`Self::persist_superblock_if_needed`]
+    /// keeps the record current.
+    ///
+    /// The record is a PARAMETER rather than a follow-up seeding call because
+    /// the advance direction maxes against its frontier: an attach that left
+    /// that at zero against a record naming N would let the first write after a
+    /// fence lower it, which is the whole defect the field exists to prevent.
+    /// As a separate call it was silently optional, and one of the three attach
+    /// sites dropped it.
+    pub fn set_superblock(&mut self, superblock: Rc<SB>, recovered: Option<&consensus::VsrState>) {
+        self.superblock = Some(superblock);
+        self.durable_offset_frontier
+            .set(recovered.map_or(0, |state| state.offset_frontier));
+    }
+
+    /// Persist this group's VSR state to its superblock when the view changed
+    /// since the last write. The split-brain gate, partition edition: callers
+    /// MUST invoke this before dispatching any view-scoped VSR message for
+    /// this partition, so a replica that acted in a view can never recover an
+    /// older one after a crash.
+    ///
+    /// It fences the SEND, not the ACT. By the time a caller reaches here the
+    /// handler has already moved `view`, `log_view`, `status`, the sequencer
+    /// and the pipeline, and the commit walk runs outside the gate, so a
+    /// failed persist still applies committed ops locally. That is the VSR
+    /// fence and it is sufficient: local state a crash forgets is state no
+    /// peer ever saw, whereas an externalized view must be recoverable.
+    ///
+    /// `true` when the send may proceed, either because the state is now
+    /// durable or because there was nothing to persist (no store attached --
+    /// in-memory / simulated partitions -- or an unchanged view). `false`
+    /// only when a write was attempted and failed, and the caller must
+    /// withhold the send. The in-memory view stays ahead of the durable one,
+    /// which a crash safely rolls back, and the next tick retries.
+    #[allow(clippy::future_not_send)]
+    #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+    pub async fn persist_superblock_if_needed(&self) -> bool {
+        let Some(superblock) = self.superblock.as_ref() else {
+            // No store (in-memory / simulated partitions): nothing can be
+            // recorded, so keep the durable cells current instead. The
+            // dispatch tripwire asserts `needs_superblock_persist()` is clear
+            // on every view-scoped send, and for a storeless group "current"
+            // is trivially true -- leaving the cells behind would trip it on
+            // the first view change.
+            self.consensus
+                .mark_superblock_durable(self.consensus.view(), self.consensus.log_view());
+            return true;
+        };
+        // Lock-free fast path: the steady state is an unchanged view with
+        // nothing to write, and skipping the lock keeps every gated send off
+        // it, notably `send_prepare_ok`, which runs this per prepare. Safe
+        // because `view`/`log_view` advance only on this single-threaded
+        // executor and no `.await` sits between the `Cell` read and the
+        // return; a concurrent advance is caught by the re-check below.
+        if !self.consensus.needs_superblock_persist() {
+            return true;
+        }
+        // A write that keeps failing must not re-run a full `atomic_replace`
+        // on every 10 ms tick. Back off first, while still reporting `false`
+        // so the send stays withheld: fail-closed is the point of this gate,
+        // and the backoff only bounds what the retry costs.
+        if self.superblock_write_is_backed_off() {
+            return false;
+        }
+        // Re-check needs-persist AFTER acquiring the lock so check and write
+        // are atomic and a redundant caller coalesces, finding the state
+        // already made durable by the writer it queued behind.
+        let _superblock_guard = self.superblock_lock.acquire().await;
+        if !self.consensus.needs_superblock_persist() {
+            return true;
+        }
+        self.write_superblock(superblock.as_ref(), self.offset_frontier())
+            .await
+    }
+
+    /// Write the current VSR state under [`Self::superblock_lock`].
+    ///
+    /// The caller must hold that lock. The state is captured HERE rather than
+    /// passed in: with writes serialized and no await between the capture and
+    /// the write, the last writer carries the freshest view, so the durable
+    /// view cannot regress. `mark_superblock_durable` takes the WRITTEN
+    /// values, never a re-read, because the in-memory view can advance across
+    /// the write's `.await`.
+    ///
+    /// # Terminal policy
+    /// There is none beyond staying fenced: a replica that cannot record the
+    /// view it is in must not act in it, so it withholds every view-scoped
+    /// send for this group, goes quiet, and its peers elect around it. Only
+    /// THIS partition's group is fenced; the rest of the node keeps serving.
+    #[allow(clippy::future_not_send)]
+    async fn write_superblock(&self, superblock: &SB, offset_frontier: u64) -> bool {
+        // ADVANCE direction: never below what this replica has already minted,
+        // and never below what the record ALREADY holds. Both bounds are
+        // needed and neither implies the other -- a failed install leaves the
+        // counter behind the record it wrote before the swap, so maxing against
+        // the counter alone lets the fence that follows lower the durable
+        // frontier. The reset direction goes through `write_superblock_inner`.
+        let advanced = offset_frontier
+            .max(self.offset_frontier())
+            .max(self.durable_offset_frontier.get());
+        self.write_superblock_inner(superblock, advanced).await
+    }
+
+    /// The write itself; the advance and reset directions differ only in the
+    /// frontier they hand in.
+    #[allow(clippy::future_not_send)]
+    async fn write_superblock_inner(&self, superblock: &SB, offset_frontier: u64) -> bool {
+        // The pairing fields stay `(0, 0)` and `commit_max` is a dead write
+        // on this plane: nothing reads either back (`restore_partition_view`
+        // restores view/log_view only), because recovery re-derives the
+        // install floor from the installed segments at boot -- a crash after
+        // an install does not re-run the transfer. Written anyway so the
+        // record shape matches the metadata plane's.
+        //
+        // `offset_frontier` is NOT dead: it is the only durable carrier of the
+        // group's offset space once the segments that named it are gone. Every
+        // write stamps the current counter, so whichever write lands last (a
+        // view change, or the explicit persist an install issues) leaves a
+        // lower bound boot can re-seed from.
+        let mut state = self.consensus.vsr_state(0, 0);
+        state.offset_frontier = offset_frontier;
+        match superblock.write(&state.to_bytes()).await {
+            Ok(()) => {
+                self.consensus
+                    .mark_superblock_durable(state.view, state.log_view);
+                self.durable_offset_frontier.set(state.offset_frontier);
+                self.superblock_write_failures.set(0);
+                self.superblock_retry_after_micros.set(0);
+                true
+            }
+            Err(error) => {
+                let failures = self.superblock_write_failures.get() + 1;
+                self.superblock_write_failures.set(failures);
+                let backoff = SUPERBLOCK_RETRY_BACKOFF_BASE_MICROS
+                    .saturating_mul(1 << failures.min(SUPERBLOCK_RETRY_BACKOFF_MAX_SHIFT))
+                    .min(SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS);
+                self.superblock_retry_after_micros
+                    .set(self.consensus.clock_realtime_micros() + backoff);
+                // Rate-limited to the backoff steps: the tick would otherwise
+                // emit this every 10 ms for as long as the disk stays broken.
+                if failures.is_power_of_two() {
+                    tracing::error!(
+                        target: "iggy.partitions.diag",
+                        plane = "partitions",
+                        replica_id = self.consensus.replica(),
+                        namespace_raw = self.consensus.namespace(),
+                        view = state.view,
+                        log_view = state.log_view,
+                        superblock_write_failures = failures,
+                        retry_in_micros = backoff,
+                        %error,
+                        "partition superblock persist failed; withholding every view-scoped \
+                         send for this group until it succeeds, so this replica stays \
+                         quorum-invisible there"
+                    );
+                }
+                false
+            }
+        }
+    }
+
+    /// Re-seed the offset counter from a recovered superblock record, taking
+    /// the MAX of what the record holds and what the recovered segments already
+    /// proved.
+    ///
+    /// The record is a lower bound, never a completeness claim: it exists
+    /// because three paths leave a replica whose counter would otherwise
+    /// restart at 0 while the group is at N (a transfer install of an all-GC'd
+    /// origin, a crash inside the install's swap window, and the
+    /// fence-and-rebuild path, which needs no crash at all). Restarting the
+    /// counter is not a lag -- replicas re-stamp `base_offset` from it and
+    /// recompute `batch_checksum` over the result, so the next replicated
+    /// prepare would persist different bytes here than on every peer, silently.
+    ///
+    /// Lives HERE rather than in the server crate so the boot paths and the
+    /// simulator share one implementation. A copy in the harness was a copy of
+    /// the max rule that had lost the max, in the one place built to catch
+    /// violations of it.
+    pub fn restore_offset_frontier(&mut self, recovered: Option<&consensus::VsrState>) {
+        let Some(frontier) = recovered
+            .map(|state| state.offset_frontier)
+            .filter(|&f| f > 0)
+        else {
+            return;
+        };
+        let recovered_end = frontier - 1;
+        if self.should_increment_offset && self.offset.load(Ordering::Acquire) >= recovered_end {
+            return;
+        }
+        tracing::info!(
+            namespace_raw = self.consensus().namespace(),
+            offset_frontier = frontier,
+            "restored partition offset frontier from its superblock"
+        );
+        self.offset.store(recovered_end, Ordering::Release);
+        self.dirty_offset.store(recovered_end, Ordering::Relaxed);
+        self.should_increment_offset = true;
+        self.stats.set_current_offset(recovered_end);
+    }
+
+    /// The next message offset this replica will mint, `0` while the offset
+    /// space is still empty. The value stamped into the durable record.
+    #[must_use]
+    pub fn offset_frontier(&self) -> u64 {
+        if self.should_increment_offset {
+            self.offset.load(Ordering::Acquire).saturating_add(1)
+        } else {
+            0
+        }
+    }
+
+    /// Force the durable record to catch up with the current offset frontier,
+    /// outside the view-change gate.
+    ///
+    /// [`Self::persist_superblock_if_needed`] fires on `(view, log_view)`
+    /// changes only, which is the right trigger for the split-brain fence and
+    /// the wrong one for the frontier: an install can move the counter by
+    /// millions without touching the view. Called where the frontier changes
+    /// with nothing else durable naming it -- after a state-transfer install
+    /// and after the convergence that follows a failed one. Returns whether the
+    /// record now holds it; a failure is logged by the writer and left to the
+    /// ordinary retry, since the install itself already succeeded.
+    #[allow(clippy::future_not_send)]
+    #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+    pub async fn persist_offset_frontier(&self) -> bool {
+        self.persist_offset_frontier_at(self.offset_frontier())
+            .await
+    }
+
+    /// Record a frontier that may be LOWER than the one already on disk.
+    ///
+    /// The frontier is conditionally monotone: it advances everywhere except a
+    /// purge, which legitimately resets the offset space to 0. The advancing
+    /// form cannot express that -- it maxes against the live counter -- and the
+    /// distinction has to be explicit: a purge that leaves the old frontier
+    /// recorded makes the next boot re-seed the counter to the state the purge
+    /// just erased, and the following append stamps `base_offset` N where every
+    /// peer stamps 0.
+    #[allow(clippy::future_not_send)]
+    #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+    pub async fn reset_offset_frontier(&self) -> bool {
+        self.reset_offset_frontier_at(self.offset_frontier()).await
+    }
+
+    /// [`Self::reset_offset_frontier`] for a frontier the live counter does not
+    /// hold yet.
+    ///
+    /// Two callers need the value spelled out rather than read off the counter.
+    /// A purge records its reset BEFORE it unlinks anything, while the counter
+    /// still names the pre-purge space, so a crash mid-unlink cannot boot into
+    /// a re-seed of the space the purge was erasing. An install under an
+    /// advancing purge generation records the offer's frontier, which is
+    /// legitimately below the local counter: the advancing form would max it
+    /// straight back up and leave the pre-purge value on disk across the swap
+    /// window.
+    #[allow(clippy::future_not_send)]
+    #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+    pub async fn reset_offset_frontier_at(&self, frontier: u64) -> bool {
+        let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+            return true;
+        };
+        if self.superblock_write_is_backed_off() {
+            return false;
+        }
+        let _superblock_guard = self.superblock_lock.acquire().await;
+        self.write_superblock_inner(superblock.as_ref(), frontier)
+            .await
+    }
+
+    /// Record the frontier immediately ahead of an irreversible quarantine,
+    /// BYPASSING the retry backoff.
+    ///
+    /// The gate exists because the other writers' callers became retry loops,
+    /// and skipping a doomed write costs them nothing. This caller is the
+    /// opposite: it writes once and then moves the segments that are the
+    /// record's only corroborating witness into `.fenced.N`, so a skip here is
+    /// not deferred work, it is the last chance gone. A disk that recovered
+    /// inside the backoff window would otherwise leave the rebuild re-seeding
+    /// from a stale record with nothing left to take the max against.
+    ///
+    /// `intended` is the frontier the caller knows the group is at, written
+    /// verbatim; `None` means the live counter is authoritative and the
+    /// advancing form applies.
+    #[allow(clippy::future_not_send)]
+    #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+    pub async fn record_frontier_before_quarantine(&self, intended: Option<u64>) -> bool {
+        let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+            return true;
+        };
+        let _superblock_guard = self.superblock_lock.acquire().await;
+        match intended {
+            Some(frontier) => {
+                self.write_superblock_inner(superblock.as_ref(), frontier)
+                    .await
+            }
+            None => {
+                self.write_superblock(superblock.as_ref(), self.offset_frontier())
+                    .await
+            }
+        }
+    }
+
+    /// Whether a recent write failure's backoff window is still open.
+    ///
+    /// The same gate [`Self::persist_superblock_if_needed`] applies before its
+    /// own write, extended to the spelled-value writers because their callers
+    /// became retry loops: a deferred purge is re-issued by the reconciler, and
+    /// without this each pass re-runs a full `atomic_replace` against a disk
+    /// that just refused one, as fast as `ENOSPC` returns.
+    fn superblock_write_is_backed_off(&self) -> bool {
+        self.consensus.clock_realtime_micros() < self.superblock_retry_after_micros.get()
+    }
+
+    /// [`Self::persist_offset_frontier`] for a frontier this replica has not
+    /// reached yet.
+    ///
+    /// Used to record an INCOMING frontier before a destructive swap: the
+    /// install unlinks the old chain and fsyncs that before the first staged
+    /// rename lands, and boot sweeps `.log.staging` unconditionally, so a crash
+    /// in that window otherwise leaves no copy of the frontier anywhere. Writing
+    /// the claim first makes it a durable lower bound the whole way through, and
+    /// over-claiming is harmless: the convergence that follows a failed install
+    /// seeds the counter from the same artifact frontier.
+    #[allow(clippy::future_not_send)]
+    #[must_use = "the bool is the durability verdict; dropping it silently ignores a failed write"]
+    pub async fn persist_offset_frontier_at(&self, frontier: u64) -> bool {
+        let Some(superblock) = self.superblock.as_ref().map(Rc::clone) else {
+            return true;
+        };
+        if self.superblock_write_is_backed_off() {
+            return false;
+        }
+        let _superblock_guard = self.superblock_lock.acquire().await;
+        self.write_superblock(superblock.as_ref(), frontier).await
+    }
+
+    /// Burn one transfer stall round; `true` once the budget is exhausted.
+    /// Lives on the partition, not the session, so a re-minted session
+    /// cannot reset it (see [`Self::transfer_attempts`]).
+    #[must_use = "the bool is the abandon verdict; dropping it disables the stall budget"]
+    pub const fn burn_transfer_attempt(&mut self) -> bool {
+        self.transfer_attempts += 1;
+        self.transfer_attempts > consensus::STATE_TRANSFER_MAX_STALL_RETRIES
+    }
+
+    /// Real transfer progress: reset the stall budget. The budget bounds
+    /// CONSECUTIVE stalls, not lifetime ones; without this a handful of
+    /// stalls scattered across a large transfer would abandon one that was
+    /// nearly done, throwing away every byte already pulled.
+    pub const fn note_transfer_progress(&mut self) {
+        self.transfer_attempts = 0;
+    }
+
+    /// Charge one transfer failure (any class) and return the consecutive
+    /// count; the shard scales its re-arm backoff by it. Never resets on a
+    /// new generation or on received chunks -- a deterministic failure
+    /// re-pulls successfully every round and still must back off -- only
+    /// [`Self::note_transfer_installed`] clears it.
+    pub const fn record_transfer_failure(&mut self) -> u32 {
+        self.transfer_failures = self.transfer_failures.saturating_add(1);
+        self.transfer_failures
+    }
+
+    /// A completed install: the one signal that genuinely proves the
+    /// transfer pipeline works end to end, so it alone resets the
+    /// consecutive-failure count.
+    pub const fn note_transfer_installed(&mut self) {
+        self.transfer_failures = 0;
+        self.transfer_refusals = 0;
+    }
+
+    /// Charge one TRANSIENT refusal and return the consecutive count.
+    ///
+    /// Separate from [`Self::record_transfer_failure`] on purpose: a transient
+    /// refusal must not touch the exponential backoff (the flat retry interval
+    /// is the point), but a partition refused for hours still has to be
+    /// visible, so the count exists only to escalate logging and feed a metric.
+    /// Reset by [`Self::note_transfer_installed`] alongside the failure count.
+    pub const fn record_transfer_refusal(&mut self) -> u32 {
+        self.transfer_refusals = self.transfer_refusals.saturating_add(1);
+        self.transfer_refusals
+    }
+
+    /// A fresh re-arm is scheduled: the stall budget starts over for it.
+    ///
+    /// Carrying an exhausted budget into the next attempt left every later
+    /// session with a single retry-interval window to land its first response --
+    /// against a re-arm backoff climbing to 1024x, and a serving side that
+    /// hashes retained bytes before it can answer, so a slow first response is
+    /// ordinary rather than a stall. The budget bounds consecutive stalls within
+    /// one attempt; `transfer_failures` and its backoff are what bound livelock
+    /// across attempts.
+    pub const fn note_transfer_rearm_scheduled(&mut self) {
+        self.transfer_attempts = 0;
+    }
+
+    /// Read-only view of the stall budget, for diagnostics. The counters
+    /// themselves are private: they are the anti-livelock argument, and the
+    /// docs promise exactly one resetter each -- a `pub` field would let any
+    /// future call site break that silently.
+    #[must_use]
+    pub const fn transfer_attempts(&self) -> u32 {
+        self.transfer_attempts
+    }
+
     pub fn configure_consumer_offset_storage(
         &mut self,
         consumer_offsets_path: String,
@@ -668,7 +1273,11 @@
         }
     }
 
-    fn persisted_offset_path(&self, kind: ConsumerKind, consumer_id: u32) -> Option<String> {
+    pub(crate) fn persisted_offset_path(
+        &self,
+        kind: ConsumerKind,
+        consumer_id: u32,
+    ) -> Option<String> {
         match kind {
             ConsumerKind::Consumer => self
                 .consumer_offsets_path
@@ -920,9 +1529,10 @@
     }
 }
 
-impl<B> Partition for IggyPartition<B>
+impl<B, SB> Partition for IggyPartition<B, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     async fn append_messages(
         &mut self,
@@ -1026,9 +1636,10 @@
     }
 }
 
-impl<B> IggyPartition<B>
+impl<B, SB> IggyPartition<B, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     #[must_use]
     fn namespace(&self) -> IggyNamespace {
@@ -2960,9 +3571,18 @@
     /// (see `rotate_segment`); falls back to the config-derived path for
     /// in-memory partitions with no directory.
     ///
+    /// Both files are opened through `SegmentStorage::new` with
+    /// `file_exists = false`, which TRUNCATES them. That is load-bearing, not
+    /// incidental: this offset may already have an `.index` on disk (a crash
+    /// between the state-transfer install's index-rename and log-rename loops
+    /// leaves final-name indexes with no logs, and the boot sweep only reaches
+    /// the ones still orphaned at startup). The `partitions`-side writers with
+    /// the same names do NOT truncate, so a recreate path that opened them
+    /// directly would read index entries from a previous generation.
+    ///
     /// # Errors
     /// If the segment's log / index file cannot be created.
-    async fn install_empty_segment(
+    pub(crate) async fn install_empty_segment(
         &mut self,
         config: &PartitionsConfig,
         start_offset: u64,
@@ -3034,6 +3654,55 @@
         Ok(())
     }
 
+    /// Record the purge's frontier reset BEFORE the purge touches anything.
+    ///
+    /// The unlinks are made durable by their own directory fsync, so a crash
+    /// between them and a reset written afterwards boots a purged directory
+    /// whose record still names the pre-purge offset space:
+    /// `restore_offset_frontier` re-seeds the counter to it while every peer
+    /// restarted at 0, and the first append stamps a `base_offset` and
+    /// `batch_checksum` no peer shares. Writing 0 first inverts the window into
+    /// a harmless one -- the record under-claims while the segments still
+    /// exist, and boot takes the max of the record and what the segments prove.
+    ///
+    /// Spelled out rather than read off the counter, which still holds the
+    /// pre-purge frontier at this point.
+    ///
+    /// # Errors
+    /// [`PurgeError::FrontierNotRecorded`]. Refused rather than logged: nothing
+    /// has been mutated yet, and a purge that cannot record its reset must not
+    /// be the one that erases the data proving the old frontier. The caller
+    /// RETRIES; it must not fence, since the chain is still whole and the live
+    /// counter still names the pre-purge space.
+    #[allow(clippy::future_not_send)]
+    async fn record_purge_frontier_reset(&mut self, generation: u64) -> Result<(), PurgeError> {
+        if self.reset_offset_frontier_at(0).await {
+            self.purge_deferred = false;
+            return Ok(());
+        }
+        self.purge_deferred = true;
+        // The ONLY operator-visible signal for the withhold: `send_prepare_ok`
+        // returns silently, correctly, since it runs per prepare. So this line
+        // has to say that the replica is now out of quorum for this group, or
+        // the symptom reads as a network fault. The consecutive count
+        // correlates it with the superblock writer's own error log, which
+        // carries the `ENOSPC` / `EIO` cause but is rate-limited to
+        // power-of-two failures, while this deferral repeats per reconciler
+        // pass.
+        warn!(
+            target: "iggy.partitions.diag",
+            plane = "partitions",
+            namespace_raw = self.namespace().inner(),
+            generation,
+            superblock_write_failures = self.superblock_write_failures.get(),
+            "cannot record the purge's offset-frontier reset; deferring the purge so the \
+             durable frontier cannot outlive the data it describes. This replica now \
+             withholds PrepareOk for this partition until the purge lands, so it is \
+             quorum-invisible there; its other partitions are unaffected"
+        );
+        Err(PurgeError::FrontierNotRecorded)
+    }
+
     /// Reset the partition to a single empty segment at offset 0 and clear all
     /// consumer / consumer-group offsets (memory + disk). This is the local
     /// effect of a committed `PurgeTopic`: it wipes message data and offsets but
@@ -3045,17 +3714,27 @@
     /// `PurgeTopic` advances the committed generation and triggers a fresh pass).
     ///
     /// # Errors
-    /// If the replacement segment's log / index file cannot be created.
+    /// [`PurgeError::FrontierNotRecorded`] before anything is mutated, which
+    /// the caller RETRIES: the reconciler re-issues the purge while
+    /// `committed > applied`, and fencing a partition that still holds its whole
+    /// chain would quarantine live data behind a counter that still names the
+    /// pre-purge offset space. [`PurgeError::Unserviceable`] once the drain has
+    /// run, which the caller FENCES (quarantine + retire for the reconciler to
+    /// rebuild), exactly as the state-transfer install's `ConvergeFailed` arm
+    /// does, or the next append panics on `active_segment()`.
+    #[allow(clippy::too_many_lines)]
     pub async fn purge(
         &mut self,
         config: &PartitionsConfig,
         generation: u64,
-    ) -> Result<(), IggyError> {
+    ) -> Result<(), PurgeError> {
         let write_lock = self.write_lock.clone();
         let _guard = write_lock.lock().await;
 
         let namespace = self.namespace();
 
+        self.record_purge_frontier_reset(generation).await?;
+
         // The purge recreates segment files at the paths it unlinks below, so
         // an in-flight poll's cached read fd would keep serving the unlinked
         // pre-purge inodes as live data. Wipe the shared read-state slots
@@ -3092,19 +3771,57 @@
             }
         }
 
-        // Recreate a fresh empty segment at offset 0 with real writers.
-        let start_offset = 0u64;
-        self.install_empty_segment(config, start_offset).await?;
+        // An in-flight state transfer was pulling the PRE-purge state: its
+        // staged segments hold data this purge just deleted, and letting the
+        // session complete renames it back in -- durably, because the install
+        // takes `max(offer generation, applied)` and this purge already stamped
+        // the newer generation, so the reconciler's purge gate never re-fires
+        // and the resurrected data outlives the process. Drop the session,
+        // cancel the scheduled re-arm, release the transfer stage so the
+        // ordinary triggers can arm a fresh one, and sweep the staged bytes.
+        self.transfer = None;
+        self.transfer_rearm = None;
+        let consensus = self.consensus();
+        if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
+            consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
+        }
+        self.reuse_scan_memo.borrow_mut().take();
+        if let Some(partition_dir) = self.partition_dir.clone() {
+            crate::state_transfer::sweep_staging_except(&partition_dir, &HashSet::new()).await;
+        }
 
-        // Reset the offset counters so new messages start at offset 0.
+        let start_offset = 0u64;
+        // Counters reset BEFORE the fallible plant, not after: `?` on
+        // `install_empty_segment` would otherwise leave the live counter at the
+        // pre-purge value, which is what the router's purge-failure fence then
+        // records and what a restart would re-seed. Safe to reorder --
+        // `install_empty_segment` takes `start_offset` as a parameter and never
+        // reads the counter, and the partition write lock is held across this
+        // whole body.
         self.offset.store(start_offset, Ordering::Release);
         self.dirty_offset.store(start_offset, Ordering::Relaxed);
         self.should_increment_offset = false;
+
+        // Recreate a fresh empty segment at offset 0 with real writers. Every
+        // segment is drained by now, so a failure here is the fence case.
+        self.install_empty_segment(config, start_offset)
+            .await
+            .map_err(PurgeError::Unserviceable)?;
+        // Make the unlinks AND the replanted dirent durable together: without
+        // this a crash can resurrect pre-purge segments until the boot re-purge
+        // fires. Bounded and self-healing, but the fsync is one call.
+        if let Some(partition_dir) = self.partition_dir.clone() {
+            let _ = crate::state_transfer::fsync_dir(&partition_dir).await;
+        }
         // The boot-time durable line marks recovered bytes that must not be
         // re-persisted, but the purge just deleted those bytes and offsets
         // restart at 0. Keeping it would make every post-purge batch at or
         // below the old line evict silently without ever reaching a segment.
+        // The installed frontier goes with it: the offset space genuinely
+        // restarts, so nothing "stands in" below any floor anymore.
         self.recovered_durable_offset = None;
+        self.installed_frontier = None;
+        self.segment_checksum_cache.borrow_mut().clear();
 
         // Clear consumer + consumer-group offsets (memory + disk). Collect the
         // file paths before deleting so the map guard is not held across an
@@ -3138,6 +3855,19 @@
         for path in consumer_paths.into_iter().chain(group_paths) {
             let _ = delete_persisted_offset(&path).await;
         }
+        // Directory fsync so those unlinks stick, mirroring the install path: a
+        // crash right after the purge otherwise resurrects the offset files at
+        // boot, and while recovery clamps a resurrected offset down to the
+        // rebuilt head, "consumed through 0" is not the intended "no entry at
+        // all" -- that consumer skips the first post-purge message.
+        for dir in self
+            .consumer_offsets_path
+            .clone()
+            .into_iter()
+            .chain(self.consumer_group_offsets_path.clone())
+        {
+            let _ = crate::state_transfer::fsync_dir(&dir).await;
+        }
         // The persisted-offset tracker mirrors the files unlinked above; a
         // stale entry would make a post-purge auto-commit skip its write and
         // lose the offset on restart.
@@ -3154,6 +3884,23 @@
         self.stats.increment_segments_count(1);
 
         self.applied_purge_generation = generation;
+        // Same commit frontier, different (now empty) bytes: a cached offer
+        // built pre-purge would advertise files the purge just unlinked.
+        self.transfer_offer_cache.borrow_mut().take();
+        // The reset itself already landed before the unlinks; this second write
+        // only re-stamps the record now that the view-scoped fields and the
+        // counter agree with it. A failure leaves the pre-unlink 0 on disk,
+        // which is the safe direction, so it is logged rather than refused.
+        if !self.reset_offset_frontier().await {
+            warn!(
+                target: "iggy.partitions.diag",
+                plane = "partitions",
+                namespace_raw = namespace.inner(),
+                generation,
+                "purge could not re-stamp the superblock after resetting the partition; \
+                 the frontier reset written before the unlinks still stands"
+            );
+        }
         Ok(())
     }
 
@@ -3244,9 +3991,9 @@
     /// peer's eviction point (everything below it is represented by this
     /// replica's recovered segments + offset files) and walk the repaired
     /// window through the normal commit path.
-    pub async fn complete_repair(&mut self, config: &PartitionsConfig) {
+    pub async fn complete_repair(&mut self, config: &PartitionsConfig) -> RepairConclusion {
         let Some(session) = self.repair else {
-            return;
+            return RepairConclusion::Done;
         };
         if let Some(floor) = session.floor {
             // A peer may have evicted past this replica's commit frontier;
@@ -3261,8 +4008,15 @@
             // would silently serve a holed log. Refuse and stay gap-stopped:
             // a visible stall beats invisible loss.
             let durable_end = self.recovered_durable_offset;
-            let connected = match (session.first_batch_offset, durable_end) {
-                (Some(first), Some(durable)) => first <= durable.saturating_add(1),
+            // Recovered bytes and an installed frontier both "stand in"
+            // below the floor; a window connecting to either is whole. `None`
+            // orders below every `Some`, so the join covers all four
+            // combinations.
+            let stand_in = durable_end
+                .map(|durable| durable.saturating_add(1))
+                .max(self.installed_frontier);
+            let connected = match (session.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
@@ -3286,7 +4040,25 @@
                      to recovered durable state (needs state transfer)"
                 );
                 self.commit_journal(config).await;
-                return;
+                // 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.
+                // 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
+                // (everything evicted) re-raises identically every round.
+                // Both are the state-transfer trigger; the session is
+                // dropped here so the caller's arming funnel starts clean,
+                // and a transfer-unavailable fallback re-arms repair fresh.
+                if self.repaired_window_is_complete(floor, session.to_op) {
+                    self.repair = None;
+                    return RepairConclusion::FloorRefused {
+                        floor,
+                        to_op: session.to_op,
+                    };
+                }
+                return RepairConclusion::InProgress;
             }
             let commit_min = self.consensus().commit_min();
             if floor > commit_min {
@@ -3317,6 +4089,22 @@
             done,
             "repair window commit walk finished"
         );
+        if done {
+            RepairConclusion::Done
+        } else {
+            RepairConclusion::InProgress
+        }
+    }
+
+    /// Whether every op in `(floor, to_op]` is journaled. An empty window
+    /// (`floor >= to_op`) counts as complete: there is nothing left that
+    /// could arrive and change the floor verdict.
+    fn repaired_window_is_complete(&self, floor: u64, to_op: u64) -> bool {
+        self.log
+            .journal()
+            .inner
+            .repaired_window_shape(floor, to_op)
+            .complete
     }
 
     /// Whether the served repair window `(floor, to_op]` arrived complete and
@@ -3329,13 +4117,8 @@
         if floor >= to_op {
             return false;
         }
-        ((floor + 1)..=to_op).all(|op| {
-            self.log
-                .journal()
-                .inner
-                .header_by_op(op)
-                .is_some_and(|header| header.operation != Operation::SendMessages)
-        })
+        let shape = self.log.journal().inner.repaired_window_shape(floor, to_op);
+        shape.complete && !shape.holds_messages
     }
 
     /// Journal a repaired `SendMessages` prepare, preserving its embedded
@@ -3398,6 +4181,24 @@
     }
 
     async fn send_prepare_ok(&self, header: &PrepareHeader) {
+        // Durable-before-send: a PrepareOk implies this replica's
+        // (view, log_view), so it must not leave until they are durable, or a
+        // crash could recover an older view than the one this ack helped
+        // commit in, losing a committed op. Mirrors the view-change dispatch
+        // gate; withhold on persist failure and let the primary's prepare
+        // retransmit re-drive the ack once a later persist succeeds.
+        if !self.persist_superblock_if_needed().await {
+            return;
+        }
+        // Same fail-closed shape for a purge this replica accepted but has not
+        // applied: its counter still names the pre-purge offset space, so an ack
+        // now helps commit an op it will stamp differently from every peer that
+        // did apply. The primary's retransmit re-drives the ack once the purge
+        // lands. Local commits still apply -- this fences the SEND, exactly as
+        // the durability gate above does.
+        if self.purge_deferred {
+            return;
+        }
         // `VsrAction::RetransmitPrepares` reads from `self.log.journal`.
         // Both `SendMessages` (via `append_send_messages_to_journal`) and
         // consumer-offset ops (via `apply_replicated_operation`) append
@@ -3634,12 +4435,356 @@
         )
     }
 
+    /// Partition whose consensus already advanced to `(view, log_view)` with
+    /// nothing marked durable, as after a view change and before the persist
+    /// gate runs.
+    fn partition_at_view(
+        view: u32,
+        log_view: u32,
+    ) -> IggyPartition<IggyMessageBus, RecordingSuperblock> {
+        let namespace = IggyNamespace::new(1, 1, 0);
+        let mut consensus = VsrConsensus::new(
+            TEST_CLUSTER,
+            0,
+            3,
+            namespace.inner(),
+            IggyMessageBus::new(0),
+            LocalPipeline::new(),
+        );
+        consensus.set_view(view);
+        consensus.set_log_view(log_view);
+        consensus.init_as_backup();
+        IggyPartition::with_in_memory_storage(
+            Arc::new(PartitionStats::default()),
+            consensus,
+            IggyByteSize::from(1024 * 1024),
+            false,
+        )
+    }
+
+    /// In-memory superblock double: records every payload, counts attempts,
+    /// and injects write failures.
+    #[derive(Default)]
+    struct RecordingSuperblock {
+        writes: RefCell<Vec<Vec<u8>>>,
+        attempts: Cell<u32>,
+        fail_writes: Cell<bool>,
+    }
+
+    impl journal::superblock::SuperblockStore for RecordingSuperblock {
+        async fn write(&self, payload: &[u8]) -> std::io::Result<()> {
+            self.attempts.set(self.attempts.get() + 1);
+            if self.fail_writes.get() {
+                return Err(std::io::Error::other("injected superblock write failure"));
+            }
+            self.writes.borrow_mut().push(payload.to_vec());
+            Ok(())
+        }
+
+        async fn read_latest(&self) -> std::io::Result<journal::superblock::SuperblockContents> {
+            Ok(self
+                .writes
+                .borrow()
+                .last()
+                .map_or(journal::superblock::SuperblockContents::Empty, |bytes| {
+                    journal::superblock::SuperblockContents::Present(bytes.clone())
+                }))
+        }
+    }
+
+    #[compio::test]
+    async fn given_storeless_partition_when_persist_gate_runs_should_mark_current_view_durable() {
+        let partition = partition_at_view(2, 1);
+        assert!(partition.consensus().needs_superblock_persist());
+
+        assert!(partition.persist_superblock_if_needed().await);
+
+        assert!(
+            !partition.consensus().needs_superblock_persist(),
+            "a storeless partition must record durable = current, or the dispatch \
+             tripwire would fire on its first view-scoped send"
+        );
+    }
+
+    #[compio::test]
+    async fn given_advanced_view_when_persist_gate_runs_should_write_vsr_state_once() {
+        let mut partition = partition_at_view(3, 2);
+        let store = Rc::new(RecordingSuperblock::default());
+        partition.set_superblock(store.clone(), None);
+
+        assert!(partition.persist_superblock_if_needed().await);
+
+        let state = consensus::VsrState::try_from(store.writes.borrow()[0].as_slice())
+            .expect("recorded payload decodes as a VsrState");
+        assert_eq!(state.cluster, TEST_CLUSTER);
+        assert_eq!(state.view, 3);
+        assert_eq!(state.log_view, 2);
+        assert_eq!(
+            (state.checkpoint_op, state.checkpoint_checksum),
+            (0, 0),
+            "no partition checkpoint exists yet, so the pairing fields stay zero"
+        );
+        assert!(!partition.consensus().needs_superblock_persist());
+
+        assert!(partition.persist_superblock_if_needed().await);
+        assert_eq!(
+            store.attempts.get(),
+            1,
+            "an unchanged view must take the lock-free fast path, not rewrite"
+        );
+    }
+
+    /// The `offset_frontier` of the most recent recorded write.
+    fn last_recorded_frontier(store: &RecordingSuperblock) -> u64 {
+        let writes = store.writes.borrow();
+        let bytes = writes.last().expect("a superblock write landed");
+        consensus::VsrState::try_from(bytes.as_slice())
+            .expect("recorded payload decodes as a VsrState")
+            .offset_frontier
+    }
+
+    /// The fence path persists the frontier while the live counter still sits
+    /// at its pre-install value, so an advance that maxes against the counter
+    /// alone erases the record and then quarantines the segments that were its
+    /// only other witness. Boot re-mints from 0 against a group at N after that.
+    #[compio::test]
+    async fn given_record_above_live_counter_when_advancing_should_keep_the_record() {
+        let mut partition = partition_at_view(1, 1);
+        let store = Rc::new(RecordingSuperblock::default());
+        partition.set_superblock(store.clone(), None);
+
+        assert!(partition.persist_offset_frontier_at(9_000).await);
+        assert_eq!(last_recorded_frontier(&store), 9_000);
+        assert_eq!(
+            partition.offset_frontier(),
+            0,
+            "a partition that never minted reports a zero frontier, which is the \
+             value the fence would otherwise persist"
+        );
+
+        assert!(partition.persist_offset_frontier().await);
+
+        assert_eq!(
+            last_recorded_frontier(&store),
+            9_000,
+            "the advance direction must not lower the durable frontier"
+        );
+    }
+
+    /// Attaching a store seeds the last-written frontier from the record
+    /// itself, so an advance maxes against what boot read off disk even before
+    /// this replica has written anything. The sibling test reaches that state by
+    /// WRITING first, which cannot catch an attach site that skips the seed.
+    #[compio::test]
+    async fn given_attached_record_when_advancing_should_keep_the_recorded_frontier() {
+        let mut partition = partition_at_view(1, 1);
+        let store = Rc::new(RecordingSuperblock::default());
+        let recovered = consensus::VsrState {
+            cluster: TEST_CLUSTER,
+            replica_id: 0,
+            replica_count: 3,
+            view: 1,
+            log_view: 1,
+            commit_max: 0,
+            checkpoint_op: 0,
+            checkpoint_checksum: 0,
+            offset_frontier: 4_200,
+        };
+        partition.set_superblock(store.clone(), Some(&recovered));
+        assert_eq!(partition.offset_frontier(), 0, "nothing minted locally");
+
+        assert!(partition.persist_offset_frontier().await);
+
+        assert_eq!(
+            last_recorded_frontier(&store),
+            4_200,
+            "the first write after an attach must not lower the record it was attached to"
+        );
+    }
+
+    /// The reset direction is the only way down, and it must actually go there:
+    /// an install under an advancing purge generation records a frontier below
+    /// the live counter on purpose.
+    #[compio::test]
+    async fn given_reset_below_live_counter_when_written_should_lower_the_record() {
+        let mut partition = partition_at_view(1, 1);
+        let store = Rc::new(RecordingSuperblock::default());
+        partition.set_superblock(store.clone(), None);
+        partition.offset.store(9_000, Ordering::Release);
+        partition.should_increment_offset = true;
+
+        assert!(partition.persist_offset_frontier().await);
+        assert_eq!(last_recorded_frontier(&store), 9_001);
+
+        assert!(partition.reset_offset_frontier_at(12).await);
+
+        assert_eq!(
+            last_recorded_frontier(&store),
+            12,
+            "the reset must record the incoming frontier, not max back up to the \
+             counter the install is about to replace"
+        );
+    }
+
+    /// A purge records its reset before it unlinks anything, so a write it
+    /// cannot make has to stop the purge while the data proving the old
+    /// frontier is still on disk.
+    #[compio::test]
+    async fn given_failing_store_when_purge_records_its_reset_should_refuse_before_mutating() {
+        let mut partition = partition_at_view(1, 1);
+        let store = Rc::new(RecordingSuperblock::default());
+        partition.set_superblock(store.clone(), None);
+        partition.offset.store(9_000, Ordering::Release);
+        partition.should_increment_offset = true;
+
+        store.fail_writes.set(true);
+        assert!(
+            matches!(
+                partition.record_purge_frontier_reset(7).await,
+                Err(PurgeError::FrontierNotRecorded)
+            ),
+            "the pre-mutation refusal must be distinguishable from a post-drain \
+             failure: the caller retries this one and fences the other"
+        );
+
+        assert!(
+            partition.purge_deferred,
+            "a deferred purge must fence the ack path: the counter still names the \
+             pre-purge offset space, and the view-change persist gate cannot see \
+             this because a stable view attempts no write at all"
+        );
+
+        store.fail_writes.set(false);
+        assert!(
+            matches!(
+                partition.record_purge_frontier_reset(7).await,
+                Err(PurgeError::FrontierNotRecorded)
+            ),
+            "the failed write armed a backoff, and the retry must respect it rather \
+             than re-running a full atomic_replace against a disk that just refused one"
+        );
+        assert_eq!(
+            store.attempts.get(),
+            1,
+            "the backed-off retry must not reach the store at all"
+        );
+
+        // Backoff expiry, without a controllable clock in this fixture.
+        partition.superblock_retry_after_micros.set(0);
+        partition
+            .record_purge_frontier_reset(7)
+            .await
+            .expect("a working store records the reset once the backoff elapses");
+        assert!(
+            !partition.purge_deferred,
+            "recording the reset releases the fence"
+        );
+        assert_eq!(
+            last_recorded_frontier(&store),
+            0,
+            "the reset is spelled out, not read off a counter still holding the \
+             pre-purge frontier"
+        );
+    }
+
+    #[compio::test]
+    async fn given_undurable_view_when_sending_prepare_ok_should_withhold_until_persisted() {
+        let bus = RecordingBus::default();
+        let replica_frames = bus.sent_to_replicas.clone();
+        let mut consensus = VsrConsensus::new(
+            TEST_CLUSTER,
+            0,
+            3,
+            IggyNamespace::new(1, 1, 0).inner(),
+            bus,
+            LocalPipeline::new(),
+        );
+        consensus.set_view(1);
+        consensus.set_log_view(1);
+        consensus.init_as_backup();
+        let mut partition: IggyPartition<RecordingBus, RecordingSuperblock> =
+            IggyPartition::with_in_memory_storage(
+                Arc::new(PartitionStats::default()),
+                consensus,
+                IggyByteSize::from(1024 * 1024),
+                false,
+            );
+        let store = Rc::new(RecordingSuperblock::default());
+        store.fail_writes.set(true);
+        partition.set_superblock(store.clone(), None);
+        // The ack path drops an op past the local head, so the head must cover it.
+        partition.consensus().sequencer().set_sequence(1);
+        let size = std::mem::size_of::<PrepareHeader>();
+        let prepare = Message::<PrepareHeader>::new(size).transmute_header(
+            |_, header: &mut PrepareHeader| {
+                header.command = Command2::Prepare;
+                header.op = 1;
+                // Current view: an older-view prepare is fenced as deposed-primary
+                // traffic and would never reach the ack send under test.
+                header.view = 1;
+                header.size = u32::try_from(size).expect("prepare header size fits in u32");
+            },
+        );
+        let header = *prepare.header();
+
+        partition.send_prepare_ok(&header).await;
+
+        assert!(
+            replica_frames.borrow().is_empty(),
+            "an ack must not leave while the advanced view is not durable"
+        );
+        assert_eq!(store.attempts.get(), 1);
+
+        // Outwait the write-failure backoff (base 10 ms doubled once by the
+        // first failure), then retry with the store healthy: the ack must
+        // persist first and then go out.
+        store.fail_writes.set(false);
+        compio::time::sleep(std::time::Duration::from_millis(50)).await;
+        partition.send_prepare_ok(&header).await;
+
+        assert_eq!(
+            replica_frames.borrow().len(),
+            1,
+            "the retried ack must go out once the view persisted"
+        );
+        assert!(!partition.consensus().needs_superblock_persist());
+    }
+
+    #[compio::test]
+    async fn given_failing_superblock_when_persist_gate_runs_should_withhold_and_back_off() {
+        let mut partition = partition_at_view(1, 1);
+        let store = Rc::new(RecordingSuperblock::default());
+        store.fail_writes.set(true);
+        partition.set_superblock(store.clone(), None);
+
+        assert!(
+            !partition.persist_superblock_if_needed().await,
+            "a failed write must withhold the send"
+        );
+        assert_eq!(store.attempts.get(), 1);
+
+        assert!(
+            !partition.persist_superblock_if_needed().await,
+            "the backoff window must withhold without retrying the write"
+        );
+        assert_eq!(
+            store.attempts.get(),
+            1,
+            "a call inside the backoff window must not touch the store"
+        );
+        assert!(
+            partition.consensus().needs_superblock_persist(),
+            "the view stays undurable until a write lands"
+        );
+    }
+
     /// Client-facing bus that records every `send_to_client` frame so tests
     /// can assert on reply bytes without a connection registry (whose slot
     /// guard would borrow the partition across `on_request(&mut self)`).
     #[derive(Debug, Default)]
     struct RecordingBus {
         sent_to_clients: Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>,
+        sent_to_replicas: Rc<RefCell<Vec<(u8, Frozen<MESSAGE_ALIGN>)>>>,
     }
 
     impl MessageBus for RecordingBus {
@@ -3656,9 +4801,10 @@
 
         async fn send_to_replica(
             &self,
-            _replica: u8,
-            _data: Frozen<MESSAGE_ALIGN>,
+            replica: u8,
+            data: Frozen<MESSAGE_ALIGN>,
         ) -> Result<(), SendError> {
+            self.sent_to_replicas.borrow_mut().push((replica, data));
             Ok(())
         }
 
@@ -4733,13 +5879,58 @@
     }
 
     #[compio::test]
+    async fn given_session_remint_when_attempts_burned_should_survive_on_partition() {
+        let mut partition = test_partition();
+        for round in 0..consensus::STATE_TRANSFER_MAX_STALL_RETRIES {
+            assert!(!partition.burn_transfer_attempt());
+            // A re-minted session must not reset the budget: it lives on the
+            // partition precisely because arming sites mint fresh sessions.
+            partition.transfer = Some(crate::state_transfer::PartitionTransferSession {
+                nonce: u128::from(round),
+                peer: 0,
+                commit_op: 0,
+                artifacts: Vec::new(),
+                target_accepted: false,
+                idle_ticks: 0,
+            });
+        }
+        assert!(partition.burn_transfer_attempt(), "budget exhausts");
+        partition.note_transfer_progress();
+        assert!(!partition.burn_transfer_attempt(), "progress resets it");
+    }
+
+    #[compio::test]
+    async fn given_repeated_failures_when_only_generation_advances_should_keep_counting() {
+        let mut partition = test_partition();
+        // A committing primary advances its generation every round; the
+        // consecutive count must keep growing regardless, or a
+        // deterministic local failure retries at network round-trip rate
+        // forever. Only a completed install resets it.
+        assert_eq!(partition.record_transfer_failure(), 1);
+        assert_eq!(partition.record_transfer_failure(), 2);
+        partition.note_transfer_progress();
+        assert_eq!(
+            partition.record_transfer_failure(),
+            3,
+            "received chunks are not install progress"
+        );
+        partition.note_transfer_installed();
+        assert_eq!(partition.record_transfer_failure(), 1, "install resets");
+    }
+
+    #[compio::test]
     async fn given_no_repaired_batch_when_window_never_arrived_should_refuse_commit_floor() {
         let mut partition = test_partition();
         partition.consensus().advance_commit_max(8);
         partition.repair = Some(armed_session(8, 5, None));
 
-        partition.complete_repair(&repair_config()).await;
+        let conclusion = partition.complete_repair(&repair_config()).await;
 
+        assert_eq!(
+            conclusion,
+            RepairConclusion::InProgress,
+            "an incomplete window is not a definitive refusal"
+        );
         assert_eq!(partition.consensus().commit_min(), 0);
         assert!(
             partition.repair.is_some(),
@@ -4759,8 +5950,9 @@
         }
         partition.repair = Some(armed_session(8, 5, None));
 
-        partition.complete_repair(&repair_config()).await;
+        let conclusion = partition.complete_repair(&repair_config()).await;
 
+        assert_eq!(conclusion, RepairConclusion::Done);
         assert!(partition.consensus().commit_min() >= 5);
     }
 
@@ -4774,9 +5966,18 @@
         }
         partition.repair = Some(armed_session(8, 5, None));
 
-        partition.complete_repair(&repair_config()).await;
+        let conclusion = partition.complete_repair(&repair_config()).await;
 
+        assert_eq!(
+            conclusion,
+            RepairConclusion::FloorRefused { floor: 5, to_op: 8 },
+            "a complete window with an unanchored message op can never connect"
+        );
         assert_eq!(partition.consensus().commit_min(), 0);
+        assert!(
+            partition.repair.is_none(),
+            "a definitive refusal hands recovery to state transfer"
+        );
     }
 
     #[compio::test]
@@ -4785,10 +5986,17 @@
         partition.consensus().advance_commit_max(8);
         partition.repair = Some(armed_session(8, 8, None));
 
-        partition.complete_repair(&repair_config()).await;
+        let conclusion = partition.complete_repair(&repair_config()).await;
 
+        // Everything the peer retained was evicted: a retry re-raises the
+        // identical empty window every round (the wedge state transfer
+        // exists to break), so this refusal is definitive.
+        assert_eq!(
+            conclusion,
+            RepairConclusion::FloorRefused { floor: 8, to_op: 8 }
+        );
         assert_eq!(partition.consensus().commit_min(), 0);
-        assert!(partition.repair.is_some());
+        assert!(partition.repair.is_none());
     }
 
     #[compio::test]
@@ -4801,9 +6009,214 @@
         // locally durable nor repaired.
         partition.repair = Some(armed_session(8, 5, Some(3)));
 
-        partition.complete_repair(&repair_config()).await;
+        let conclusion = partition.complete_repair(&repair_config()).await;
 
+        assert_eq!(
+            conclusion,
+            RepairConclusion::InProgress,
+            "with the window incomplete, later frames can still lower the \
+             first batch offset into connection"
+        );
         assert_eq!(partition.consensus().commit_min(), 0);
+        assert!(partition.repair.is_some());
+    }
+    /// Temp partition directory for the state-transfer fence specs below.
+    async fn transfer_fence_dir(label: &str) -> String {
+        let dir = std::env::temp_dir().join(format!(
+            "iggy-transfer-fence-{label}-{}-{}",
+            std::process::id(),
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .expect("system clock after epoch")
+                .as_nanos(),
+        ));
+        compio::fs::create_dir_all(&dir)
+            .await
+            .expect("create temp partition dir");
+        dir.to_string_lossy().into_owned()
+    }
+
+    fn armed_transfer(peer: u8) -> crate::state_transfer::PartitionTransferSession {
+        crate::state_transfer::PartitionTransferSession {
+            nonce: 7,
+            peer,
+            commit_op: 12,
+            artifacts: Vec::new(),
+            target_accepted: true,
+            idle_ticks: 0,
+        }
+    }
+
+    /// A purge must not leave a transfer running: its staged segments hold
+    /// PRE-purge data, and completing the install renames it back in durably
+    /// (the install takes `max(offer generation, applied)`, and this purge
+    /// already stamped the newer one, so the reconciler's purge gate never
+    /// re-fires).
+    #[compio::test]
+    async fn given_armed_transfer_when_purged_should_abandon_session_and_rearm() {
+        let partition_dir = transfer_fence_dir("purge-abandons").await;
+        let mut partition = test_partition();
+        partition.set_partition_dir(partition_dir.clone());
+        partition.transfer = Some(armed_transfer(1));
+        partition.transfer_rearm = Some(crate::state_transfer::PendingTransferRearm {
+            peer: 2,
+            after_ticks: 5,
+        });
+        partition.consensus().begin_state_transfer_await();
+
+        partition
+            .purge(&repair_config(), 3)
+            .await
+            .expect("purge partition");
+
+        assert!(
+            partition.transfer.is_none(),
+            "purge must drop the in-flight transfer session"
+        );
+        assert!(
+            partition.transfer_rearm.is_none(),
+            "purge must cancel the scheduled re-arm"
+        );
+        assert_eq!(
+            partition.consensus().state_transfer_stage(),
+            consensus::StateTransferStage::Idle,
+            "purge must release the transfer stage so a later trigger can arm"
+        );
+
+        let _ = std::fs::remove_dir_all(&partition_dir);
+    }
+
+    /// An offer whose frontier sits below this replica's own offset counter is
+    /// refused: installing it would rewind the counter, and the next replicated
+    /// prepare is re-stamped from it, so this replica would persist different
+    /// bytes (and a different `batch_checksum`) than the rest of the group.
+    #[compio::test]
+    async fn given_offer_below_local_counter_when_installed_should_refuse_rewind() {
+        let partition_dir = transfer_fence_dir("rewind-refused").await;
+        let mut partition = test_partition();
+        partition.set_partition_dir(partition_dir.clone());
+        partition.should_increment_offset = true;
+        partition.offset.store(99, Ordering::Release);
+
+        let behind = crate::state_transfer::ConsumerOffsetsWire {
+            purge_generation: 0,
+            next_offset: 50,
+            consumers: Vec::new(),
+            groups: Vec::new(),
+        };
+        let refused = partition
+            .install_state_transfer(&repair_config(), 12, Vec::new(), &behind.encode(), 0)
+            .await;
+        assert!(
+            matches!(
+                refused,
+                Err(
+                    crate::state_transfer::PartitionInstallError::OfferRewindsDurableData {
+                        offer_next_offset: 50,
+                        local_next_offset: 100,
+                    }
+                )
+            ),
+            "expected a rewind refusal, got {refused:?}"
+        );
+
+        // A purge at the origin is the one legitimate rewind, and the artifact
+        // carries the generation that proves it: the same offer passes the fence
+        // once its generation advances past the COMMITTED one the caller reads
+        // off the metadata plane (0 here), not past this replica's memory-only
+        // applied value.
+        let purged = crate::state_transfer::ConsumerOffsetsWire {
+            purge_generation: 1,
+            next_offset: 0,
+            consumers: Vec::new(),
+            groups: Vec::new(),
+        };
+        let accepted = partition
+            .install_state_transfer(&repair_config(), 12, Vec::new(), &purged.encode(), 0)
+            .await;
+        assert!(
+            !matches!(
+                accepted,
+                Err(crate::state_transfer::PartitionInstallError::OfferRewindsDurableData { .. })
+            ),
+            "a purge-advancing offer must pass the rewind fence, got {accepted:?}"
+        );
+
+        let _ = std::fs::remove_dir_all(&partition_dir);
+    }
+
+    /// The canonical post-restart rejoin: this replica applied a purge before
+    /// the restart, so the metadata plane's COMMITTED generation is 1 while its
+    /// own memory-only `applied_purge_generation` is back at 0. Gated on the
+    /// local field, `offered(1) > applied(0)` reads as an advancing purge and
+    /// disables the rewind refusal -- on the one path it exists to guard.
+    #[compio::test]
+    async fn given_restarted_replica_when_offer_matches_committed_purge_should_refuse_rewind() {
+        let partition_dir = transfer_fence_dir("restart-purge-rewind").await;
+        let mut partition = test_partition();
+        partition.set_partition_dir(partition_dir.clone());
+        partition.should_increment_offset = true;
+        partition.offset.store(99, Ordering::Release);
+        assert_eq!(
+            partition.applied_purge_generation(),
+            0,
+            "the local generation is memory-only and starts over after a restart"
+        );
+
+        let offer = crate::state_transfer::ConsumerOffsetsWire {
+            purge_generation: 1,
+            next_offset: 50,
+            consumers: Vec::new(),
+            groups: Vec::new(),
+        };
+        let refused = partition
+            .install_state_transfer(&repair_config(), 12, Vec::new(), &offer.encode(), 1)
+            .await;
+
+        assert!(
+            matches!(
+                refused,
+                Err(
+                    crate::state_transfer::PartitionInstallError::OfferRewindsDurableData {
+                        offer_next_offset: 50,
+                        local_next_offset: 100,
+                    }
+                )
+            ),
+            "an offer that merely matches the committed generation is not a purge \
+             advancing past it, so the rewind fence must hold: got {refused:?}"
+        );
+
+        let _ = std::fs::remove_dir_all(&partition_dir);
+    }
+
+    /// Primary-by-index at view 0 with nothing committed refuses to serve: an
+    /// empty group is trivially "caught up", so this gate is the only thing
+    /// separating a real primary from a phantom whose directory vanished, whose
+    /// zero-segment offer at frontier 0 would make a data-holding receiver
+    /// unlink its chain.
+    #[compio::test]
+    async fn given_nothing_committed_when_offer_requested_should_refuse() {
+        let partition_dir = transfer_fence_dir("nothing-committed").await;
+        let mut partition = test_partition();
+        partition.set_partition_dir(partition_dir.clone());
+        assert_eq!(partition.consensus().commit_max(), 0);
+
+        let refused = partition.state_transfer_offer(&repair_config()).await;
+        assert!(
+            matches!(
+                refused,
+                Err(crate::state_transfer::PartitionTransferUnavailable::NothingCommitted)
+            ),
+            "expected a NothingCommitted refusal, got {refused:?}"
+        );
+        assert!(
+            refused.is_err_and(|reason| reason.transient()),
+            "the refusal must be transient: the requester rotates rather than \
+             charging its failure count"
+        );
+
+        let _ = std::fs::remove_dir_all(&partition_dir);
     }
 
     fn batch_stats(base_offset: u64, message_count: u32) -> CommittedBatchStats {
diff --git a/core/partitions/src/iggy_partitions.rs b/core/partitions/src/iggy_partitions.rs
index c73e8a9..a25aed6 100644
--- a/core/partitions/src/iggy_partitions.rs
+++ b/core/partitions/src/iggy_partitions.rs
@@ -25,6 +25,7 @@
 use iggy_binary_protocol::{
     Command2, ConsensusHeader, Operation, PrepareHeader, PrepareOkHeader, RequestHeader,
 };
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
 use message_bus::MessageBus;
 use server_common::send_messages2::{ChecksumMode, convert_request_message, encrypt_batch_request};
 use server_common::sharding::{IggyNamespace, LocalIdx, ShardId};
@@ -65,7 +66,7 @@
 /// For example, shard 0 might have `partition_ids` [0, 2, 4] while shard 1
 /// has `partition_ids` [1, 3, 5]. The `LocalIdx` provides the actual index
 /// into the `partitions` Vec.
-pub struct IggyPartitions<B>
+pub struct IggyPartitions<B, SB = PingPongSuperblock>
 where
     B: MessageBus,
 {
@@ -79,7 +80,7 @@
     /// only on the shard's pump task. Reconciler routes mutations
     /// through `ReconcileOp` + `ReconcileApply`. Cross-task
     /// access would be UB under cooperative `.await` interleaving.
-    partitions: UnsafeCell<Vec<IggyPartition<B>>>,
+    partitions: UnsafeCell<Vec<IggyPartition<B, SB>>>,
     /// Same single-pump invariant as `partitions`.
     ///
     /// `BTreeMap`, not `HashMap`: iteration order via [`Self::namespaces`] must
@@ -112,9 +113,10 @@
     borrow_active: Cell<u32>,
 }
 
-impl<B> IggyPartitions<B>
+impl<B, SB> IggyPartitions<B, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     #[must_use]
     pub fn new(shard_id: ShardId, config: PartitionsConfig) -> Self {
@@ -147,7 +149,7 @@
         &self.config
     }
 
-    fn partitions(&self) -> &Vec<IggyPartition<B>> {
+    fn partitions(&self) -> &Vec<IggyPartition<B, SB>> {
         // SAFETY: see the `partitions` field doc. The returned `&` is sound only
         // while not held across an `.await` on a non-pump task (a sibling
         // reconcile could realloc); single-threadedness alone is not enough.
@@ -179,13 +181,13 @@
     }
 
     /// Get partition by local index.
-    pub fn get(&self, local_idx: LocalIdx) -> Option<&IggyPartition<B>> {
+    pub fn get(&self, local_idx: LocalIdx) -> Option<&IggyPartition<B, SB>> {
         self.partitions().get(*local_idx)
     }
 
     /// Get mutable partition by local index.
     #[allow(clippy::mut_from_ref)]
-    fn get_mut(&self, local_idx: LocalIdx) -> Option<&mut IggyPartition<B>> {
+    fn get_mut(&self, local_idx: LocalIdx) -> Option<&mut IggyPartition<B, SB>> {
         // SAFETY: `&mut` is sound on the pump task only (the sole mutator); see
         // `namespace_map_mut`. Single-threadedness alone is not enough.
         unsafe { (&mut *self.partitions.get()).get_mut(*local_idx) }
@@ -211,7 +213,7 @@
     /// [`Self::with_partition`]; the `&mut` path above is uncounted (it is
     /// pump-only, so it cannot alias this same-task mutation).
     #[doc(hidden)]
-    pub fn insert(&self, namespace: IggyNamespace, partition: IggyPartition<B>) -> LocalIdx {
+    pub fn insert(&self, namespace: IggyNamespace, partition: IggyPartition<B, SB>) -> LocalIdx {
         #[cfg(debug_assertions)]
         debug_assert_eq!(
             self.borrow_active.get(),
@@ -247,7 +249,7 @@
     /// the borrow to a synchronous closure, and must never hold the reference
     /// across an `.await` (a sibling task's reconcile could reallocate the vec
     /// mid-await).
-    pub fn get_by_ns(&self, namespace: &IggyNamespace) -> Option<&IggyPartition<B>> {
+    pub fn get_by_ns(&self, namespace: &IggyNamespace) -> Option<&IggyPartition<B, SB>> {
         if self.is_tombstoned(namespace) {
             return None;
         }
@@ -264,7 +266,7 @@
     pub fn with_partition<R>(
         &self,
         namespace: &IggyNamespace,
-        f: impl FnOnce(&IggyPartition<B>) -> R,
+        f: impl FnOnce(&IggyPartition<B, SB>) -> R,
     ) -> Option<R> {
         let partition = self.get_by_ns(namespace)?;
         #[cfg(debug_assertions)]
@@ -295,7 +297,7 @@
     /// Get mutable partition by namespace directly. Tombstone-gated like
     /// [`Self::get_by_ns`].
     #[allow(clippy::mut_from_ref)]
-    pub fn get_mut_by_ns(&self, namespace: &IggyNamespace) -> Option<&mut IggyPartition<B>> {
+    pub fn get_mut_by_ns(&self, namespace: &IggyNamespace) -> Option<&mut IggyPartition<B, SB>> {
         if self.is_tombstoned(namespace) {
             return None;
         }
@@ -325,7 +327,7 @@
     /// [`Self::with_partition`]; the `&mut` path above is uncounted (it is
     /// pump-only, so it cannot alias this same-task mutation).
     #[doc(hidden)]
-    pub fn remove(&self, namespace: &IggyNamespace) -> Option<IggyPartition<B>> {
+    pub fn remove(&self, namespace: &IggyNamespace) -> Option<IggyPartition<B, SB>> {
         #[cfg(debug_assertions)]
         debug_assert_eq!(
             self.borrow_active.get(),
@@ -364,7 +366,7 @@
     ///
     /// Same pump-only safety discipline as [`Self::remove`].
     #[doc(hidden)]
-    pub fn remove_many(&self, namespaces: &[IggyNamespace]) -> Vec<IggyPartition<B>> {
+    pub fn remove_many(&self, namespaces: &[IggyNamespace]) -> Vec<IggyPartition<B, SB>> {
         namespaces.iter().filter_map(|ns| self.remove(ns)).collect()
     }
 
@@ -489,9 +491,10 @@
     }
 }
 
-impl<B> Plane<VsrConsensus<B>> for IggyPartitions<B>
+impl<B, SB> Plane<VsrConsensus<B>> for IggyPartitions<B, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     async fn on_request(&self, message: <VsrConsensus<B> as Consensus>::Message<RequestHeader>) {
         let namespace = IggyNamespace::from_raw(message.header().namespace);
@@ -596,9 +599,10 @@
     }
 }
 
-impl<B> PlaneIdentity<VsrConsensus<B>> for IggyPartitions<B>
+impl<B, SB> PlaneIdentity<VsrConsensus<B>> for IggyPartitions<B, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     fn is_applicable<H>(&self, message: &<VsrConsensus<B> as Consensus>::Message<H>) -> bool
     where
diff --git a/core/partitions/src/journal.rs b/core/partitions/src/journal.rs
index 0b2eaf8..c3a5b96 100644
--- a/core/partitions/src/journal.rs
+++ b/core/partitions/src/journal.rs
@@ -47,6 +47,16 @@
     pub message_count: u32,
 }
 
+/// What one pass over the journal headers found for a repair window.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RepairedWindowShape {
+    /// Every op in the window is resident. An EMPTY window is complete: nothing
+    /// left can arrive and change the floor verdict.
+    pub complete: bool,
+    /// At least one resident op in the window is a `SendMessages`.
+    pub holds_messages: bool,
+}
+
 /// Lookup key for querying messages from the journal.
 ///
 /// `ceiling` is the inclusive commit-frontier bound: the resident journal holds
@@ -283,9 +293,30 @@
 }
 
 impl PartitionJournal<PartitionJournalMemStorage> {
-    /// Entry bytes for `op`, from the resident journal or the evicted ring.
-    /// `None` when the op predates the ring (bulk-sync territory) or was
-    /// never journaled here.
+    /// Drop EVERYTHING this journal holds: resident entries, the
+    /// op/offset/timestamp indexes, and the evicted repair ring.
+    ///
+    /// State-transfer install only. The installed segments supersede every
+    /// journaled op at or below the new commit floor, and the stale suffix
+    /// ABOVE it (prepared-but-uncommitted ops from a superseded view) would
+    /// collide with the new view's prepares at the same op numbers. The
+    /// journal is memory-only, so a full clear IS the partition plane's
+    /// suffix truncation; the receiver re-fetches the live tail through
+    /// normal journal repair afterwards. Ring caps and the retention flag
+    /// survive: they are configuration, not content.
+    pub fn clear_all(&self) {
+        {
+            let inner = unsafe { &*self.inner.get() };
+            let _ = inner.storage.drain();
+        }
+        unsafe { &mut *self.op_to_storage_offset.get() }.clear();
+        unsafe { &mut *self.offset_to_op.get() }.clear();
+        unsafe { &mut *self.timestamp_to_op.get() }.clear();
+        unsafe { &mut *self.headers.get() }.clear();
+        unsafe { &mut *self.evicted_ring.get() }.clear();
+        self.evicted_ring_bytes.set(0);
+    }
+
     /// Disable repair retention (single-replica groups: nobody to repair).
     pub fn set_repair_retention(&self, enabled: bool) {
         self.repair_retention.set(enabled);
@@ -311,6 +342,9 @@
         op_to_storage_offset.len()
     }
 
+    /// Entry bytes for `op`, from the resident journal or the evicted ring.
+    /// `None` when the op predates the ring (bulk-sync territory) or was
+    /// never journaled here.
     pub fn repair_entry(&self, op: u64) -> Option<JournalBuffer> {
         {
             let op_to_storage_offset = unsafe { &*self.op_to_storage_offset.get() };
@@ -631,6 +665,68 @@
         headers.iter().find(|header| header.op == op).copied()
     }
 
+    /// Presence and message-carrying shape of the repair window `(floor, to_op]`
+    /// in ONE pass over the header vec.
+    ///
+    /// [`Self::header_by_op`] is a linear scan with no index, so asking it
+    /// op-by-op over a window is O(window x headers): on the floor-refusal path
+    /// the replica is gap-stopped, so nothing evicts and the header vec grows
+    /// with the live tail, and the default 4096-op window over ~100k resident
+    /// headers is on the order of 4e8 comparisons -- synchronous, on the shard
+    /// pump, per repair round. Long enough to miss heartbeat and view-change
+    /// deadlines for every group on the core and turn one rejoin into an
+    /// election storm.
+    ///
+    /// The evicted ring is deliberately NOT consulted, matching the op-by-op
+    /// form: consulting it would change the floor-refusal verdict.
+    pub fn repaired_window_shape(&self, floor: u64, to_op: u64) -> RepairedWindowShape {
+        let headers = unsafe { &*self.headers.get() };
+        let expected = to_op.saturating_sub(floor);
+        // More in-window ops than resident headers can never be covered, and
+        // `expected` is unbounded here (`to_op` rides the local `commit_max`),
+        // so this is both the early answer and what keeps the bitset below from
+        // being sized off an arbitrary number.
+        if expected > headers.len() as u64 {
+            return RepairedWindowShape {
+                complete: false,
+                holds_messages: headers.iter().any(|header| {
+                    header.op > floor
+                        && header.op <= to_op
+                        && header.operation == Operation::SendMessages
+                }),
+            };
+        }
+        // Dense window, so a flat presence vector beats a `HashSet`: no hashing
+        // per op and one contiguous allocation. One BYTE per op rather than one
+        // bit -- `expected` is bounded by `headers.len()`, so the 8x over a real
+        // bitset buys simpler indexing at a size the caller already holds in
+        // headers.
+        #[allow(clippy::cast_possible_truncation)]
+        let expected_len = expected as usize;
+        let mut present = vec![false; expected_len];
+        let mut covered = 0usize;
+        let mut holds_messages = false;
+        for header in headers
+            .iter()
+            .filter(|header| header.op > floor && header.op <= to_op)
+        {
+            if header.operation == Operation::SendMessages {
+                holds_messages = true;
+            }
+            #[allow(clippy::cast_possible_truncation)]
+            let slot = (header.op - floor - 1) as usize;
+            if !present[slot] {
+                present[slot] = true;
+                covered += 1;
+            }
+        }
+        RepairedWindowShape {
+            // In-window ops only, deduplicated, so a count match IS coverage.
+            complete: covered == expected_len,
+            holds_messages,
+        }
+    }
+
     /// Headers for the contiguous op run `from_op ..= commit_max`, in op order,
     /// stopping at the first missing op. A replication gap must not be skipped:
     /// the caller advances `commit_min` strictly by one, so a hole would break
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 33e33d0..75b4c70 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -28,6 +28,7 @@
 mod offset_storage;
 mod poll_plan;
 mod segment;
+pub mod state_transfer;
 mod types;
 
 use iggy_binary_protocol::PrepareHeader;
@@ -35,7 +36,7 @@
 pub use iggy_index::IggyIndex;
 pub use iggy_index_reader::IggyIndexReader;
 pub use iggy_index_writer::IggyIndexWriter;
-pub use iggy_partition::IggyPartition;
+pub use iggy_partition::{IggyPartition, PurgeError};
 pub use iggy_partitions::IggyPartitions;
 pub use journal::{EVICTED_RING_BYTES_MAX, EVICTED_RING_CAPACITY};
 pub use messages_writer::MessagesWriter;
@@ -46,7 +47,8 @@
 pub use server_common::send_messages2::{IggyMessage2, IggyMessage2Header, IggyMessages2};
 pub use types::{
     AppendResult, Fragment, PartitionOffsets, PartitionsConfig, PollFragments, PollQueryResult,
-    PollingArgs, PollingConsumer, REPAIR_RETRY_TICKS, RepairSession, SendMessagesResult,
+    PollingArgs, PollingConsumer, REPAIR_RETRY_TICKS, RepairConclusion, RepairSession,
+    SendMessagesResult,
 };
 
 /// Partition-level data plane operations.
diff --git a/core/partitions/src/offset_storage.rs b/core/partitions/src/offset_storage.rs
index ebd4cf9..3938435 100644
--- a/core/partitions/src/offset_storage.rs
+++ b/core/partitions/src/offset_storage.rs
@@ -25,9 +25,11 @@
 const OFFSET_SIZE: usize = core::mem::size_of::<u64>();
 
 pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Result<(), IggyError> {
-    if let Some(parent) = Path::new(path).parent()
-        && !parent.exists()
-    {
+    // No `exists()` probe first: that is a BLOCKING `std::path` stat on the pump
+    // in front of every write, which serialises a batched fan-out on stats
+    // before it can submit any I/O. `create_dir_all` is already a no-op on an
+    // existing directory.
+    if let Some(parent) = Path::new(path).parent() {
         create_dir_all(parent).await.map_err(|_| {
             IggyError::CannotCreateConsumerOffsetsDirectory(parent.display().to_string())
         })?;
@@ -114,13 +116,14 @@
 /// # Errors
 /// Returns [`IggyError::CannotDeleteConsumerOffsetFile`] if the unlink fails.
 pub async fn delete_persisted_offset(path: &str) -> Result<(), IggyError> {
-    if !Path::new(path).exists() {
-        return Ok(());
+    // NotFound is tolerated on the result instead of probed for: the probe was
+    // a blocking stat on the pump before every unlink, and "already gone" is
+    // exactly the outcome this wants anyway.
+    match remove_file(path).await {
+        Ok(()) => Ok(()),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(_) => Err(IggyError::CannotDeleteConsumerOffsetFile(path.to_owned())),
     }
-
-    remove_file(path)
-        .await
-        .map_err(|_| IggyError::CannotDeleteConsumerOffsetFile(path.to_owned()))
 }
 
 #[cfg(test)]
diff --git a/core/partitions/src/state_transfer.rs b/core/partitions/src/state_transfer.rs
new file mode 100644
index 0000000..833f07a
--- /dev/null
+++ b/core/partitions/src/state_transfer.rs
@@ -0,0 +1,3002 @@
+// 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.
+
+//! Partition-plane state transfer: the offer a serving primary builds, the
+//! receiver session with its disk-spilled segment staging, and the wire
+//! codec for the consumer-offset artifact.
+//!
+//! A rejoining replica whose journal repair proved the gap below the commit
+//! floor is unrepairable (`RepairConclusion::FloorRefused`) pulls this
+//! partition's retained segments plus its consumer-offset table from the
+//! group's caught-up primary, installs them, and hands the live tail back to
+//! ordinary journal repair. Artifacts ride the plane-agnostic manifest/chunk
+//! protocol from `core/consensus`; everything in this module is the
+//! partition-specific payload handling on either end.
+
+use crate::messages_writer::MessagesWriter;
+use crate::offset_storage::{delete_persisted_offset, persist_offset};
+use crate::segment::Segment;
+use crate::types::PartitionsConfig;
+use crate::{IggyIndexWriter, IggyPartition};
+use compio::io::{AsyncReadAtExt, AsyncWriteAtExt};
+use consensus::le_cursor::{LeCursor, Truncated, split_verified_trailer};
+use consensus::state_manifest::artifact_kind;
+use consensus::{ArtifactProgress, Sequencer as _, StateArtifactHasher, state_artifact_checksum};
+use iggy_common::{ConsumerGroupId, ConsumerKind, ConsumerOffset, IggyByteSize};
+use journal::superblock::SuperblockStore;
+use message_bus::MessageBus;
+use server_common::SegmentStorage;
+use server_common::send_messages2::decode_batch_slice;
+use std::collections::HashSet;
+use std::fmt;
+use std::mem::size_of;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+use std::sync::atomic::Ordering;
+
+/// Framing marker for the consumer-offsets wire artifact, "ICO1".
+pub(crate) const CONSUMER_OFFSETS_MAGIC: [u8; 4] = *b"ICO1";
+
+/// Version byte following the magic.
+///
+/// Any layout change bumps this, INCLUDING appended fields: the decoder
+/// deliberately fails closed on unknown versions and on trailing bytes,
+/// because a v2 field can change the meaning of fields v1 already read.
+pub(crate) const CONSUMER_OFFSETS_VERSION: u8 = 1;
+
+/// Per-section entry ceiling for the consumer-offsets artifact.
+///
+/// A corruption guard, not a target: it bounds the allocation `decode`
+/// makes from a length field a peer sent, exactly like the manifest's own
+/// entry ceiling.
+pub(crate) const CONSUMER_OFFSETS_ENTRIES_MAX: u32 = 1 << 20;
+
+/// One in-flight partition state transfer on the receiving replica.
+///
+/// Mirrors the metadata plane's session, plus `staged`: completed
+/// `SEGMENT_LOG` artifacts are validated and spilled to `.staging` files as
+/// they finish (bounding receiver memory to one in-flight artifact), and the
+/// walk metadata recorded here is what the install consumes. NO retry budget
+/// lives in here -- three of four metadata arming sites re-minted the
+/// session, so a per-session counter bounded nothing. The partition plane's
+/// budgets live on the partition: the stall budget
+/// (`transfer_attempts`, reset on received chunks) and the consecutive
+/// failure count driving the re-arm backoff (reset only by a completed
+/// install; deliberately NOT generation-keyed -- a committing origin
+/// advances its generation every round). A deterministically undecodable
+/// artifact therefore re-pulls once per backed-off round, capped at 1024x
+/// the base interval, rather than being refused outright.
+#[derive(Debug)]
+pub struct PartitionTransferSession {
+    pub nonce: u128,
+    /// Serving primary; also the stall re-request target.
+    pub peer: u8,
+    /// Serving peer's applied frontier from the accepted descriptor.
+    ///
+    /// Not a decode-budget generation: this plane keeps no such budget (see the
+    /// struct doc), it counts consecutive failures on the partition instead.
+    pub commit_op: u64,
+    /// One slot per offered artifact, in manifest order. A slot moves from
+    /// `Pending` to `Staged` when its segment payload is validated and
+    /// spilled (freeing the buffer); the single enum makes a
+    /// progress/spilled/staged desync unrepresentable.
+    pub artifacts: Vec<TransferArtifact>,
+    /// Whether a descriptor has been accepted (an accepted EMPTY manifest is
+    /// distinguishable from "still waiting").
+    pub target_accepted: bool,
+    /// Ticks with no frame progress; at the configured repair-retry
+    /// threshold the missing piece is re-requested.
+    pub idle_ticks: u32,
+}
+
+/// A scheduled transfer re-arm (see `IggyPartition::transfer_rearm`).
+///
+/// The shard tick sweep counts `after_ticks` down and arms a fresh session
+/// against `peer` when it reaches zero, provided nothing else armed one in
+/// the meantime.
+#[derive(Debug, Clone, Copy)]
+pub struct PendingTransferRearm {
+    pub peer: u8,
+    pub after_ticks: u32,
+}
+
+/// One artifact slot of an in-flight partition transfer.
+///
+/// `SEGMENT_LOG` artifacts pass through both states; the consumer-offsets
+/// artifact stays `Pending` until the install consumes its buffer.
+#[derive(Debug)]
+pub enum TransferArtifact {
+    /// Still pulling: manifest entry plus the bytes received so far.
+    Pending(ArtifactProgress),
+    /// Validated and spilled to `.staging` files; the buffer is freed and
+    /// the walk metadata is what the install consumes.
+    Staged(StagedSegmentMeta),
+}
+
+impl TransferArtifact {
+    #[must_use]
+    pub const fn pending(&self) -> Option<&ArtifactProgress> {
+        match self {
+            Self::Pending(progress) => Some(progress),
+            Self::Staged(_) => None,
+        }
+    }
+
+    pub const fn pending_mut(&mut self) -> Option<&mut ArtifactProgress> {
+        match self {
+            Self::Pending(progress) => Some(progress),
+            Self::Staged(_) => None,
+        }
+    }
+}
+
+impl consensus::ChunkProgress for TransferArtifact {
+    fn declared_len(&self) -> u64 {
+        match self {
+            Self::Pending(progress) => progress.entry.len,
+            Self::Staged(meta) => meta.size,
+        }
+    }
+
+    fn received_len(&self) -> u64 {
+        match self {
+            Self::Pending(progress) => progress.buf.len() as u64,
+            // Staged == validated == every declared byte arrived; the chunk
+            // cursor then skips it, subsuming the old `spilled` flags.
+            Self::Staged(meta) => meta.size,
+        }
+    }
+
+    fn extend_from_chunk(&mut self, payload: &[u8]) {
+        match self {
+            // Delegated, not re-implemented: the two must agree about how a
+            // buffer grows, and the reservation below only fires if this arm
+            // routes through the same impl.
+            Self::Pending(progress) => progress.extend_from_chunk(payload),
+            // Unreachable through `append_chunk`: a staged slot reports
+            // itself complete, so no in-window offset can address it.
+            Self::Staged(_) => debug_assert!(false, "chunk appended to a staged artifact"),
+        }
+    }
+
+    fn reserve_declared(&mut self) {
+        match self {
+            Self::Pending(progress) => progress.reserve_declared(),
+            Self::Staged(_) => {}
+        }
+    }
+}
+
+/// What the receiver learned walking one validated, staged segment artifact:
+/// everything the install needs to rebuild the in-memory `Segment` without
+/// re-reading the file.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StagedSegmentMeta {
+    pub start_offset: u64,
+    pub end_offset: u64,
+    /// Byte length of the locally rebuilt sparse index sidecar, recorded at
+    /// the walk so the install does not re-stat the renamed file.
+    pub index_size: u64,
+    /// Payload byte length == the manifest entry's `len` == the final `.log`
+    /// file size.
+    pub size: u64,
+    pub start_timestamp: u64,
+    pub end_timestamp: u64,
+    pub max_timestamp: u64,
+    /// `{start_offset:020}.log.staging` in the partition directory. The
+    /// `.staging` extension is invisible to boot recovery, which filters on
+    /// `extension == "log"`.
+    pub log_staging: PathBuf,
+    /// The locally rebuilt sparse index for the staged log, one entry per
+    /// batch (denser than the origin's per-flush-chunk index; recovery is
+    /// sparse-tolerant either way).
+    pub index_staging: PathBuf,
+}
+
+/// Memoized streaming checksum state for one segment file: the hasher fed
+/// exactly `hashed_len` of its bytes, plus the stamp at that length.
+///
+/// Keyed per segment rather than per `(base offset, size)` pair so the ACTIVE
+/// segment extends its own hasher as it grows, instead of missing the memo on
+/// every byte it gained and re-reading from byte zero. Every path that plants a
+/// new file at an existing base offset (purge, install, converge) clears the
+/// whole map, which is what makes "same base offset, longer file, same leading
+/// bytes" hold.
+pub(crate) struct SegmentChecksumMemo {
+    hashed_len: u64,
+    /// The stamp is NOT cached alongside: `StateArtifactHasher::finish` takes
+    /// `&self`, so it is a read of this hasher, and a second copy is just a
+    /// field that can drift.
+    hasher: StateArtifactHasher,
+}
+
+impl SegmentChecksumMemo {
+    fn new() -> Self {
+        Self {
+            hashed_len: 0,
+            hasher: StateArtifactHasher::new(),
+        }
+    }
+}
+
+/// The result of the last staged-segment reuse scan.
+///
+/// A scan reads every length-matching staged file whole, verifies it, and
+/// re-walks every batch -- sequentially, on the pump. Peer rotation and stall
+/// re-arms mint a fresh session against the same segment set, so without this
+/// the full cost is re-paid per arm. `digest` covers every `SEGMENT_LOG`
+/// manifest entry, so a hit means the new offer expects byte-identical staged
+/// files; any write to a staging file, or any unlink of one, drops the memo, so
+/// a hit can never describe bytes that were replaced meanwhile.
+pub(crate) struct ReuseScanMemo {
+    digest: u64,
+    adopted: Vec<(u32, StagedSegmentMeta)>,
+}
+
+impl StagedSegmentMeta {
+    /// Assemble the metadata a completed walk produced. Shared by the spill and
+    /// the reuse-adopt path, which differ only in whether they also wrote the
+    /// payload.
+    const fn from_walk(
+        entry: &consensus::StateArtifact,
+        stats: SegmentWalkStats,
+        index_size: u64,
+        log_staging: PathBuf,
+        index_staging: PathBuf,
+    ) -> Self {
+        Self {
+            start_offset: entry.frontier,
+            end_offset: stats.end_offset,
+            size: entry.len,
+            index_size,
+            start_timestamp: stats.start_timestamp,
+            end_timestamp: stats.end_timestamp,
+            max_timestamp: stats.max_timestamp,
+            log_staging,
+            index_staging,
+        }
+    }
+}
+
+/// The consumer-offset artifact: both offset maps plus the applied purge
+/// generation, at the offer's `commit_op`.
+///
+/// The purge generation rides here because a receiver that missed a
+/// `PurgeTopic` would otherwise install post-purge data at a stale local
+/// generation and the reconciler would immediately re-wipe it, costing a
+/// full extra transfer.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub(crate) struct ConsumerOffsetsWire {
+    pub purge_generation: u64,
+    /// The origin group's message-offset frontier: the offset the NEXT
+    /// append will mint, `0` for a partition that never appended. Segments
+    /// alone cannot carry this -- retention can GC every sealed segment
+    /// while the counter stands at N, and installing such an offer without
+    /// this field would restart the receiver's offset space at 0, forking
+    /// every future batch stamp from the rest of the group.
+    pub next_offset: u64,
+    /// `(consumer id, offset)`, ascending by id.
+    pub consumers: Vec<(u32, u64)>,
+    /// `(consumer group id, offset)`, ascending by id.
+    pub groups: Vec<(u32, u64)>,
+}
+
+impl ConsumerOffsetsWire {
+    /// Encode: `magic | version u8 | purge_generation u64 | next_offset u64 |
+    /// consumer_count u32 | group_count u32 | {id u32, offset u64}xN |
+    /// {id u32, offset u64}xM | XxHash3_64 trailer`. Little-endian
+    /// throughout.
+    #[must_use]
+    pub fn encode(&self) -> Vec<u8> {
+        // Size exactly rather than guess; the reservation assert keeps the
+        // arithmetic honest as fields are added.
+        let reserved = CONSUMER_OFFSETS_MAGIC.len()
+            + size_of::<u8>()
+            + 2 * size_of::<u64>()
+            + 2 * size_of::<u32>()
+            + (self.consumers.len() + self.groups.len()) * (size_of::<u32>() + size_of::<u64>())
+            + size_of::<u64>();
+        let mut out = Vec::with_capacity(reserved);
+        out.extend_from_slice(&CONSUMER_OFFSETS_MAGIC);
+        out.push(CONSUMER_OFFSETS_VERSION);
+        out.extend_from_slice(&self.purge_generation.to_le_bytes());
+        out.extend_from_slice(&self.next_offset.to_le_bytes());
+        #[allow(clippy::cast_possible_truncation)]
+        out.extend_from_slice(&(self.consumers.len() as u32).to_le_bytes());
+        #[allow(clippy::cast_possible_truncation)]
+        out.extend_from_slice(&(self.groups.len() as u32).to_le_bytes());
+        for (id, offset) in self.consumers.iter().chain(self.groups.iter()) {
+            out.extend_from_slice(&id.to_le_bytes());
+            out.extend_from_slice(&offset.to_le_bytes());
+        }
+        debug_assert_eq!(out.len() + size_of::<u64>(), reserved, "encode reservation");
+        let trailer = state_artifact_checksum(&out);
+        out.extend_from_slice(&trailer.to_le_bytes());
+        out
+    }
+
+    /// Decode and validate a peer's consumer-offset artifact.
+    ///
+    /// The artifact checksum already verified transit; these validations are
+    /// about the PEER's encoder (duplicate ids, count fields, trailing
+    /// bytes), which the transit checksum cannot vouch for. Offset-value
+    /// sanity is deliberately NOT here: it needs the installed end offset,
+    /// so the install clamps, mirroring boot recovery.
+    ///
+    /// # Errors
+    /// Any [`ConsumerOffsetsWireError`]; the input is never partially
+    /// trusted.
+    pub fn decode(bytes: &[u8]) -> Result<Self, ConsumerOffsetsWireError> {
+        let content = split_verified_trailer(bytes).map_err(|mismatch| match mismatch {
+            None => ConsumerOffsetsWireError::Truncated,
+            Some((expected, actual)) => {
+                ConsumerOffsetsWireError::ChecksumMismatch { expected, actual }
+            }
+        })?;
+        let mut cursor = LeCursor::new(content);
+        let magic = cursor.take(CONSUMER_OFFSETS_MAGIC.len())?;
+        if magic != CONSUMER_OFFSETS_MAGIC {
+            return Err(ConsumerOffsetsWireError::BadMagic);
+        }
+        let version = cursor.u8()?;
+        if version != CONSUMER_OFFSETS_VERSION {
+            return Err(ConsumerOffsetsWireError::UnsupportedVersion { version });
+        }
+        let purge_generation = cursor.u64()?;
+        let next_offset = cursor.u64()?;
+        let consumer_count = cursor.u32()?;
+        let group_count = cursor.u32()?;
+        let consumers = Self::decode_section(&mut cursor, "consumers", consumer_count)?;
+        let groups = Self::decode_section(&mut cursor, "groups", group_count)?;
+        if !cursor.remaining().is_empty() {
+            // Distinct from `Truncated`: extra bytes point at a NEWER
+            // encoder, and telling the operator the artifact is short would
+            // send them the wrong way.
+            return Err(ConsumerOffsetsWireError::TrailingBytes {
+                extra: cursor.remaining().len(),
+            });
+        }
+        Ok(Self {
+            purge_generation,
+            next_offset,
+            consumers,
+            groups,
+        })
+    }
+
+    fn decode_section(
+        cursor: &mut LeCursor<'_>,
+        section: &'static str,
+        count: u32,
+    ) -> Result<Vec<(u32, u64)>, ConsumerOffsetsWireError> {
+        // Ceiling BEFORE the reservation: `count` is peer input and this is
+        // the only check between it and an eager allocation.
+        if count > CONSUMER_OFFSETS_ENTRIES_MAX {
+            return Err(ConsumerOffsetsWireError::TooManyEntries {
+                section,
+                count,
+                max: CONSUMER_OFFSETS_ENTRIES_MAX,
+            });
+        }
+        // The count is peer input and the reservation is 12 bytes per element
+        // after alignment, so it is checked against the bytes actually present
+        // before allocating: a ~30 byte artifact could otherwise ask for tens of
+        // megabytes across the two sections. 12 is the wire stride below -- the
+        // groups call sees exactly `12 * count` bytes remaining, so a wider
+        // guard would reject every non-empty artifact.
+        if count as usize * (size_of::<u32>() + size_of::<u64>()) > cursor.remaining().len() {
+            return Err(ConsumerOffsetsWireError::Truncated);
+        }
+        let mut entries = Vec::with_capacity(count as usize);
+        let mut previous: Option<u32> = None;
+        for _ in 0..count {
+            let id = cursor.u32()?;
+            let offset = cursor.u64()?;
+            // Ascending-strict doubles as the duplicate reject and makes the
+            // encoding canonical: one table, one byte sequence.
+            if previous.is_some_and(|previous| id <= previous) {
+                return Err(ConsumerOffsetsWireError::NonAscendingId { section, id });
+            }
+            previous = Some(id);
+            entries.push((id, offset));
+        }
+        Ok(entries)
+    }
+}
+
+/// Failure decoding the consumer-offsets WIRE artifact (state transfer).
+/// Named for the format: the on-disk offset files are a different codec with
+/// different trust (this node's own bytes vs a peer's).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConsumerOffsetsWireError {
+    Truncated,
+    BadMagic,
+    UnsupportedVersion {
+        version: u8,
+    },
+    /// Bytes remained after this version's field set: a newer encoder.
+    TrailingBytes {
+        extra: usize,
+    },
+    ChecksumMismatch {
+        expected: u64,
+        actual: u64,
+    },
+    TooManyEntries {
+        section: &'static str,
+        count: u32,
+        max: u32,
+    },
+    /// Ids in a section are not strictly ascending: a duplicate, or an
+    /// out-of-order entry. Both are the same encoder bug and both break the
+    /// canonical form the encoding promises.
+    NonAscendingId {
+        section: &'static str,
+        id: u32,
+    },
+}
+
+impl From<Truncated> for ConsumerOffsetsWireError {
+    fn from(_: Truncated) -> Self {
+        Self::Truncated
+    }
+}
+
+impl fmt::Display for ConsumerOffsetsWireError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Truncated => write!(f, "consumer-offsets artifact is truncated"),
+            Self::BadMagic => write!(f, "consumer-offsets artifact carries a foreign magic"),
+            Self::TrailingBytes { extra } => write!(
+                f,
+                "consumer-offsets artifact carries {extra} trailing bytes past this \
+                 version's field set (a newer encoder?)"
+            ),
+            Self::UnsupportedVersion { version } => write!(
+                f,
+                "consumer-offsets artifact version {version} is not understood \
+                 (this build speaks {CONSUMER_OFFSETS_VERSION})"
+            ),
+            Self::ChecksumMismatch { expected, actual } => write!(
+                f,
+                "consumer-offsets artifact checksum mismatch: expected {expected}, got {actual}"
+            ),
+            Self::TooManyEntries {
+                section,
+                count,
+                max,
+            } => write!(
+                f,
+                "consumer-offsets artifact {section} count {count} exceeds the {max} ceiling"
+            ),
+            Self::NonAscendingId { section, id } => write!(
+                f,
+                "consumer-offsets artifact {section} id {id} does not ascend \
+                 (duplicate, or out of order)"
+            ),
+        }
+    }
+}
+
+impl std::error::Error for ConsumerOffsetsWireError {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn table() -> ConsumerOffsetsWire {
+        ConsumerOffsetsWire {
+            purge_generation: 3,
+            next_offset: 43,
+            consumers: vec![(1, 10), (7, 42)],
+            groups: vec![(2, 5)],
+        }
+    }
+
+    #[test]
+    fn given_offset_table_when_encoded_should_round_trip() {
+        let encoded = table().encode();
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&encoded).expect("round trip"),
+            table()
+        );
+    }
+
+    #[test]
+    fn given_empty_table_when_encoded_should_round_trip() {
+        let empty = ConsumerOffsetsWire {
+            purge_generation: 0,
+            next_offset: 0,
+            consumers: Vec::new(),
+            groups: Vec::new(),
+        };
+        let encoded = empty.encode();
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&encoded).expect("round trip"),
+            empty
+        );
+    }
+
+    #[test]
+    fn given_flipped_bit_when_decoded_should_reject_checksum() {
+        let mut encoded = table().encode();
+        encoded[6] ^= 1;
+        assert!(matches!(
+            ConsumerOffsetsWire::decode(&encoded),
+            Err(ConsumerOffsetsWireError::ChecksumMismatch { .. })
+        ));
+    }
+
+    #[test]
+    fn given_truncated_bytes_when_decoded_should_reject() {
+        let encoded = table().encode();
+        for len in 0..encoded.len() {
+            assert!(
+                ConsumerOffsetsWire::decode(&encoded[..len]).is_err(),
+                "strict prefix of {len} bytes must fail closed"
+            );
+        }
+    }
+
+    #[test]
+    fn given_unknown_version_when_decoded_should_reject() {
+        let mut wrong = table().encode();
+        // Bump the version byte and re-seal so only the version check fires.
+        wrong[CONSUMER_OFFSETS_MAGIC.len()] = CONSUMER_OFFSETS_VERSION + 1;
+        let content_len = wrong.len() - size_of::<u64>();
+        let trailer = state_artifact_checksum(&wrong[..content_len]);
+        wrong[content_len..].copy_from_slice(&trailer.to_le_bytes());
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&wrong),
+            Err(ConsumerOffsetsWireError::UnsupportedVersion {
+                version: CONSUMER_OFFSETS_VERSION + 1,
+            })
+        );
+    }
+
+    #[test]
+    fn given_foreign_magic_when_decoded_should_reject() {
+        let mut wrong = table().encode();
+        // Rewrite the magic and re-seal so only the magic check can fire.
+        wrong[0] = b'X';
+        let content_len = wrong.len() - size_of::<u64>();
+        let trailer = state_artifact_checksum(&wrong[..content_len]);
+        wrong[content_len..].copy_from_slice(&trailer.to_le_bytes());
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&wrong),
+            Err(ConsumerOffsetsWireError::BadMagic)
+        );
+    }
+
+    #[test]
+    fn given_count_past_ceiling_when_decoded_should_reject_before_allocating() {
+        let mut bytes = Vec::new();
+        bytes.extend_from_slice(&CONSUMER_OFFSETS_MAGIC);
+        bytes.push(CONSUMER_OFFSETS_VERSION);
+        bytes.extend_from_slice(&0u64.to_le_bytes());
+        bytes.extend_from_slice(&0u64.to_le_bytes());
+        bytes.extend_from_slice(&(CONSUMER_OFFSETS_ENTRIES_MAX + 1).to_le_bytes());
+        bytes.extend_from_slice(&0u32.to_le_bytes());
+        let trailer = state_artifact_checksum(&bytes);
+        bytes.extend_from_slice(&trailer.to_le_bytes());
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&bytes),
+            Err(ConsumerOffsetsWireError::TooManyEntries {
+                section: "consumers",
+                count: CONSUMER_OFFSETS_ENTRIES_MAX + 1,
+                max: CONSUMER_OFFSETS_ENTRIES_MAX,
+            })
+        );
+    }
+
+    #[test]
+    fn given_duplicate_or_unordered_ids_when_decoded_should_reject() {
+        let duplicate = ConsumerOffsetsWire {
+            purge_generation: 0,
+            next_offset: 0,
+            consumers: vec![(5, 1), (5, 2)],
+            groups: Vec::new(),
+        };
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&duplicate.encode()),
+            Err(ConsumerOffsetsWireError::NonAscendingId {
+                section: "consumers",
+                id: 5,
+            })
+        );
+        let unordered = ConsumerOffsetsWire {
+            purge_generation: 0,
+            next_offset: 0,
+            consumers: Vec::new(),
+            groups: vec![(9, 1), (4, 2)],
+        };
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&unordered.encode()),
+            Err(ConsumerOffsetsWireError::NonAscendingId {
+                section: "groups",
+                id: 4,
+            })
+        );
+    }
+
+    #[test]
+    fn given_trailing_bytes_when_decoded_should_reject() {
+        let mut padded = table().encode();
+        let content_len = padded.len() - size_of::<u64>();
+        padded.truncate(content_len);
+        padded.push(0);
+        let trailer = state_artifact_checksum(&padded);
+        padded.extend_from_slice(&trailer.to_le_bytes());
+        assert_eq!(
+            ConsumerOffsetsWire::decode(&padded),
+            Err(ConsumerOffsetsWireError::TrailingBytes { extra: 1 }),
+            "bytes past the last section must fail closed"
+        );
+    }
+}
+
+/// What a full validation walk over one segment payload derived.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct SegmentWalkStats {
+    pub end_offset: u64,
+    pub start_timestamp: u64,
+    pub end_timestamp: u64,
+    pub max_timestamp: u64,
+}
+
+/// Failure validating a transferred segment payload.
+///
+/// The artifact checksum already proved transit; these are about the bytes
+/// themselves (the peer's disk, or its encoder), which transit integrity
+/// cannot vouch for.
+#[derive(Debug)]
+pub enum SegmentWalkError {
+    /// Batch header/checksum rejected at `position`.
+    Batch {
+        position: u64,
+        source: iggy_common::IggyError,
+    },
+    /// First batch does not start at the artifact's declared base offset.
+    BaseOffsetMismatch { expected: u64, actual: u64 },
+    /// A batch's base offset does not continue the previous batch.
+    NonContiguous { expected: u64, actual: u64 },
+    /// A batch's offset arithmetic overflows `u64`; the operands are
+    /// peer-controlled, so this is a rejection, not a clamp.
+    OffsetOverflow { position: u64 },
+    /// The payload holds no batches; empty segments are never offered.
+    Empty,
+}
+
+impl fmt::Display for SegmentWalkError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Batch { position, source } => {
+                write!(f, "segment batch at byte {position} rejected: {source}")
+            }
+            Self::BaseOffsetMismatch { expected, actual } => write!(
+                f,
+                "segment first batch starts at offset {actual}, manifest says {expected}"
+            ),
+            Self::NonContiguous { expected, actual } => write!(
+                f,
+                "segment batch starts at offset {actual}, expected {expected}"
+            ),
+            Self::OffsetOverflow { position } => {
+                write!(
+                    f,
+                    "segment batch at byte {position} overflows the offset space"
+                )
+            }
+            Self::Empty => write!(f, "segment payload holds no batches"),
+        }
+    }
+}
+
+impl std::error::Error for SegmentWalkError {}
+
+/// Walk every batch of a transferred `.log` payload.
+///
+/// Validates each header and `batch_checksum` (`decode_batch_slice`),
+/// proves offset continuity from the manifest's declared base, and derives
+/// the segment metadata plus a locally rebuilt sparse index (one 24-byte
+/// entry per batch -- denser than the origin's per-flush-chunk index, which
+/// recovery tolerates).
+///
+/// # Errors
+/// [`SegmentWalkError`] on the first invalid byte; nothing is partially
+/// trusted.
+pub(crate) async fn walk_segment_payload(
+    base_offset: u64,
+    bytes: &[u8],
+) -> Result<(SegmentWalkStats, Vec<u8>), SegmentWalkError> {
+    let mut position = 0usize;
+    let mut next_offset = base_offset;
+    let mut stats: Option<SegmentWalkStats> = None;
+    let mut index_bytes = Vec::new();
+    let mut indexed_position: Option<usize> = None;
+    let mut since_yield = 0usize;
+    while position < bytes.len() {
+        // The walk re-hashes every message (`decode_batch_slice` verifies
+        // `batch_checksum`), so a multi-GiB artifact is a long CPU pass on the
+        // pump task. What these yields buy is NOT tick liveness: the consensus
+        // tick is a sibling `select_biased!` arm of this same task and arms are
+        // not polled while another arm's body awaits, so every group's tick and
+        // heartbeat on this shard stay frozen for the duration either way (see
+        // the tick-starvation TODO in `shard::router`). They buy the reactor:
+        // detached tasks and io_uring completions make progress instead of
+        // waiting out the whole pass. Moving the verify + walk off the pump is
+        // what would fix the tick, and the nonce re-check after the spill is
+        // already shaped for that.
+        if since_yield >= OFFER_HASH_CHUNK_LEN {
+            since_yield = 0;
+            yield_to_reactor().await;
+        }
+        let batch =
+            decode_batch_slice(&bytes[position..]).map_err(|source| SegmentWalkError::Batch {
+                position: position as u64,
+                source,
+            })?;
+        let header = batch.header;
+        if stats.is_none() && header.base_offset != base_offset {
+            return Err(SegmentWalkError::BaseOffsetMismatch {
+                expected: base_offset,
+                actual: header.base_offset,
+            });
+        }
+        if header.base_offset != next_offset {
+            return Err(SegmentWalkError::NonContiguous {
+                expected: next_offset,
+                actual: header.base_offset,
+            });
+        }
+        if header.message_count == 0 {
+            return Err(SegmentWalkError::Batch {
+                position: position as u64,
+                source: iggy_common::IggyError::InvalidMessagesCount,
+            });
+        }
+        // Peer-controlled operands under a reject-on-first-invalid contract:
+        // checked, not saturating -- a clamp would misdirect the diagnostic.
+        let Some(batch_end) = header
+            .base_offset
+            .checked_add(u64::from(header.message_count) - 1)
+        else {
+            return Err(SegmentWalkError::OffsetOverflow {
+                position: position as u64,
+            });
+        };
+        // The append-time canonical stamp, exactly what the flush path writes
+        // into index entries and segment bounds; `origin_timestamp` is
+        // client-supplied and would give the installed replica a divergent
+        // timestamp column (polls and retention keyed differently per node).
+        let timestamp = header.base_timestamp;
+        // STRIDED, not one entry per batch: the origin writes one entry per
+        // flush chunk, and a per-batch index is dense enough that a transferred
+        // segment never fits the sealed-index residency cap
+        // (`poll_plan::SEALED_INDEX_RESIDENT_MAX_BYTES`), so every sealed poll
+        // would fall back to binary-searching the file with single-entry preads
+        // -- a slow path `poll_plan` reserves for a
+        // `messages_required_to_save = 1` misconfiguration, which a transfer
+        // would otherwise produce unconditionally. Both consumers do lower-bound
+        // lookups and recovery walks forward from the last entry by design, so
+        // sparser is correct; the first batch always gets one.
+        let stride_reached = indexed_position
+            .is_none_or(|indexed| position.saturating_sub(indexed) >= INDEX_STRIDE_BYTES);
+        if stride_reached {
+            indexed_position = Some(position);
+            index_bytes.extend_from_slice(&header.base_offset.to_le_bytes());
+            index_bytes.extend_from_slice(&timestamp.to_le_bytes());
+            index_bytes.extend_from_slice(&(position as u64).to_le_bytes());
+        }
+        stats = Some(stats.map_or(
+            SegmentWalkStats {
+                end_offset: batch_end,
+                start_timestamp: timestamp,
+                end_timestamp: timestamp,
+                max_timestamp: timestamp,
+            },
+            |previous| SegmentWalkStats {
+                end_offset: batch_end,
+                start_timestamp: previous.start_timestamp,
+                end_timestamp: timestamp,
+                max_timestamp: previous.max_timestamp.max(timestamp),
+            },
+        ));
+        next_offset = batch_end
+            .checked_add(1)
+            .ok_or(SegmentWalkError::OffsetOverflow {
+                position: position as u64,
+            })?;
+        // No trailing-bytes guard: the header decode floors `batch_length`
+        // at the 256-byte command header (no zero-step loop is possible)
+        // and `decode_batch_slice` already rejects a body shorter than
+        // `total_size()`.
+        position += header.total_size();
+        since_yield += header.total_size();
+    }
+    stats.map_or(Err(SegmentWalkError::Empty), |stats| {
+        Ok((stats, index_bytes))
+    })
+}
+
+/// One offered segment: its manifest entry plus WHERE its bytes live.
+///
+/// The offer deliberately holds paths, not payloads -- the serving side
+/// loads one artifact at a time at chunk-serve time, bounding its memory to
+/// one segment per requester regardless of how much the partition retains.
+#[derive(Debug, Clone)]
+pub struct SegmentArtifactSource {
+    pub entry: consensus::StateArtifact,
+    pub log_path: String,
+}
+
+/// One artifact of an offer, addressed by manifest index: segment payloads
+/// live on disk (loaded at chunk-serve time), the offsets table is resident.
+#[derive(Debug)]
+pub enum PartitionArtifactSource<'a> {
+    Segment(&'a SegmentArtifactSource),
+    Offsets(&'a std::rc::Rc<Vec<u8>>),
+}
+
+/// A built partition state-transfer offer: everything at `commit_op`, with
+/// segment payloads addressed by path and only the (small) offsets artifact
+/// resident.
+#[derive(Debug)]
+pub struct PartitionStateTransferOffer {
+    /// `== commit_min == commit_max` at build (caught-up primary gate).
+    pub commit_op: u64,
+    /// Ascending base offset; one artifact per non-empty retained segment.
+    pub segments: Vec<SegmentArtifactSource>,
+    /// The consumer-offsets artifact, resident (a few KB at most).
+    pub offsets: (consensus::StateArtifact, std::rc::Rc<Vec<u8>>),
+}
+
+impl PartitionStateTransferOffer {
+    /// Manifest order: segments ascending, then the offsets artifact last,
+    /// so a receiver spills every segment before it holds the table.
+    #[must_use]
+    pub fn manifest(&self) -> Vec<consensus::StateArtifact> {
+        let mut entries: Vec<_> = self.segments.iter().map(|source| source.entry).collect();
+        entries.push(self.offsets.0);
+        entries
+    }
+
+    /// Never zero: the offsets artifact is always present.
+    #[must_use]
+    pub const fn artifact_count(&self) -> usize {
+        self.segments.len() + 1
+    }
+
+    /// The artifact at `index` in [`Self::manifest`] order (segments
+    /// ascending, offsets last) without materialising the manifest vec.
+    #[must_use]
+    pub fn artifact_at(&self, index: usize) -> Option<PartitionArtifactSource<'_>> {
+        match index.cmp(&self.segments.len()) {
+            std::cmp::Ordering::Less => {
+                Some(PartitionArtifactSource::Segment(&self.segments[index]))
+            }
+            std::cmp::Ordering::Equal => Some(PartitionArtifactSource::Offsets(&self.offsets.1)),
+            std::cmp::Ordering::Greater => None,
+        }
+    }
+
+    #[must_use]
+    pub fn total_len(&self) -> u64 {
+        self.segments
+            .iter()
+            .map(|source| source.entry.len)
+            .sum::<u64>()
+            + self.offsets.0.len
+    }
+}
+
+/// Why a partition cannot serve a state transfer right now.
+///
+/// Distinct variants because the operator responses differ: "not the
+/// caught-up primary" is routine (requester retries elsewhere), an
+/// unreadable segment is a local fault on THIS node.
+#[derive(Debug)]
+pub enum PartitionTransferUnavailable {
+    NotCaughtUpPrimary,
+    /// In-memory / simulated partition: nothing on disk to serve.
+    NoPartitionDir,
+    RepairInProgress,
+    /// Primary-by-index of a group that has committed nothing: an empty group
+    /// is trivially "caught up", so this is the only thing separating a real
+    /// primary from a view-0 phantom whose directory vanished.
+    NothingCommitted,
+    /// More retained segments than the manifest can carry entries for.
+    ManifestTooLarge {
+        entries: usize,
+        max: usize,
+    },
+    /// The segment chain changed while the offer's checksum passes ran, so the
+    /// stamps no longer describe the bytes the offer addresses.
+    SegmentSetChanged,
+    /// This round's share of the checksum pass ran out with retained bytes
+    /// still unhashed. Progress is memoized, so the next request resumes where
+    /// this one stopped rather than starting the pass again.
+    OfferBuildInProgress {
+        /// Bytes hashed so far over the chain AS THIS ROUND SEES IT. Carries
+        /// across rounds through the memo rather than resetting per round, but
+        /// retention GC dropping an already-hashed segment lowers it and
+        /// `remaining` together, so it tracks the live chain, not a monotone
+        /// total.
+        hashed: u64,
+        /// Bytes of it still unhashed. The pair is the only signal that
+        /// separates a converging multi-round build from a stalled one.
+        remaining: u64,
+    },
+    FlushFailed(iggy_common::IggyError),
+    SegmentUnreadable {
+        start_offset: u64,
+        source: std::io::Error,
+    },
+}
+
+impl PartitionTransferUnavailable {
+    /// Whether the refusal says "not right now" rather than "this node is
+    /// broken". A requester charges its consecutive-failure count (and the
+    /// exponential re-arm backoff behind it) only for the latter: a primary
+    /// that is momentarily behind its own frontier is the common case under
+    /// produce load, and charging it pins the backoff at its ceiling while
+    /// nothing else recovers the partition.
+    #[must_use]
+    pub const fn transient(&self) -> bool {
+        match self {
+            Self::NotCaughtUpPrimary
+            | Self::RepairInProgress
+            | Self::NothingCommitted
+            | Self::SegmentSetChanged
+            | Self::OfferBuildInProgress { .. } => true,
+            Self::NoPartitionDir
+            | Self::ManifestTooLarge { .. }
+            | Self::FlushFailed(_)
+            | Self::SegmentUnreadable { .. } => false,
+        }
+    }
+}
+
+impl fmt::Display for PartitionTransferUnavailable {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::NotCaughtUpPrimary => write!(f, "not the caught-up primary of this group"),
+            Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
+            Self::RepairInProgress => write!(f, "partition is itself mid-repair"),
+            Self::NothingCommitted => write!(
+                f,
+                "primary by index at view 0 with nothing committed; refusing to serve an empty offer"
+            ),
+            Self::ManifestTooLarge { entries, max } => write!(
+                f,
+                "offer needs {entries} manifest entries, past the {max} ceiling"
+            ),
+            Self::SegmentSetChanged => {
+                write!(f, "segment chain changed while the offer was being built")
+            }
+            Self::OfferBuildInProgress { hashed, remaining } => write!(
+                f,
+                "offer checksum pass has hashed {hashed} bytes with {remaining} to go; \
+                 resuming on the next request"
+            ),
+            Self::FlushFailed(source) => {
+                write!(f, "flushing the committed prefix failed: {source}")
+            }
+            Self::SegmentUnreadable {
+                start_offset,
+                source,
+            } => write!(f, "segment {start_offset:0>20}.log is unreadable: {source}"),
+        }
+    }
+}
+
+impl std::error::Error for PartitionTransferUnavailable {}
+
+/// Outcome of a completed install. A degraded install is a SUCCESS: the
+/// segments and floor landed; only some consumer-offset file writes failed,
+/// and the next offset commit blind-writes those files.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PartitionInstallOutcome {
+    /// The consensus op the install applied. Named for what it holds: every
+    /// other `frontier` in this module is a MESSAGE OFFSET
+    /// (`VsrState::offset_frontier`, `StateArtifact::frontier`,
+    /// `installed_frontier`), and op-vs-offset confusion is what produced this
+    /// PR's durability defects.
+    pub applied_commit_op: u64,
+    /// Every transferred offset file was WRITTEN (and the offset
+    /// directories fsynced, so the old files' unlinks stick). Not a
+    /// durability claim for the file contents: `persist_offset` fsyncs only
+    /// under `consumer_offset_enforce_fsync`, matching the normal
+    /// offset-commit path -- shipped default off.
+    pub offsets_written: bool,
+}
+
+/// Failure installing a transferred partition state. Every `check`-phase
+/// variant means NOTHING was mutated.
+#[derive(Debug)]
+pub enum PartitionInstallError {
+    NoPartitionDir,
+    /// `commit_op` fell below this replica's commit frontier; installing
+    /// would rewind `commit_min` (the anti-rewind assert, as a refusal).
+    StaleTransfer {
+        commit_op: u64,
+        commit_min: u64,
+    },
+    /// The incoming frontier could not be made durable before the swap, so the
+    /// install refuses rather than enter a window whose only durable witness
+    /// would be the segments the failure path quarantines away.
+    FrontierNotDurable {
+        frontier: u64,
+    },
+    /// The offer's offset frontier is below this replica's own offset
+    /// counter, so installing it would rewind the offset space: the next
+    /// replicated prepare would be re-stamped from the rewound counter and
+    /// persist different bytes (and a different `batch_checksum`) here than
+    /// on the rest of the group.
+    OfferRewindsDurableData {
+        offer_next_offset: u64,
+        local_next_offset: u64,
+    },
+    Offsets(ConsumerOffsetsWireError),
+    /// Duplicate base offset in the staged set.
+    DuplicateSegment {
+        start_offset: u64,
+    },
+    /// A hole between consecutive staged segments.
+    SegmentSetHole {
+        previous_end: u64,
+        next_start: u64,
+    },
+    /// Filesystem failure at/after the swap; disk holds a contiguous prefix
+    /// of the new state and a crash-restart recovers it (see the module
+    /// crash-window notes). The IN-MEMORY partition is converged to an
+    /// empty, honestly-lagging state before this returns, so the live
+    /// process stays serviceable and the normal triggers re-transfer.
+    SwapIo {
+        path: String,
+        source: std::io::Error,
+    },
+    /// Re-opening an installed segment failed; disk holds the full new
+    /// state, a restart boot-recovers it.
+    SegmentOpen {
+        path: String,
+        source: iggy_common::IggyError,
+    },
+    /// The post-failure convergence itself failed: the partition holds no
+    /// serviceable segment chain and every append or poll would panic. The
+    /// caller must fence this one partition (tear it down for the
+    /// reconciler to rebuild from disk) instead of leaving a live handle
+    /// whose first use kills the whole shard.
+    ConvergeFailed {
+        source: iggy_common::IggyError,
+        /// The offer's frontier, carried because the LIVE counter is not it on
+        /// this path: a mutate failure can leave the counter at its pre-install
+        /// value, and under an advancing purge generation that value is above
+        /// the group's. The fence records this instead, or it would stamp the
+        /// stale counter over the reset the install already made and then
+        /// quarantine the segments that would have contradicted it.
+        frontier: u64,
+    },
+}
+
+impl fmt::Display for PartitionInstallError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
+            Self::StaleTransfer {
+                commit_op,
+                commit_min,
+            } => write!(
+                f,
+                "transfer frontier {commit_op} is below the local commit frontier {commit_min}"
+            ),
+            Self::FrontierNotDurable { frontier } => write!(
+                f,
+                "could not record the incoming offset frontier {frontier} before the swap"
+            ),
+            Self::OfferRewindsDurableData {
+                offer_next_offset,
+                local_next_offset,
+            } => write!(
+                f,
+                "offer frontier {offer_next_offset} is below this replica's own next offset \
+                 {local_next_offset}; installing it would rewind the offset space"
+            ),
+            Self::Offsets(source) => write!(f, "consumer-offsets artifact rejected: {source}"),
+            Self::DuplicateSegment { start_offset } => {
+                write!(f, "duplicate staged segment at base offset {start_offset}")
+            }
+            Self::SegmentSetHole {
+                previous_end,
+                next_start,
+            } => write!(
+                f,
+                "staged segment set holds a hole: previous ends at {previous_end}, next starts at {next_start}"
+            ),
+            Self::SwapIo { path, source } => write!(f, "swap io failed at {path}: {source}"),
+            Self::SegmentOpen { path, source } => {
+                write!(f, "re-opening installed segment {path} failed: {source}")
+            }
+            Self::ConvergeFailed { source, frontier } => write!(
+                f,
+                "post-failure convergence failed at frontier {frontier}, \
+                 the partition must be fenced: {source}"
+            ),
+        }
+    }
+}
+
+impl std::error::Error for PartitionInstallError {}
+
+impl From<ConsumerOffsetsWireError> for PartitionInstallError {
+    fn from(source: ConsumerOffsetsWireError) -> Self {
+        Self::Offsets(source)
+    }
+}
+
+/// Suffix marking a half-transferred file inside the partition directory.
+///
+/// Provably invisible to boot recovery, which filters on `extension == "log"`,
+/// and swept wholesale at boot by
+/// `segment_recovery::sweep_scratch_files_and_collect_offsets`.
+pub const STAGING_SUFFIX: &str = ".staging";
+
+/// Staging-file names inside the partition directory.
+fn staging_paths(partition_dir: &str, start_offset: u64) -> (PathBuf, PathBuf) {
+    (
+        PathBuf::from(format!(
+            "{partition_dir}/{start_offset:0>20}.log{STAGING_SUFFIX}"
+        )),
+        PathBuf::from(format!(
+            "{partition_dir}/{start_offset:0>20}.index{STAGING_SUFFIX}"
+        )),
+    )
+}
+
+/// Every entry of one partition directory, as paths.
+///
+/// BLOCKING `read_dir` on the pump: compio-fs 0.12 exposes no async directory
+/// walk, and `spawn_blocking` is not an escape either -- the shard executors run
+/// `thread_pool_limit(0)`. Bounded by the entry count of ONE partition directory,
+/// but it is a real stall (and under the write lock at the converge site), so it
+/// stays recorded rather than hidden.
+///
+/// Enumeration only: the three callers keep their own predicates and their own
+/// error policies (propagate / silent skip / log-and-fail), which is what
+/// `sweep_staging_except`'s do-not-widen warning depends on.
+fn segment_dir_entries(partition_dir: &str) -> std::io::Result<Vec<PathBuf>> {
+    Ok(std::fs::read_dir(partition_dir)?
+        .flatten()
+        .map(|entry| entry.path())
+        .collect())
+}
+
+/// Move every segment file in `partition_dir` aside into `<dir>.fenced.<n>/`,
+/// returning the directory used.
+///
+/// The partition directory itself STAYS, and so do its two superblock slots:
+/// they hold the group's only durable `(view, log_view)`, and moving them would
+/// make the rebuild read an empty directory -- no `restore_partition_view`,
+/// `consensus.init()` instead of `init_as_backup()`, no replica-identity guard --
+/// so the group would re-enter view 0 after acting in view N and could answer a
+/// retransmitted DVC with `(0, 0)`, letting a quorum adopt a log shorter than the
+/// committed prefix.
+///
+/// Nothing reclaims the fenced copies: they are evidence for an operator,
+/// bounded to 1000 per partition by the suffix search, and never read again
+/// (recovery keys on `.log` files inside the partition directory, and the fenced
+/// subdirectory is not one).
+///
+/// # Errors
+/// The underlying `std::io::Error`. A failure is NOT recoverable by rebuilding:
+/// the rebuild plants segment 0 with `file_exists = false` and truncates
+/// whatever the failed quarantine left, so callers tombstone the partition and
+/// leave the bytes for an operator.
+pub async fn quarantine_segment_files(partition_dir: &str) -> std::io::Result<String> {
+    // `create_dir`, not stat-then-create: one syscall per attempt instead of
+    // two, and race-free. Deliberately NOT `create_dir_all`, which succeeds on
+    // an existing directory and would silently merge this fence into an earlier
+    // copy.
+    let mut target = None;
+    for attempt in 0..1000 {
+        let candidate = format!("{partition_dir}.fenced.{attempt}");
+        match compio::fs::create_dir(&candidate).await {
+            Ok(()) => {
+                target = Some(candidate);
+                break;
+            }
+            // Lost the race for this suffix; the next iteration probes the
+            // next one.
+            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+            Err(error) => return Err(error),
+        }
+    }
+    let Some(target) = target else {
+        return Err(std::io::Error::other(
+            "a thousand fenced copies of this partition already exist",
+        ));
+    };
+    for path in segment_dir_entries(partition_dir)? {
+        let quarantined = path.to_str().is_some_and(|path| {
+            [".log", ".index", STAGING_SUFFIX]
+                .iter()
+                .any(|suffix| path.ends_with(suffix))
+        });
+        if !quarantined {
+            continue;
+        }
+        let Some(name) = path.file_name() else {
+            continue;
+        };
+        compio::fs::rename(&path, &PathBuf::from(&target).join(name)).await?;
+    }
+    // All three touched directories: the target (its new dirents), the source
+    // (the removals), and the source's parent (the target directory itself is a
+    // new dirent there). Without the target-side syncs a crash can leave the
+    // moved files linked in neither directory -- only forensics are at stake,
+    // but forensics are the whole point of the copies.
+    fsync_dir(&target).await?;
+    fsync_dir(partition_dir).await?;
+    if let Some(parent) = Path::new(partition_dir).parent().and_then(Path::to_str) {
+        fsync_dir(parent).await?;
+    }
+    Ok(target)
+}
+
+/// Unlink every staging file in `partition_dir` except `keep`.
+///
+/// Best-effort disk hygiene shared by the reuse scan and the install: a file
+/// that survives is swept at the next boot, so a failed unlink is not worth
+/// failing either caller for. The CONVERGE sweep is deliberately not this
+/// function -- it deletes the live chain as well and must propagate its
+/// errors.
+/// Do NOT widen this predicate to the quarantine's three-suffix list if the two
+/// are ever unified: the keep-lists callers pass hold staging paths only (purge
+/// passes none), so a wider filter would unlink every live `.log` and `.index`
+/// on the partition -- worst at the reuse scan, which runs at descriptor-accept
+/// on a serving partition.
+pub(crate) async fn sweep_staging_except(partition_dir: &str, keep: &HashSet<&Path>) {
+    let Ok(entries) = segment_dir_entries(partition_dir) else {
+        return;
+    };
+    for path in entries {
+        let is_staging = path
+            .to_str()
+            .is_some_and(|path| path.ends_with(STAGING_SUFFIX));
+        if is_staging && !keep.contains(path.as_path()) {
+            let _ = compio::fs::remove_file(&path).await;
+        }
+    }
+}
+
+fn final_paths(partition_dir: &str, start_offset: u64) -> (String, String) {
+    (
+        format!("{partition_dir}/{start_offset:0>20}.log"),
+        format!("{partition_dir}/{start_offset:0>20}.index"),
+    )
+}
+
+/// Consumer-offset files written concurrently while installing a transfer.
+/// Each is an open + write + optional fsync, so the width trades reactor queue
+/// depth against how long one partition monopolises it; matches the tick's
+/// superblock pre-pass.
+const OFFSET_PERSIST_CONCURRENCY: usize = 16;
+
+/// One consumer-offset file the install is about to write. Collected before any
+/// write is issued so the offset maps and the persisted-offset tracker are never
+/// borrowed across a batch's await.
+struct PlannedOffsetWrite {
+    kind: ConsumerKind,
+    id: u32,
+    path: String,
+    value: u64,
+}
+
+/// fsync the partition directory so a rename made durable stays durable.
+/// Async so the wait parks the task instead of the whole shard reactor;
+/// every other future on the pump keeps running through it.
+pub(crate) async fn fsync_dir(partition_dir: &str) -> std::io::Result<()> {
+    compio::fs::File::open(partition_dir)
+        .await?
+        .sync_all()
+        .await
+}
+
+impl<B, SB> IggyPartition<B, SB>
+where
+    B: MessageBus,
+    SB: SuperblockStore,
+{
+    /// Build (or serve from cache) this group's state-transfer offer.
+    ///
+    /// Force-flushes the committed prefix first so the segments cover every
+    /// committed `SendMessages` op and the offset table covers every
+    /// committed offset op; `commit_op = commit_min` then names the exact
+    /// state the artifacts represent. Segment bytes are NOT loaded here: the
+    /// offer records `(entry, path)` and the serving side loads one artifact
+    /// at a time, so building costs one streaming checksum pass per segment
+    /// and the resident footprint is just the offsets table.
+    ///
+    /// # Errors
+    /// [`PartitionTransferUnavailable`]; the requester falls back to journal
+    /// repair or retries after the next trigger.
+    #[allow(clippy::too_many_lines)]
+    pub async fn state_transfer_offer(
+        &mut self,
+        config: &PartitionsConfig,
+    ) -> Result<Rc<PartitionStateTransferOffer>, PartitionTransferUnavailable> {
+        if !consensus::is_caught_up_primary(self.consensus()) {
+            return Err(PartitionTransferUnavailable::NotCaughtUpPrimary);
+        }
+        if self.partition_dir.is_none() {
+            // Also defuses the in-memory trap where `segment.size` grows with
+            // no bytes on disk ("simulated in-memory batch persistence").
+            return Err(PartitionTransferUnavailable::NoPartitionDir);
+        }
+        if self.repair.is_some() {
+            return Err(PartitionTransferUnavailable::RepairInProgress);
+        }
+        // Primary-by-index at view 0 over an empty log passes every gate above
+        // yet knows nothing: a group whose directory is absent boots through
+        // `consensus.init()`, comes up Normal at view 0, and an empty group is
+        // trivially "caught up". Its offer would be zero segments at frontier
+        // 0, which makes a receiver holding real data unlink its own chain.
+        //
+        // A RESTARTED replica holding a full chain matches this shape too
+        // (`commit_max == 0` because the partition journal is memory-only,
+        // `installed_frontier == None` for a recovered non-empty chain). That
+        // is the load-bearing reason this refusal is safe rather than a
+        // wedge: every transfer-arm site presupposes a peer that already
+        // reported commit > 0 (repair floor refusals, StartView adoption), so
+        // nobody ever asks a cluster where everything still reports 0.
+        // Extending the gate with `recovered_durable_offset.is_some()` would
+        // be WRONG: such an offer carries `commit_op = 0`, so the receiver's
+        // floor becomes a no-op while its counter jumps to the frontier.
+        if self.consensus().commit_max() == 0 && self.installed_frontier.is_none() {
+            return Err(PartitionTransferUnavailable::NothingCommitted);
+        }
+        self.flush_committed_messages(config)
+            .await
+            .map_err(PartitionTransferUnavailable::FlushFailed)?;
+        let commit_op = self.consensus().commit_min();
+        if let Some(cached) = self.transfer_offer_cache.borrow().as_ref()
+            && cached.commit_op == commit_op
+        {
+            // Returns BEFORE the chain re-validation below, deliberately:
+            // re-validating on every hit is the walk the cache exists to skip.
+            // Retention GC on an idle partition therefore costs one wasted
+            // round -- the chunk serve fails `Stale` and the eviction path
+            // re-enumerates -- which is the cheaper side of the trade.
+            return Ok(Rc::clone(cached));
+        }
+
+        // Enumerate under the write lock so GC (`remove_sealed_segments_up_to`,
+        // also write-locked) cannot unlink a file between enumeration and read.
+        // The checksum passes below run with the lock RELEASED: they are the
+        // expensive part, and the same mutex serializes
+        // `append_send_messages_to_journal` and `commit_messages_inner`, so
+        // holding it across a multi-GiB first pass stalls this partition's
+        // produce and commit for the whole pass. The chain is re-validated
+        // under the lock afterwards.
+        let write_lock = self.write_lock.clone();
+        // Sampled with the chain: a purge inside the hash window below both
+        // truncates every file and restarts the offset space, so a size that
+        // grew back past its planned length would pass the size re-check while
+        // the stamps describe post-purge bytes at pre-purge offsets.
+        let planned_purge_generation = self.applied_purge_generation;
+        let planned: Vec<(u64, u64, String)> = {
+            let _guard = write_lock.lock().await;
+            let mut planned = Vec::with_capacity(self.log.segments().len());
+            for (segment, storage) in self.log.segments().iter().zip(self.log.storages()) {
+                let size = segment.size.as_bytes_u64();
+                if size == 0 {
+                    continue;
+                }
+                let (log_path, _) = storage.segment_and_index_paths();
+                let Some(log_path) = log_path else {
+                    return Err(PartitionTransferUnavailable::SegmentUnreadable {
+                        start_offset: segment.start_offset,
+                        source: std::io::Error::other("segment holds bytes but no backing file"),
+                    });
+                };
+                planned.push((segment.start_offset, size, log_path));
+            }
+            planned
+        };
+        // One manifest entry per planned segment plus the offsets table. The
+        // manifest encoder ASSERTS its entry ceiling and that assert survives
+        // release builds, so a partition retaining more segments than the
+        // ceiling would panic this shard the moment a peer asked it to serve.
+        // A small configured `segment_size` makes a chain that long ordinary,
+        // so refuse the request instead of tripping the assert.
+        let manifest_entries = planned.len() + 1;
+        let manifest_entries_max = consensus::state_manifest::STATE_MANIFEST_ENTRIES_MAX as usize;
+        if manifest_entries > manifest_entries_max {
+            return Err(PartitionTransferUnavailable::ManifestTooLarge {
+                entries: manifest_entries,
+                max: manifest_entries_max,
+            });
+        }
+
+        // The checksum pass is the expensive part and it runs inside ONE frame
+        // body: the router's tick arm is not polled while another arm's body
+        // awaits, and the yields inside `hash_segment_range` move the reactor,
+        // not this shard's consensus ticks. A cold pass over multi-GiB
+        // retention therefore silences every group on this core for its whole
+        // duration, past `heartbeat_timeout`, on the node that by construction
+        // is the caught-up primary of those groups.
+        //
+        // Bounded per round instead. The memo carries partial progress, so a
+        // refusal here is not lost work: the requester re-asks on its flat
+        // transient interval and each round advances the pass by the budget
+        // until the offer completes.
+        let mut budget = OFFER_HASH_BUDGET_PER_ROUND_BYTES;
+        let mut segments = Vec::with_capacity(planned.len());
+        for (start_offset, size, log_path) in &planned {
+            let Some(checksum) = self
+                .segment_checksum(*start_offset, *size, log_path, &mut budget)
+                .await?
+            else {
+                // CUMULATIVE across rounds, read back off the memo: per-round
+                // figures are constant by construction (a partial round always
+                // spends exactly the budget and always stops inside one
+                // segment), so they render identically on round 1 and round 30
+                // and an operator cannot tell a converging pass from a wedged
+                // one. This is the only window onto a multi-round build.
+                let hashed = self.hashed_prefix_len(&planned);
+                let total = planned.iter().map(|(_, size, _)| *size).sum::<u64>();
+                // The completing round's sweep is skipped on this path, so
+                // prune here too: retention GC can unlink segments across a
+                // long build, and their memos would otherwise accumulate until
+                // some round finally runs the loop to the end.
+                self.retain_segment_checksum_memos(&planned);
+                return Err(PartitionTransferUnavailable::OfferBuildInProgress {
+                    hashed,
+                    remaining: total.saturating_sub(hashed),
+                });
+            };
+            segments.push(SegmentArtifactSource {
+                entry: consensus::StateArtifact {
+                    kind: artifact_kind::SEGMENT_LOG,
+                    frontier: *start_offset,
+                    len: *size,
+                    checksum,
+                },
+                log_path: log_path.clone(),
+            });
+        }
+
+        // Re-validate the chain under the lock: the passes above yielded, so GC
+        // could have unlinked a sealed segment or a purge could have planted a
+        // fresh file at a planned path. Every stamp would then describe bytes
+        // the offer no longer addresses, so refuse and let the requester ask
+        // again against the chain that exists now.
+        {
+            let _guard = write_lock.lock().await;
+            let live: std::collections::HashMap<u64, u64> = self
+                .log
+                .segments()
+                .iter()
+                .map(|segment| (segment.start_offset, segment.size.as_bytes_u64()))
+                .collect();
+            // Append-only within a segment instance, so a live size BELOW the
+            // planned one means the file was replaced rather than extended.
+            let changed = planned_purge_generation != self.applied_purge_generation
+                || planned.iter().any(|(start_offset, size, _)| {
+                    live.get(start_offset)
+                        .is_none_or(|live_size| live_size < size)
+                });
+            if changed {
+                return Err(PartitionTransferUnavailable::SegmentSetChanged);
+            }
+            // Sweep memo entries whose segment left the chain (GC), so the map
+            // tracks the live chain rather than growing with history.
+            self.segment_checksum_cache
+                .borrow_mut()
+                .retain(|start_offset, _| live.contains_key(start_offset));
+        }
+
+        // Second phantom gate, on the BUILT offer rather than on `commit_max`.
+        // The gate above keys on `commit_max() == 0`, which a replica that lifted
+        // its commit floor through an offsets-only repair window clears while
+        // still holding zero bytes; such a replica passes `is_caught_up_primary`
+        // and would hand a data-holding peer an empty chain at frontier 0,
+        // making it unlink its own. An offer with no segments AND no offset
+        // space is indistinguishable from that phantom, and a group genuinely
+        // in that state has nothing worth transferring anyway.
+        let offsets_wire = self.offsets_wire_snapshot();
+        if segments.is_empty() && offsets_wire.next_offset == 0 {
+            return Err(PartitionTransferUnavailable::NothingCommitted);
+        }
+        let offsets_bytes = Rc::new(offsets_wire.encode());
+        let offsets_entry = consensus::StateArtifact::for_bytes(
+            artifact_kind::CONSUMER_OFFSETS,
+            commit_op,
+            &offsets_bytes,
+        );
+        let offer = Rc::new(PartitionStateTransferOffer {
+            commit_op,
+            segments,
+            offsets: (offsets_entry, offsets_bytes),
+        });
+        *self.transfer_offer_cache.borrow_mut() = Some(Rc::clone(&offer));
+        Ok(offer)
+    }
+
+    /// Bytes of `planned` the memo already covers, clamped per segment to the
+    /// planned length so a memo carrying an active segment's later growth
+    /// cannot report more than this offer will hash.
+    fn hashed_prefix_len(&self, planned: &[(u64, u64, String)]) -> u64 {
+        let memos = self.segment_checksum_cache.borrow();
+        planned
+            .iter()
+            .map(|(start_offset, size, _)| {
+                memos
+                    .get(start_offset)
+                    .map_or(0, |memo| memo.hashed_len.min(*size))
+            })
+            .sum()
+    }
+
+    /// Drop memo entries whose segment is no longer in `planned`, which is the
+    /// live chain as of this round.
+    ///
+    /// Set-based rather than a scan per entry: this runs on every
+    /// budget-exhausted round, inside the frame body the budget exists to
+    /// bound, and `planned` is capped by `STATE_MANIFEST_ENTRIES_MAX` rather
+    /// than by anything an operator sized, so the quadratic form dominates the
+    /// hashing it was meant to make room for.
+    fn retain_segment_checksum_memos(&self, planned: &[(u64, u64, String)]) {
+        let live: HashSet<u64> = planned
+            .iter()
+            .map(|(start_offset, _, _)| *start_offset)
+            .collect();
+        self.segment_checksum_cache
+            .borrow_mut()
+            .retain(|start_offset, _| live.contains(start_offset));
+    }
+
+    /// The artifact stamp over the first `size` bytes of a segment file,
+    /// extending the memoized hasher rather than re-reading what it already
+    /// covered.
+    ///
+    /// Sealed segments hit the memo outright. The active one pays for its delta
+    /// only, which is what keeps a committing primary off a full re-read of the
+    /// retained history every round: `commit_op` advances per round, so the
+    /// offer cache misses even when nothing else changed.
+    ///
+    /// `budget` caps the bytes this call may read, charged as it goes. `None`
+    /// means the budget ran out first: the memo holds everything hashed so far
+    /// and the next call resumes from it.
+    ///
+    /// # Errors
+    /// [`PartitionTransferUnavailable::SegmentUnreadable`] when the file is
+    /// unreadable or shorter than `size`.
+    async fn segment_checksum(
+        &self,
+        start_offset: u64,
+        size: u64,
+        log_path: &str,
+        budget: &mut u64,
+    ) -> Result<Option<u64>, PartitionTransferUnavailable> {
+        // Taken OUT of the map for the read: the hash awaits, and a half-fed
+        // hasher left visible could be extended twice by a second build.
+        let memo = self
+            .segment_checksum_cache
+            .borrow_mut()
+            .remove(&start_offset);
+        let mut memo = match memo {
+            // `<=`, so the already-hashed case falls through to the shared tail:
+            // `hash_segment_range` returns before opening the file when
+            // `from == to`, and the finish + reinsert below is the same work the
+            // separate arm did.
+            Some(memo) if memo.hashed_len <= size => memo,
+            // Segment bytes are append-only within one segment instance (the
+            // failed-index-save path rewinds the writer cursor and returns
+            // BEFORE the size increment), and every path that plants a fresh
+            // file at an existing base offset clears the whole map, so a
+            // shrunk size means the two have drifted.
+            shrunk => {
+                debug_assert!(
+                    shrunk.is_none(),
+                    "segment {start_offset} shrank to {size} bytes below its memo"
+                );
+                SegmentChecksumMemo::new()
+            }
+        };
+        // Clamped to the round's remaining budget, so a single multi-GiB
+        // segment is split across rounds rather than being the granularity
+        // floor. `finish` does not consume the hasher, so a partial pass is
+        // simply a memo nobody stamps yet.
+        let target = size.min(memo.hashed_len.saturating_add(*budget));
+        let hashed = target.saturating_sub(memo.hashed_len);
+        // Dropped on failure, not reinserted: the hasher is fed chunk by chunk
+        // and a mid-range error leaves it holding bytes `hashed_len` does not
+        // account for, so resuming from it would stamp a checksum over a
+        // doubly-fed prefix. Losing the partial pass is the cheap side.
+        hash_segment_range(log_path, memo.hashed_len, target, &mut memo.hasher, None)
+            .await
+            .map_err(|source| PartitionTransferUnavailable::SegmentUnreadable {
+                start_offset,
+                source,
+            })?;
+        memo.hashed_len = target;
+        let checksum = (target == size).then(|| memo.hasher.finish());
+        self.segment_checksum_cache
+            .borrow_mut()
+            .insert(start_offset, memo);
+        *budget = budget.saturating_sub(hashed);
+        Ok(checksum)
+    }
+
+    /// [`quarantine_segment_files`] over this partition's directory, for the
+    /// shard's `ConvergeFailed` fence -- the safety argument (segment files
+    /// move, superblock slots STAY, copies are unreclaimed operator evidence)
+    /// lives on the free function. `None` for an in-memory partition.
+    ///
+    /// # Errors
+    /// The underlying `std::io::Error`; see [`quarantine_segment_files`] for why
+    /// a failure is not something the rebuild can absorb.
+    pub async fn quarantine_partition_dir(&self) -> std::io::Result<Option<String>> {
+        let Some(dir) = self.partition_dir.clone() else {
+            return Ok(None);
+        };
+        quarantine_segment_files(&dir).await.map(Some)
+    }
+
+    /// Release the cached offer once no requester holds one (the shard's
+    /// offer-expiry sweep).
+    pub fn clear_state_transfer_offer_cache(&self) {
+        self.transfer_offer_cache.borrow_mut().take();
+    }
+
+    /// Snapshot the live offset maps + purge generation into the wire shape.
+    /// Eagerly auto-committed offsets can run slightly ahead of committed
+    /// state; that is safe because their covering ops sit in
+    /// `(commit_op, commit_max]`, which the receiver's tail repair replays,
+    /// and offset applies converge (monotone auto-commit, verbatim stores).
+    fn offsets_wire_snapshot(&self) -> ConsumerOffsetsWire {
+        // Every key is minted from a u32 wire id, so the narrowing filter is
+        // an invariant, not a policy: say so out loud instead of silently
+        // shrinking the snapshot when it ever breaks.
+        let mut consumers: Vec<(u32, u64)> = self
+            .consumer_offsets
+            .pin()
+            .iter()
+            .filter_map(|(id, offset)| {
+                let narrowed = u32::try_from(*id).ok();
+                debug_assert!(narrowed.is_some(), "consumer offset key {id} exceeds u32");
+                narrowed.map(|id| (id, offset.offset.load(Ordering::Acquire)))
+            })
+            .collect();
+        consumers.sort_unstable_by_key(|(id, _)| *id);
+        let mut groups: Vec<(u32, u64)> = self
+            .consumer_group_offsets
+            .pin()
+            .iter()
+            .filter_map(|(id, offset)| {
+                let narrowed = u32::try_from(id.0).ok();
+                debug_assert!(
+                    narrowed.is_some(),
+                    "consumer group offset key {} exceeds u32",
+                    id.0
+                );
+                narrowed.map(|id| (id, offset.offset.load(Ordering::Acquire)))
+            })
+            .collect();
+        groups.sort_unstable_by_key(|(id, _)| *id);
+        // The append counter, not the segment end: retention can GC every
+        // sealed segment while the counter stands at N, and the receiver
+        // must resume minting at N either way.
+        let next_offset = self.offset_frontier();
+        ConsumerOffsetsWire {
+            purge_generation: self.applied_purge_generation,
+            next_offset,
+            consumers,
+            groups,
+        }
+    }
+
+    /// Validate one completed `SEGMENT_LOG` artifact and spill it to staging
+    /// files, returning the walk metadata. Frees receiver memory as it goes:
+    /// after this the session drops the artifact's buffer.
+    ///
+    /// # Errors
+    /// `Err(walk error description)` when the payload fails validation (the
+    /// caller charges the decode budget), or a staging-write failure
+    /// description.
+    pub async fn spill_transfer_segment(
+        &self,
+        entry: &consensus::StateArtifact,
+        bytes: Vec<u8>,
+    ) -> Result<StagedSegmentMeta, SpillError> {
+        let Some(partition_dir) = self.partition_dir.clone() else {
+            return Err(SpillError::NoPartitionDir);
+        };
+        // This write replaces whatever the reuse memo recorded for this path,
+        // so the memo cannot outlive it: a later scan against an older offer
+        // must re-read the file rather than trust a walk of the old bytes.
+        self.reuse_scan_memo.borrow_mut().take();
+        // Artifact-level integrity FIRST, exactly as the metadata plane
+        // verifies every artifact before decoding: the walk's per-batch
+        // checksums prove batch bodies, not that these are the bytes the
+        // manifest promised (length alone is implied by completion).
+        if !verify_state_artifact_yielding(entry, &bytes).await {
+            return Err(SpillError::ManifestChecksum {
+                frontier: entry.frontier,
+            });
+        }
+        let (stats, index_bytes) = walk_segment_payload(entry.frontier, &bytes)
+            .await
+            .map_err(SpillError::Walk)?;
+        let (log_staging, index_staging) = staging_paths(&partition_dir, entry.frontier);
+        let index_size = index_bytes.len() as u64;
+        // Two writes, not a loop: each moves its buffer into compio's
+        // owned-buffer API, so a segment-sized payload is never copied.
+        write_staging_file(&log_staging, bytes)
+            .await
+            .map_err(|source| SpillError::StagingIo {
+                path: log_staging.clone(),
+                source,
+            })?;
+        write_staging_file(&index_staging, index_bytes)
+            .await
+            .map_err(|source| SpillError::StagingIo {
+                path: index_staging.clone(),
+                source,
+            })?;
+        fsync_dir(&partition_dir)
+            .await
+            .map_err(|source| SpillError::StagingIo {
+                path: PathBuf::from(&partition_dir),
+                source,
+            })?;
+        Ok(StagedSegmentMeta::from_walk(
+            entry,
+            stats,
+            index_size,
+            log_staging,
+            index_staging,
+        ))
+    }
+
+    /// Adopt an already-verified staged log without rewriting it: walk the
+    /// payload once (validation + a rebuilt sparse index), write only the
+    /// index sidecar, and return the walk metadata. The reuse scan calls
+    /// this after `verify_state_artifact` proved the bytes match the
+    /// manifest; rewriting the byte-identical log (and re-verifying a third
+    /// time) is exactly the work reuse exists to skip. The index write
+    /// stays: the scan never checks `.index.staging`, and a missing sidecar
+    /// would hand the install a missing rename source.
+    ///
+    /// No directory fsync here: every sidecar lands in the same directory, so
+    /// the caller fsyncs ONCE after its loop instead of once per adoption.
+    async fn adopt_staged_segment(
+        &self,
+        entry: &consensus::StateArtifact,
+        bytes: &[u8],
+    ) -> Result<StagedSegmentMeta, SpillError> {
+        let Some(partition_dir) = self.partition_dir.clone() else {
+            return Err(SpillError::NoPartitionDir);
+        };
+        let (stats, index_bytes) = walk_segment_payload(entry.frontier, bytes)
+            .await
+            .map_err(SpillError::Walk)?;
+        let (log_staging, index_staging) = staging_paths(&partition_dir, entry.frontier);
+        let index_size = index_bytes.len() as u64;
+        write_staging_file(&index_staging, index_bytes)
+            .await
+            .map_err(|source| SpillError::StagingIo {
+                path: index_staging.clone(),
+                source,
+            })?;
+        Ok(StagedSegmentMeta::from_walk(
+            entry,
+            stats,
+            index_size,
+            log_staging,
+            index_staging,
+        ))
+    }
+
+    /// Scan the partition directory for staging files left by an earlier
+    /// session and adopt every one that matches a manifest entry byte-for-
+    /// byte (length + artifact checksum + full re-walk). Sealed segments are
+    /// immutable, so on a retry or peer re-target typically only the active
+    /// segment and the offsets artifact re-pull. Staging strays matching no
+    /// entry are swept.
+    ///
+    /// A scan against a segment set this partition already scanned short-
+    /// circuits through [`ReuseScanMemo`]: rotating to another peer would
+    /// otherwise re-read and re-walk every staged file, up to 2 GiB each,
+    /// sequentially, on the pump.
+    pub async fn reuse_staged_segments(
+        &self,
+        manifest: &[consensus::StateArtifact],
+    ) -> Vec<(u32, StagedSegmentMeta)> {
+        let Some(partition_dir) = self.partition_dir.clone() else {
+            return Vec::new();
+        };
+        let digest = segment_manifest_digest(manifest);
+        // Cloned out of the borrow: the file re-checks below await.
+        let memoized = self
+            .reuse_scan_memo
+            .borrow()
+            .as_ref()
+            .filter(|memo| memo.digest == digest)
+            .map(|memo| memo.adopted.clone());
+        if let Some(adopted) = memoized {
+            // The memo proves the bytes were validated; only their continued
+            // presence needs re-checking (an install or a converge between the
+            // two scans unlinks them).
+            let mut intact = true;
+            for (_, meta) in &adopted {
+                let log_matches = matches!(
+                    compio::fs::metadata(&meta.log_staging).await,
+                    Ok(metadata) if metadata.len() == meta.size
+                );
+                if !log_matches || compio::fs::metadata(&meta.index_staging).await.is_err() {
+                    intact = false;
+                    break;
+                }
+            }
+            if intact {
+                return adopted;
+            }
+            self.reuse_scan_memo.borrow_mut().take();
+        }
+        let mut adopted = Vec::new();
+        let mut matched_paths = Vec::new();
+        for (index, entry) in manifest.iter().enumerate() {
+            if entry.kind != artifact_kind::SEGMENT_LOG {
+                continue;
+            }
+            let (log_staging, _) = staging_paths(&partition_dir, entry.frontier);
+            // Length short-circuit BEFORE reading: length is the first
+            // conjunct of the artifact check anyway, and the common retry
+            // case is precisely an active-segment length mismatch -- no
+            // point reading up to 2 GiB just to discard it.
+            match compio::fs::metadata(&log_staging).await {
+                Ok(metadata) if metadata.len() == entry.len => {}
+                _ => continue,
+            }
+            let Ok(bytes) = compio::fs::read(&log_staging).await else {
+                continue;
+            };
+            if !verify_state_artifact_yielding(entry, &bytes).await {
+                continue;
+            }
+            if let Ok(meta) = self.adopt_staged_segment(entry, &bytes).await {
+                matched_paths.push(meta.log_staging.clone());
+                matched_paths.push(meta.index_staging.clone());
+                #[allow(clippy::cast_possible_truncation)]
+                adopted.push((index as u32, meta));
+            }
+        }
+        // Every rebuilt sidecar landed in the same directory, so one fsync
+        // covers them all. Its failure discards EVERY adoption: the per-adopt
+        // filter above no longer sees a durability failure, and an undurable
+        // sidecar handed to the install is a missing rename source after a
+        // crash.
+        if !adopted.is_empty() && fsync_dir(&partition_dir).await.is_err() {
+            adopted.clear();
+            matched_paths.clear();
+        }
+        // Sweep strays: anything staged that no adopted meta claims.
+        let keep: HashSet<&Path> = matched_paths.iter().map(PathBuf::as_path).collect();
+        sweep_staging_except(&partition_dir, &keep).await;
+        *self.reuse_scan_memo.borrow_mut() = Some(ReuseScanMemo {
+            digest,
+            adopted: adopted.clone(),
+        });
+        adopted
+    }
+
+    /// Install a fully transferred partition state: swap the staged segment
+    /// files in, rebuild the in-memory log over them, replace the consumer
+    /// offset tables, clear the journal, and lift the commit floor to
+    /// `commit_op`. The live tail `(commit_op, commit_max]` is left to
+    /// ordinary journal repair.
+    ///
+    /// Two-phase: every validation runs before any mutation. The mutate
+    /// phase's crash windows all recover as an honestly-shorter partition
+    /// (see the swap ordering comments); no durable completeness claim
+    /// exists anywhere, so boot re-derives from whatever files survive.
+    ///
+    /// # Errors
+    /// [`PartitionInstallError`]; check-phase variants mutate nothing.
+    #[allow(clippy::too_many_lines)]
+    pub async fn install_state_transfer(
+        &mut self,
+        config: &PartitionsConfig,
+        commit_op: u64,
+        mut staged: Vec<StagedSegmentMeta>,
+        offsets_bytes: &[u8],
+        committed_purge_generation: u64,
+    ) -> Result<PartitionInstallOutcome, PartitionInstallError> {
+        // ---- check phase: nothing below may mutate ----
+        let Some(partition_dir) = self.partition_dir.clone() else {
+            return Err(PartitionInstallError::NoPartitionDir);
+        };
+        let commit_min = self.consensus().commit_min();
+        if commit_op < commit_min {
+            // The receiver's commit walk is frozen while transferring (the
+            // `is_transferring` dispatch gates), and install runs on the
+            // single pump task, so this is a refusal of a genuinely stale
+            // offer, not a race.
+            return Err(PartitionInstallError::StaleTransfer {
+                commit_op,
+                commit_min,
+            });
+        }
+        // The install rewinds the sequencer to `commit_op`, which erases ops
+        // this replica may already have journaled and acked. Bounding it below
+        // by what this replica knows to be COMMITTED keeps the erased window to
+        // ops it does not know are committed -- the checkable form of an
+        // argument the rewind's own comment only asserts. Free on an honest
+        // offer: only a caught-up primary can serve, so its `commit_min`
+        // equals its `commit_max`, and the receiver's descriptor gate already
+        // refused any peer whose `commit_max` was below this one's.
+        let commit_max = self.consensus().commit_max();
+        if commit_op < commit_max {
+            return Err(PartitionInstallError::StaleTransfer {
+                commit_op,
+                commit_min: commit_max,
+            });
+        }
+        let offsets_wire = ConsumerOffsetsWire::decode(offsets_bytes)?;
+        // Anti-rewind against the LOCAL OFFSET COUNTER, not the commit
+        // frontier: the partition journal is memory-only and
+        // `restore_partition_view` restores view/log_view alone, so `commit_min`
+        // is 0 after every restart however much data sits on disk -- the
+        // `StaleTransfer` refusal above is inert on exactly the canonical
+        // rejoin. The counter is the one signal that is `Some`-equivalent in
+        // EVERY state the offset space has advanced through (recovered bytes,
+        // an installed frontier, a converge after a failed install --
+        // `recovered_durable_offset` is `None` in the last two), and it is
+        // precisely what a rewind corrupts: received prepares are pre-stamp,
+        // `stamp_prepare_for_persistence` overwrites `base_offset` from this
+        // counter and recomputes `batch_checksum` over it, so a rewound
+        // counter persists different bytes and a different checksum on this
+        // replica than on the rest of the group. A purge is the one
+        // legitimate rewind, and the artifact carries the generation that
+        // proves one happened.
+        // Against the METADATA plane's committed generation, which the caller
+        // reads off durable state, NOT against `self.applied_purge_generation`:
+        // that one is memory-only and reads 0 after every restart, so a
+        // post-restart rejoin of any ever-purged topic would see
+        // `offered > 0 == applied` and call it an advancing purge. That is the
+        // canonical rejoin, and treating it as a purge disables the
+        // `OfferRewindsDurableData` refusal below -- the one guard standing
+        // between an offer that rewinds this replica's offset space and its
+        // durable data.
+        let purge_advances = offsets_wire.purge_generation > committed_purge_generation;
+        let local_next_offset = self.offset_frontier();
+        if !purge_advances && local_next_offset > 0 && offsets_wire.next_offset < local_next_offset
+        {
+            return Err(PartitionInstallError::OfferRewindsDurableData {
+                offer_next_offset: offsets_wire.next_offset,
+                local_next_offset,
+            });
+        }
+        staged.sort_unstable_by_key(|meta| meta.start_offset);
+        for pair in staged.windows(2) {
+            if pair[1].start_offset == pair[0].start_offset {
+                return Err(PartitionInstallError::DuplicateSegment {
+                    start_offset: pair[1].start_offset,
+                });
+            }
+            if pair[1].start_offset != pair[0].end_offset + 1 {
+                return Err(PartitionInstallError::SegmentSetHole {
+                    previous_end: pair[0].end_offset,
+                    next_start: pair[1].start_offset,
+                });
+            }
+        }
+
+        // ---- mutate phase ----
+        // Record the INCOMING frontier before anything destructive: the swap
+        // below unlinks the old chain and makes that durable before the first
+        // staged rename lands, and boot sweeps `.log.staging`, so a crash in
+        // that window would otherwise leave the frontier named by nothing at
+        // all and the replica would re-mint from 0 against a group at N.
+        //
+        // REFUSED, not logged and continued: this write is the sole durable
+        // carrier of the frontier on the path that matters, and if the converge
+        // that follows a failed install also fails, the fence quarantines away
+        // the very segments that would otherwise witness the counter. A
+        // storeless partition returns true early, so refusing here cannot
+        // wedge the in-memory case. Nothing has been mutated yet.
+        //
+        // Under `purge_advances` the offer's frontier is legitimately BELOW the
+        // live counter and must be written as a RESET. The advancing form would
+        // max it back up to the pre-purge value, and the reset it defers to
+        // belongs to a `purge` this replica provably never ran -- `purge_advances`
+        // is true precisely because it missed one. A crash between the old
+        // chain's unlink fsync and the last staged rename would then boot with
+        // zero `.log` files and re-seed the counter from the pre-purge frontier,
+        // above a group that restarted at the offer's, and the next prepare
+        // would stamp a `base_offset` and `batch_checksum` no peer shares.
+        let frontier_durable = if purge_advances {
+            self.reset_offset_frontier_at(offsets_wire.next_offset)
+                .await
+        } else {
+            self.persist_offset_frontier_at(offsets_wire.next_offset)
+                .await
+        };
+        if !frontier_durable {
+            return Err(PartitionInstallError::FrontierNotDurable {
+                frontier: offsets_wire.next_offset,
+            });
+        }
+        // The write lock spans the convergence too: a mutate failure leaves
+        // the segment vectors drained, and a concurrent replicated append
+        // indexing `segments().len() - 1` on the emptied vec is exactly the
+        // race every other segment-vec mutator takes this lock against.
+        let write_lock = self.write_lock.clone();
+        let _guard = write_lock.lock().await;
+        // Captured before `staged` moves: the convergence may only claim an
+        // offset frontier when the offer itself proved nothing is retained
+        // below it.
+        let staged_was_empty = staged.is_empty();
+        let outcome = self
+            .apply_checked_install(config, commit_op, staged, &offsets_wire, &partition_dir)
+            .await;
+        if outcome.is_err() {
+            // A mutate-phase failure can leave the log drained or half
+            // rebuilt while the disk already holds any prefix of the new
+            // chain. Converge BOTH the live state and the disk to an empty,
+            // honestly-lagging partition, so the next flush or poll cannot
+            // hit an empty segment vec and no stray chain can resurrect at
+            // boot; the normal triggers re-transfer the rest. The offset
+            // counter still seeds from the artifact frontier: a replica
+            // that resumed minting at 0 would fork its batch stamps from
+            // the group. A convergence failure outranks the install error:
+            // the partition cannot serve and the caller must fence it.
+            self.converge_to_empty_after_failed_install(
+                config,
+                offsets_wire.next_offset,
+                staged_was_empty,
+            )
+            .await
+            .map_err(|source| PartitionInstallError::ConvergeFailed {
+                source,
+                frontier: offsets_wire.next_offset,
+            })?;
+        }
+        // The frontier just moved with nothing durable naming it (an all-GC'd
+        // origin leaves no segment carrying it, and the crash windows inside
+        // the swap leave none either), so record it before returning. Runs for
+        // the converge path too: it seeds the counter from the same artifact.
+        //
+        // Logged rather than refused: the install already mutated, and the
+        // pre-swap write above left a valid lower bound on disk either way. The
+        // failure still matters -- the ordinary retry is the view-change gate,
+        // which an idle group may not reach for a long time -- so it must not
+        // pass silently.
+        if !self.persist_offset_frontier().await {
+            tracing::error!(
+                target: "iggy.partitions.diag",
+                plane = "partitions",
+                namespace_raw = self.consensus().namespace(),
+                frontier = self.offset_frontier(),
+                "state-transfer install could not record the installed offset frontier; \
+                 the durable record stays at the pre-swap claim until the next view change"
+            );
+        }
+        outcome
+    }
+
+    /// The install's mutate phase; every early return is a failure the
+    /// caller converges from. Split out so the convergence handling cannot
+    /// be forgotten on a new error path. Runs under the caller's write-lock
+    /// guard.
+    #[allow(clippy::too_many_lines)]
+    async fn apply_checked_install(
+        &mut self,
+        config: &PartitionsConfig,
+        commit_op: u64,
+        staged: Vec<StagedSegmentMeta>,
+        offsets_wire: &ConsumerOffsetsWire,
+        partition_dir: &str,
+    ) -> Result<PartitionInstallOutcome, PartitionInstallError> {
+        // Sweep staging strays a dead earlier attempt left behind, keeping
+        // only what THIS install is about to rename. Bounded disk hygiene;
+        // the reuse-scan sweeps too, and boot sweeps ALL of `.staging`
+        // (`sweep_scratch_files_and_collect_offsets`), so a transfer abandoned
+        // for good leaks at most until the next restart.
+        // A SET, not a list: the sweep tests every staging dirent against this,
+        // and `staged` is peer-supplied up to `STATE_MANIFEST_ENTRIES_MAX`, so a
+        // linear membership test makes the whole sweep quadratic in a number the
+        // requester chooses -- under the partition write lock, with no yields.
+        let keep: HashSet<&Path> = staged
+            .iter()
+            .flat_map(|meta| [meta.log_staging.as_path(), meta.index_staging.as_path()])
+            .collect();
+        sweep_staging_except(partition_dir, &keep).await;
+
+        // The install recreates segment files at paths it unlinks (the staged
+        // chain can reuse the same base offsets), so an in-flight poll's
+        // cached read fd would keep serving the unlinked pre-install inodes
+        // as live data -- worst case, purged messages returning checksum-clean
+        // on a receiver that missed the purge. Same hazard and same fix as
+        // `purge`: wipe the shared read-state slots first, so suspended walks
+        // re-resolve by path and see the fresh files.
+        self.log.invalidate_sealed_read_state();
+        self.segment_checksum_cache.borrow_mut().clear();
+        // Every staging file this install does not rename away is gone by the
+        // time it returns, and the ones it does rename stop being staging
+        // files, so no memo entry can survive it.
+        self.reuse_scan_memo.borrow_mut().take();
+
+        // Unlink the old segment chain oldest-first (a crash mid-loop leaves
+        // the NEWEST suffix, which is contiguous) and drop the in-memory
+        // vectors in lockstep, exactly as `purge` does.
+        let namespace_raw = self.consensus().namespace();
+        while let Some((_, mut storage)) = self.log.retire_front() {
+            let (messages_path, index_path) = storage.segment_and_index_paths();
+            let _ = storage.shutdown();
+            drop(storage);
+            for path in messages_path.into_iter().chain(index_path) {
+                match compio::fs::remove_file(&path).await {
+                    Ok(()) => {}
+                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+                    Err(error) => {
+                        // Propagated, not shrugged off: a surviving old
+                        // `.log` outside the staged set resurrects at boot
+                        // (recovery takes every `.log` stem), and can push
+                        // the recovered chain past the installed one. The
+                        // converge path re-sweeps the directory.
+                        warn_unlink(namespace_raw, &path, &error);
+                        return Err(PartitionInstallError::SwapIo {
+                            path,
+                            source: error,
+                        });
+                    }
+                }
+            }
+        }
+        fsync_dir(partition_dir)
+            .await
+            .map_err(|source| PartitionInstallError::SwapIo {
+                path: partition_dir.to_owned(),
+                source,
+            })?;
+
+        // Rename staged -> final: ALL indexes first, one directory fsync,
+        // then each log rename with its own fsync (N+1 total, down from 2N).
+        // Every index is durable before any log rename is issued -- strictly
+        // stronger than index_i-before-log_i -- because boot recovery
+        // derives segment bounds from the index and treats a `.log` without
+        // its `.index` as fatal, while an orphaned `.index` is invisible
+        // (recovery keys on `.log` stems). Each log rename remains its
+        // segment's commit point, and a crash mid-loop leaves a strict
+        // PREFIX of the new chain visible, which boots as a shorter
+        // contiguous partition and re-triggers transfer for the rest. Not
+        // fewer fsyncs than this: one-per-pair would lean on intra-directory
+        // rename ordering POSIX does not grant.
+        //
+        // KNOWN WINDOW, above and here: the old chain's unlinks are already
+        // durable and no staged log has landed yet. The frontier IS named
+        // durably across it -- the install records it in the superblock before
+        // the first unlink and refuses outright if that write fails -- so a
+        // crash here boots to zero segments with the counter re-seeded from the
+        // record, and the replica takes a clean full re-transfer. The residual
+        // is narrow: a record that predates this install (a fresh joiner's
+        // view-adoption write leaves frontier 0, which reads as no record at
+        // all), where `repaired_window_is_offsets_only` can then accept a
+        // complete offsets-only window with the counter still at 0.
+        for meta in &staged {
+            let (_, index_final) = final_paths(partition_dir, meta.start_offset);
+            compio::fs::rename(&meta.index_staging, &index_final)
+                .await
+                .map_err(|source| PartitionInstallError::SwapIo {
+                    path: index_final.clone(),
+                    source,
+                })?;
+        }
+        fsync_dir(partition_dir)
+            .await
+            .map_err(|source| PartitionInstallError::SwapIo {
+                path: partition_dir.to_owned(),
+                source,
+            })?;
+        // One directory handle for the whole loop. The per-rename fsync STAYS --
+        // each log rename is that segment's commit point and the ordering is the
+        // crash-safety argument -- but re-opening the directory to make each one
+        // is an `open`+`close` per segment for no durability gain.
+        let dir_handle = compio::fs::File::open(partition_dir)
+            .await
+            .map_err(|source| PartitionInstallError::SwapIo {
+                path: partition_dir.to_owned(),
+                source,
+            })?;
+        for meta in &staged {
+            let (log_final, _) = final_paths(partition_dir, meta.start_offset);
+            compio::fs::rename(&meta.log_staging, &log_final)
+                .await
+                .map_err(|source| PartitionInstallError::SwapIo {
+                    path: log_final.clone(),
+                    source,
+                })?;
+            dir_handle
+                .sync_all()
+                .await
+                .map_err(|source| PartitionInstallError::SwapIo {
+                    path: partition_dir.to_owned(),
+                    source,
+                })?;
+        }
+
+        // Rebuild the in-memory log over the installed files: sealed
+        // segments with metadata from the validation walk, real storage over
+        // the final paths, and writers on the LAST segment only (the
+        // hydrate pattern; earlier segments are sealed and never written).
+        for meta in &staged {
+            let (log_final, index_final) = final_paths(partition_dir, meta.start_offset);
+            // Retried once: by this point every rename already landed, so a
+            // failed open converges away a chain that is COMPLETE AND DURABLE
+            // on disk and the re-pull transfers the whole thing again. The
+            // sweep itself is right (a chain the live state does not know
+            // about would resurrect at boot), so one retry against a
+            // transient open failure is the only cheap save available.
+            let open = || {
+                SegmentStorage::new(
+                    &log_final,
+                    &index_final,
+                    meta.size,
+                    meta.index_size,
+                    config.enforce_fsync,
+                    config.enforce_fsync,
+                    true,
+                )
+            };
+            let storage = match open().await {
+                Ok(storage) => storage,
+                Err(_) => open()
+                    .await
+                    .map_err(|source| PartitionInstallError::SegmentOpen {
+                        path: log_final.clone(),
+                        source,
+                    })?,
+            };
+            let mut segment = Segment::new(meta.start_offset, config.segment_size);
+            segment.sealed = true;
+            segment.start_timestamp = meta.start_timestamp;
+            segment.end_timestamp = meta.end_timestamp;
+            segment.max_timestamp = meta.max_timestamp;
+            segment.end_offset = meta.end_offset;
+            segment.size = IggyByteSize::from(meta.size);
+            segment.current_position = meta.size;
+            self.log.add_persisted_segment(segment, storage, None, None);
+        }
+        if staged.is_empty() {
+            // An empty offered set (everything GC'd behind the consumer
+            // barrier on the sender) installs a fresh segment at the
+            // artifact's frontier, exactly where rotation would have put
+            // it, so post-install traffic lands in a segment named for the
+            // offsets it holds.
+            self.install_empty_segment(config, offsets_wire.next_offset)
+                .await
+                .map_err(|source| PartitionInstallError::SegmentOpen {
+                    path: partition_dir.to_owned(),
+                    source,
+                })?;
+            // The per-log fsync below lives inside the staged loop, which is
+            // empty on this path, and `install_empty_segment` opens with
+            // `file_exists = false` (no writer-creation fsyncs), so without
+            // this the frontier-bearing dirent is page-cache only: a crash
+            // right after the install boots an empty directory and re-derives
+            // the counter at 0.
+            fsync_dir(partition_dir)
+                .await
+                .map_err(|source| PartitionInstallError::SwapIo {
+                    path: partition_dir.to_owned(),
+                    source,
+                })?;
+        } else {
+            let last = self.log.segments().len() - 1;
+            let storage = self.log.storages()[last].clone();
+            if let (Some(messages_reader), Some(index_reader), Some(messages_w), Some(index_w)) = (
+                storage.messages_reader.as_ref(),
+                storage.index_reader.as_ref(),
+                storage.messages_writer.as_ref(),
+                storage.index_writer.as_ref(),
+            ) {
+                let messages_writer = MessagesWriter::new(
+                    &messages_reader.path(),
+                    messages_w.size_counter(),
+                    config.enforce_fsync,
+                    true,
+                )
+                .await
+                .map_err(|source| PartitionInstallError::SegmentOpen {
+                    path: messages_reader.path(),
+                    source,
+                })?;
+                let index_writer = IggyIndexWriter::new(
+                    &index_reader.path(),
+                    index_w.size_counter(),
+                    config.enforce_fsync,
+                    true,
+                )
+                .await
+                .map_err(|source| PartitionInstallError::SegmentOpen {
+                    path: index_reader.path(),
+                    source,
+                })?;
+                self.log.messages_writers_mut()[last] = Some(Rc::new(messages_writer));
+                self.log.index_writers_mut()[last] = Some(Rc::new(index_writer));
+            }
+            self.log.segments_mut()[last].sealed = false;
+        }
+
+        // The installed segments supersede every journaled op; stale
+        // residents (below OR above the floor) would collide with the new
+        // view's prepares. Memory-only journal, so a full clear IS the
+        // suffix truncation.
+        self.log.journal().inner.clear_all();
+        // The wrapper's flush accounting too, or thresholds and tail-repair
+        // appends fold onto a pre-install base until the first real evict.
+        self.log.journal_mut().info = crate::log::JournalInfo::default();
+
+        // Consumer offsets: replace both maps through the SAME Arcs (the
+        // data plane holds clones), unlink the old files, install the
+        // transferred entries with locally minted paths, clamped like boot
+        // recovery clamps. The clamp anchors on the GROUP FRONTIER, not the
+        // staged end: an empty staged set (everything GC'd at the origin)
+        // must not rewind every transferred offset to 0 -- a durable,
+        // client-visible rewind the replicas would then disagree on.
+        let installed_end = staged.last().map(|meta| meta.end_offset);
+        let next_offset = offsets_wire
+            .next_offset
+            .max(installed_end.map_or(0, |end| end + 1));
+        let mut offsets_written = true;
+        // A key that fails the u32 narrowing would strand its old offset
+        // file's delete, which boot can then resurrect: unreachable while
+        // keys are minted from u32 wire ids, so assert it.
+        let old_consumer_paths: Vec<String> = {
+            let guard = self.consumer_offsets.pin();
+            let paths = guard
+                .iter()
+                .filter_map(|(key, _)| {
+                    let narrowed = u32::try_from(*key).ok();
+                    debug_assert!(narrowed.is_some(), "consumer offset key {key} exceeds u32");
+                    narrowed.and_then(|id| self.persisted_offset_path(ConsumerKind::Consumer, id))
+                })
+                .collect();
+            guard.clear();
+            paths
+        };
+        let old_group_paths: Vec<String> = {
+            let guard = self.consumer_group_offsets.pin();
+            let paths = guard
+                .iter()
+                .filter_map(|(key, _)| {
+                    let narrowed = u32::try_from(key.0).ok();
+                    debug_assert!(
+                        narrowed.is_some(),
+                        "consumer group offset key {} exceeds u32",
+                        key.0
+                    );
+                    narrowed
+                        .and_then(|id| self.persisted_offset_path(ConsumerKind::ConsumerGroup, id))
+                })
+                .collect();
+            guard.clear();
+            paths
+        };
+        for path in old_consumer_paths.into_iter().chain(old_group_paths) {
+            if let Err(error) = delete_persisted_offset(&path).await {
+                // Not fatal, but not silent either: a stranded file is an id
+                // absent from the NEW table (matching ids get overwritten at
+                // the same path), and boot resurrects it. Sharpest after a
+                // purged origin ships `next_offset = 0`, where the clamp drops
+                // every incoming entry and the whole old table survives while
+                // the install still reports success.
+                tracing::warn!(
+                    target: "iggy.partitions.diag",
+                    plane = "partitions",
+                    namespace_raw = self.consensus().namespace(),
+                    path = %path,
+                    %error,
+                    "failed to unlink a superseded consumer-offset file during install"
+                );
+            }
+        }
+        self.persisted_offsets.borrow_mut().clear();
+        self.pending_consumer_offset_commits.clear();
+        self.last_polled_offsets.pin().clear();
+
+        // `None` when the group's offset space is empty (`next_offset == 0`,
+        // a purged origin): clamping every transferred offset to 0 would tell
+        // each consumer it consumed offset 0 on a partition that never minted
+        // one, so a `Next` poll skips the first message. Dropping the entries
+        // is what "no offsets yet" means.
+        let clamp = |offset: u64| next_offset.checked_sub(1).map(|last| offset.min(last));
+        if self.consumer_offsets_path.is_none() || self.consumer_group_offsets_path.is_none() {
+            // Nothing to write the transferred table into: unreachable via
+            // the server boot paths (they always configure storage), but if
+            // it ever fires the table was dropped and the flag must say so.
+            offsets_written = false;
+        }
+        // Both maps are populated first (no await, so nothing borrows across
+        // one), then the files are written in capped batches. One await per
+        // file put a rejoin carrying thousands of consumers on the pump for
+        // thousands of sequential open + write + optional fsync round trips;
+        // the tick's superblock pre-pass sets the precedent for the width.
+        let mut planned: Vec<PlannedOffsetWrite> =
+            Vec::with_capacity(offsets_wire.consumers.len() + offsets_wire.groups.len());
+        if let Some(dir) = self.consumer_offsets_path.clone() {
+            for (id, offset) in &offsets_wire.consumers {
+                let Some(value) = clamp(*offset) else {
+                    continue;
+                };
+                let entry = ConsumerOffset::default_for_consumer(*id, &dir);
+                entry.offset.store(value, Ordering::Release);
+                let path = entry.path.clone();
+                self.consumer_offsets.pin().insert(*id as usize, entry);
+                planned.push(PlannedOffsetWrite {
+                    kind: ConsumerKind::Consumer,
+                    id: *id,
+                    path,
+                    value,
+                });
+            }
+        }
+        if let Some(dir) = self.consumer_group_offsets_path.clone() {
+            for (id, offset) in &offsets_wire.groups {
+                let Some(value) = clamp(*offset) else {
+                    continue;
+                };
+                let group_id = ConsumerGroupId(*id as usize);
+                let entry = ConsumerOffset::default_for_consumer_group(group_id, &dir);
+                entry.offset.store(value, Ordering::Release);
+                let path = entry.path.clone();
+                self.consumer_group_offsets.pin().insert(group_id, entry);
+                planned.push(PlannedOffsetWrite {
+                    kind: ConsumerKind::ConsumerGroup,
+                    id: *id,
+                    path,
+                    value,
+                });
+            }
+        }
+        let enforce_fsync = self.consumer_offset_enforce_fsync;
+        for batch in planned.chunks(OFFSET_PERSIST_CONCURRENCY) {
+            let writes = batch.iter().map(|write| async move {
+                let written = persist_offset(&write.path, write.value, enforce_fsync)
+                    .await
+                    .is_ok();
+                (written, write.kind, write.id, write.value)
+            });
+            for (written, kind, id, value) in futures::future::join_all(writes).await {
+                if written {
+                    self.persisted_offsets
+                        .borrow_mut()
+                        .insert((kind, id), value);
+                } else {
+                    offsets_written = false;
+                }
+            }
+        }
+        // Directory fsync so the OLD files' unlinks stick: without it a
+        // crash right after install resurrects the pre-transfer offset
+        // files at boot. The per-file content durability stays governed by
+        // `consumer_offset_enforce_fsync` like every other offset commit.
+        for dir in self
+            .consumer_offsets_path
+            .clone()
+            .into_iter()
+            .chain(self.consumer_group_offsets_path.clone())
+        {
+            if fsync_dir(&dir).await.is_err() {
+                offsets_written = false;
+            }
+        }
+
+        // Counters and stats. The offset counter seeds from the ARTIFACT's
+        // frontier, not the installed segments: base offsets are minted
+        // locally per replica, so a counter behind the group (all sealed
+        // segments GC'd at the origin, empty active one skipped) would stamp
+        // the next replicated batch differently from the primary -- same op,
+        // different persisted bytes, different checksum. Segments can only
+        // trail the artifact (both were built at `commit_op`), so take the
+        // max defensively. The stats mutate through the EXISTING Arc:
+        // partition counters are never snapshotted, and the data plane's
+        // registered handle must keep reading the same cells.
+        let end = next_offset.saturating_sub(1);
+        self.offset.store(end, Ordering::Release);
+        self.dirty_offset.store(end, Ordering::Relaxed);
+        self.should_increment_offset = next_offset > 0;
+        self.recovered_durable_offset = installed_end;
+        // Where the group's offset space starts on this replica: everything
+        // below is represented by this install, so the repair floor check
+        // can connect windows that begin at (or below) it even with no
+        // durable bytes on disk. Deliberately NOT `recovered_durable_offset`:
+        // that field also gates repaired-batch persistence, and overstating
+        // it would silently drop the `(commit_op, commit_max]` replay window.
+        // `Some(0)` is filtered out: it reads as a real claim in the
+        // `NothingCommitted` serve gate (`installed_frontier.is_none()`), so a
+        // replica holding zero bytes at frontier 0 would start serving empty
+        // offers -- the phantom shape that gate exists to stop. Behavior is
+        // otherwise unchanged: the repair floor stand-in already treats
+        // `Some(0)` and `None` identically.
+        self.installed_frontier = (next_offset > 0).then_some(next_offset);
+        self.stats.zero_out_all();
+        #[allow(clippy::cast_possible_truncation)]
+        self.stats
+            .increment_segments_count(self.log.segments().len() as u32);
+        self.stats
+            .increment_size_bytes(staged.iter().map(|meta| meta.size).sum());
+        self.stats.increment_messages_count(
+            staged
+                .iter()
+                .map(|meta| meta.end_offset - meta.start_offset + 1)
+                .sum(),
+        );
+        self.stats.set_current_offset(end);
+
+        // A receiver that missed a purge must not be re-wiped by the
+        // reconciler right after installing post-purge data.
+        self.applied_purge_generation = self
+            .applied_purge_generation
+            .max(offsets_wire.purge_generation);
+        // Releasing the deferred-purge fence with it. Satisfying the generation
+        // here is what stops the reconciler re-issuing the purge that armed the
+        // fence, so leaving the flag set strands the replica quorum-invisible on
+        // this group for good. The fence's premise is discharged either way:
+        // it exists because the counter still named the pre-purge offset space,
+        // and this install just re-seeded that counter and recorded it durably
+        // before the swap.
+        self.purge_deferred = false;
+
+        let consensus = self.consensus();
+        if commit_op > consensus.commit_min() {
+            consensus.set_commit_floor(commit_op);
+        }
+        // The sequencer is SET, not raised: the install cleared the whole
+        // journal, so ops in `(commit_op, old_sequencer]` -- journaled and
+        // PrepareOk'd before the transfer armed, since a transferring replica
+        // withholds acks -- are ops consensus still claims and the journal can
+        // no longer serve. The primary's retransmit dies in the backup gap
+        // check, so a DVC from here would advertise an op this replica cannot
+        // walk -- tail repair targets the `(commit_op, commit_max]` gap, not
+        // this one. The pipeline is cleared in the same breath:
+        // its entries are backed by the same erased journal, and
+        // `LocalPipeline::push` asserts op sequentiality in release, so a bare
+        // rewind would turn the silent desync into a shard panic on a replica
+        // promoted mid-transfer. (`last_prepare_checksum` needs nothing: it is
+        // only read as a `parent:` stamp when building a prepare.)
+        consensus.sequencer().set_sequence(commit_op);
+        consensus.pipeline().borrow_mut().clear();
+        consensus.advance_commit_max(commit_op);
+        self.observed_view = self.consensus().view();
+        self.repair = None;
+        self.transfer_offer_cache.borrow_mut().take();
+
+        Ok(PartitionInstallOutcome {
+            applied_commit_op: commit_op,
+            offsets_written,
+        })
+    }
+
+    /// Converge the live partition AND its directory to an empty,
+    /// honestly-lagging shape after a failed install: no segment files at
+    /// all (the failure can land anywhere from "old chain unlinked" to
+    /// "new chain fully renamed in", and any survivor would either be
+    /// truncated in place by the empty plant or resurrect at boot in front
+    /// of / behind a later install), a fresh empty segment at the group's
+    /// offset frontier, an empty journal, and boot-equivalent counters.
+    /// Consensus state (commit floor, view) is left alone; the replica is
+    /// simply behind, and the normal triggers re-transfer.
+    ///
+    /// # Errors
+    /// [`iggy_common::IggyError`] when the sweep or the empty plant fails;
+    /// the partition then has no serviceable chain and the caller must
+    /// fence it (see [`PartitionInstallError::ConvergeFailed`]).
+    async fn converge_to_empty_after_failed_install(
+        &mut self,
+        config: &PartitionsConfig,
+        minted_next_offset: u64,
+        staged_was_empty: bool,
+    ) -> Result<(), iggy_common::IggyError> {
+        while let Some((_, mut storage)) = self.log.retire_front() {
+            let _ = storage.shutdown();
+        }
+        self.log.journal().inner.clear_all();
+        self.log.journal_mut().info = crate::log::JournalInfo::default();
+        // Every segment and staging file this partition had is about to be
+        // unlinked, so neither memo can describe anything real afterwards. The
+        // checksum map's own doc promises the clear happens here; without it the
+        // promise rested on the caller clearing it first.
+        self.segment_checksum_cache.borrow_mut().clear();
+        self.reuse_scan_memo.borrow_mut().take();
+
+        // Sweep EVERY segment file, not the in-memory count's worth: after
+        // a late failure the renamed-in new chain is on disk while the
+        // in-memory vectors were already drained, so only the directory
+        // itself knows what needs unlinking.
+        if let Some(partition_dir) = self.partition_dir.clone() {
+            let swept: Vec<PathBuf> = match segment_dir_entries(&partition_dir) {
+                Ok(entries) => entries
+                    .into_iter()
+                    .filter(|path| {
+                        path.to_str().is_some_and(|path| {
+                            [".log", ".index", STAGING_SUFFIX]
+                                .iter()
+                                .any(|extension| path.ends_with(extension))
+                        })
+                    })
+                    .collect(),
+                Err(error) => {
+                    tracing::error!(
+                        target: "iggy.partitions.diag",
+                        plane = "partitions",
+                        namespace_raw = self.consensus().namespace(),
+                        partition_dir,
+                        %error,
+                        "converge sweep cannot list the partition directory"
+                    );
+                    return Err(iggy_common::IggyError::CannotReadPartitions);
+                }
+            };
+            for path in swept {
+                match compio::fs::remove_file(&path).await {
+                    Ok(()) => {}
+                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+                    Err(error) => {
+                        warn_unlink(
+                            self.consensus().namespace(),
+                            &path.display().to_string(),
+                            &error,
+                        );
+                        return Err(iggy_common::IggyError::CannotDeleteFile);
+                    }
+                }
+            }
+            fsync_dir(&partition_dir)
+                .await
+                .map_err(|_| iggy_common::IggyError::CannotSyncFile)?;
+        }
+
+        self.install_empty_segment(config, minted_next_offset)
+            .await?;
+        // Empty data, but NOT offset zero: the artifact already proved the
+        // group's frontier, and a counter reset would stamp the next
+        // replicated batch differently from the primary. Only the durable
+        // claim goes back to None; the partition is honestly lagging.
+        let end = minted_next_offset.saturating_sub(1);
+        self.offset.store(end, Ordering::Release);
+        self.dirty_offset.store(end, Ordering::Relaxed);
+        self.should_increment_offset = minted_next_offset > 0;
+        self.recovered_durable_offset = None;
+        // The frontier claims "everything below me is represented here", and the
+        // repair floor check accepts any floor at or below it. Nothing was
+        // installed, so the claim holds ONLY when the offer itself proved the
+        // origin retains nothing below its frontier -- an empty staged set. With
+        // segments staged and the install failed, this replica holds zero bytes
+        // of a range it would otherwise declare whole, and `set_commit_floor`
+        // would lift `commit_min` over ops it cannot serve.
+        self.installed_frontier =
+            (staged_was_empty && minted_next_offset > 0).then_some(minted_next_offset);
+        self.stats.zero_out_all();
+        self.stats.increment_segments_count(1);
+        // `zero_out_all` clears the reported offset too, and the counter above
+        // sits at `minted_next_offset - 1`: the success path keeps the two in
+        // step, so this one does as well.
+        self.stats.set_current_offset(end);
+        self.repair = None;
+        self.transfer_offer_cache.borrow_mut().take();
+        Ok(())
+    }
+}
+
+/// Failure validating or staging one transferred segment artifact.
+#[derive(Debug)]
+pub enum SpillError {
+    NoPartitionDir,
+    /// The received bytes are not what the manifest promised.
+    ManifestChecksum {
+        frontier: u64,
+    },
+    /// The payload failed its format validation walk.
+    Walk(SegmentWalkError),
+    /// Writing or syncing a staging file failed.
+    StagingIo {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+}
+
+impl fmt::Display for SpillError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::NoPartitionDir => write!(f, "partition has no on-disk directory"),
+            Self::ManifestChecksum { frontier } => write!(
+                f,
+                "segment artifact at base offset {frontier} fails its manifest checksum"
+            ),
+            Self::Walk(source) => write!(f, "{source}"),
+            Self::StagingIo { path, source } => {
+                write!(f, "staging io failed at {}: {source}", path.display())
+            }
+        }
+    }
+}
+
+impl std::error::Error for SpillError {}
+
+/// Payload bytes between rebuilt sparse-index entries.
+///
+/// Targets the ORIGIN's density (one entry per flush chunk), not maximum
+/// sparseness: at this stride a maximum-size 1 GiB segment rebuilds ~16k
+/// entries (~384 KiB), inside `poll_plan::SEALED_INDEX_RESIDENT_MAX_BYTES`, so
+/// a transferred segment caches its index like any other instead of taking the
+/// per-poll binary-search fallback.
+const INDEX_STRIDE_BYTES: usize = 64 * 1024;
+
+/// Hand the core back to the reactor mid-CPU-pass.
+///
+/// Reactor only: the consensus tick shares this task as a sibling
+/// `select_biased!` arm, and arms are not polled while one arm's body awaits, so
+/// yielding here does not unfreeze ticks or heartbeats.
+///
+/// A zero-duration timer, NOT a bare self-waking yield: this runtime does not
+/// reliably re-poll a task that woke itself from inside its own poll, and a
+/// pump that suspends that way stops driving consensus entirely (the frame
+/// handler never resumes, ticks stop, the node goes quiet until something else
+/// wakes it). Registering with the reactor is what every other yield on these
+/// paths does -- the serving side yields through real file reads.
+async fn yield_to_reactor() {
+    compio::time::sleep(std::time::Duration::ZERO).await;
+}
+
+/// Chunk size for the offer build's streaming checksum pass. Large enough
+/// that per-chunk overhead is noise, small enough that the pump yields to
+/// the reactor many times per segment.
+const OFFER_HASH_CHUNK_LEN: usize = 1 << 20;
+
+/// Bytes one offer-build round may read and hash before it refuses and resumes
+/// on the next request.
+///
+/// The pass holds a frame body, and this shard's consensus ticks are a sibling
+/// select arm that stays unpolled for its duration, so the budget is really a
+/// bound on how long every OTHER group on this core goes without a heartbeat.
+///
+/// A BYTE budget standing in for a time bound, so the margin is storage-class
+/// specific: 256 MiB is roughly a quarter second on commodity `NVMe` against
+/// the shipped 5 s `heartbeat_timeout`, but about 2 s on a throttled cloud
+/// volume at 125 MB/s baseline, which is most of that window. Sized for the
+/// slower case still leaving room, and large enough that ordinary retention
+/// finishes in one round. An elapsed-time clamp would bound it properly on
+/// every storage class.
+const OFFER_HASH_BUDGET_PER_ROUND_BYTES: u64 = 256 * 1024 * 1024;
+
+/// Feed bytes `[from, to)` of `path` into `hasher`, read in
+/// [`OFFER_HASH_CHUNK_LEN`] chunks with one reactor yield per chunk, appending
+/// each chunk to `sink` when one is given.
+///
+/// The single chunked reader for both passes over a segment file: the offer
+/// build's checksum extension (no sink) and the serving side's load + re-verify
+/// (sink collects the artifact). Errors on a file shorter than `to`: the segment
+/// accounts bytes the disk does not hold.
+async fn hash_segment_range(
+    path: &str,
+    from: u64,
+    to: u64,
+    hasher: &mut StateArtifactHasher,
+    mut sink: Option<&mut Vec<u8>>,
+) -> std::io::Result<()> {
+    if from >= to {
+        return Ok(());
+    }
+    let file = compio::fs::File::open(path).await?;
+    let mut position = from;
+    // One buffer for the whole pass; `BufResult` hands it back per read
+    // precisely so the alloc + memset are not paid per chunk. Re-allocated
+    // (not resized) when the tail chunk is shorter: compio reads into a
+    // Vec's CAPACITY, and a shrunken length over the old 1 MiB capacity made
+    // `read_exact_at` demand a full megabyte at EOF.
+    #[allow(clippy::cast_possible_truncation)]
+    let mut buf = vec![0u8; OFFER_HASH_CHUNK_LEN.min((to - from) as usize)];
+    while position < to {
+        #[allow(clippy::cast_possible_truncation)]
+        let want = OFFER_HASH_CHUNK_LEN.min((to - position) as usize);
+        if buf.len() != want {
+            buf = vec![0u8; want];
+        }
+        let compio::BufResult(read, returned) = file.read_exact_at(buf, position).await;
+        buf = returned;
+        read.map_err(|source| {
+            std::io::Error::other(format!(
+                "reading segment bytes at {position} of {to} failed: {source}"
+            ))
+        })?;
+        hasher.update(&buf);
+        if let Some(sink) = sink.as_deref_mut() {
+            sink.extend_from_slice(&buf);
+        }
+        position += want as u64;
+    }
+    Ok(())
+}
+
+/// Read the first `entry.len` bytes of a served segment file and re-verify them
+/// against the manifest entry, chunked through [`hash_segment_range`] with one
+/// reactor yield per chunk.
+///
+/// The serving side runs this on the pump to answer a single chunk request, so
+/// it reads and hashes in chunks rather than in one pass. The yields keep the
+/// REACTOR moving (detached tasks, `io_uring` completions); they do not keep this
+/// shard's consensus ticks alive, which are a sibling select arm of the same
+/// task and stay frozen for the duration.
+///
+/// The file may legitimately be LONGER than the entry (an active segment that
+/// kept appending after the offer was built); the artifact is the prefix.
+///
+/// # Errors
+/// [`SegmentLoadError`], which the caller maps onto the refusal it sends: a
+/// collapsed `Option` here told a requester that a dying disk was a momentary
+/// blip forever, because the refusal it drives is classified by cause.
+pub async fn load_verified_segment_artifact(
+    log_path: &str,
+    entry: &consensus::StateArtifact,
+) -> Result<Vec<u8>, SegmentLoadError> {
+    let mut hasher = StateArtifactHasher::new();
+    #[allow(clippy::cast_possible_truncation)]
+    let mut bytes = Vec::with_capacity(entry.len as usize);
+    hash_segment_range(log_path, 0, entry.len, &mut hasher, Some(&mut bytes))
+        .await
+        .map_err(SegmentLoadError::classify)?;
+    if hasher.finish() != entry.checksum {
+        return Err(SegmentLoadError::ChecksumMismatch);
+    }
+    Ok(bytes)
+}
+
+/// Why a served segment could not be handed to a requester.
+///
+/// The split is the whole point: a short read is what a concurrent GC
+/// unlink-and-recreate legitimately produces and a checksum mismatch means the
+/// offer is simply stale, but `EIO` / `EACCES` / a failed open is a fault on
+/// THIS node, and telling the requester it was transient hides a dying disk
+/// behind an endless peer rotation.
+#[derive(Debug)]
+pub enum SegmentLoadError {
+    /// The file is gone, shorter than the entry, or otherwise out of step with
+    /// an offer built earlier. Retryable from the requester's side.
+    Stale(std::io::Error),
+    /// The bytes are present but no longer hash to the manifest entry.
+    ChecksumMismatch,
+    /// A local fault: unreadable device, permissions, an open that failed.
+    LocalFault(std::io::Error),
+}
+
+impl SegmentLoadError {
+    fn classify(source: std::io::Error) -> Self {
+        // `raw_os_error`, not just `kind()`: std maps EIO to
+        // `ErrorKind::Uncategorized`, so a dying disk is invisible to a
+        // kind-only match -- the exact case this split exists to catch.
+        // Everything unrecognised stays STALE: a short read past EOF is what a
+        // racing GC unlink-and-recreate legitimately produces.
+        const EIO: i32 = 5;
+        if source.raw_os_error() == Some(EIO) {
+            return Self::LocalFault(source);
+        }
+        match source.kind() {
+            std::io::ErrorKind::PermissionDenied => Self::LocalFault(source),
+            _ => Self::Stale(source),
+        }
+    }
+
+    /// Whether the requester should retry without charging a failure.
+    #[must_use]
+    pub const fn transient(&self) -> bool {
+        matches!(self, Self::Stale(_) | Self::ChecksumMismatch)
+    }
+}
+
+impl fmt::Display for SegmentLoadError {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Stale(source) => {
+                write!(f, "served segment no longer matches the offer: {source}")
+            }
+            Self::ChecksumMismatch => {
+                write!(
+                    f,
+                    "served segment bytes no longer hash to the manifest entry"
+                )
+            }
+            Self::LocalFault(source) => {
+                write!(f, "served segment is unreadable on this node: {source}")
+            }
+        }
+    }
+}
+
+impl std::error::Error for SegmentLoadError {}
+
+/// [`consensus::verify_state_artifact`] with reactor yields.
+///
+/// The receiver runs this on the pump for a whole artifact (up to a segment),
+/// and a non-yielding hash of that size makes the node quorum-invisible for its
+/// duration and starves the same-core segment cleaner.
+async fn verify_state_artifact_yielding(entry: &consensus::StateArtifact, bytes: &[u8]) -> bool {
+    if bytes.len() as u64 != entry.len {
+        return false;
+    }
+    let mut hasher = StateArtifactHasher::new();
+    for chunk in bytes.chunks(OFFER_HASH_CHUNK_LEN) {
+        hasher.update(chunk);
+        yield_to_reactor().await;
+    }
+    hasher.finish() == entry.checksum
+}
+
+/// The purge generation an encoded consumer-offsets artifact carries, or `0`
+/// when it cannot be decoded.
+///
+/// Lets the shard refuse an offer built BEFORE a committed purge without
+/// duplicating the wire codec: the install's own generation handling only ever
+/// widens permission, so a stale offer would resurrect purged data with the
+/// local applied generation left at the newer value, which the reconciler's
+/// re-wipe gate then reads as "already applied".
+#[must_use]
+pub fn offered_purge_generation(offsets_bytes: &[u8]) -> u64 {
+    ConsumerOffsetsWire::decode(offsets_bytes)
+        .map(|wire| wire.purge_generation)
+        .unwrap_or_default()
+}
+
+/// Stamp over every `SEGMENT_LOG` entry of a manifest, keying
+/// [`ReuseScanMemo`]. Equal digests mean the two offers expect byte-identical
+/// staged files, so a scan already done for one answers the other; the offsets
+/// artifact is excluded because the scan never looks at it (and it re-encodes
+/// per build, so including it would defeat the memo on every rotation).
+fn segment_manifest_digest(manifest: &[consensus::StateArtifact]) -> u64 {
+    let mut hasher = StateArtifactHasher::new();
+    for entry in manifest
+        .iter()
+        .filter(|entry| entry.kind == artifact_kind::SEGMENT_LOG)
+    {
+        hasher.update(&entry.frontier.to_le_bytes());
+        hasher.update(&entry.len.to_le_bytes());
+        hasher.update(&entry.checksum.to_le_bytes());
+    }
+    hasher.finish()
+}
+
+async fn write_staging_file(path: &Path, payload: Vec<u8>) -> std::io::Result<()> {
+    let mut file = compio::fs::File::create(path).await?;
+    let (result, _) = file.write_all_at(payload, 0).await.into();
+    result?;
+    file.sync_data().await?;
+    Ok(())
+}
+
+fn warn_unlink(namespace_raw: u64, path: &str, error: &std::io::Error) {
+    tracing::warn!(
+        target: "iggy.partitions.diag",
+        plane = "partitions",
+        namespace_raw,
+        path = %path,
+        %error,
+        "failed to unlink segment file during state-transfer install"
+    );
+}
diff --git a/core/partitions/src/types.rs b/core/partitions/src/types.rs
index 11c7796..f052ff7 100644
--- a/core/partitions/src/types.rs
+++ b/core/partitions/src/types.rs
@@ -236,6 +236,27 @@
     pub idle_ticks: u32,
 }
 
+/// How a repair-window commit walk concluded, decided by
+/// `IggyPartition::complete_repair`.
+///
+/// `#[must_use]` because `FloorRefused` is the partition plane's
+/// state-transfer trigger: repair proved the gap below the floor is neither
+/// locally durable nor repairable, so ignoring it wedges the replica
+/// gap-stopped forever.
+#[must_use]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RepairConclusion {
+    /// The walk fell short of `to_op`; the session stays armed and the stall
+    /// retry re-requests the remains.
+    InProgress,
+    /// The walk reached the requested frontier; the session was dropped.
+    Done,
+    /// The floor's continuity check failed: ops below it are neither locally
+    /// durable nor repaired. The session was dropped here -- state transfer
+    /// supersedes repair -- and the caller arms the transfer.
+    FloorRefused { floor: u64, to_op: u64 },
+}
+
 /// Configuration for partition operations.
 ///
 /// Mirrors the relevant fields from the server's `PartitionConfig` and
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index cbcd6e9..10a5299 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -955,12 +955,42 @@
 # large batches can pin. Must be > 0 and <= "256 MiB".
 evicted_ring_bytes_max = "16 MiB"
 
+# Byte budget for segment payloads a SERVING shard keeps resident to answer
+# state-transfer chunk requests. PER SHARD, and shard count defaults to core
+# count, so the process-wide high-water is this times the core count on top of
+# page cache -- keep that product in mind before raising it. The default is a
+# FIXED 2176 MiB: two sealed segments at the SHIPPED system.segment.size of
+# 1 GiB, each of which can close one whole message_bus.max_message_size past
+# its target, which is why it is not 2 GiB. It does not track your segment
+# size. How many groups this shard serves at once IS derived from yours:
+# floor(this / max(partition.transfer_artifact_bytes_max,
+# system.segment.size + 64 MiB)), minimum one. So raising either that knob or
+# system.segment.size without raising this lowers concurrency and can take it
+# to one, serialising rejoins, and nothing at boot warns about it.
+# Below one segment a single rejoining node thrashes the cache by itself and
+# every miss re-reads and re-hashes a whole segment to serve one 256 KiB chunk.
+# Running under the budget costs re-reads, not failures.
+# Must be > 0 and <= "64 GiB".
+transfer_served_cache_bytes_max = "2176 MiB"
+
+# Alloc ceiling for ONE received state-transfer artifact, per shard. The
+# receiver holds it resident through verify, walk and staging write, and up to
+# four transfers run at once. MUST cover system.segment.size plus
+# message_bus.max_message_size (a segment may close one whole batch past its
+# cap): under that, a legal segment is refused, the whole manifest with it, and
+# the partition livelocks re-requesting it from every peer. Boot validates the
+# floor. Raising this above the floor for headroom also DIVIDES the serving
+# concurrency derived from transfer_served_cache_bytes_max above, so raise that
+# in step. Must be > 0 and <= "64 GiB".
+transfer_artifact_bytes_max = "1088 MiB"
+
 # Message bus configuration.
 # Tunables for the inter-shard / inter-replica internal bus that ships
 # consensus traffic between replicas and SDK-client traffic between
 # shards. These knobs are consensus-liveness-critical (max_batch gates
 # throughput under backpressure). Defaults match
 # core::message_bus::config::MessageBusConfig::default().
+
 [message_bus]
 # Maximum number of BusMessage entries coalesced into a single writev(2)
 # call. Hard upper bound: IOV_MAX/2 = 512 on Linux.
diff --git a/core/server-ng/src/auth.rs b/core/server-ng/src/auth.rs
index 65dc83a..74a3844 100644
--- a/core/server-ng/src/auth.rs
+++ b/core/server-ng/src/auth.rs
@@ -34,6 +34,7 @@
     MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
 };
 use iggy_common::{IggyError, IggyTimestamp, PersonalAccessToken, UserStatus};
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use metadata::impls::metadata::StreamsFrontend;
 use server_common::Message;
@@ -60,8 +61,8 @@
     LazyLock::force(&DUMMY_PASSWORD_HASH);
 }
 
-pub(crate) fn verify_login_credentials<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn verify_login_credentials<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     username: &str,
     password: &str,
 ) -> Result<u32, LoginRegisterError>
@@ -70,6 +71,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // Same bounds the legacy server enforces before any lookup or hashing;
     // also keeps arbitrary-length input out of the password hash. Collapsed
@@ -106,8 +108,8 @@
     })
 }
 
-pub(crate) fn verify_pat_credentials<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn verify_pat_credentials<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     token: &str,
 ) -> Result<u32, LoginRegisterError>
 where
@@ -115,6 +117,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     verify_pat_credentials_with_expiry(shard, token).map(|(user_id, _)| user_id)
 }
@@ -123,8 +126,8 @@
 /// seconds, `u64::MAX` when the PAT never expires). The HTTP extractor keys a
 /// per-token VSR session table on this expiry for lazy eviction; the wire and
 /// login paths only need the user id and go through [`verify_pat_credentials`].
-pub(crate) fn verify_pat_credentials_with_expiry<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn verify_pat_credentials_with_expiry<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     token: &str,
 ) -> Result<(u32, u64), LoginRegisterError>
 where
@@ -132,6 +135,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let token_hash = PersonalAccessToken::hash_token(token);
     // PAT expiry gates the login accept/reject, and that outcome folds into
@@ -174,8 +178,8 @@
 }
 
 #[allow(clippy::future_not_send)]
-pub(crate) async fn complete_login_register<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn complete_login_register<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
     vsr_client_id: u128,
@@ -188,6 +192,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let sdk_info = ClientSdkInfo {
         sdk_name: client_version.sdk_name.as_str().to_owned(),
@@ -283,8 +288,8 @@
 /// SDK surfaces the real reason (every frame transport decodes
 /// `Command2::Eviction`) instead of a decode error or a timeout.
 #[allow(clippy::future_not_send)]
-pub(crate) async fn surface_login_failure<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn surface_login_failure<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request_header: &RequestHeader,
     error: &LoginRegisterError,
@@ -293,6 +298,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if error.is_terminal() {
         send_login_eviction(
@@ -317,8 +323,8 @@
 /// same login on the same connection. Only call for transient errors -- see
 /// [`surface_login_failure`].
 #[allow(clippy::future_not_send)]
-async fn send_login_transient_reply<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_login_transient_reply<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request_header: &RequestHeader,
 ) where
@@ -326,6 +332,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let commit = current_metadata_commit(shard);
     // `TransientNotAccepted`: a login/register replay is safe under any
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index 9fddb38..57994d9 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -25,7 +25,8 @@
 };
 use crate::http;
 use crate::partition_helpers::{
-    configure_consumer_offsets, ensure_initial_segment, validate_namespace_bounds,
+    build_partition_fresh, configure_consumer_offsets, ensure_initial_segment,
+    open_partition_superblock, restore_partition_view, validate_namespace_bounds,
 };
 use crate::segment_recovery::{RecoveredSegment, load_persisted_segments};
 use crate::server_error::{ServerNgError, ShardJoinFailure, ShardJoinFailureKind};
@@ -50,7 +51,7 @@
 };
 use iggy_common::{Aes256GcmEncryptor, EncryptorKind, IggyByteSize, PartitionStats, variadic};
 use journal::prepare_journal::PrepareJournal;
-use journal::superblock::{DynSuperblockStore, PingPongSuperblock};
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
 use journal::{Journal, JournalHandle};
 use message_bus::client_listener::{self, RequestHandler};
 use message_bus::installer;
@@ -71,8 +72,9 @@
 };
 use metadata::IggyMetadata;
 use metadata::MuxStateMachine;
+use metadata::ReplicaIdentity;
 use metadata::impls::metadata::{IggySnapshot, StreamsFrontend};
-use metadata::impls::recovery::{ReplicaIdentity, recover};
+use metadata::impls::recovery::recover;
 use metadata::stm::mux::WithFactory;
 use metadata::stm::snapshot::Snapshot;
 use metadata::stm::stream::{Partition, Streams};
@@ -130,14 +132,17 @@
 
 /// The shard type the dispatch layer is generic over.
 ///
-/// `B`/`MJ`/`S` are free; the metadata state machine (`M`) and shards table
-/// (`T`) are pinned, being identical in production and the simulator.
-/// Production instantiates it as [`ServerNgShard`]; the simulator supplies its
-/// own `B`/`MJ`/`S`.
-pub type ShellShard<B, MJ, S> = IggyShard<B, MJ, S, ServerNgMuxStateMachine, PapayaShardsTable>;
+/// `B`/`MJ`/`S`/`SB` are free; the metadata state machine (`M`) and shards
+/// table (`T`) are pinned, being identical in production and the simulator.
+/// Production instantiates it as [`ServerNgShard`], defaulting `SB` to the
+/// on-disk [`PingPongSuperblock`]; the simulator supplies its own
+/// `B`/`MJ`/`S`/`SB`.
+pub type ShellShard<B, MJ, S, SB = PingPongSuperblock> =
+    IggyShard<B, MJ, S, ServerNgMuxStateMachine, PapayaShardsTable, SB>;
 
 /// Late-bound self-reference the deferred dispatch handlers upgrade per frame.
-pub type ShellShardHandle<B, MJ, S> = Rc<RefCell<Option<Weak<ShellShard<B, MJ, S>>>>>;
+pub type ShellShardHandle<B, MJ, S, SB = PingPongSuperblock> =
+    Rc<RefCell<Option<Weak<ShellShard<B, MJ, S, SB>>>>>;
 
 /// Bus bounds the dispatch/pump path needs (matches `run_message_pump`).
 /// Blanket-impl'd, so it is only shorthand for the four underlying bounds.
@@ -185,9 +190,9 @@
 /// They share one fresh [`SessionManager`]. The caller must set the weak
 /// self-reference in `shard_handle` once the shard is built, so the
 /// handlers can upgrade it per frame.
-pub fn wire_shell_handlers<B, MJ, S>(
+pub fn wire_shell_handlers<B, MJ, S, SB>(
     bus: &B,
-    shard_handle: &ShellShardHandle<B, MJ, S>,
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
     system_config: Arc<NgSystemConfig>,
     max_tokens_per_user: u32,
 ) -> ShellHandlers
@@ -196,6 +201,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let sessions = Rc::new(RefCell::new(SessionManager::new()));
     ShellHandlers {
@@ -1025,7 +1031,7 @@
         // ping-pong sequence counter. Consensus recovers its true (view, log_view)
         // from `recovered_state` instead of inferring a stale view from the WAL.
         let consensus = restore_metadata_consensus(&owner, &topology, config, Rc::clone(&bus));
-        let superblock: Rc<dyn DynSuperblockStore> = Rc::new(owner.superblock);
+        let superblock = Rc::new(owner.superblock);
         (
             Some(consensus),
             Some(owner.journal),
@@ -1723,17 +1729,114 @@
     for (stream_id, topic_id, partition_stats, partition_metadata) in owned {
         validate_namespace_bounds(config, stream_id, topic_id, partition_metadata.id)?;
         let namespace = IggyNamespace::new(stream_id, topic_id, partition_metadata.id);
-        let partition = load_partition(
+        let partition = match load_partition(
             config,
             namespace,
-            partition_stats,
+            Arc::clone(&partition_stats),
             &partition_metadata,
             topology.cluster_id,
             topology.self_replica_id,
             topology.replica_count,
             Rc::clone(&bus),
         )
-        .await?;
+        .await
+        {
+            Ok(partition) => partition,
+            // ONE damaged local chain must not take the node down. The shapes
+            // this refuses are exactly what a failed state-transfer quarantine
+            // leaves behind, so fence that group the same way the runtime path
+            // does -- move its segment files aside, keeping the superblock so it
+            // cannot re-enter view 0 -- and materialise it fresh. The ordinary
+            // rejoin path (repair, then state transfer on a refused floor)
+            // recovers its data from a peer.
+            Err(ServerNgError::PartitionChainRefused { dir, reason, .. }) => {
+                let partition_dir = dir.to_string_lossy().into_owned();
+                error!(
+                    stream_id,
+                    topic_id,
+                    partition_id = partition_metadata.id,
+                    partition_dir,
+                    %reason,
+                    "refusing the recovered segment chain; fencing this partition and \
+                     rebuilding it empty for the rejoin path"
+                );
+                match partitions::state_transfer::quarantine_segment_files(&partition_dir).await {
+                    Ok(fenced_dir) => error!(
+                        stream_id,
+                        topic_id,
+                        partition_id = partition_metadata.id,
+                        fenced_dir,
+                        "quarantined the refused segment files; they are kept for inspection"
+                    ),
+                    Err(error) => {
+                        // NOT rebuilt: `build_partition_fresh` reaches
+                        // `ensure_initial_segment`, which opens segment 0 with
+                        // `file_exists = false` and TRUNCATES whatever the
+                        // failed quarantine left behind. The likeliest failures
+                        // (suffix cap exhausted, `create_dir_all`) move zero
+                        // files, so rebuilding would destroy the oldest segment
+                        // on the first attempt while the higher-offset survivors
+                        // keep refusing every boot -- a loop that never
+                        // terminates and eats the chain one segment at a time.
+                        // Tombstone instead: the namespace stays unmaterialised
+                        // and unrouted, the reconciler backs off, and an
+                        // operator still has every byte.
+                        error!(
+                            stream_id,
+                            topic_id,
+                            partition_id = partition_metadata.id,
+                            partition_dir,
+                            %error,
+                            "failed to quarantine the refused segment files; leaving this \
+                             partition tombstoned rather than rebuilding over them"
+                        );
+                        partition_stats.zero_out_all();
+                        partitions.tombstone(namespace);
+                        continue;
+                    }
+                }
+                // The refused load already folded its segment counts in.
+                partition_stats.zero_out_all();
+                build_partition_fresh(
+                    config,
+                    namespace,
+                    partition_stats,
+                    topology.cluster_id,
+                    topology.self_replica_id,
+                    topology.replica_count,
+                    Rc::clone(&bus),
+                )
+                .await?
+            }
+            // An untrustworthy superblock fences ONE group, not the node. The
+            // segment files stay exactly where they are -- unlike a refused
+            // chain, the data on disk is not the thing in doubt -- so there is
+            // nothing to quarantine and nothing to rebuild: rebuilding fresh
+            // would hand this replica a view-0 identity while a record it
+            // cannot read says otherwise. Tombstoned, the namespace stays
+            // unmaterialised and unrouted, the reconciler backs off, and an
+            // operator has every byte plus a message naming the directory.
+            Err(
+                error @ (ServerNgError::PartitionSuperblockIo { .. }
+                | ServerNgError::PartitionSuperblockVersionUnknown { .. }
+                | ServerNgError::PartitionSuperblockUnverifiable { .. }
+                | ServerNgError::PartitionSuperblockUndecodable { .. }
+                | ServerNgError::PartitionSuperblockIdentityMismatch { .. }),
+            ) => {
+                error!(
+                    stream_id,
+                    topic_id,
+                    partition_id = partition_metadata.id,
+                    %error,
+                    "cannot trust this partition's durable consensus state; tombstoning the \
+                     partition and continuing to boot the rest of the shard"
+                );
+                partition_stats.zero_out_all();
+                partitions.tombstone(namespace);
+                continue;
+            }
+            Err(error) => return Err(error),
+        };
         partitions.insert(namespace, partition);
         shards_table.insert(
             namespace,
@@ -1795,6 +1898,15 @@
     // Repair pacing is shared by both planes' repair loops, so it is a
     // per-shard tunable set once here rather than per consensus group.
     shard.set_repair_retry_ticks(repair_retry_ticks(config));
+    shard.set_served_segment_cache_bytes_max(
+        config
+            .partition
+            .transfer_served_cache_bytes_max
+            .as_bytes_u64(),
+    );
+    shard.set_partition_artifact_len_max(
+        config.partition.transfer_artifact_bytes_max.as_bytes_u64(),
+    );
     shard.set_repair_chunk_max(config.cluster.repair_chunk_max as u64);
     // Bounds a served state-transfer chunk. A frame above the bus ceiling is
     // rejected by the RECEIVING transport, which tears the replica connection
@@ -1833,6 +1945,14 @@
 const _: () = assert!(
     configs::ng_partition::DEFAULT_EVICTED_RING_BYTES_MAX == partitions::EVICTED_RING_BYTES_MAX
 );
+const _: () = assert!(
+    configs::ng_partition::DEFAULT_TRANSFER_ARTIFACT_BYTES_MAX
+        == shard::PARTITION_ARTIFACT_LEN_DEFAULT
+);
+const _: () = assert!(
+    configs::ng_partition::DEFAULT_TRANSFER_SERVED_CACHE_BYTES_MAX
+        == shard::SERVED_SEGMENT_CACHE_BYTES_DEFAULT
+);
 const _: () =
     assert!(configs::ng_cluster::DEFAULT_REPAIR_CHUNK_MAX as u64 == shard::REPAIR_CHUNK_MAX);
 const _: () = assert!(
@@ -2153,7 +2273,7 @@
     // Request queue holds 2x the prepare depth (buffered requests drain as
     // prepares commit); depth is the per-partition `[partition]` knob.
     let prepare_queue_depth = config.partition.prepare_queue_depth;
-    let consensus = VsrConsensus::new(
+    let mut consensus = VsrConsensus::new(
         cluster_id,
         self_replica_id,
         replica_count,
@@ -2168,10 +2288,33 @@
     consensus.set_view_change_status_ticks(view_change_status_ticks(config));
     consensus.set_request_start_view_ticks(request_start_view_ticks(config));
     consensus.set_probe_attempts_max(config.cluster.view_probe_attempts_max);
-    // A recovered partition lost its consensus state with the process: the
+
+    // (view, log_view) come from the group's durable superblock when present;
+    // a present but unverifiable record already refused boot inside
+    // `open_partition_superblock`. Restored BEFORE choosing how to join, so
+    // the backup probe below never advertises a view older than the recorded
+    // one.
+    let partition_dir = config
+        .system
+        .get_partition_path(stream_id, topic_id, partition_id);
+    let (superblock, recovered_state) = open_partition_superblock(
+        &partition_dir,
+        ReplicaIdentity {
+            cluster: cluster_id,
+            replica_id: self_replica_id,
+            replica_count,
+        },
+    )
+    .await?;
+    if let Some(state) = recovered_state.as_ref() {
+        restore_partition_view(&mut consensus, state);
+    }
+
+    // A recovered partition lost its journal state with the process: the
     // partition journal is in-memory and segments carry no op numbers, so
-    // this replica cannot know the group's (op, commit). In a cluster it
-    // boots as a quorum-invisible backup and probes for the current view
+    // this replica cannot know the group's (op, commit) even when the
+    // superblock restored its view. In a cluster it boots as a
+    // quorum-invisible backup and probes for the current view
     // (`RequestStartView`): the view's primary answers with a `StartView`,
     // journal repair fills the rejoin window, and the commit floor settles
     // at the serving peer's retention point. The probe re-broadcasts on its
@@ -2207,6 +2350,7 @@
             })?;
 
     let mut partition = IggyPartition::new(stats.clone(), consensus);
+    partition.set_superblock(superblock, recovered_state.as_ref());
     // Recovered partitions honor the same config-surfaced ring ceilings as the
     // fresh-create path (build_partition_fresh). Retention is already off for
     // single-replica groups, so this only sizes the multi-replica ring.
@@ -2214,11 +2358,7 @@
         config.partition.evicted_ring_capacity,
         config.partition.evicted_ring_bytes_max.as_bytes_u64(),
     );
-    partition.set_partition_dir(config.system.get_partition_path(
-        stream_id,
-        topic_id,
-        partition_id,
-    ));
+    partition.set_partition_dir(partition_dir);
     hydrate_partition_log(
         &mut partition,
         config,
@@ -2229,33 +2369,52 @@
     )
     .await?;
 
-    let current_offset = partition
+    let sized_end = partition
         .log
         .segments()
         .iter()
         .filter(|segment| segment.size > IggyByteSize::default())
         .map(|segment| segment.end_offset)
+        .max();
+    // An empty chain whose segment is named for a nonzero offset is the
+    // shape a state-transfer install (or its converge) plants at the group
+    // frontier after the origin GC'd everything: the file name carries the
+    // frontier, and re-minting offsets from 0 here would fork this
+    // replica's batch stamps from the rest of the group after a restart.
+    let empty_frontier = partition
+        .log
+        .segments()
+        .iter()
+        .map(|segment| segment.start_offset)
         .max()
-        .unwrap_or(0);
+        .filter(|&start| sized_end.is_none() && start > 0);
+    let current_offset = sized_end.or_else(|| empty_frontier.map(|start| start - 1));
     partition.created_at = partition_metadata.created_at;
-    if partition
-        .log
-        .segments()
-        .iter()
-        .any(|segment| segment.size > IggyByteSize::default())
-    {
-        partition.recovered_durable_offset = Some(current_offset);
-    }
-    partition.offset.store(current_offset, Ordering::Release);
-    partition
-        .dirty_offset
-        .store(current_offset, Ordering::Relaxed);
-    partition.should_increment_offset = partition
-        .log
-        .segments()
-        .iter()
-        .any(|segment| segment.size > IggyByteSize::default());
-    partition.stats.set_current_offset(current_offset);
+    partition.recovered_durable_offset = sized_end;
+    // The OFFSET COUNTER is restored from that file name (above), but the
+    // `installed_frontier` CLAIM deliberately is not: the claim says "everything
+    // below me is represented here", and `converge_to_empty_after_failed_install`
+    // refuses to make it when staged segments were dropped -- yet a converge
+    // plants exactly the same empty `{frontier:020}.log` a legitimate empty
+    // install does, so boot provably cannot tell them apart. Re-deriving it here
+    // would hand the refused claim back: the repair floor stand-in would accept a
+    // commit floor over ops this replica holds zero bytes for, and the replica
+    // would pass the serve gate and offer that emptiness onward, making a peer
+    // unlink its own chain. Leaving it `None` costs one spurious full
+    // re-transfer on the legitimate empty-install restart; a false caught-up
+    // claim is not recoverable. A durable home for the frontier (the partition
+    // superblock already reserves a field) is what would settle it properly.
+    let counter = current_offset.unwrap_or(0);
+    partition.offset.store(counter, Ordering::Release);
+    partition.dirty_offset.store(counter, Ordering::Relaxed);
+    partition.should_increment_offset = current_offset.is_some();
+    partition.stats.set_current_offset(counter);
+    // The durable frontier is a LOWER BOUND on top of what the segments proved:
+    // it is the only carrier left when the segments that named the frontier are
+    // gone (an all-GC'd origin's install, a crash inside the swap window), and
+    // taking the max means real recovered data always wins.
+    partition.restore_offset_frontier(recovered_state.as_ref());
+    let current_offset = partition.offset.load(Ordering::Acquire);
 
     configure_consumer_offsets(&mut partition, config, namespace, current_offset)?;
     ensure_initial_segment(&mut partition, config, stream_id, topic_id, partition_id).await?;
diff --git a/core/server-ng/src/consumer_group.rs b/core/server-ng/src/consumer_group.rs
index 23244d2..0347136 100644
--- a/core/server-ng/src/consumer_group.rs
+++ b/core/server-ng/src/consumer_group.rs
@@ -42,6 +42,7 @@
 };
 use iggy_binary_protocol::{KIND_CONSUMER_GROUP, Operation, RequestHeader, WireIdentifier};
 use iggy_common::IggyError;
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::stm::consumer_group::{
@@ -60,8 +61,8 @@
 /// (via the partition-read mesh), so the cooperative rebalance pending-revokes
 /// only those and hands off never-polled/drained partitions synchronously at
 /// join. Every other operation passes through.
-pub(crate) async fn maybe_rewrite_consumer_group_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn maybe_rewrite_consumer_group_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: Message<RequestHeader>,
 ) -> Result<Message<RequestHeader>, IggyError>
 where
@@ -69,6 +70,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let operation = request.header().operation;
     let client_id = request.header().client;
@@ -112,8 +114,8 @@
 /// no `PendingRevocation` record, so the reconciler never revisits it -- a
 /// misclassification here just redelivers the uncommitted range to the new
 /// owner, which is correct under at-least-once.
-async fn gather_in_flight<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn gather_in_flight<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
     group_id: &WireIdentifier,
@@ -123,6 +125,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let streams = shard.plane.metadata().mux_stm.streams();
     let Some(monotonic_group_id) = streams.resolve_consumer_group_id(stream_id, topic_id, group_id)
@@ -203,8 +206,8 @@
 /// purge agree, and a re-created group (new id) never inherits a stale offset.
 /// Individual-consumer ops and every other operation pass through untouched.
 #[allow(clippy::cast_possible_truncation)]
-pub(crate) fn maybe_rewrite_consumer_offset_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn maybe_rewrite_consumer_offset_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: Message<RequestHeader>,
 ) -> Result<Message<RequestHeader>, IggyError>
 where
@@ -212,6 +215,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let operation = request.header().operation;
     if !matches!(
@@ -260,8 +264,8 @@
 /// Resolve the monotonic group id for a group consumer-offset op, or `None` for
 /// an individual consumer (kind != 2) / unresolved group (leave the body as-is;
 /// the apply / read path handle the miss).
-fn resolve_group_offset_id<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_group_offset_id<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     consumer: &WireConsumer,
     namespace: (&WireIdentifier, &WireIdentifier),
 ) -> Option<u64>
@@ -270,6 +274,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if consumer.kind != KIND_CONSUMER_GROUP {
         return None;
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 72c238f..cbae3f2 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -84,6 +84,7 @@
     WireIdentifier, is_protocol_compatible,
 };
 use iggy_common::{IggyError, PollingStrategy, SnapshotCompression, SystemSnapshotType};
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use message_bus::AUTO_COMMIT_CLIENT_ID;
 use message_bus::client_listener::RequestHandler;
@@ -113,8 +114,8 @@
 pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
 pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
 
-pub(crate) fn make_client_request_handler<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn make_client_request_handler<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     system_config: Arc<NgSystemConfig>,
     max_tokens_per_user: u32,
@@ -124,6 +125,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let shard = Rc::clone(shard);
     let sessions = Rc::clone(sessions);
@@ -177,14 +179,15 @@
 /// against the local partitions plane and push the result back over the
 /// carried reply sender. The requesting shard bounds the wait with a
 /// timeout, so a dropped reply degrades to a client-visible read failure.
-pub(crate) fn make_partition_read_handler<B, MJ, S>(
-    shard_handle: &ShellShardHandle<B, MJ, S>,
+pub(crate) fn make_partition_read_handler<B, MJ, S, SB>(
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
 ) -> PartitionReadHandler
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let shard_handle = Rc::clone(shard_handle);
     // Runs synchronously on the shard pump (see `process_lifecycle` ->
@@ -266,8 +269,8 @@
 /// map), then replicate the auto-committed offset and send the reply. Holds no
 /// partition reference across the IO, so it is sound concurrently with the
 /// pump's `&mut` writes; the auto-commit submit re-borrows synchronously after.
-fn spawn_poll_io<B, MJ, S>(
-    shard: Rc<ShellShard<B, MJ, S>>,
+fn spawn_poll_io<B, MJ, S, SB>(
+    shard: Rc<ShellShard<B, MJ, S, SB>>,
     namespace: IggyNamespace,
     plan: PollPlan,
     reply: shard::Sender<PartitionReadReply>,
@@ -276,6 +279,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let bus = shard.bus.clone();
     bus.spawn(async move {
@@ -322,8 +326,8 @@
 /// data, hence no log). The gate reads committed state only, so an offset that
 /// merely sits in flight keeps resubmitting until its covering op commits -- a
 /// dropped op self-heals on the next poll instead of being suppressed forever.
-fn submit_auto_commit<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn submit_auto_commit<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     namespace: IggyNamespace,
     applied: &AutoCommitApplied,
 ) where
@@ -331,6 +335,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     enum AutoCommitGate {
         Submit,
@@ -425,14 +430,15 @@
     }))
 }
 
-pub(crate) fn make_deferred_replica_message_handler<B, MJ, S>(
-    shard_handle: &ShellShardHandle<B, MJ, S>,
+pub(crate) fn make_deferred_replica_message_handler<B, MJ, S, SB>(
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
 ) -> MessageHandler
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let shard_handle = Rc::clone(shard_handle);
     Rc::new(move |_replica_id, message| {
@@ -442,9 +448,9 @@
     })
 }
 
-pub(crate) fn make_deferred_client_request_handler<B, MJ, S>(
+pub(crate) fn make_deferred_client_request_handler<B, MJ, S, SB>(
     bus: &B,
-    shard_handle: &ShellShardHandle<B, MJ, S>,
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
     sessions: &Rc<RefCell<SessionManager>>,
     system_config: Arc<NgSystemConfig>,
     max_tokens_per_user: u32,
@@ -454,6 +460,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let shard_handle = Rc::clone(shard_handle);
     let sessions = Rc::clone(sessions);
@@ -510,14 +517,15 @@
 /// proposal. Spawns a task so the awaiting peer is woken once the op
 /// commits; replies `None` on transient submit failure so the peer never
 /// blocks forever.
-pub(crate) fn make_metadata_submit_handler<B, MJ, S>(
-    shard_handle: &ShellShardHandle<B, MJ, S>,
+pub(crate) fn make_metadata_submit_handler<B, MJ, S, SB>(
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
 ) -> shard::MetadataSubmitHandler
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let shard_handle = Rc::clone(shard_handle);
     Rc::new(move |submit| {
@@ -634,8 +642,8 @@
 // empty-reply fail-fast below and must log in.
 
 #[allow(clippy::too_many_arguments)]
-fn enqueue_client_request<B, MJ, S>(
-    shard: Rc<ShellShard<B, MJ, S>>,
+fn enqueue_client_request<B, MJ, S, SB>(
+    shard: Rc<ShellShard<B, MJ, S, SB>>,
     sessions: Rc<RefCell<SessionManager>>,
     system_config: Arc<NgSystemConfig>,
     max_tokens_per_user: u32,
@@ -648,6 +656,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     queues
         .borrow_mut()
@@ -674,8 +683,8 @@
 }
 
 #[allow(clippy::future_not_send)]
-async fn drain_client_requests<B, MJ, S>(
-    shard: Rc<ShellShard<B, MJ, S>>,
+async fn drain_client_requests<B, MJ, S, SB>(
+    shard: Rc<ShellShard<B, MJ, S, SB>>,
     sessions: Rc<RefCell<SessionManager>>,
     system_config: Arc<NgSystemConfig>,
     max_tokens_per_user: u32,
@@ -687,6 +696,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     loop {
         let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
@@ -725,8 +735,8 @@
 }
 
 #[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_client_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_client_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     system_config: &Arc<NgSystemConfig>,
     max_tokens_per_user: u32,
@@ -737,6 +747,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let request = match message.try_into_typed::<RequestHeader>() {
         Ok(request) => request,
@@ -1033,8 +1044,8 @@
 /// out of the Users STM. Built here rather than in `build_non_replicated_response`
 /// which has no session context.
 #[allow(clippy::future_not_send)]
-async fn handle_get_personal_access_tokens<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_personal_access_tokens<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
@@ -1043,6 +1054,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let response = build_get_personal_access_tokens_response(shard, sessions, transport_client_id);
     send_non_replicated_bytes(
@@ -1059,8 +1071,8 @@
 /// `SessionManager` (not `IggyMetadata`), so built here rather than in
 /// `build_non_replicated_response`.
 #[allow(clippy::future_not_send)]
-async fn handle_get_me<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_me<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
@@ -1069,6 +1081,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let response = build_get_me_response(shard, sessions, transport_client_id);
     send_non_replicated_bytes(
@@ -1099,8 +1112,8 @@
 /// `vsr_client_id` keys the consumer-group offset fence (the member id),
 /// not the transport id stamped into the partition-op header.
 #[allow(clippy::future_not_send)]
-pub(crate) async fn dispatch_partition_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn dispatch_partition_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: Message<RequestHeader>,
     vsr_client_id: u128,
     bound_session: u64,
@@ -1111,6 +1124,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let header = *request.header();
     let namespace = match resolve_partition_request_namespace(
@@ -1237,8 +1251,8 @@
 }
 
 #[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_non_replicated_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_non_replicated_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     system_config: &Arc<NgSystemConfig>,
     transport_client_id: u128,
@@ -1248,6 +1262,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     const CODE_RANGE: std::ops::Range<usize> = 0..4;
     let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
@@ -1392,8 +1407,8 @@
 }
 
 #[allow(clippy::future_not_send, clippy::too_many_arguments)]
-async fn handle_default_non_replicated<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_default_non_replicated<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     code: u32,
     request: &Message<RequestHeader>,
@@ -1405,6 +1420,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // Gate by command code before the shared builder runs. The builder stays
     // authz-free (it is byte-shared with the HTTP read path, which gates
@@ -1463,8 +1479,8 @@
 /// plain authentication must not suffice), then await the off-thread
 /// collection (see `snapshot::collect`) and reply with the raw ZIP bytes.
 #[allow(clippy::future_not_send)]
-async fn handle_get_snapshot<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_snapshot<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     system_config: &Arc<NgSystemConfig>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
@@ -1474,6 +1490,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_snapshot) {
         send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
@@ -1544,8 +1561,8 @@
 /// metadata commit. Shared by the `get_me` / `get_clients` / `get_client`
 /// arms.
 #[allow(clippy::future_not_send)]
-async fn send_non_replicated_bytes<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_non_replicated_bytes<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: &Message<RequestHeader>,
     transport_client_id: u128,
     bytes: Bytes,
@@ -1555,6 +1572,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let commit = current_metadata_commit(shard);
     let reply = NonReplicatedResponse::Bytes(bytes).into_reply(
@@ -1580,14 +1598,15 @@
 /// eviction context is best-effort off the metadata consensus (peer shards
 /// have none; zeroes are cosmetic -- the SDK only reads the reason).
 #[allow(clippy::future_not_send)]
-async fn send_unauthenticated_eviction<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_unauthenticated_eviction<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
 ) where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let ctx = shard.plane.metadata().consensus.as_ref().map_or(
         consensus::EvictionContext {
@@ -1621,8 +1640,8 @@
 /// groups + rebalances via the replicated `Logout`) and sends a session-
 /// terminal `Eviction(StaleClient)` so the client fails fast and can reconnect.
 #[allow(clippy::future_not_send)]
-pub(crate) async fn run_heartbeat_verifier<B, MJ, S>(
-    shard: Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn run_heartbeat_verifier<B, MJ, S, SB>(
+    shard: Rc<ShellShard<B, MJ, S, SB>>,
     sessions: Rc<RefCell<SessionManager>>,
     interval: std::time::Duration,
     stop_rx: shard::Receiver<()>,
@@ -1631,6 +1650,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // Legacy `MAX_THRESHOLD`: a client is stale once it misses ~1.2 intervals.
     let max_age = interval.mul_f64(1.2);
@@ -1680,8 +1700,8 @@
 /// membership through a replicated `Logout`) and notify the client with a
 /// session-terminal `Eviction(StaleClient)`.
 #[allow(clippy::future_not_send)]
-async fn evict_stale_client<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn evict_stale_client<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
 ) where
@@ -1689,6 +1709,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let bound = sessions.borrow_mut().remove_connection(transport_client_id);
     if let Some((vsr_client_id, session)) = bound {
@@ -1732,8 +1753,8 @@
 /// Failures reply with an empty body so the SDK fails fast on decode
 /// instead of hanging until its read timeout.
 #[allow(clippy::future_not_send)]
-async fn handle_poll_messages<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_poll_messages<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
     user_id: Option<u32>,
@@ -1742,6 +1763,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Ok(wire) = PollMessagesRequest::decode_from(request_body(request)) else {
         // Undecodable poll: keep the fail-fast empty-poll shape.
@@ -1830,8 +1852,8 @@
 /// Serve `get_consumer_offset`. An empty body decodes as `None` on the SDK
 /// side (no offset stored / partition unknown).
 #[allow(clippy::future_not_send)]
-async fn handle_get_consumer_offset<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_get_consumer_offset<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
     user_id: Option<u32>,
@@ -1840,6 +1862,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Ok(wire) = GetConsumerOffsetRequest::decode_from(request_body(request)) else {
         // Undecodable: an empty body decodes as None (no offset) on the SDK.
@@ -1902,8 +1925,8 @@
 /// The member is keyed by the connection's bound VSR client id
 /// (`header().client`). An empty body decodes as "no assignment" on the SDK.
 #[allow(clippy::future_not_send)]
-async fn handle_sync_consumer_group<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_sync_consumer_group<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
 ) where
@@ -1911,6 +1934,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let body = match SyncConsumerGroupRequest::decode_from(request_body(request)) {
         Ok(wire) => shard
@@ -1955,8 +1979,8 @@
 /// in lockstep, so a silent drop wedges every subsequent request on that
 /// connection.
 #[allow(clippy::future_not_send)]
-async fn send_empty_partition_reply<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn send_empty_partition_reply<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request_header: &RequestHeader,
 ) where
@@ -1964,6 +1988,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let commit = current_metadata_commit(shard);
     let reply = build_empty_reply(request_header, transport_client_id, 0, commit);
@@ -2002,8 +2027,8 @@
 /// cover - a row seeded from the hash by a shard that owns nothing. Readiness
 /// belongs to the owner, which is where it is now enforced.
 #[allow(clippy::future_not_send)]
-async fn wait_for_partition_routable<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn wait_for_partition_routable<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     namespace: IggyNamespace,
 ) -> bool
 where
@@ -2011,6 +2036,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     const ATTEMPT_DELAY: std::time::Duration = std::time::Duration::from_millis(50);
     // 3s budget at 50ms per attempt. Counting attempts, not reading a
@@ -2048,8 +2074,8 @@
 /// id = the connection's bound VSR client) and the HTTP route (client id 0,
 /// which fences group polls closed).
 #[allow(clippy::cast_possible_truncation)]
-pub(crate) fn resolve_poll_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_poll_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     wire: &PollMessagesRequest,
     client_id: u128,
 ) -> Result<DecodedPollRequest, IggyError>
@@ -2058,6 +2084,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let strategy = polling_strategy_from_wire(&wire.strategy)?;
     let args = PollingArgs::new(strategy, wire.count, wire.auto_commit);
@@ -2114,8 +2141,8 @@
 /// namespace, partition, and polling consumer. Shared by the TCP dispatch and
 /// the HTTP route; needs no client id because offset reads are not fenced
 /// (any client may read a group's offset, member or not).
-pub(crate) fn resolve_consumer_offset_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_consumer_offset_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     wire: &GetConsumerOffsetRequest,
 ) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError>
 where
@@ -2123,6 +2150,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // Omitted partition reads partition 0, matching the legacy resolver for
     // both consumer kinds (`unwrap_or(0)`).
@@ -2199,8 +2227,8 @@
 /// committed op. A dropped reply (shard-0 inbox full / shutdown) maps to a
 /// transient `Canceled`, which the caller wraps so the SDK replays.
 #[allow(clippy::future_not_send)]
-pub(crate) async fn submit_register_on_owner<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn submit_register_on_owner<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     vsr_client_id: u128,
     user_id: u32,
 ) -> Result<BoundSession, MetadataSubmitError>
@@ -2209,6 +2237,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if shard.id == 0 {
         return shard
@@ -2232,8 +2261,8 @@
 
 /// Logout counterpart of [`submit_register_on_owner`].
 #[allow(clippy::future_not_send)]
-pub(crate) async fn submit_logout_on_owner<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn submit_logout_on_owner<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     vsr_client_id: u128,
     session: u64,
     request: u64,
@@ -2243,6 +2272,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if shard.id == 0 {
         return shard
@@ -2275,8 +2305,8 @@
 /// of mistaking a dropped delete for success. Only a malformed / unresolvable
 /// request is acked empty without a commit.
 #[allow(clippy::future_not_send)]
-async fn handle_delete_segments_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_delete_segments_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     bound: Option<(u128, u64)>,
     request: &Message<RequestHeader>,
@@ -2285,6 +2315,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let header = *request.header();
     let body = request_body(request);
@@ -2406,8 +2437,8 @@
 /// the error.
 #[allow(clippy::future_not_send)]
 #[allow(clippy::cast_possible_truncation)]
-pub(crate) async fn resolve_delete_segments_truncate<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     template: &RequestHeader,
     client_id: u128,
     session: u64,
@@ -2418,6 +2449,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let parsed = DeleteSegmentsRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
     let namespace_raw = match resolve_partition_request_namespace(
@@ -2528,8 +2560,8 @@
 /// shard 0 and forwards for peer-homed connections; its session guard drops a
 /// stale logout for a reused client id.
 #[allow(clippy::future_not_send)]
-fn submit_disconnect_logout<B, MJ, S>(
-    shard: Rc<ShellShard<B, MJ, S>>,
+fn submit_disconnect_logout<B, MJ, S, SB>(
+    shard: Rc<ShellShard<B, MJ, S, SB>>,
     vsr_client_id: u128,
     session: u64,
 ) where
@@ -2537,6 +2569,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // Synthetic request id: header validation rejects `request == 0` for
     // non-register ops, and a disconnect has no client-issued request id.
@@ -2569,8 +2602,8 @@
 /// `client` id (it's the VSR id, not the transport/home-shard-encoding id).
 /// `None` = transient submit failure (SDK read-timeout replays).
 #[allow(clippy::future_not_send)]
-pub(crate) async fn submit_client_request_on_owner<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn submit_client_request_on_owner<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: Message<RequestHeader>,
 ) -> Option<Message<GenericHeader>>
 where
@@ -2578,6 +2611,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if shard.id == 0 {
         return shard
@@ -2596,8 +2630,8 @@
 }
 
 #[allow(clippy::future_not_send)]
-async fn handle_logout_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_logout_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
     request: Message<RequestHeader>,
@@ -2606,6 +2640,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Some((vsr_client_id, session)) = sessions.borrow().get_session(transport_client_id) else {
         warn!(
@@ -2640,8 +2675,8 @@
     }
 }
 
-fn ensure_transport_connection<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn ensure_transport_connection<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
 ) where
@@ -2649,6 +2684,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Some(meta) = shard.bus.client_meta(transport_client_id) else {
         return;
@@ -2659,8 +2695,8 @@
 }
 
 #[allow(clippy::future_not_send, clippy::too_many_lines)]
-async fn handle_login_register_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+async fn handle_login_register_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
     request: Message<RequestHeader>,
@@ -2669,6 +2705,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let body = request_body(&request);
     let vsr_client_id = request.header().client;
@@ -2810,8 +2847,8 @@
 /// metadata shard and zeroed elsewhere -- the SDK only reads the reason,
 /// plus the protocol window on `IncompatibleProtocol`.
 #[allow(clippy::future_not_send)]
-pub(crate) async fn send_login_eviction<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) async fn send_login_eviction<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     vsr_client_id: u128,
     reason: EvictionReason,
@@ -2820,6 +2857,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let ctx = shard.plane.metadata().consensus.as_ref().map_or(
         EvictionContext {
@@ -2849,14 +2887,15 @@
     }
 }
 
-pub(crate) fn upgrade_shard_handle<B, MJ, S>(
-    shard_handle: &ShellShardHandle<B, MJ, S>,
-) -> Option<Rc<ShellShard<B, MJ, S>>>
+pub(crate) fn upgrade_shard_handle<B, MJ, S, SB>(
+    shard_handle: &ShellShardHandle<B, MJ, S, SB>,
+) -> Option<Rc<ShellShard<B, MJ, S, SB>>>
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard_handle
         .borrow()
diff --git a/core/server-ng/src/dispatch/authz.rs b/core/server-ng/src/dispatch/authz.rs
index f6f30f6..08ed5ca 100644
--- a/core/server-ng/src/dispatch/authz.rs
+++ b/core/server-ng/src/dispatch/authz.rs
@@ -40,6 +40,7 @@
 use iggy_binary_protocol::requests::topics::{GetTopicRequest, GetTopicsRequest};
 use iggy_binary_protocol::{Operation, PrepareHeader, RequestHeader, WireDecode, WireIdentifier};
 use iggy_common::IggyError;
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::permissioner::Permissioner;
@@ -56,8 +57,8 @@
 /// namespace already resolved, so the entity exists; a `None` user id (which
 /// the bound-session gate should preclude) fails closed with `Unauthenticated`
 /// rather than allow an unattributed write.
-pub(super) fn authorize_partition_op<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_partition_op<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     operation: Operation,
     user_id: Option<u32>,
     stream_id: usize,
@@ -68,6 +69,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Some(user_id) = user_id else {
         return Some(IggyError::Unauthenticated.as_code());
@@ -134,8 +136,8 @@
 /// better, the connection decodes replies in lockstep and would wedge on every
 /// later request.
 #[allow(clippy::future_not_send)]
-pub(super) async fn send_partition_deny_reply<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) async fn send_partition_deny_reply<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     transport_client_id: u128,
     request_header: &RequestHeader,
     status: u32,
@@ -144,6 +146,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let commit = current_metadata_commit(shard);
     let reply = build_deny_reply(request_header, transport_client_id, 0, commit, status);
@@ -164,8 +167,8 @@
 
 /// Run an unscoped non-replicated-read rule for the acting user. A `None` user
 /// id (only the pre-auth path, which serves ungated codes) fails closed.
-pub(super) fn authorize_uid<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_uid<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     user_id: Option<u32>,
     rule: impl FnOnce(&Permissioner, u32) -> Result<(), IggyError>,
 ) -> Result<(), IggyError>
@@ -174,6 +177,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let user_id = user_id.ok_or(IggyError::Unauthenticated)?;
     shard
@@ -188,8 +192,8 @@
 /// (stream, topic). `None` proceeds (allowed, or a resolution miss the caller's
 /// own not-found path handles); `Some(status)` denies. A `None` user id fails
 /// closed.
-pub(super) fn authorize_partition_read<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_partition_read<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
     user_id: Option<u32>,
@@ -200,6 +204,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Some(user_id) = user_id else {
         return Some(IggyError::Unauthenticated.as_code());
@@ -225,8 +230,8 @@
 /// pre-auth (bootstrap / leader discovery; the dispatch allowlist admits it
 /// unauthenticated) and, like every other code the builder serves, is ungated
 /// here.
-pub(super) fn authorize_default_read<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) fn authorize_default_read<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     code: u32,
     body: &[u8],
     user_id: Option<u32>,
@@ -236,6 +241,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // A `u32` match cannot be exhaustive: every gated code is named explicitly,
     // and the final arm is the ungated set the builder serves without a rule.
@@ -247,35 +253,35 @@
         // permissioner rule to run (legacy runs none either).
         GET_PERSONAL_ACCESS_TOKENS_CODE => user_id.map(|_| ()).ok_or(IggyError::Unauthenticated),
         GET_STREAMS_CODE => authorize_uid(shard, user_id, Permissioner::get_streams),
-        GET_STREAM_CODE => gate_stream_scoped::<GetStreamRequest, _, _, _>(
+        GET_STREAM_CODE => gate_stream_scoped::<GetStreamRequest, _, _, _, _>(
             shard,
             user_id,
             body,
             |request| &request.stream_id,
             Permissioner::get_stream,
         ),
-        GET_TOPICS_CODE => gate_stream_scoped::<GetTopicsRequest, _, _, _>(
+        GET_TOPICS_CODE => gate_stream_scoped::<GetTopicsRequest, _, _, _, _>(
             shard,
             user_id,
             body,
             |request| &request.stream_id,
             Permissioner::get_topics,
         ),
-        GET_TOPIC_CODE => gate_topic_scoped::<GetTopicRequest, _, _, _>(
+        GET_TOPIC_CODE => gate_topic_scoped::<GetTopicRequest, _, _, _, _>(
             shard,
             user_id,
             body,
             |request| (&request.stream_id, &request.topic_id),
             Permissioner::get_topic,
         ),
-        GET_CONSUMER_GROUP_CODE => gate_topic_scoped::<GetConsumerGroupRequest, _, _, _>(
+        GET_CONSUMER_GROUP_CODE => gate_topic_scoped::<GetConsumerGroupRequest, _, _, _, _>(
             shard,
             user_id,
             body,
             |request| (&request.stream_id, &request.topic_id),
             Permissioner::get_consumer_group,
         ),
-        GET_CONSUMER_GROUPS_CODE => gate_topic_scoped::<GetConsumerGroupsRequest, _, _, _>(
+        GET_CONSUMER_GROUPS_CODE => gate_topic_scoped::<GetConsumerGroupsRequest, _, _, _, _>(
             shard,
             user_id,
             body,
@@ -291,8 +297,8 @@
 /// surfaces the typed error, so a poll denial never reaches the empty-poll
 /// "0 messages" body path.
 #[allow(clippy::future_not_send)]
-pub(super) async fn send_non_replicated_deny<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(super) async fn send_non_replicated_deny<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: &Message<RequestHeader>,
     transport_client_id: u128,
     status: u32,
@@ -301,6 +307,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let commit = current_metadata_commit(shard);
     let reply = build_deny_reply(
@@ -328,8 +335,8 @@
 /// resolve it to the committed slab id, then run `rule`. A malformed body or a
 /// resolution miss returns `Ok(())` so the builder's own error / not-found
 /// reply is what the client sees (decode-and-notfound-before-permission).
-fn gate_stream_scoped<T: WireDecode, B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn gate_stream_scoped<T: WireDecode, B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     user_id: Option<u32>,
     body: &[u8],
     stream_id: impl FnOnce(&T) -> &WireIdentifier,
@@ -340,6 +347,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Ok(request) = T::decode_from(body) else {
         return Ok(());
@@ -356,8 +364,8 @@
 /// topic) pair, resolve both to committed slab ids, then run `rule`. A malformed
 /// body or a resolution miss on either returns `Ok(())` so the builder's own
 /// error / not-found reply holds (decode-and-notfound-before-permission).
-fn gate_topic_scoped<T: WireDecode, B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn gate_topic_scoped<T: WireDecode, B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     user_id: Option<u32>,
     body: &[u8],
     ids: impl FnOnce(&T) -> (&WireIdentifier, &WireIdentifier),
@@ -368,6 +376,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let Ok(request) = T::decode_from(body) else {
         return Ok(());
@@ -383,8 +392,8 @@
 
 /// Resolve a wire stream identifier to its committed slab id, or `None` on a
 /// miss (the gate then falls through to the builder's not-found reply).
-fn resolve_stream_scope<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_stream_scope<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
 ) -> Option<usize>
 where
@@ -392,6 +401,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard
         .plane
@@ -403,8 +413,8 @@
 
 /// Resolve a wire (stream, topic) pair to committed slab ids, or `None` if
 /// either misses.
-fn resolve_topic_scope<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_topic_scope<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
 ) -> Option<(usize, usize)>
@@ -413,6 +423,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard.plane.metadata().mux_stm.streams().read(|inner| {
         let stream_id = resolve_stream_id(inner, stream_id)?;
diff --git a/core/server-ng/src/partition_helpers.rs b/core/server-ng/src/partition_helpers.rs
index e76da44..020c515 100644
--- a/core/server-ng/src/partition_helpers.rs
+++ b/core/server-ng/src/partition_helpers.rs
@@ -28,20 +28,22 @@
 use crate::server_error::ServerNgError;
 use compio::fs::create_dir_all;
 use configs::server_ng::ServerNgConfig;
-use consensus::{LocalPipeline, VsrConsensus};
+use consensus::{LocalPipeline, VsrConsensus, VsrState};
 use iggy_common::{
     ConsumerGroupOffsets, ConsumerOffsets, IggyError, IggyTimestamp, PartitionStats,
 };
+use journal::superblock::{PingPongSuperblock, SuperblockContents};
 use message_bus::IggyMessageBus;
+use metadata::{IdentityField, ReplicaIdentity};
 use partitions::{IggyIndexWriter, IggyPartition, MessagesWriter, Segment};
 use server_common::SegmentStorage;
 use server_common::fs_utils::remove_dir_all;
 use server_common::sharding::IggyNamespace;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::Ordering;
-use tracing::{error, warn};
+use tracing::{error, info, warn};
 
 /// Validate that a namespace fits within the static caps declared in
 /// `config.extra.namespace`.
@@ -338,13 +340,26 @@
         return Ok(());
     }
 
-    let messages_path = config
-        .system
-        .get_messages_file_path(stream_id, topic_id, partition_id, 0);
+    // At the RESTORED FRONTIER, not always 0: after a crash inside the install's
+    // swap window the chain is empty while the recorded frontier is N, and a
+    // segment named 0 would then take the first append's `base_offset = N` --
+    // `rposition(|s| s.start_offset <= offset)` routes every poll for `0..N-1`
+    // into it, the next boot makes that shape durable, and this replica starts
+    // offering peers a segment that claims `[0..N]`.
+    let start_offset = partition.offset_frontier();
+    let messages_path =
+        config
+            .system
+            .get_messages_file_path(stream_id, topic_id, partition_id, start_offset);
     let index_path = config
         .system
-        .get_index_path(stream_id, topic_id, partition_id, 0);
+        .get_index_path(stream_id, topic_id, partition_id, start_offset);
     let enforce_fsync = config.system.partition.enforce_fsync;
+    // `file_exists = false` TRUNCATES both files, which is load-bearing here: a
+    // fenced-and-rebuilt partition (or one whose quarantine failed) can reach
+    // this with a stale `.index` at offset 0 on disk. The `partitions`-side
+    // writers with the same names do NOT truncate, so opening them directly
+    // instead would read index entries from a previous generation.
     let storage = SegmentStorage::new(
         &messages_path,
         &index_path,
@@ -378,7 +393,7 @@
         .map(|writer| writer.size_counter())
         .unwrap_or_default();
     partition.log.add_persisted_segment(
-        Segment::new(0, config.system.segment.size),
+        Segment::new(start_offset, config.system.segment.size),
         storage,
         Some(Rc::new(
             MessagesWriter::new(
@@ -426,6 +441,117 @@
     Ok(())
 }
 
+/// Open the durable superblock for one partition's consensus group and read
+/// back the last recorded VSR state.
+///
+/// Mirrors the metadata plane's recovery contract: an EMPTY superblock is a
+/// genuinely fresh group (or one that never changed view) and yields `None`;
+/// a present record must decode and match this replica's identity; a present
+/// but unverifiable record is an error, because treating it as fresh would
+/// let this replica re-enter a view it already acted in. The boot path
+/// tombstones just that partition rather than refusing the whole node.
+///
+/// The returned store is the ONE open instance for this group: the partition
+/// keeps writing through it, and re-opening later would fork the ping-pong
+/// sequence counter.
+///
+/// # Errors
+///
+/// [`ServerNgError::PartitionSuperblockIo`] when the directory or a slot
+/// cannot be read; the `VersionUnknown` / `Unverifiable` / `Undecodable` /
+/// `IdentityMismatch` variants when a record exists but cannot be trusted.
+pub(crate) async fn open_partition_superblock(
+    partition_dir: &str,
+    identity: ReplicaIdentity,
+) -> Result<(Rc<PingPongSuperblock>, Option<VsrState>), ServerNgError> {
+    let io_error = |source| ServerNgError::PartitionSuperblockIo {
+        dir: PathBuf::from(partition_dir),
+        source,
+    };
+    // The load path can reach a partition whose directory was never
+    // materialized on this replica (a committed create it missed); the
+    // superblock lives inside that directory either way.
+    create_dir_all(partition_dir).await.map_err(io_error)?;
+    let (superblock, latest) = PingPongSuperblock::open_with_latest(partition_dir)
+        .await
+        .map_err(io_error)?;
+    let recovered_state = match latest {
+        SuperblockContents::Present(bytes) => {
+            Some(VsrState::try_from(bytes.as_slice()).map_err(|source| {
+                ServerNgError::PartitionSuperblockUndecodable {
+                    dir: PathBuf::from(partition_dir),
+                    source,
+                }
+            })?)
+        }
+        SuperblockContents::Unreadable {
+            version: Some(version),
+        } => {
+            return Err(ServerNgError::PartitionSuperblockVersionUnknown {
+                dir: PathBuf::from(partition_dir),
+                version,
+            });
+        }
+        SuperblockContents::Unreadable { version: None } => {
+            return Err(ServerNgError::PartitionSuperblockUnverifiable {
+                dir: PathBuf::from(partition_dir),
+            });
+        }
+        SuperblockContents::Empty => None,
+    };
+    if let Some(state) = recovered_state.as_ref() {
+        let mismatch = |field, expected: u128, found: u128| {
+            Err(ServerNgError::PartitionSuperblockIdentityMismatch {
+                dir: PathBuf::from(partition_dir),
+                field,
+                expected,
+                found,
+            })
+        };
+        if state.cluster != identity.cluster {
+            return mismatch(IdentityField::Cluster, identity.cluster, state.cluster);
+        }
+        if state.replica_id != identity.replica_id {
+            return mismatch(
+                IdentityField::ReplicaId,
+                identity.replica_id.into(),
+                state.replica_id.into(),
+            );
+        }
+        if state.replica_count != identity.replica_count {
+            return mismatch(
+                IdentityField::ReplicaCount,
+                identity.replica_count.into(),
+                state.replica_count.into(),
+            );
+        }
+    }
+    Ok((Rc::new(superblock), recovered_state))
+}
+
+/// Restore `(view, log_view)` from a recovered superblock record and mark
+/// them durable (read back from disk, durable by definition). Runs BEFORE
+/// `init` / `init_as_backup` so the join path never advertises a view older
+/// than the recorded one.
+pub(crate) fn restore_partition_view(
+    consensus: &mut VsrConsensus<Rc<IggyMessageBus>>,
+    state: &VsrState,
+) {
+    // The one line proving the durable record was READ BACK, not merely written:
+    // the group's whole anti-regression guarantee rests on this call running, and
+    // a replica that came back at view 0 is otherwise indistinguishable from one
+    // that resumed correctly until it votes.
+    info!(
+        namespace_raw = consensus.namespace(),
+        view = state.view,
+        log_view = state.log_view,
+        "restored partition view from its superblock"
+    );
+    consensus.set_view(state.view);
+    consensus.set_log_view(state.log_view);
+    consensus.mark_superblock_durable(state.view, state.log_view);
+}
+
 /// Materialise a brand-new [`IggyPartition`] for a namespace that has no on-disk state yet.
 ///
 /// Counterpart to bootstrap's `load_partition`, which hydrates from
@@ -437,7 +563,7 @@
 /// Steps performed (all idempotent on retry after a partial failure):
 /// 1. Validate namespace fits within the configured caps.
 /// 2. Create directory hierarchy on disk.
-/// 3. Build per-partition VSR consensus group at view 0.
+/// 3. Build per-partition VSR consensus group, resuming any superblock-recorded view.
 /// 4. Configure empty consumer-offset storage with the on-disk paths set.
 /// 5. Provision the initial segment + writers (offset 0).
 ///
@@ -448,7 +574,7 @@
 /// # Errors
 ///
 /// Returns [`ServerNgError`] when bounds validation, directory creation,
-/// or segment provisioning fails.
+/// superblock recovery, or segment provisioning fails.
 pub async fn build_partition_fresh(
     config: &ServerNgConfig,
     namespace: IggyNamespace,
@@ -490,7 +616,7 @@
     // Request queue holds 2x the prepare depth (buffered requests drain as
     // prepares commit); depth is the per-partition `[partition]` knob.
     let prepare_queue_depth = config.partition.prepare_queue_depth;
-    let consensus = VsrConsensus::new(
+    let mut consensus = VsrConsensus::new(
         cluster_id,
         self_replica_id,
         replica_count,
@@ -506,6 +632,27 @@
     consensus.set_view_change_status_ticks(crate::bootstrap::view_change_status_ticks(config));
     consensus.set_request_start_view_ticks(crate::bootstrap::request_start_view_ticks(config));
     consensus.set_probe_attempts_max(config.cluster.view_probe_attempts_max);
+
+    // The hierarchy create above guarantees the directory exists; recover this
+    // group's durable (view, log_view) before choosing how to join, so a
+    // restart materialization resumes from the view it last recorded instead
+    // of re-entering an older one.
+    let partition_dir = config
+        .system
+        .get_partition_path(stream_id, topic_id, partition_id);
+    let (superblock, recovered_state) = open_partition_superblock(
+        &partition_dir,
+        ReplicaIdentity {
+            cluster: cluster_id,
+            replica_id: self_replica_id,
+            replica_count,
+        },
+    )
+    .await?;
+    if let Some(state) = recovered_state.as_ref() {
+        restore_partition_view(&mut consensus, state);
+    }
+
     // A partition directory that already holds segment bytes is a RESTART
     // materialization, not a fresh create: this replica's group state died
     // with the process, so claiming view-0 primaryship would heartbeat
@@ -523,6 +670,7 @@
     }
 
     let mut partition = IggyPartition::new(stats, consensus);
+    partition.set_superblock(superblock, recovered_state.as_ref());
     // Surface the evicted-ring ceilings from config onto the fresh journal.
     // IggyPartition::new has already disabled retention for single-replica
     // groups (nobody to serve), so this only sizes the multi-replica ring; the
@@ -531,11 +679,7 @@
         config.partition.evicted_ring_capacity,
         config.partition.evicted_ring_bytes_max.as_bytes_u64(),
     );
-    partition.set_partition_dir(config.system.get_partition_path(
-        stream_id,
-        topic_id,
-        partition_id,
-    ));
+    partition.set_partition_dir(partition_dir);
     partition.created_at = IggyTimestamp::now();
     partition.offset.store(0, Ordering::Release);
     partition.dirty_offset.store(0, Ordering::Relaxed);
@@ -546,7 +690,24 @@
         "fresh partition must not carry recovered segments"
     );
 
-    configure_consumer_offsets(&mut partition, config, namespace, 0)?;
+    // A "fresh" build is also how a FENCED partition comes back (the shard
+    // tombstones it and the reconciler rebuilds through here), and the fence
+    // deliberately leaves the superblock in place, so the recorded frontier is
+    // the rebuild's only anchor.
+    //
+    // It is a LOWER BOUND, not a guarantee: the record is written on view
+    // changes and transfer installs, so it lags the counter arbitrarily -- a
+    // fresh joiner that adopted a view while empty and then filled via repair
+    // has a record still reading 0, and this rebuild would re-seed at 0. For
+    // ordinary crash recovery that staleness is harmless (segments survive and
+    // win the max); it is the fence paths that promote the stale bound to sole
+    // source of truth. Closing it needs the runtime fence to persist the
+    // frontier before quarantining, and the boot-path chain refusal to carry
+    // the refused chain's max `end_offset` on its error.
+    partition.restore_offset_frontier(recovered_state.as_ref());
+    let current_offset = partition.offset.load(Ordering::Acquire);
+
+    configure_consumer_offsets(&mut partition, config, namespace, current_offset)?;
     ensure_initial_segment(&mut partition, config, stream_id, topic_id, partition_id).await?;
 
     Ok(partition)
@@ -607,3 +768,108 @@
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use journal::superblock::SuperblockStore;
+
+    const CLUSTER: u128 = 7;
+    const REPLICA: u8 = 1;
+    const REPLICAS: u8 = 3;
+
+    fn recorded_state(view: u32, log_view: u32) -> VsrState {
+        VsrState {
+            cluster: CLUSTER,
+            replica_id: REPLICA,
+            replica_count: REPLICAS,
+            view,
+            log_view,
+            commit_max: 42,
+            checkpoint_op: 0,
+            checkpoint_checksum: 0,
+            offset_frontier: 0,
+        }
+    }
+
+    fn partition_dir(root: &tempfile::TempDir) -> String {
+        root.path().join("partition").to_string_lossy().into_owned()
+    }
+
+    const fn test_identity() -> ReplicaIdentity {
+        ReplicaIdentity {
+            cluster: CLUSTER,
+            replica_id: REPLICA,
+            replica_count: REPLICAS,
+        }
+    }
+
+    #[compio::test]
+    async fn given_fresh_partition_dir_when_superblock_opened_should_yield_no_state() {
+        let root = tempfile::tempdir().expect("tempdir");
+        // A not-yet-materialized directory must open as fresh, not error: the
+        // helper creates it, since a follower can reach load before its first
+        // segment write.
+        let (_store, recovered) = open_partition_superblock(&partition_dir(&root), test_identity())
+            .await
+            .expect("open a fresh partition superblock");
+        assert!(
+            recovered.is_none(),
+            "an empty superblock is a fresh group, never an error"
+        );
+    }
+
+    #[compio::test]
+    async fn given_recorded_view_when_superblock_reopened_should_recover_state() {
+        let root = tempfile::tempdir().expect("tempdir");
+        let dir = partition_dir(&root);
+        let (store, recovered) = open_partition_superblock(&dir, test_identity())
+            .await
+            .expect("first open");
+        assert!(recovered.is_none());
+        let state = recorded_state(3, 2);
+        store
+            .write(&state.to_bytes())
+            .await
+            .expect("record the advanced view");
+        drop(store);
+
+        let (_store, recovered) = open_partition_superblock(&dir, test_identity())
+            .await
+            .expect("reopen after a restart");
+
+        assert_eq!(
+            recovered,
+            Some(state),
+            "a restarted partition must recover exactly the state it recorded"
+        );
+    }
+
+    #[compio::test]
+    async fn given_foreign_cluster_record_when_superblock_opened_should_refuse_boot() {
+        let root = tempfile::tempdir().expect("tempdir");
+        let dir = partition_dir(&root);
+        let (store, _) = open_partition_superblock(&dir, test_identity())
+            .await
+            .expect("first open");
+        let foreign = VsrState {
+            cluster: CLUSTER + 1,
+            ..recorded_state(1, 1)
+        };
+        store
+            .write(&foreign.to_bytes())
+            .await
+            .expect("record a foreign identity");
+        drop(store);
+
+        let refused = open_partition_superblock(&dir, test_identity()).await;
+
+        match refused {
+            Err(ServerNgError::PartitionSuperblockIdentityMismatch { field, .. }) => {
+                assert_eq!(field, IdentityField::Cluster);
+            }
+            Err(other) => panic!("expected an identity mismatch, got {other}"),
+            Ok(_) => panic!("a copied or misplaced partition directory must refuse boot"),
+        }
+    }
+}
diff --git a/core/server-ng/src/partition_reconciler.rs b/core/server-ng/src/partition_reconciler.rs
index fde1d68..de8c036 100644
--- a/core/server-ng/src/partition_reconciler.rs
+++ b/core/server-ng/src/partition_reconciler.rs
@@ -193,6 +193,11 @@
 const BACKOFF_BASE: Duration = Duration::from_secs(1);
 const BACKOFF_MAX: Duration = Duration::from_mins(1);
 
+/// Consecutive same-cause failures before [`ReconcilerCtx::record_failure`]
+/// escalates to an operator-visible error (the backoff is capped, so
+/// retries alone never surface a permanently failing partition).
+const ESCALATE_AFTER_ATTEMPTS: u32 = 10;
+
 /// Doubles per attempt, clamped at `BACKOFF_MAX`.
 fn next_backoff(attempts: u32) -> Duration {
     let shift = attempts.saturating_sub(1).min(6);
@@ -294,6 +299,25 @@
         });
         entry.attempts = entry.attempts.saturating_add(1);
         entry.next_retry_at = now + next_backoff(entry.attempts);
+        // Operator escalation: persistent on-disk corruption makes every
+        // retry fail identically, and unlike the boot path (which refuses
+        // loudly and fatally), this loop would hide the dead partition
+        // behind per-attempt error logs forever. Escalate once the backoff
+        // has long been at its ceiling, then again each doubling so a log
+        // pipeline cannot miss it.
+        if entry.attempts >= ESCALATE_AFTER_ATTEMPTS
+            && (entry.attempts == ESCALATE_AFTER_ATTEMPTS || entry.attempts.is_power_of_two())
+        {
+            error!(
+                namespace_raw = ns.inner(),
+                ?cause,
+                attempts = entry.attempts,
+                "partition reconciliation keeps failing; retries cannot repair \
+                 persistent on-disk damage -- operator intervention needed \
+                 (inspect the partition directory; moving it aside lets the \
+                 reconciler rebuild the replica from its group)"
+            );
+        }
     }
 
     /// Drop records whose namespace left both target and local sets;
@@ -377,6 +401,13 @@
     /// acted on is not answered: aging answers requests, discarding also
     /// destroys prepares.
     parked_reclaimed: usize,
+    /// Purges staged this pass. Counted so the pass does not arm the
+    /// fast-skip: the pump can DEFER a purge it could not record
+    /// (`PurgeError::FrontierNotRecorded`), which leaves
+    /// `applied_purge_generation` unmoved and bumps no revision, so an armed
+    /// skip would swallow the only re-issue and drop a committed `PurgeTopic`
+    /// on this replica for good.
+    purges_staged: usize,
     /// Rebuilds deferred until an in-flight `ConfirmRemove` drains. Counted
     /// so the pass does not arm the fast-skip: the pump's drop clears the
     /// tombstone and re-wakes us without bumping `Streams::revision`, so an
@@ -394,6 +425,7 @@
             + self.stale
             + self.cg_offsets_purged
             + self.trims_pending
+            + self.purges_staged
             + self.deferred
             + self.parked_reclaimed
     }
@@ -449,7 +481,7 @@
     reconcile_parked_frames(ctx, &staged, &mut counters);
     reconcile_consumer_group_offsets(ctx, &mut counters).await;
     reconcile_segment_truncations(ctx, &mut counters);
-    reconcile_partition_purges(ctx);
+    reconcile_partition_purges(ctx, &mut counters);
 
     let local_set: AHashSet<IggyNamespace> =
         ctx.shard.plane.partitions().namespaces().copied().collect();
@@ -1062,7 +1094,7 @@
 /// `PurgeTopic` generation is newer than the one the local partition last
 /// applied. The pump re-checks the generation before wiping, so a redundant
 /// pass (e.g. from an unrelated revision bump) is a no-op.
-fn reconcile_partition_purges(ctx: &ReconcilerCtx) {
+fn reconcile_partition_purges(ctx: &ReconcilerCtx, counters: &mut PassCounters) {
     let partitions = ctx.shard.plane.partitions();
     let namespaces: Vec<_> = partitions.namespaces().copied().collect();
     let streams = ctx.shard.plane.metadata().mux_stm.streams();
@@ -1072,11 +1104,19 @@
             namespace.topic_id(),
             namespace.partition_id(),
         );
-        let applied = partitions
-            .get_by_ns(&namespace)
-            .map_or(0, partitions::IggyPartition::applied_purge_generation);
+        // `namespaces()` is NOT tombstone-filtered while `get_by_ns` is, so an
+        // absent partition would read `applied = 0` and re-stage a purge on
+        // every pass for any ever-purged topic. That was inert while staging
+        // counted as nothing; now that it disarms the fast-skip it would pin
+        // the O(N) scan on forever and enqueue a lifecycle frame per pass that
+        // the pump's tombstone-gated handler silently discards.
+        let Some(partition) = partitions.get_by_ns(&namespace) else {
+            continue;
+        };
+        let applied = partition.applied_purge_generation();
         if committed > applied {
             ctx.shard.request_purge_partition(namespace, committed);
+            counters.purges_staged += 1;
         }
     }
 }
diff --git a/core/server-ng/src/responses.rs b/core/server-ng/src/responses.rs
index 2254b1c..2896541 100644
--- a/core/server-ng/src/responses.rs
+++ b/core/server-ng/src/responses.rs
@@ -79,6 +79,7 @@
     RequestHeader, WireDecode, WireEncode, WireIdentifier, WireName, WirePartitioning,
 };
 use iggy_common::{EncryptorKind, Identifier, IggyError, IggyExpiry, IggyTimestamp, MaxTopicSize};
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use metadata::impls::metadata::StreamsFrontend;
 use partitions::PollFragments;
@@ -98,8 +99,8 @@
 /// (`user_id`, transport kind, peer address) comes from the per-shard
 /// [`SessionManager`]; the `consumer_groups` list is read from the
 /// (replicated) consumer-group STM by the connection's bound VSR client id.
-pub(crate) fn build_get_personal_access_tokens_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn build_get_personal_access_tokens_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
 ) -> GetPersonalAccessTokensResponse
@@ -108,6 +109,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // PATs are per-user; list the requesting connection's own tokens, resolved
     // from this shard's `SessionManager` (like `get_me`) then read out of the
@@ -134,8 +136,8 @@
     })
 }
 
-pub(crate) fn build_get_me_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn build_get_me_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     sessions: &Rc<RefCell<SessionManager>>,
     transport_client_id: u128,
 ) -> ClientDetailsResponse
@@ -144,6 +146,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let mut client = sessions
         .borrow()
@@ -208,8 +211,8 @@
 /// `consumer_groups_count` is resolved from the connection's bound VSR client
 /// id against the replicated `Streams` STM (memberships are keyed by VSR id, not
 /// transport id). Connections that never bound (pre-register) count 0.
-pub(crate) fn connected_client_to_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn connected_client_to_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     info: &ConnectedClientInfo,
 ) -> ClientResponse
 where
@@ -217,6 +220,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let consumer_groups_count = info.vsr_client_id.map_or(0, |vsr_client_id| {
         #[allow(clippy::cast_possible_truncation)]
@@ -245,8 +249,8 @@
 /// touch the offset of a partition it currently owns. `Ok` for individual
 /// consumers (no fence) and for owned group partitions; `Err` otherwise so a
 /// stale client re-syncs instead of corrupting the shared group offset.
-fn fence_group_offset<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn fence_group_offset<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     consumer: &WireConsumer,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
@@ -258,6 +262,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     if consumer.kind != KIND_CONSUMER_GROUP {
         return Ok(());
@@ -288,8 +293,8 @@
 
 /// Fence a consumer-group offset op then resolve its target partition
 /// namespace. Shared by the four `Store`/`Delete` consumer-offset arms.
-fn fence_and_resolve_offset_namespace<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn fence_and_resolve_offset_namespace<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     consumer: &WireConsumer,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
@@ -301,6 +306,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     fence_group_offset(
         shard,
@@ -313,8 +319,8 @@
     resolve_partition_namespace(shard, stream_id, topic_id, partition_id)
 }
 
-pub(crate) fn resolve_partition_request_namespace<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_partition_request_namespace<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     operation: Operation,
     body: &[u8],
     client_id: u128,
@@ -324,6 +330,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let namespace = match operation {
         Operation::SendMessages => {
@@ -405,8 +412,8 @@
     Ok(namespace.inner())
 }
 
-fn resolve_send_messages_namespace<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn resolve_send_messages_namespace<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     header: &SendMessagesHeader,
 ) -> Result<IggyNamespace, IggyError>
 where
@@ -414,6 +421,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let partition_id = match &header.partitioning {
         WirePartitioning::PartitionId(partition_id) => *partition_id,
@@ -440,8 +448,8 @@
     )
 }
 
-pub(crate) fn resolve_partition_namespace<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn resolve_partition_namespace<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
     partition_id: Option<u32>,
@@ -451,6 +459,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let partition_id = partition_id.ok_or(IggyError::InvalidIdentifier)?;
     shard
@@ -468,8 +477,8 @@
 /// is the caller's transport-level peer address, used only by the
 /// cluster-metadata read to pick each node's advertised address; `None`
 /// degrades to the catch-all address.
-pub(crate) fn build_non_replicated_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn build_non_replicated_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     code: u32,
     body: &[u8],
     user_id: Option<u32>,
@@ -481,6 +490,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     match code {
         GET_CLUSTER_METADATA_CODE => Ok(NonReplicatedResponse::Bytes(
@@ -599,9 +609,9 @@
 /// The leader marking comes from this shard's consensus view; a shard without
 /// consensus (any shard but 0) still serves the full roster, only with no node
 /// marked leader.
-fn build_cluster_metadata_response<B, MJ, S>(
+fn build_cluster_metadata_response<B, MJ, S, SB>(
     roster: &ClusterRoster,
-    shard: &Rc<ShellShard<B, MJ, S>>,
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     client_ip: Option<IpAddr>,
 ) -> ClusterMetadataResponse
 where
@@ -609,6 +619,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     // Shard 0 reads its live consensus; delegated shards use the view shard 0
     // publishes into the roster, so leader marking works on every shard.
@@ -647,14 +658,15 @@
     }
 }
 
-fn build_stats_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_stats_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
 ) -> Result<StatsResponse, IggyError>
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let (
         streams_count,
@@ -850,8 +862,8 @@
     }
 }
 
-fn build_get_stream_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_stream_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
 ) -> Result<Option<GetStreamResponse>, IggyError>
 where
@@ -859,6 +871,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let default_max_topic_size = shard.plane.metadata().default_max_topic_size();
     let default_message_expiry = shard.plane.metadata().default_message_expiry();
@@ -883,14 +896,15 @@
     })
 }
 
-fn build_get_streams_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_streams_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
 ) -> Result<GetStreamsResponse, IggyError>
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard.plane.metadata().mux_stm.streams().read(|streams| {
         streams
@@ -912,14 +926,15 @@
     })
 }
 
-fn build_get_users_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_users_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
 ) -> Result<GetUsersResponse, IggyError>
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard.plane.metadata().mux_stm.users().read(|users| {
         users
@@ -931,8 +946,8 @@
     })
 }
 
-fn build_get_user_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_user_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     user_id: &WireIdentifier,
 ) -> Result<Option<UserDetailsResponse>, IggyError>
 where
@@ -940,6 +955,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard.plane.metadata().mux_stm.users().read(|users| {
         let resolved = match user_id {
@@ -980,8 +996,8 @@
     Ok(GetPersonalAccessTokensResponse { tokens })
 }
 
-fn build_get_topic_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_topic_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
 ) -> Result<Option<GetTopicResponse>, IggyError>
@@ -990,6 +1006,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let default_max_topic_size = shard.plane.metadata().default_max_topic_size();
     let default_message_expiry = shard.plane.metadata().default_message_expiry();
@@ -1019,8 +1036,8 @@
     })
 }
 
-fn build_get_topics_response<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn build_get_topics_response<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
 ) -> Result<GetTopicsResponse, IggyError>
 where
@@ -1028,6 +1045,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let default_max_topic_size = shard.plane.metadata().default_max_topic_size();
     let default_message_expiry = shard.plane.metadata().default_message_expiry();
@@ -1050,8 +1068,8 @@
 /// Reject a consumer-group read whose parent stream/topic is absent with the
 /// legacy typed error naming the level that missed; the group itself missing
 /// stays the shared not-found reply (empty over TCP, 404 over HTTP).
-fn ensure_topic_exists<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+fn ensure_topic_exists<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     stream_id: &WireIdentifier,
     topic_id: &WireIdentifier,
 ) -> Result<(), IggyError>
@@ -1060,6 +1078,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard.plane.metadata().mux_stm.streams().read(|streams| {
         let resolved_stream =
@@ -1425,12 +1444,13 @@
     reply
 }
 
-pub(crate) fn current_metadata_commit<B, MJ, S>(shard: &Rc<ShellShard<B, MJ, S>>) -> u64
+pub(crate) fn current_metadata_commit<B, MJ, S, SB>(shard: &Rc<ShellShard<B, MJ, S, SB>>) -> u64
 where
     B: ShellBus,
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     shard
         .plane
diff --git a/core/server-ng/src/segment_recovery.rs b/core/server-ng/src/segment_recovery.rs
index a82c7e3..ef9ba54 100644
--- a/core/server-ng/src/segment_recovery.rs
+++ b/core/server-ng/src/segment_recovery.rs
@@ -27,17 +27,20 @@
 //! recovery panic). This module is the server-ng-owned loader, reading the same
 //! 24-byte format its writer emits.
 
-use crate::server_error::ServerNgError;
+use crate::server_error::{PartitionChainRefusal, ServerNgError};
 use configs::server_ng::ServerNgConfig;
 use iggy_common::{IggyByteSize, IggyError, PartitionStats};
+use partitions::state_transfer::STAGING_SUFFIX;
 use partitions::{IggyIndexReader, Segment};
 use server_common::SegmentStorage;
-use server_common::send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Header};
+use server_common::send_messages2::{COMMAND_HEADER_SIZE, SendMessages2Header, decode_batch_slice};
 use std::fs;
 use std::os::unix::fs::FileExt;
+use std::path::PathBuf;
 use tracing::{error, warn};
 
 const LOG_EXTENSION: &str = "log";
+const INDEX_EXTENSION: &str = "index";
 
 /// A persisted segment recovered from disk: its metadata plus the storage
 /// handles (readers/writers) opened over its `.log` / `.index` files.
@@ -67,7 +70,12 @@
     let partition_path = config
         .system
         .get_partition_path(stream_id, topic_id, partition_id);
-    let mut start_offsets = collect_segment_start_offsets(&partition_path)?;
+    // ONE directory walk feeds both: the sweep only ever unlinks `.staging` and
+    // orphan `.index` files, never a `.log`, so the log stems it already
+    // collects ARE the post-sweep start-offset set. Note the error policy is
+    // the collect side's (NotFound => empty, anything else => refuse boot); the
+    // sweep's silent return would swallow an EACCES that must not be ignored.
+    let mut start_offsets = sweep_scratch_files_and_collect_offsets(&partition_path)?;
     start_offsets.sort_unstable();
 
     let enforce_fsync = config.system.partition.enforce_fsync;
@@ -98,13 +106,15 @@
         )
         .await?;
 
-        // An index without a single whole entry means any log bytes were torn
-        // off mid-write (the crash landed between the message write and the
-        // index write). Recover the segment as EMPTY: counting the bytes with
-        // `end_offset == start_offset` would fabricate one phantom message for
-        // the bootstrap non-empty filters, and appending after the torn bytes
-        // would strand undecodable garbage inside the readable range. Zeroed
-        // sizes make the next append overwrite the torn bytes instead.
+        // `bounds == None` now means the log holds no whole BATCH either (the
+        // index-less path above already tried walking the log), so there is
+        // nothing to recover: zeroed sizes make the next append overwrite the
+        // torn bytes, where counting them with `end_offset == start_offset`
+        // would fabricate one phantom message for the bootstrap non-empty
+        // filters and strand undecodable garbage inside the readable range.
+        // Note this is NOT tail-only -- a torn index is reachable mid-chain on
+        // the shipped `enforce_fsync = false`, which is why the walk above
+        // exists rather than refusing the partition.
         let (start_timestamp, end_timestamp, end_offset, effective_messages_size) =
             if let Some((start_timestamp, end_timestamp, end_offset, walked_size)) = bounds {
                 (start_timestamp, end_timestamp, end_offset, walked_size)
@@ -170,12 +180,93 @@
         last.segment.sealed = false;
     }
 
+    ensure_contiguous_chain(
+        &recovered,
+        &partition_path,
+        stream_id,
+        topic_id,
+        partition_id,
+    )?;
+
     Ok(recovered)
 }
 
-/// Parses the zero-padded start offset out of every `.log` file name in the
-/// partition directory. A missing directory means a never-persisted partition.
-fn collect_segment_start_offsets(partition_path: &str) -> Result<Vec<u64>, ServerNgError> {
+/// Contiguity guard: recovery takes every `.log` stem in the directory, so a
+/// stray file (an unlink a failed state-transfer install could not finish,
+/// an operator copy) would otherwise splice a hole or an overlap into the
+/// chain and push `current_offset` past data this replica does not hold.
+/// Refuse loudly instead of serving a holed log.
+///
+/// The refusal names the partition and its directory so the caller can fence
+/// THAT group rather than abort the node's boot: the shapes it rejects are
+/// exactly what a failed quarantine leaves behind, and one damaged local chain
+/// must not take the whole node down.
+fn ensure_contiguous_chain(
+    recovered: &[RecoveredSegment],
+    partition_path: &str,
+    stream_id: usize,
+    topic_id: usize,
+    partition_id: usize,
+) -> Result<(), ServerNgError> {
+    let refused = |reason| {
+        Err(ServerNgError::PartitionChainRefused {
+            dir: PathBuf::from(partition_path),
+            stream_id,
+            topic_id,
+            partition_id,
+            reason,
+        })
+    };
+    for pair in recovered.windows(2) {
+        let previous = &pair[0].segment;
+        let next = &pair[1].segment;
+        // A NON-tail empty segment can only be an orphan pairing: the torn-
+        // tail leniency (an index-less crash tail recovered as empty) only
+        // ever applies to the LAST element, and a size-0 segment followed by
+        // more chain is exactly what a failed converge rebuild leaves behind.
+        // Skipping it here was the guard's blind spot.
+        if previous.size == IggyByteSize::default() {
+            return refused(PartitionChainRefusal::EmptyNonTailSegment {
+                empty_start: previous.start_offset,
+                next_start: next.start_offset,
+            });
+        }
+        if next.start_offset != previous.end_offset + 1 {
+            return refused(PartitionChainRefusal::Hole {
+                previous_start: previous.start_offset,
+                previous_end: previous.end_offset,
+                next_start: next.start_offset,
+            });
+        }
+    }
+    Ok(())
+}
+
+/// Unlink the partition directory's scratch leftovers: every `*.staging` spill
+/// file, and every `.index` with no `.log` beside it.
+///
+/// Boot is the one sweep that always runs. The install-time and reuse-time
+/// staging sweeps only fire on the NEXT transfer attempt, so a transfer
+/// abandoned for good would otherwise leak a full partition copy across
+/// restarts; staging files are pure scratch (never a rename source until an
+/// install owns them), so unlinking is always safe.
+///
+/// Orphaned indexes come from the state-transfer install, which renames ALL
+/// indexes to their final names, fsyncs the directory, and only then renames the
+/// logs -- a crash in that window is GUARANTEED to leave final-name `.index`
+/// files with no `.log`. Recovery keys on `.log` stems, so nothing else ever
+/// looks at them again: they are invisible to it and to the size stats, and
+/// without this they are a permanent leak at offsets the partition may never
+/// revisit. Unlinking rather than keeping them is safe because every path that
+/// recreates a segment at a given base offset opens its index through
+/// `SegmentStorage::new(.., file_exists = false)` first, which TRUNCATES: the
+/// stale entries are never read, only overwritten.
+/// Sweeps boot-time scratch (`.staging` spill, orphan `.index`) and returns the
+/// start offset parsed out of every remaining zero-padded `.log` file name. A
+/// missing directory means a never-persisted partition.
+fn sweep_scratch_files_and_collect_offsets(
+    partition_path: &str,
+) -> Result<Vec<u64>, ServerNgError> {
     let entries = match fs::read_dir(partition_path) {
         Ok(entries) => entries,
         Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
@@ -188,22 +279,48 @@
             return Err(IggyError::CannotReadPartitions.into());
         }
     };
-
+    let mut swept = Vec::new();
+    let mut orphan_candidates = Vec::new();
+    let mut log_stems = std::collections::HashSet::new();
     let mut start_offsets = Vec::new();
     for entry in entries.flatten() {
         let path = entry.path();
-        if path.extension().and_then(|ext| ext.to_str()) != Some(LOG_EXTENSION) {
+        let Some(as_str) = path.to_str() else {
+            continue;
+        };
+        if as_str.ends_with(STAGING_SUFFIX) {
+            swept.push(path);
             continue;
         }
-        if let Some(start_offset) = path
-            .file_stem()
-            .and_then(|stem| stem.to_str())
-            .and_then(|stem| stem.parse::<u64>().ok())
-        {
-            start_offsets.push(start_offset);
+        match path.extension().and_then(|extension| extension.to_str()) {
+            Some(LOG_EXTENSION) => {
+                if let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) {
+                    log_stems.insert(stem.to_owned());
+                    if let Ok(start_offset) = stem.parse::<u64>() {
+                        start_offsets.push(start_offset);
+                    }
+                }
+            }
+            Some(INDEX_EXTENSION) => orphan_candidates.push(path),
+            _ => {}
         }
     }
-
+    swept.extend(orphan_candidates.into_iter().filter(|path| {
+        !path
+            .file_stem()
+            .and_then(|stem| stem.to_str())
+            .is_some_and(|stem| log_stems.contains(stem))
+    }));
+    for path in swept {
+        if let Err(error) = fs::remove_file(&path) {
+            warn!(
+                partition_path,
+                path = %path.display(),
+                %error,
+                "failed to sweep a stale scratch file at boot"
+            );
+        }
+    }
     Ok(start_offsets)
 }
 
@@ -219,6 +336,7 @@
 /// `enforce_fsync` there is no ordering barrier between the message write and
 /// the index write, and a tail torn mid-flush would otherwise pass while
 /// `end_offset` claims offsets whose bytes are incomplete.
+#[allow(clippy::too_many_lines)]
 async fn recover_segment_bounds(
     index_path: &str,
     messages_path: &str,
@@ -272,12 +390,20 @@
             // true end offset; a header that no longer decodes marks a torn
             // tail, which truncates the readable range to the last whole
             // batch so the next append overwrites the torn bytes.
+            // Opened ONCE for the walk: the helper used to open the file per
+            // batch, which is an open + pread + close for every batch in the
+            // segment, synchronously, at boot. A failure to open a file that
+            // just stat'd walks nothing, which lands on the divergence refusal
+            // below rather than recovering an indexed segment as empty.
+            let messages = fs::File::open(messages_path).ok();
             let mut position = last.position;
             let mut end_offset = last.offset;
             let mut end_timestamp = last.timestamp;
             let mut walked_any = false;
-            while position < messages_size {
-                let Some(header) = read_batch_header(messages_path, position, messages_size) else {
+            while let Some(messages) = messages.as_ref()
+                && position < messages_size
+            {
+                let Some(header) = read_batch_header(messages, position, messages_size) else {
                     break;
                 };
                 let extent = position.saturating_add(header.total_size() as u64);
@@ -306,6 +432,81 @@
             }
             Ok(Some((first.timestamp, end_timestamp, end_offset, position)))
         }
+        // No whole index entry, but the log holds bytes: recover the bounds by
+        // WALKING the log from byte 0 instead of declaring the segment empty.
+        //
+        // The index is not the only self-describing copy -- batch headers carry
+        // their own offsets, timestamps and lengths -- and with the shipped
+        // `enforce_fsync = false` there is no write ordering between a log and
+        // its index, so a torn index is reachable on default config for a
+        // MID-CHAIN segment too, not just the tail. Recovering that as empty
+        // then trips the contiguity guard and refuses the whole partition:
+        // total serve loss (and offset reuse from 0) for a chain whose bytes
+        // are all present. The walk stops at the first header that does not
+        // decode or does not fit, which keeps the torn-tail truncation the
+        // indexed path performs.
+        _ if messages_size > 0 => {
+            // Opened once, as above. Nothing walked means no whole batch,
+            // which is the `Ok(None)` the tail of this arm already returns.
+            let messages = fs::File::open(messages_path).ok();
+            let mut position = 0u64;
+            let mut start_timestamp = None;
+            let mut end_offset = start_offset;
+            let mut end_timestamp = 0;
+            let mut expected_offset = start_offset;
+            let mut scratch = Vec::new();
+            while let Some(messages) = messages.as_ref()
+                && position < messages_size
+            {
+                let Some(header) = read_batch_header(messages, position, messages_size) else {
+                    break;
+                };
+                let extent = position.saturating_add(header.total_size() as u64);
+                if extent > messages_size {
+                    break;
+                }
+                // The FILENAME is the only trustworthy anchor once the index is
+                // gone, and `read_batch_header` checks a length, not a checksum.
+                // A torn header claiming an offset below `start_offset` would
+                // underflow the message count the caller derives; one claiming a
+                // jump above becomes this partition's counter, and the next
+                // prepare stamps a `base_offset` diverged from every peer. So
+                // the chain has to be contiguous from the filename onward, and
+                // the batch has to verify before its header is believed.
+                if header.base_offset != expected_offset
+                    || !batch_verifies(messages, position, &header, &mut scratch)
+                {
+                    break;
+                }
+                if header.message_count > 0 {
+                    end_offset = header
+                        .base_offset
+                        .saturating_add(u64::from(header.message_count) - 1);
+                    end_timestamp = header.base_timestamp;
+                    start_timestamp.get_or_insert(header.base_timestamp);
+                    expected_offset = end_offset.saturating_add(1);
+                }
+                position = extent;
+            }
+            let Some(start_timestamp) = start_timestamp else {
+                // Not one whole batch either: the bytes really are unusable, so
+                // the caller's empty recovery is right after all.
+                return Ok(None);
+            };
+            warn!(
+                stream_id,
+                topic_id,
+                partition_id,
+                start_offset,
+                messages_size,
+                walked_size = position,
+                "sparse index holds no whole entry; recovered segment bounds by \
+                 walking the log instead of discarding it (the index repopulates \
+                 on the next flush, and polls take the index-less fallback until \
+                 then)"
+            );
+            Ok(Some((start_timestamp, end_timestamp, end_offset, position)))
+        }
         _ => Ok(None),
     }
 }
@@ -313,16 +514,34 @@
 /// The batch command header at `position` in the messages file, or `None`
 /// when the header does not fit / decode (`position` past the file, header
 /// truncated, or garbage bytes).
+/// Whether the batch at `position` decodes and passes its own `batch_checksum`.
+///
+/// The index-less recovery walk trusts nothing else: without an index the only
+/// anchors are the filename and the payload's self-description, and a torn
+/// header is exactly what that walk exists to survive.
+fn batch_verifies(
+    messages: &fs::File,
+    position: u64,
+    header: &SendMessages2Header,
+    scratch: &mut Vec<u8>,
+) -> bool {
+    scratch.clear();
+    scratch.resize(header.total_size(), 0);
+    if messages.read_exact_at(scratch, position).is_err() {
+        return false;
+    }
+    decode_batch_slice(scratch).is_ok()
+}
+
 fn read_batch_header(
-    messages_path: &str,
+    messages: &fs::File,
     position: u64,
     messages_size: u64,
 ) -> Option<SendMessages2Header> {
     if position.checked_add(COMMAND_HEADER_SIZE as u64)? > messages_size {
         return None;
     }
-    let file = fs::File::open(messages_path).ok()?;
     let mut header_bytes = [0u8; COMMAND_HEADER_SIZE];
-    file.read_exact_at(&mut header_bytes, position).ok()?;
+    messages.read_exact_at(&mut header_bytes, position).ok()?;
     SendMessages2Header::decode(&header_bytes).ok()
 }
diff --git a/core/server-ng/src/server_error.rs b/core/server-ng/src/server_error.rs
index 262170b..49581a1 100644
--- a/core/server-ng/src/server_error.rs
+++ b/core/server-ng/src/server_error.rs
@@ -15,10 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use consensus::VsrStateError;
 use metadata::impls::recovery::RecoveryError;
 use server_common::log::LogError;
 use shard::ShardCtorError;
 use shard_allocator::ShardingError;
+use std::path::PathBuf;
 use thiserror::Error;
 
 #[derive(Debug, Error)]
@@ -102,6 +104,65 @@
     Logging(#[source] LogError),
     #[error("failed to recover metadata snapshot and journal")]
     MetadataRecovery(#[source] RecoveryError),
+    #[error("failed to open partition superblock at {dir}")]
+    PartitionSuperblockIo {
+        dir: PathBuf,
+        #[source]
+        source: std::io::Error,
+    },
+    // Quarantines the one partition rather than treating the group as fresh or
+    // reading through to a superseded view: mirrors the metadata plane's
+    // `RecoveryError::SuperblockUnreadable` policy, minus the boot refusal,
+    // because one unreadable partition directory must not strand every healthy
+    // group on the shard.
+    #[error(
+        "partition superblock at {dir} is present but its format version \
+         {version} is unrecognized by this build (a downgrade, or a corrupt \
+         version field)"
+    )]
+    PartitionSuperblockVersionUnknown { dir: PathBuf, version: u16 },
+    #[error(
+        "partition superblock at {dir} is present but a copy holds bytes that \
+         do not verify (bit-rot or a checksum failure), so its latest \
+         generation cannot be established"
+    )]
+    PartitionSuperblockUnverifiable { dir: PathBuf },
+    #[error(
+        "partition superblock at {dir} was checksum-clean but did not decode; \
+         tombstoning this partition rather than inferring a stale view"
+    )]
+    PartitionSuperblockUndecodable {
+        dir: PathBuf,
+        #[source]
+        source: VsrStateError,
+    },
+    #[error(
+        "partition superblock at {dir} belongs to a different {field}: expected \
+         {expected}, found {found}; a copied or misplaced data directory, or the \
+         cluster was resized without reconfiguration"
+    )]
+    PartitionSuperblockIdentityMismatch {
+        dir: PathBuf,
+        field: metadata::IdentityField,
+        expected: u128,
+        found: u128,
+    },
+    // Per-partition, not fatal: the boot path fences this one group (quarantines
+    // its segment files and materialises it fresh) instead of taking the node
+    // down for one damaged local chain. The shapes it reports are exactly what a
+    // failed state-transfer quarantine leaves behind, and the rebuild recovers
+    // the data from a peer.
+    #[error(
+        "partition {stream_id}/{topic_id}/{partition_id} at {dir} recovered an \
+         unusable segment chain: {reason}"
+    )]
+    PartitionChainRefused {
+        dir: PathBuf,
+        stream_id: usize,
+        topic_id: usize,
+        partition_id: usize,
+        reason: PartitionChainRefusal,
+    },
     #[error(
         "shard {shard_id} aborted while waiting for shard-0 to broadcast the metadata \
          factory bundle; shard 0 dropped its sender (most likely it failed to recover)"
@@ -195,6 +256,49 @@
     ShardJoinFailures { failures: Vec<ShardJoinFailure> },
 }
 
+/// Why a recovered segment chain cannot be served.
+///
+/// Both shapes mean the same thing operationally -- the local files do not form
+/// a chain this replica can serve -- but they are distinguished because they
+/// point at different causes: an empty non-tail segment is a failed rebuild's
+/// orphan pairing, a hole is a stray or half-unlinked file.
+#[derive(Debug)]
+pub enum PartitionChainRefusal {
+    EmptyNonTailSegment {
+        empty_start: u64,
+        next_start: u64,
+    },
+    Hole {
+        previous_start: u64,
+        previous_end: u64,
+        next_start: u64,
+    },
+}
+
+impl std::fmt::Display for PartitionChainRefusal {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::EmptyNonTailSegment {
+                empty_start,
+                next_start,
+            } => write!(
+                f,
+                "segment {empty_start} is empty yet {next_start} follows it, so the \
+                 chain cannot be served past it"
+            ),
+            Self::Hole {
+                previous_start,
+                previous_end,
+                next_start,
+            } => write!(
+                f,
+                "segment {previous_start} ends at offset {previous_end} but the next \
+                 starts at {next_start}, leaving a hole"
+            ),
+        }
+    }
+}
+
 /// Per-shard outcome captured by [`crate::bootstrap::ShardHandles::join_all`]
 /// when a shard either returned `Err` or panicked.
 ///
diff --git a/core/server-ng/src/users.rs b/core/server-ng/src/users.rs
index 2fb03af..94a5cb2 100644
--- a/core/server-ng/src/users.rs
+++ b/core/server-ng/src/users.rs
@@ -43,6 +43,7 @@
 use iggy_binary_protocol::requests::users::{ChangePasswordRequest, CreateUserRequest};
 use iggy_binary_protocol::{Operation, PrepareHeader, RequestHeader};
 use iggy_common::IggyError;
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use metadata::impls::metadata::StreamsFrontend;
 use server_common::{Message, crypto};
@@ -57,8 +58,8 @@
 /// rejection (see [`verify_and_rewrite_change_password`]). Every other operation
 /// passes through unchanged. Returns [`IggyError::InvalidCommand`] only on an
 /// undecodable password body.
-pub(crate) fn maybe_rewrite_user_password_request<B, MJ, S>(
-    shard: &Rc<ShellShard<B, MJ, S>>,
+pub(crate) fn maybe_rewrite_user_password_request<B, MJ, S, SB>(
+    shard: &Rc<ShellShard<B, MJ, S, SB>>,
     request: Message<RequestHeader>,
 ) -> Result<Message<RequestHeader>, IggyError>
 where
@@ -66,6 +67,7 @@
     MJ: JournalHandle + 'static,
     MJ::Target: Journal<MJ::Storage, Entry = Message<PrepareHeader>, Header = PrepareHeader>,
     S: 'static,
+    SB: SuperblockStore + 'static,
 {
     let operation = request.header().operation;
     let body = request_body(&request);
diff --git a/core/shard/src/builder.rs b/core/shard/src/builder.rs
index 4de7cee..f171784 100644
--- a/core/shard/src/builder.rs
+++ b/core/shard/src/builder.rs
@@ -36,6 +36,7 @@
 };
 use consensus::VsrConsensus;
 use journal::JournalHandle;
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
 use message_bus::client_listener::RequestHandler;
 use message_bus::replica::listener::MessageHandler;
 use message_bus::{MessageBus, SendError};
@@ -47,17 +48,17 @@
 use crate::shards_table::ShardsTable;
 
 /// A freshly constructed [`IggyShard`].
-pub struct BuiltShard<B, MJ, S, M, T>
+pub struct BuiltShard<B, MJ, S, M, T, SB = PingPongSuperblock>
 where
     B: MessageBus,
 {
-    pub shard: IggyShard<B, MJ, S, M, T>,
+    pub shard: IggyShard<B, MJ, S, M, T, SB>,
 }
 
 /// Builder that pairs [`IggyShard`] construction with coordinator wiring
 /// on shard 0. Non-zero shards skip the coordinator entirely; the
 /// `coord_config` field is then ignored.
-pub struct IggyShardBuilder<B, MJ, S, M, T>
+pub struct IggyShardBuilder<B, MJ, S, M, T, SB = PingPongSuperblock>
 where
     B: MessageBus,
 {
@@ -68,8 +69,8 @@
     on_metadata_submit: MetadataSubmitHandler,
     on_list_clients: ListClientsHandler,
     on_partition_read: PartitionReadHandler,
-    metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
-    partitions: IggyPartitions<B>,
+    metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+    partitions: IggyPartitions<B, SB>,
     senders: Vec<TaggedSender>,
     inbox: Receiver<ShardFrame>,
     shards_table: T,
@@ -78,13 +79,14 @@
     metrics: ShardMetrics,
 }
 
-impl<B, MJ, S, M, T> IggyShardBuilder<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShardBuilder<B, MJ, S, M, T, SB>
 where
     B: MessageBus + Clone + 'static,
     T: ShardsTable,
     MJ: JournalHandle,
     S: Send + 'static,
     M: StateMachine,
+    SB: SuperblockStore,
 {
     /// Create a builder carrying every input needed by both
     /// [`IggyShard::new`] and (for shard 0) `ShardZeroCoordinator::new`.
@@ -97,8 +99,8 @@
         on_metadata_submit: MetadataSubmitHandler,
         on_list_clients: ListClientsHandler,
         on_partition_read: PartitionReadHandler,
-        metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
-        partitions: IggyPartitions<B>,
+        metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+        partitions: IggyPartitions<B, SB>,
         senders: Vec<TaggedSender>,
         inbox: Receiver<ShardFrame>,
         shards_table: T,
@@ -137,7 +139,7 @@
     /// [`ShardCtorError::ShardCountOverflow`] if `senders.len()` does not
     /// fit in `u16`. Both are bootstrap programming errors and the
     /// `u16` overflow check fires on every shard, not only shard 0.
-    pub fn build(self) -> Result<BuiltShard<B, MJ, S, M, T>, ShardCtorError> {
+    pub fn build(self) -> Result<BuiltShard<B, MJ, S, M, T, SB>, ShardCtorError> {
         let is_shard_zero = self.identity.id == 0;
 
         // Fail fast before installing forward closures: a misordered
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index aec89d6..5563f21 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -28,8 +28,10 @@
 #[cfg(any(test, feature = "simulator"))]
 use consensus::LocalPipeline;
 use consensus::{
-    CommitOutcome, Consensus, ConsensusClock, MetadataHandle, MuxPlane, PartitionsHandle, Pipeline,
-    Plane, PlaneKind, Sequencer, VsrAction, VsrConsensus, build_deny_reply_from_request_header,
+    ChunkProgress, CommitOutcome, Consensus, ConsensusClock, MetadataHandle, MuxPlane,
+    PartitionsHandle, Pipeline, Plane, PlaneKind, STATE_TRANSFER_MAX_DECODE_RETRIES,
+    STATE_TRANSFER_MAX_STALL_RETRIES, Sequencer, VsrAction, VsrConsensus,
+    build_deny_reply_from_request_header,
 };
 #[cfg(any(test, feature = "simulator"))]
 use crossfire::AsyncRxTrait;
@@ -46,6 +48,7 @@
 use iggy_common::PartitionStats;
 use iggy_common::variadic;
 use iggy_common::{IggyError, IggyExpiry, IggyTimestamp};
+use journal::superblock::{PingPongSuperblock, SuperblockStore};
 use journal::{Journal, JournalHandle};
 use message_bus::MessageBus;
 use message_bus::client_listener::RequestHandler;
@@ -56,13 +59,9 @@
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::stm::StateMachine;
 use metadata::{BoundSession, MetadataSubmitError};
+use partitions::state_transfer::TransferArtifact;
 use partitions::{IggyPartition, IggyPartitions, PollFragments, PollingArgs, PollingConsumer};
 use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
-// Read only by the durable-before-send tripwire, which is `debug_assertions`-only, so
-// an unconditional import warns in release builds. CI's `-D warnings` rides clippy,
-// which builds debug, so that warning goes unobserved there.
-#[cfg(debug_assertions)]
-use server_common::sharding::METADATA_CONSENSUS_NAMESPACE;
 use server_common::{MESSAGE_ALIGN, Message, MessageBag, iobuf::Frozen};
 use shards_table::ShardsTable;
 use std::cell::{Cell, RefCell};
@@ -72,8 +71,8 @@
 #[cfg(any(test, feature = "simulator"))]
 use std::sync::Arc;
 
-pub type ShardPlane<B, J, S, M> =
-    MuxPlane<variadic!(IggyMetadata<VsrConsensus<B>, J, S, M>, IggyPartitions<B>)>;
+pub type ShardPlane<B, J, S, M, SB = PingPongSuperblock> =
+    MuxPlane<variadic!(IggyMetadata<VsrConsensus<B>, J, S, M, SB>, IggyPartitions<B, SB>)>;
 
 pub struct ShardIdentity {
     pub id: u16,
@@ -644,7 +643,7 @@
 /// Funnelling through the pump keeps `IggyPartitions` single-writer:
 /// without it the cooperative `.await` scheduler would race
 /// `insert` / `remove` against the pump's live `&mut IggyPartition` (UB).
-pub enum ReconcileOp<B>
+pub enum ReconcileOp<B, SB = PingPongSuperblock>
 where
     B: MessageBus,
 {
@@ -654,7 +653,7 @@
     /// reconcile pass can detect a slab-key-reused stale partition.
     InsertOwned {
         namespace: IggyNamespace,
-        partition: Box<IggyPartition<B>>,
+        partition: Box<IggyPartition<B, SB>>,
         epoch: u64,
     },
     /// Seed a routing row for a partition owned by a peer shard.
@@ -797,20 +796,6 @@
 /// clamp the same way a default deployment does.
 const DEFAULT_BUS_MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024;
 
-/// Stall rounds a receiver spends on ONE peer before abandoning the transfer
-/// and falling back to journal repair. The retry has no peer re-selection, so
-/// this is what keeps a peer that died mid-transfer from wedging the rejoining
-/// node; repair then re-picks a target and re-arms a transfer if the gap is
-/// still below the new peer's retained floor.
-const STATE_TRANSFER_MAX_STALL_RETRIES: u32 = 5;
-
-/// Decode-failure rounds a receiver spends on ONE snapshot generation before
-/// refusing to pull it again. Keyed on the offered `snapshot_seq`: a peer that
-/// checkpoints resets the budget (new bytes are worth full retries), while a
-/// generation this build cannot decode ends up costing one refused descriptor
-/// per repair round instead of a full snapshot pull.
-const STATE_TRANSFER_MAX_DECODE_RETRIES: u32 = 5;
-
 /// Serving-side offer lifetime, as a multiple of the repair-retry interval. An
 /// offer resets its counter on every chunk it serves, so this only expires one
 /// that stopped being pulled -- a receiver that finished installing (the
@@ -825,21 +810,6 @@
 /// drops the session with every byte already downloaded).
 const STATE_TRANSFER_SERVED_EXPIRY_MULTIPLE: u32 = 3;
 
-/// One artifact of an accepted transfer target: its manifest entry plus the
-/// bytes received so far (chunks are sequential, so `buf.len()` doubles as
-/// the next request offset).
-#[derive(Debug)]
-struct ArtifactProgress {
-    entry: consensus::StateArtifact,
-    buf: Vec<u8>,
-}
-
-impl ArtifactProgress {
-    const fn complete(&self) -> bool {
-        self.buf.len() as u64 == self.entry.len
-    }
-}
-
 /// One in-flight metadata state transfer (shard 0 only): a cluster-restart
 /// rejoin replacing its snapshot-shaped state (metadata snapshot + client
 /// table) from the live primary before tail repair.
@@ -850,9 +820,18 @@
     peer: u8,
     /// Serving peer's applied frontier from the accepted descriptor.
     commit_op: u64,
+    /// Snapshot generation of the ACCEPTED descriptor, the key the decode
+    /// budget is charged against.
+    ///
+    /// Recorded at accept because the install-time scan can fail to find it --
+    /// a manifest whose snapshot entry is absent, or a checksum mismatch on an
+    /// earlier artifact aborting the scan -- and an uncharged failure re-armed
+    /// the same peer forever: an unbounded full-manifest re-pull loop. The
+    /// descriptor cannot be accepted without one, so it is always present here.
+    generation: u64,
     /// Empty until the `StateTransferTarget` manifest is accepted, then one
     /// entry per offered artifact, pulled in manifest order.
-    artifacts: Vec<ArtifactProgress>,
+    artifacts: Vec<consensus::ArtifactProgress>,
     /// Whether a descriptor has been accepted (an accepted EMPTY manifest is
     /// distinguishable from "still waiting").
     target_accepted: bool,
@@ -868,9 +847,216 @@
 ///
 /// The offer itself is refcounted, so simultaneous rejoiners on the same
 /// snapshot generation share one copy of the snapshot bytes.
+/// Which plane's offer a served entry holds, and -- for partitions -- the
+/// at-most-one segment payload currently resident. Partition offers address
+/// segment bytes by path; the serving side loads one artifact at a time at
+/// chunk-serve time, so n retained gigabytes never pin n resident gigabytes.
+enum ServedOffer {
+    Metadata(Rc<metadata::StateTransferOffer>),
+    Partition(Rc<partitions::state_transfer::PartitionStateTransferOffer>),
+}
+
+/// Largest `segment.size` any configuration can set, mirroring
+/// `configs::server_config::validators::SEGMENT_MAX_SIZE_BYTES` (the `configs`
+/// crate is not a dependency here). Both the served-payload budget and the
+/// per-artifact alloc cap are derived from it rather than hand-tuned.
+const SEGMENT_SIZE_CEILING_BYTES: u64 = 1 << 30;
+
+/// The most one segment can overshoot its size cap: rotation checks the cap
+/// AFTER appending, so a segment closes at most one maximum-size batch past it.
+///
+/// Derived from the BUS frame cap, not `MAX_PAYLOAD_SIZE`: server-ng never
+/// enforces the latter (its only enforcement sites are the legacy server and the
+/// SDK batch types), so the largest appendable batch is whatever the message bus
+/// will frame. This tracks the shipped `message_bus.max_message_size` default; an
+/// operator raising that is caught by the config validator, which requires
+/// `partition.transfer_artifact_bytes_max` to cover `system.segment.size` plus
+/// the configured bus cap.
+const SEGMENT_SIZE_OVERSHOOT_BYTES: u64 = 64 * 1024 * 1024;
+
+/// Default alloc ceiling for ONE received state-transfer artifact.
+///
+/// Mirrors `[partition] transfer_artifact_bytes_max`. Free const so the config
+/// crate's copy can be pinned to it by a `const _: () = assert!(..)` at the
+/// server-ng build edge, the way every other runtime default is.
+pub const PARTITION_ARTIFACT_LEN_DEFAULT: u64 =
+    SEGMENT_SIZE_CEILING_BYTES + SEGMENT_SIZE_OVERSHOOT_BYTES;
+
+/// Default per-shard resident budget for served segment payloads
+/// (`[partition] transfer_served_cache_bytes_max`). Pinned like
+/// [`PARTITION_ARTIFACT_LEN_DEFAULT`].
+pub const SERVED_SEGMENT_CACHE_BYTES_DEFAULT: u64 =
+    PARTITION_ARTIFACT_LEN_DEFAULT * CONCURRENT_SERVED_SEGMENTS;
+
+/// Distinct max-size segments the served-payload budget holds at once.
+///
+/// TWO, not the receiver's in-flight cap of four: the budget is PER SHARD and
+/// shard count defaults to core count, so each segment here multiplies by the
+/// core count during a whole-node rejoin, on top of page cache and the receive
+/// side's own in-flight artifacts.
+///
+/// The gap between this and the in-flight cap is closed by ADMITTING fewer
+/// concurrent transfers rather than by holding more bytes: see
+/// `IggyShard::partition_transfer_admission_cap`, which derives its cap from
+/// this budget so the two can never disagree. Overrunning the budget does not
+/// degrade gracefully -- distinct groups are distinct cache keys, so a surplus
+/// pull evicts the others on every chunk and none of them converge -- and an
+/// operator who wants more concurrency raises the knob, which raises the cap
+/// with it.
+const CONCURRENT_SERVED_SEGMENTS: u64 = 2;
+
+/// Shard-wide cache of segment payloads loaded to serve partition chunks,
+/// content-addressed by `(namespace, manifest checksum)` so every requester
+/// pulling the same offer generation shares ONE resident copy (per-requester
+/// slots pinned R copies on whole-node rejoins), while requesters on
+/// different generations never alias. LRU-evicted under a byte budget; an
+/// oversized single segment still loads (the serve could not proceed
+/// otherwise) and simply owns the budget until aged out.
+#[derive(Default)]
+struct ServedSegmentCache {
+    entries: HashMap<(u64, u64), CachedSegmentPayload>,
+    resident_bytes: u64,
+    use_seq: u64,
+    /// Idle sweeps run so far; entries carry the reading at their last use, so
+    /// their age is measured on the OFFER clock rather than the raw tick.
+    sweeps: u64,
+}
+
+/// One resident payload, its last-use sequence (the LRU key), and the sweep
+/// reading at that use (the age key).
+struct CachedSegmentPayload {
+    payload: Rc<Vec<u8>>,
+    last_use: u64,
+    last_use_sweep: u64,
+}
+
+impl ServedSegmentCache {
+    /// Byte budget across all resident segment payloads on ONE shard, so the
+    /// process-wide bound is this times the shard count. LRU pressure from new
+    /// inserts plus the idle sweep below reclaim it; a single segment larger than
+    /// the budget still loads (the serve could not proceed otherwise) and owns
+    /// the budget until it ages out. A config knob can follow if operators need
+    /// to trade it against page cache.
+    ///
+    /// Sized for CONCURRENT pulls, not one: at exactly one max-size segment
+    /// (`segment.size` defaults to and is capped at 1 GiB) a single receiver
+    /// arming its `PARTITION_TRANSFERS_INFLIGHT_MAX` transfers thrashes the
+    /// cache by itself -- distinct partitions are distinct keys, so the pulls
+    /// evict each other on every chunk, and each miss re-reads and re-hashes a
+    /// whole segment to serve one 256 KiB chunk. That is the 4096:1 read
+    /// amplification this cache exists to prevent, plus an offer eviction per
+    /// failed re-verify feeding the hard-failure backoff.
+    /// Drop every payload that has served nothing for `idle_sweeps_max` sweeps.
+    ///
+    /// The budget comes from the caller because the two clocks differ: this
+    /// sweep runs on the raw 10 ms consensus tick while the offers these
+    /// payloads back expire on `retry_ticks * MULTIPLE`. Counting bare sweeps
+    /// gave a payload ~100 ms against an offer's ~10 s, so one dropped chunk
+    /// frame -- whose only re-drive is the 1 s stall sweep -- evicted the
+    /// payload and made the resume re-read and re-hash the whole segment to
+    /// serve the next 256 KiB. The trade in the other direction: an abandoned
+    /// pull now pins its resident payload for the full offer window.
+    ///
+    /// Runs from the same place offers expire: without it, one rejoin leaves a
+    /// permanent high-water of resident bytes (nothing else releases the cache
+    /// once the pulls stop).
+    fn expire_idle(&mut self, idle_sweeps_max: u64) {
+        self.sweeps += 1;
+        // Strictly BELOW the floor: at `<=` an entry stamped on sweep 0 matches
+        // `0 <= 0` on the very first sweep and is dropped whatever the budget
+        // says, and every other entry loses one sweep of its lifetime. Harmless
+        // in production, but it makes the budget untestable at its boundary.
+        let floor = self.sweeps.saturating_sub(idle_sweeps_max);
+        let stale: Vec<(u64, u64)> = self
+            .entries
+            .iter()
+            .filter(|(_, cached)| cached.last_use_sweep < floor)
+            .map(|(&key, _)| key)
+            .collect();
+        for key in stale {
+            if let Some(evicted) = self.entries.remove(&key) {
+                self.resident_bytes = self
+                    .resident_bytes
+                    .saturating_sub(evicted.payload.len() as u64);
+            }
+        }
+    }
+
+    /// Drop every payload cached for `namespace`, crediting their bytes back.
+    ///
+    /// A purge unlinks the segments these payloads copy, and the cache key is
+    /// the manifest checksum over the PRE-purge bytes, so nothing about a hit
+    /// can notice: the serve path answers from the resident copy without
+    /// touching disk, and every served chunk resets the expiry clock, so an
+    /// active puller keeps purged data alive indefinitely.
+    fn evict_namespace(&mut self, namespace: u64) {
+        let stale: Vec<(u64, u64)> = self
+            .entries
+            .keys()
+            .filter(|(entry_namespace, _)| *entry_namespace == namespace)
+            .copied()
+            .collect();
+        for key in stale {
+            if let Some(evicted) = self.entries.remove(&key) {
+                self.resident_bytes = self
+                    .resident_bytes
+                    .saturating_sub(evicted.payload.len() as u64);
+            }
+        }
+    }
+
+    fn get(&mut self, namespace: u64, checksum: u64) -> Option<Rc<Vec<u8>>> {
+        self.use_seq += 1;
+        let use_seq = self.use_seq;
+        let sweeps = self.sweeps;
+        let cached = self.entries.get_mut(&(namespace, checksum))?;
+        cached.last_use = use_seq;
+        cached.last_use_sweep = sweeps;
+        Some(Rc::clone(&cached.payload))
+    }
+
+    fn insert(&mut self, namespace: u64, checksum: u64, payload: Rc<Vec<u8>>, budget: u64) {
+        let incoming = payload.len() as u64;
+        // Credited BEFORE the eviction scan: re-inserting an existing key frees
+        // its own slot, and charging that only afterwards evicted neighbours to
+        // make room for bytes that were about to be released.
+        if let Some(replaced) = self.entries.remove(&(namespace, checksum)) {
+            self.resident_bytes = self
+                .resident_bytes
+                .saturating_sub(replaced.payload.len() as u64);
+        }
+        while self.resident_bytes.saturating_add(incoming) > budget && !self.entries.is_empty() {
+            let Some((&key, _)) = self
+                .entries
+                .iter()
+                .min_by_key(|(_, cached)| cached.last_use)
+            else {
+                break;
+            };
+            if let Some(evicted) = self.entries.remove(&key) {
+                self.resident_bytes = self
+                    .resident_bytes
+                    .saturating_sub(evicted.payload.len() as u64);
+            }
+        }
+        self.use_seq += 1;
+        // The key was removed above, so this never replaces an entry whose bytes
+        // still need crediting back.
+        self.entries.insert(
+            (namespace, checksum),
+            CachedSegmentPayload {
+                payload,
+                last_use: self.use_seq,
+                last_use_sweep: self.sweeps,
+            },
+        );
+        self.resident_bytes = self.resident_bytes.saturating_add(incoming);
+    }
+}
+
 struct ServedStateTransfer {
     nonce: u128,
-    offer: Rc<metadata::StateTransferOffer>,
+    offer: ServedOffer,
     /// Ticks since this offer last served a chunk. An offer owns a full copy of
     /// the snapshot and the encoded client table, so a completed or abandoned
     /// transfer must not pin them for the process lifetime. There is no
@@ -886,22 +1072,71 @@
     fully_served: bool,
 }
 
+/// One `StateTransferTarget` descriptor: the offer if there is one, plus what
+/// the serving replica knows about its own progress.
+///
+/// The progress fields ride along even on a refusal, so a receiver can tell a
+/// peer that is momentarily behind from one that knows less than it does. They
+/// are CONSTRUCTOR arguments rather than an optional builder step: as an
+/// optional step every one of the eight construction sites had to remember it,
+/// and two did not.
+struct TransferDescriptor<'a> {
+    /// `Some((manifest, commit_op))` when the peer can serve.
+    offer: Option<(&'a [consensus::StateArtifact], u64)>,
+    /// Serving replica's view and commit frontier at build time.
+    view: u32,
+    commit_max: u64,
+    /// A refusal the requester should retry soon WITHOUT charging its
+    /// consecutive-failure count. Always false when `offer` is `Some`.
+    transient: bool,
+}
+
+impl<'a> TransferDescriptor<'a> {
+    const fn available(
+        offer: &'a [consensus::StateArtifact],
+        commit_op: u64,
+        view: u32,
+        commit_max: u64,
+    ) -> Self {
+        Self {
+            offer: Some((offer, commit_op)),
+            view,
+            commit_max,
+            transient: false,
+        }
+    }
+
+    const fn unavailable(transient: bool, view: u32, commit_max: u64) -> Self {
+        Self {
+            offer: None,
+            view,
+            commit_max,
+            transient,
+        }
+    }
+}
+
 /// What `on_request_state_chunk` decided inside its offers borrow; the wire
 /// sends run after the borrow drops.
 enum ChunkReply {
     Chunk(Message<StateChunkHeader>),
-    /// Offer evicted (e.g. the serving process restarted): the requester
-    /// gets an unavailable descriptor and restarts its session.
-    UnknownOffer,
+    /// Offer evicted (e.g. the serving process restarted, or the segment it
+    /// named can no longer be served): the requester gets an unavailable
+    /// descriptor and restarts its session. `transient` carries whether the
+    /// cause was this node's fault, which is what decides if the requester
+    /// charges a failure.
+    Unavailable {
+        transient: bool,
+    },
 }
 
-pub struct IggyShard<B, MJ, S, M, T = ()>
+pub struct IggyShard<B, MJ, S, M, T = (), SB = PingPongSuperblock>
 where
     B: MessageBus,
 {
     pub id: u16,
     pub name: String,
-    pub plane: ShardPlane<B, MJ, S, M>,
+    pub plane: ShardPlane<B, MJ, S, M, SB>,
 
     /// Handle to the local bus. Retained alongside the bus owned by every
     /// consensus plane so the router can reach the `ConnectionInstaller`
@@ -927,9 +1162,22 @@
     /// repair takes over at install. See [`MetadataTransferSession`].
     metadata_transfer: RefCell<Option<MetadataTransferSession>>,
 
-    /// Serving-side cache of state-transfer offers, keyed by requester
-    /// replica id. Bounded by the replica count; replaced per fresh nonce.
-    metadata_transfer_offers: RefCell<HashMap<u8, ServedStateTransfer>>,
+    /// Serving-side cache of state-transfer offers, both planes, keyed by
+    /// `(namespace, requester replica id)`. Bounded by the replica count times
+    /// the groups this shard serves; replaced per fresh nonce.
+    state_transfer_offers: RefCell<HashMap<(u64, u8), ServedStateTransfer>>,
+    /// Partition groups with an offer build under way but no offer yet, keyed
+    /// by namespace and carrying ticks since the last request that advanced it.
+    ///
+    /// A build spans rounds (the checksum pass is budgeted per frame body), and
+    /// during those rounds nothing in `state_transfer_offers` names the group,
+    /// so admission control cannot see it without this. Aged out on the same
+    /// clock as an idle offer, since a requester that walked away leaves
+    /// nothing else to release the slot.
+    partition_offer_builds: RefCell<HashMap<u64, u32>>,
+
+    /// See [`ServedSegmentCache`].
+    served_segment_cache: RefCell<ServedSegmentCache>,
 
     /// Handler for inbound [`MetadataSubmit`] frames. Only shard 0 receives
     /// these (it owns the metadata consensus group); peers send them here
@@ -990,7 +1238,7 @@
 
     /// Reconciler → pump funnel. Borrow discipline: every push / drain
     /// runs without `.await` inside the borrow.
-    reconcile_queue: RefCell<VecDeque<ReconcileOp<B>>>,
+    reconcile_queue: RefCell<VecDeque<ReconcileOp<B, SB>>>,
 
     /// Partition-plane frames that arrived before this shard's reconciler
     /// materialised the namespace (post-`CreateTopic` convergence window).
@@ -1049,6 +1297,17 @@
     /// `[cluster] repair_retry_interval` at bootstrap.
     repair_retry_ticks: Cell<u32>,
 
+    /// Live `[partition] transfer_served_cache_bytes_max`: the byte budget for
+    /// segment payloads this shard keeps resident to serve chunk requests.
+    /// Defaults to [`SERVED_SEGMENT_CACHE_BYTES_DEFAULT`]; server-ng
+    /// overrides it at bootstrap.
+    served_segment_cache_bytes_max: Cell<u64>,
+
+    /// Live `[partition] transfer_artifact_bytes_max`: the alloc ceiling for one
+    /// RECEIVED artifact. Defaults to [`PARTITION_ARTIFACT_LEN_DEFAULT`];
+    /// server-ng overrides it at bootstrap.
+    partition_artifact_len_max: Cell<u64>,
+
     /// Live `[message_bus] max_message_size`. Bounds a served state chunk: a
     /// frame above this is rejected by the RECEIVING transport, which tears
     /// down the whole replica connection. Defaults to a value that leaves
@@ -1078,10 +1337,11 @@
     metadata_transfer_decode_failures: Cell<Option<(u64, u32)>>,
 }
 
-impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
 where
     B: MessageBus + 'static,
     T: ShardsTable,
+    SB: SuperblockStore,
 {
     /// Depth of this shard's inbound frame queue.
     ///
@@ -1131,8 +1391,8 @@
         on_metadata_submit: MetadataSubmitHandler,
         on_list_clients: ListClientsHandler,
         on_partition_read: PartitionReadHandler,
-        metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
-        partitions: IggyPartitions<B>,
+        metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+        partitions: IggyPartitions<B, SB>,
         senders: Vec<TaggedSender>,
         inbox: Receiver<ShardFrame>,
         shards_table: T,
@@ -1172,7 +1432,11 @@
             shard_park_shedding: Cell::new(false),
             metadata_repair: RefCell::new(None),
             metadata_transfer: RefCell::new(None),
-            metadata_transfer_offers: RefCell::new(HashMap::new()),
+            state_transfer_offers: RefCell::new(HashMap::new()),
+            partition_offer_builds: RefCell::new(HashMap::new()),
+            served_segment_cache: RefCell::new(ServedSegmentCache::default()),
+            served_segment_cache_bytes_max: Cell::new(SERVED_SEGMENT_CACHE_BYTES_DEFAULT),
+            partition_artifact_len_max: Cell::new(PARTITION_ARTIFACT_LEN_DEFAULT),
             repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
             repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
             bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
@@ -1188,6 +1452,18 @@
         self.repair_retry_ticks.set(ticks);
     }
 
+    /// Override the serving-side resident payload budget from configuration.
+    /// Called once per shard at bootstrap.
+    pub fn set_served_segment_cache_bytes_max(&self, bytes: u64) {
+        self.served_segment_cache_bytes_max.set(bytes);
+    }
+
+    /// Override the per-artifact receive ceiling from configuration. Called once
+    /// per shard at bootstrap.
+    pub fn set_partition_artifact_len_max(&self, bytes: u64) {
+        self.partition_artifact_len_max.set(bytes);
+    }
+
     /// Override the per-round repair-serving chunk ceiling from configuration.
     /// Called once per shard at bootstrap; the simulator and tests keep the
     /// compile-time [`REPAIR_CHUNK_MAX`] default.
@@ -1368,8 +1644,8 @@
     pub fn without_inbox(
         identity: ShardIdentity,
         bus: B,
-        metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M>,
-        partitions: IggyPartitions<B>,
+        metadata: IggyMetadata<VsrConsensus<B>, MJ, S, M, SB>,
+        partitions: IggyPartitions<B, SB>,
         shards_table: T,
         partition_consensus: PartitionConsensusConfig<B>,
     ) -> Self {
@@ -1410,7 +1686,11 @@
             shard_park_shedding: Cell::new(false),
             metadata_repair: RefCell::new(None),
             metadata_transfer: RefCell::new(None),
-            metadata_transfer_offers: RefCell::new(HashMap::new()),
+            state_transfer_offers: RefCell::new(HashMap::new()),
+            partition_offer_builds: RefCell::new(HashMap::new()),
+            served_segment_cache: RefCell::new(ServedSegmentCache::default()),
+            served_segment_cache_bytes_max: Cell::new(SERVED_SEGMENT_CACHE_BYTES_DEFAULT),
+            partition_artifact_len_max: Cell::new(PARTITION_ARTIFACT_LEN_DEFAULT),
             repair_chunk_max: Cell::new(REPAIR_CHUNK_MAX),
             repair_retry_ticks: Cell::new(partitions::REPAIR_RETRY_TICKS),
             bus_max_message_size: Cell::new(DEFAULT_BUS_MAX_MESSAGE_SIZE),
@@ -1487,7 +1767,7 @@
     /// Marker `try_send` is best-effort; the pump's tail drain on every
     /// frame and its consensus-tick drain catch dropped markers, so the
     /// queue never strands ops for longer than one tick.
-    pub fn enqueue_reconcile_op(&self, op: ReconcileOp<B>) {
+    pub fn enqueue_reconcile_op(&self, op: ReconcileOp<B, SB>) {
         self.reconcile_queue.borrow_mut().push_back(op);
         let Some(sender) = self.senders.get(self.id as usize) else {
             return;
@@ -1584,7 +1864,7 @@
         // Ahead of the staged ops and outside their empty-queue early return: a
         // re-parked frame waits on inbox capacity, not on a reconcile op.
         self.retry_reparked_frames();
-        let staged: Vec<ReconcileOp<B>> = {
+        let staged: Vec<ReconcileOp<B, SB>> = {
             let mut q = self.reconcile_queue.borrow_mut();
             if q.is_empty() {
                 return;
@@ -1635,13 +1915,15 @@
                 }
                 ReconcileOp::ConfirmRemove { namespace } => {
                     // Tombstone bit set + shards_table row removed synchronously
-                    // by the reconciler before this op was enqueued, so no
-                    // in-flight frame can reach the partition between `remove`
-                    // and the drop here. Teardown already unlinked the on-disk
-                    // hierarchy via `delete_partitions_from_disk`, so the
-                    // partition drops inline: its compio file handles close
-                    // through io_uring without blocking, and no fsync is wanted
-                    // on data that is already gone.
+                    // before this op was enqueued -- by the reconciler on a real
+                    // delete, by `fence_partition_for_rebuild` when a partition
+                    // is retired for rebuild -- so no in-flight frame can reach
+                    // the partition between `remove` and the drop here. On the
+                    // delete path teardown already unlinked the on-disk hierarchy
+                    // via `delete_partitions_from_disk`, so the partition drops
+                    // inline: its compio file handles close through io_uring
+                    // without blocking, and no fsync is wanted on data that is
+                    // already gone.
                     let removed = partitions.remove(&namespace);
                     partitions.untombstone(&namespace);
                     // A topic created then deleted before its `InsertOwned`
@@ -1678,6 +1960,96 @@
     }
 }
 
+/// The serving replica's `(view, commit_max)` for a descriptor.
+///
+/// Sampled per branch, always AFTER any offer build: the build force-flushes and
+/// hashes a budgeted slice of the un-memoized segments (a first multi-GiB
+/// serve takes several rounds to complete an offer at all) while
+/// reading its `commit_op` post-flush, so a pre-build sample could advertise a
+/// `commit_max` below the descriptor's own `commit_op`. Harmless on the receiver
+/// (the values are only compared against its own locals) but it makes its gate
+/// refuse, and refusals feed a backoff.
+const fn serving_progress<B, SB>(partition: &IggyPartition<B, SB>) -> (u32, u64)
+where
+    B: MessageBus,
+    SB: SuperblockStore,
+{
+    (
+        partition.consensus().view(),
+        partition.consensus().commit_max(),
+    )
+}
+
+/// The next replica to try after a transfer against `failed_peer` failed.
+///
+/// Prefers the view's primary: it is the only replica that can pass the serving
+/// side's caught-up-primary gate, so rotating by ring index alone can spend a
+/// full backoff round on a backup that must refuse -- and, worse, can land on a
+/// phantom view-0 primary of an empty group. Falls back to walking the ring past
+/// the failed peer, skipping this replica; a cluster of two has no alternative
+/// and retries the same peer.
+///
+/// `failed_peer` and `primary` are both bounded by `replica_count` at their
+/// ingress, which is what keeps the `+ 1` here from wrapping a peer id of 255
+/// onto replica 0.
+const fn next_transfer_peer(self_id: u8, failed_peer: u8, replica_count: u8, primary: u8) -> u8 {
+    if replica_count <= 1 {
+        return failed_peer;
+    }
+    if primary != self_id && primary != failed_peer {
+        return primary;
+    }
+    let mut candidate = (failed_peer + 1) % replica_count;
+    if candidate == self_id {
+        candidate = (candidate + 1) % replica_count;
+    }
+    if candidate == self_id {
+        failed_peer
+    } else {
+        candidate
+    }
+}
+
+/// Consecutive transient refusals before the re-arm starts logging at `error`,
+/// and the interval it re-logs at afterwards. Sized so a peer that is briefly
+/// behind stays quiet while a partition that never rejoins becomes loud.
+const TRANSFER_REFUSALS_BEFORE_ESCALATION: u32 = 10;
+
+/// Exponential re-arm backoff, scaled by the consecutive-failure count and
+/// capped at 1024x the base so a long outage settles into a slow poll
+/// instead of climbing forever.
+fn transfer_rearm_backoff(base_ticks: u32, failures: u32) -> u32 {
+    base_ticks.saturating_mul(1 << failures.min(10))
+}
+
+/// Split a handler's action list into `(local, wire)`. A failed superblock
+/// persist must fence only the WIRE sends: the local actions -- pipeline
+/// rebuild, commit walk -- flip no externally visible view state, and
+/// dropping them can wedge the group permanently. Concretely,
+/// `complete_view_change_as_primary` clears its pipeline before emitting
+/// `RebuildPipeline`; dropping that rebuild leaves a primary that drops
+/// every backup `PrepareOk` for the orphaned window as `UnknownPrepare`, and
+/// once the persist heals (backoff ceiling ~1s) the probing backups adopt
+/// this primary and stop escalating, so the 5s election that would rescue
+/// the group never fires -- writes are accepted and never commit again. The
+/// DVC quorum latch does not re-emit on retried DVCs, making the drop
+/// permanent. A short persist hiccup must not be worse than a sustained
+/// outage.
+///
+/// Partition-plane callers must route BOTH halves through BOTH dispatchers:
+/// the partition `RebuildPipeline` executes in
+/// `dispatch_partition_journal_actions` (its journal lives on the
+/// partition), while `dispatch_vsr_actions` runs it only for the metadata
+/// plane -- locals sent to one dispatcher alone silently skip the rebuild.
+fn split_local_actions(actions: Vec<VsrAction>) -> (Vec<VsrAction>, Vec<VsrAction>) {
+    actions.into_iter().partition(|action| {
+        matches!(
+            action,
+            VsrAction::RebuildPipeline { .. } | VsrAction::CommitJournal
+        )
+    })
+}
+
 /// Routing verdict of [`IggyShard::park_if_unmaterialised`].
 enum ParkOutcome<H> {
     /// Namespace is materialised (or the frame is not a partition op):
@@ -1835,9 +2207,10 @@
 
 /// Local message processing — these methods handle messages that have been
 /// routed to this shard via the message pump.
-impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
 where
     B: MessageBus,
+    SB: SuperblockStore,
 {
     /// Dispatch an incoming network message to the appropriate consensus plane.
     ///
@@ -1855,7 +2228,7 @@
     #[allow(clippy::future_not_send)]
     pub async fn on_message(&self, message: Message<GenericHeader>)
     where
-        B: MessageBus,
+        B: MessageBus + 'static,
         MJ: JournalHandle,
         <MJ as JournalHandle>::Target: Journal<
                 <MJ as JournalHandle>::Storage,
@@ -1917,8 +2290,16 @@
                             let planes = self.plane.inner();
                             let config = planes.1.0.config();
                             let namespace = IggyNamespace::from_raw(routing.1);
+                            // Same transfer gate as the view-change walks: a
+                            // walk during a transfer can advance commit_min
+                            // past the incoming frontier and trip the
+                            // install's StaleTransfer refusal after the full
+                            // pull. This is the highest-frequency walk (one
+                            // per replicated prepare), so it needs the gate
+                            // most.
                             if let Some(partition) = planes.1.0.get_mut_by_ns(&namespace)
                                 && partition.consensus().is_follower()
+                                && !partition.consensus().is_transferring()
                             {
                                 partition.commit_journal(config).await;
                             }
@@ -2756,9 +3137,25 @@
     /// `-p iggy-server-ng` build excludes the `simulator` feature and this
     /// method; `cargo build --workspace` compiles it in but with no
     /// production caller.
+    /// `superblock` is this group's durable `(view, log_view)` store. Passing
+    /// `None` keeps the storeless branch, where the persist gate marks every view
+    /// durable without writing anything -- fine for specs that never restart a
+    /// replica, but it means the gate itself, its write-failure fence, and view
+    /// recovery are all unexercised. A caller that hands one in (the simulator,
+    /// which retains the store across a replica rebuild) gets the production
+    /// contract: a recorded view is restored before the group joins, and a failed
+    /// write withholds every view-scoped send.
+    ///
+    /// `recovered_state` is that store's last record, read by the caller (the
+    /// store's read is async and this is not), mirroring how `new_shard` takes the
+    /// metadata plane's.
     #[cfg(any(test, feature = "simulator"))]
-    pub fn init_partition(&self, namespace: IggyNamespace)
-    where
+    pub fn init_partition(
+        &self,
+        namespace: IggyNamespace,
+        superblock: Option<Rc<SB>>,
+        recovered_state: Option<consensus::VsrState>,
+    ) where
         B: MessageBus + Clone,
     {
         let partitions = self.plane.partitions();
@@ -2766,7 +3163,7 @@
             return;
         }
 
-        let consensus = VsrConsensus::with_clock(
+        let mut consensus = VsrConsensus::with_clock(
             self.partition_consensus.cluster_id,
             self.partition_consensus.self_replica_id,
             self.partition_consensus.replica_count,
@@ -2775,15 +3172,31 @@
             LocalPipeline::new(),
             self.partition_consensus.clock.clone(),
         );
+        // Recorded view first, exactly as the two boot paths order it: restoring
+        // after `init` would advertise a view older than the recorded one.
+        if let Some(state) = recovered_state.as_ref() {
+            consensus.set_view(state.view);
+            consensus.set_log_view(state.log_view);
+            consensus.mark_superblock_durable(state.view, state.log_view);
+        }
         consensus.init();
 
         let stats = Arc::new(PartitionStats::default());
-        let partition = IggyPartition::with_in_memory_storage(
+        let mut partition = IggyPartition::with_in_memory_storage(
             stats,
             consensus,
             partitions.config().segment_size,
             partitions.config().enforce_fsync,
         );
+        if let Some(superblock) = superblock {
+            partition.set_superblock(superblock, recovered_state.as_ref());
+        }
+        // The SAME call the boot paths make, not a copy of it: this restore is
+        // a max against what the segments already proved, and a harness running
+        // a divergent copy of that rule cannot catch a violation of it. Without
+        // the restore at all, a simulator replica rebuilt against a retained
+        // store resumes minting at 0 while its group is at N.
+        partition.restore_offset_frontier(recovered_state.as_ref());
         partitions.insert(namespace, partition);
     }
 
@@ -2796,12 +3209,12 @@
     #[allow(clippy::mut_from_ref)]
     fn resolve_partition_target<'a>(
         &self,
-        partitions: &'a IggyPartitions<B>,
+        partitions: &'a IggyPartitions<B, SB>,
         namespace: u64,
         view: u32,
         replica: u8,
         frame: &'static str,
-    ) -> Option<&'a mut IggyPartition<B>>
+    ) -> Option<&'a mut IggyPartition<B, SB>>
     where
         B: MessageBus,
     {
@@ -2845,8 +3258,10 @@
             && consensus.namespace() == header.namespace
         {
             let actions = consensus.handle_start_view_change(PlaneKind::Metadata, &header);
+            let (local_actions, wire_actions) = split_local_actions(actions);
+            dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
             if planes.0.persist_superblock_if_needed(consensus).await {
-                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
             }
             return;
         }
@@ -2862,8 +3277,15 @@
         };
         let consensus = partition.consensus();
         let actions = consensus.handle_start_view_change(PlaneKind::Partitions, &header);
-        dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
-        dispatch_partition_journal_actions(consensus, partition, &actions).await;
+        let (local_actions, wire_actions) = split_local_actions(actions);
+        // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+        // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+        // and `CommitJournal` is a no-op in both.
+        dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+        if partition.persist_superblock_if_needed().await {
+            dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+            dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+        }
     }
 
     #[allow(clippy::future_not_send)]
@@ -2885,12 +3307,14 @@
             && consensus.namespace() == header.namespace
         {
             let actions = consensus.handle_do_view_change(PlaneKind::Metadata, &header);
+            let (local_actions, wire_actions) = split_local_actions(actions);
+            dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
             if planes.0.persist_superblock_if_needed(consensus).await {
-                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
             }
             // Same transfer gate as `on_start_view` and `on_commit`: the
             // pre-install STM must not walk while a transfer is in flight.
-            if actions
+            if local_actions
                 .iter()
                 .any(|action| matches!(action, VsrAction::CommitJournal))
                 && !consensus.is_transferring()
@@ -2912,17 +3336,29 @@
         };
         let consensus = partition.consensus();
         let actions = consensus.handle_do_view_change(PlaneKind::Partitions, &header);
-        dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
-        dispatch_partition_journal_actions(consensus, partition, &actions).await;
-        if actions
+        let (local_actions, wire_actions) = split_local_actions(actions);
+        // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+        // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+        // and `CommitJournal` is a no-op in both.
+        dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+        if partition.persist_superblock_if_needed().await {
+            dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+            dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+        }
+        // Outside the gate: the persist fences the SEND, not the local commit
+        // walk (state a crash forgets is state no peer ever saw). Same
+        // transfer gate as the metadata arm: no walk while transferring.
+        if local_actions
             .iter()
             .any(|action| matches!(action, VsrAction::CommitJournal))
+            && !partition.consensus().is_transferring()
         {
             partition.commit_journal(config).await;
         }
     }
 
     #[allow(clippy::future_not_send)]
+    #[allow(clippy::too_many_lines)]
     async fn on_start_view(&self, msg: Message<StartViewHeader>)
     where
         B: MessageBus,
@@ -2947,8 +3383,10 @@
             // makes emptiness the adoption signal -- and the arms below must
             // not fire on a StartView this replica did not adopt.
             let adopted = !actions.is_empty();
+            let (local_actions, wire_actions) = split_local_actions(actions);
+            dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
             if planes.0.persist_superblock_if_needed(consensus).await {
-                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
             }
             // State transfer (rejoin behind the peers' retained floor): the
             // adopted view names a live primary to fetch snapshot-shaped state
@@ -2997,7 +3435,7 @@
             // committed stay journaled-but-unapplied forever, because the
             // follow-up heartbeats see commit_max already advanced and skip
             // their own commit_journal.
-            if actions
+            if local_actions
                 .iter()
                 .any(|action| matches!(action, VsrAction::CommitJournal))
             {
@@ -3013,6 +3451,21 @@
         }
 
         let config = planes.1.0.config();
+        // Counted BEFORE the `&mut partition` below exists: the scan takes
+        // shared borrows of every partition (see `arm_partition_transfer`).
+        // Gated on the arm actually being possible, so a stale or misdirected
+        // frame -- and every StartView for a group that is not awaiting a
+        // transfer, which is all of them during an ordinary view change -- does
+        // not pay a node-wide scan. (A shard-level counter would remove the scan
+        // entirely, but `IggyPartition::transfer` is `pub` and cleared inside the
+        // partitions crate, so an externally maintained count would drift; that
+        // refactor is a prerequisite, not a detail.)
+        let transfers_inflight = if Self::may_arm_partition_transfer(&planes.1.0, header.namespace)
+        {
+            self.partition_transfers_inflight()
+        } else {
+            0
+        };
         let Some(partition) = self.resolve_partition_target(
             &planes.1.0,
             header.namespace,
@@ -3024,9 +3477,49 @@
         };
         let consensus = partition.consensus();
         let actions = consensus.handle_start_view(PlaneKind::Partitions, &header);
-        dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
-        dispatch_partition_journal_actions(consensus, partition, &actions).await;
-        if actions
+        let adopted = !actions.is_empty();
+        let (local_actions, wire_actions) = split_local_actions(actions);
+        // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+        // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+        // and `CommitJournal` is a no-op in both.
+        dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+        if partition.persist_superblock_if_needed().await {
+            dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+            dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+        }
+        // Gate on actual adoption: a rejected StartView returns no actions,
+        // and re-arming on one would re-mint the nonce and drop an in-flight
+        // descriptor.
+        if adopted
+            && partition.consensus().state_transfer_stage()
+                == consensus::StateTransferStage::AwaitingTarget
+        {
+            tracing::info!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                peer = header.replica,
+                "adopted a live view while awaiting transfer; requesting partition state transfer"
+            );
+            // The announcing replica becomes `session.peer`, which the re-arm
+            // path feeds to `next_transfer_peer`'s ring arithmetic, so an id
+            // outside the cluster must not get that far.
+            if self.peer_is_known(header.replica, "StartView") {
+                let _ = self
+                    .arm_partition_transfer(partition, header.replica, transfers_inflight)
+                    .await;
+            }
+            return;
+        }
+        // A commit walk during Fetching can advance commit_min past the
+        // incoming frontier (or trip the install's anti-rewind refusal), so
+        // gate on the whole transfer, not one stage.
+        if partition.consensus().is_transferring() {
+            return;
+        }
+        // Outside the gate: the persist fences the SEND, not the local commit
+        // walk or the repair fetch below (a fetch asks to LEARN, it does not
+        // advertise this replica's view).
+        if local_actions
             .iter()
             .any(|action| matches!(action, VsrAction::CommitJournal))
         {
@@ -3034,36 +3527,11 @@
         }
         // Same gap-fill as the metadata arm: a journal-less rejoiner that
         // adopted the new view still lacks the window's entries; repair from
-        // the announcing primary, floor settled by its RangeEvicted.
-        let consensus = partition.consensus();
-        if consensus.is_normal()
-            && consensus.commit_min() < consensus.commit_max()
-            && partition.repair.is_none()
-        {
-            let nonce = iggy_common::random_id::get_uuid();
-            let to_op = consensus.commit_max();
-            let from_op = consensus.commit_min() + 1;
-            let cluster = consensus.cluster();
-            let self_id = consensus.replica();
-            partition.repair = Some(partitions::RepairSession {
-                nonce,
-                to_op,
-                floor: None,
-                peer: header.replica,
-                first_batch_offset: None,
-                idle_ticks: 0,
-            });
-            self.send_request_prepares(
-                cluster,
-                self_id,
-                header.replica,
-                nonce,
-                from_op,
-                to_op,
-                header.namespace,
-            )
+        // the announcing primary, floor settled by its RangeEvicted. The shared
+        // helper carries one guard more than this site needs (`is_transferring`,
+        // already covered by the early return above) and logs the arm.
+        self.maybe_request_partition_repair(partition, header.replica)
             .await;
-        }
     }
 
     #[allow(clippy::future_not_send)]
@@ -3139,12 +3607,28 @@
         };
         let consensus = partition.consensus();
         match consensus.handle_commit(&header) {
-            CommitOutcome::Advanced => partition.commit_journal(config).await,
+            CommitOutcome::Advanced => {
+                if !partition.consensus().is_transferring() {
+                    partition.commit_journal(config).await;
+                    // Same-view late-joiner backstop: a lagging backup drops
+                    // out-of-order prepares silently and StartView adoption
+                    // is otherwise the only repair-arming site, so without
+                    // this a same-view gap wedges until a view change. If
+                    // the primary compacted past the gap, repair answers
+                    // RangeEvicted and the refusal path converts to
+                    // transfer.
+                    self.maybe_request_partition_repair(partition, header.replica)
+                        .await;
+                }
+            }
             CommitOutcome::RespondStartView => {
-                // Partition consensus is not superblock-durable yet, so there is no
-                // view to persist before answering here; the metadata arm above
-                // gates its StartView on the durable view.
-                respond_start_view::<B, _, MJ>(consensus).await;
+                // Durable-before-send, as the metadata arm above: the StartView
+                // advertises this replica's current view. Withhold on failure;
+                // the stale peer keeps heartbeating, so it re-triggers once a
+                // later persist succeeds.
+                if partition.persist_superblock_if_needed().await {
+                    respond_start_view::<B, _, MJ>(consensus).await;
+                }
             }
             CommitOutcome::Accepted => {}
         }
@@ -3171,8 +3655,10 @@
             && consensus.namespace() == header.namespace
         {
             let actions = consensus.handle_request_start_view(PlaneKind::Metadata, &header);
+            let (local_actions, wire_actions) = split_local_actions(actions);
+            dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &local_actions).await;
             if planes.0.persist_superblock_if_needed(consensus).await {
-                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &actions).await;
+                dispatch_vsr_actions(consensus, planes.0.journal.as_ref(), &wire_actions).await;
             }
             return;
         }
@@ -3185,7 +3671,19 @@
         };
         let consensus = partition.consensus();
         let actions = consensus.handle_request_start_view(PlaneKind::Partitions, &header);
-        dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
+        let (local_actions, wire_actions) = split_local_actions(actions);
+        // Locals go to the partition dispatcher ONLY: `RebuildPipeline`
+        // executes there (`dispatch_vsr_actions` bails on `journal: None`)
+        // and `CommitJournal` is a no-op in both.
+        dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+        // Wire to BOTH, like every other partition site: the journal
+        // dispatcher owns SendPrepareOk and the debug durable-before-send
+        // tripwire, and skipping it would drop both silently the day this
+        // handler emits one.
+        if partition.persist_superblock_if_needed().await {
+            dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+            dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+        }
     }
 
     /// Serve a repair range from this replica's journal: stream
@@ -3312,11 +3810,25 @@
         let cluster = partition.consensus().cluster();
         let self_id = partition.consensus().replica();
         let to_op = header.to_op.min(partition.consensus().commit_max());
-        let retained_from = partition.log.journal().inner.repair_retained_from();
+        // `None` means the journal holds NOTHING, not "nothing was evicted":
+        // the partition journal is memory-only and `clear_all` wipes the
+        // evicted ring with it, so a freshly installed or freshly restarted
+        // peer answers `None` for every op it once had. Reading that as "no
+        // eviction" served a bare `RepairDone`, left the requester's floor at
+        // `None`, and `FloorRefused` -- the ONLY route that arms a partition
+        // state transfer -- never fired: a lagging replica on an idle
+        // partition spun repair forever against a peer-sticky retry. An empty
+        // journal instead reports eviction from the commit frontier, which
+        // refuses the floor into a transfer (the empty window passes the
+        // completeness check) and heals in one round.
+        let retained_from = partition
+            .log
+            .journal()
+            .inner
+            .repair_retained_from()
+            .unwrap_or_else(|| partition.consensus().commit_min().saturating_add(1));
         let mut from_op = header.from_op;
-        if let Some(retained_from) = retained_from
-            && retained_from > from_op
-        {
+        if retained_from > from_op {
             self.send_repair_range_reply(
                 cluster,
                 self_id,
@@ -3393,8 +3905,24 @@
         });
         let header = *msg.header();
         let planes = self.plane.inner();
+        // Legacy acceptance: pre-upgrade metadata WAL entries were journaled
+        // before prepares stamped `consensus.namespace()`, and repair ships
+        // stored bytes verbatim, so without it a mixed-version metadata repair
+        // re-ships the same 0-stamped entries forever.
+        //
+        // Keyed on the OPERATION, not on whether partition 0/0/0 exists: raw
+        // namespace 0 is `IggyNamespace::new(0, 0, 0)` and ids slab-allocate
+        // from 0, so 0/0/0 is the first partition every cluster creates -- on a
+        // single-shard node a "no partition 0 materialised" conjunct goes false
+        // the moment one topic exists and disables this migration exactly where
+        // it is needed. `is_metadata_plane` is the plane's OWN applicability
+        // predicate (the session ops `Register`/`Logout` replicate here without
+        // being metadata mutations, so `is_metadata` alone is too narrow), which
+        // is why both sites share it rather than re-deriving the set.
+        let metadata_plane_op = header.operation.is_metadata_plane();
+        let legacy_metadata_claim = header.namespace == 0 && metadata_plane_op;
         if let Some(ref consensus) = planes.0.consensus
-            && consensus.namespace() == header.namespace
+            && (consensus.namespace() == header.namespace || legacy_metadata_claim)
         {
             let session = *self.metadata_repair.borrow();
             let Some(session) = session else {
@@ -3435,6 +3963,25 @@
             consensus.set_last_prepare_checksum(header.checksum);
             return;
         }
+        // A metadata-plane op that did not match above (no metadata consensus on
+        // this shard, or a namespace neither plane claims) is DROPPED, never
+        // offered to the partition arm. Falling through let a metadata prepare
+        // reach `apply_repaired_prepare`: it journals nothing, but it resets the
+        // partition repair session's idle ticks (masking a genuine stall) and
+        // carries the metadata prepare's checksum into the partition consensus
+        // via `set_last_prepare_checksum` -- inert only while prepare checksums
+        // are structurally zero, and a cross-plane parent stamp the moment the
+        // checksum chain is activated (see the note in `consensus::impls`).
+        if metadata_plane_op {
+            tracing::debug!(
+                shard = self.id,
+                op = header.op,
+                operation = ?header.operation,
+                namespace_raw = header.namespace,
+                "dropping a metadata-plane repair prepare this shard cannot journal"
+            );
+            return;
+        }
         let Some(partition) = planes
             .1
             .0
@@ -3553,6 +4100,15 @@
             }
             return;
         }
+        // Counted BEFORE the `&mut partition` below exists, and only when an arm
+        // is possible at all: see the StartView site for why the scan is gated
+        // rather than replaced with a counter.
+        let transfers_inflight = if Self::may_arm_partition_transfer(&planes.1.0, header.namespace)
+        {
+            self.partition_transfers_inflight()
+        } else {
+            0
+        };
         let config = planes.1.0.config().clone();
         let Some(partition) = planes
             .1
@@ -3581,7 +4137,57 @@
                 // the next chunk is pulled immediately; a stalled window is
                 // left to the retry timer.
                 let before = partition.consensus().commit_min();
-                partition.complete_repair(&config).await;
+                if let partitions::RepairConclusion::FloorRefused { floor, to_op } =
+                    partition.complete_repair(&config).await
+                {
+                    if partition.consensus().state_transfer_stage()
+                        == consensus::StateTransferStage::Idle
+                        && partition.transfer_rearm.is_none()
+                    {
+                        // Repair proved the gap below the floor is neither
+                        // locally durable nor repairable: the one authoritative
+                        // "repair is impossible" signal. Arm from Idle only; a
+                        // transfer already in flight owns the stage, and its own
+                        // post-install tail repair can re-raise through this
+                        // path, each round lifting the floor, so it converges.
+                        // A pending scheduled re-arm owns recovery likewise --
+                        // arming here would defeat its backoff.
+                        tracing::info!(
+                            shard = self.id,
+                            namespace_raw = header.namespace,
+                            floor,
+                            to_op,
+                            peer = header.replica,
+                            attempts = partition.transfer_attempts(),
+                            "partition repair floor unreachable; converting to state transfer"
+                        );
+                        // Same reason as the StartView arm: this id becomes
+                        // `session.peer` and later reaches the peer rotation.
+                        if self.peer_is_known(header.replica, "RepairRangeReply") {
+                            partition.consensus().begin_state_transfer_await();
+                            let _ = self
+                                .arm_partition_transfer(
+                                    partition,
+                                    header.replica,
+                                    transfers_inflight,
+                                )
+                                .await;
+                        }
+                    } else {
+                        // The refusal cleared the repair session, so falling
+                        // through would log "repair complete" right after
+                        // the refusal diagnostic. The in-flight transfer (or
+                        // the scheduled re-arm) owns recovery from here.
+                        tracing::info!(
+                            shard = self.id,
+                            namespace_raw = header.namespace,
+                            floor,
+                            to_op,
+                            "partition repair floor refused; transfer in flight or scheduled"
+                        );
+                    }
+                    return;
+                }
                 if partition.repair.is_none() {
                     tracing::info!(
                         shard = self.id,
@@ -3807,11 +4413,13 @@
         target: u8,
         nonce: u128,
         namespace: u64,
-        offer: Option<&metadata::StateTransferOffer>,
+        descriptor: TransferDescriptor<'_>,
     ) where
         B: MessageBus,
     {
-        let manifest = offer.map(|offer| consensus::encode_state_manifest(&offer.manifest()));
+        let manifest = descriptor
+            .offer
+            .map(|(entries, _)| consensus::encode_state_manifest(entries));
         let total_size =
             size_of::<StateTransferTargetHeader>() + manifest.as_ref().map_or(0, Vec::len);
         let mut msg = Message::<StateTransferTargetHeader>::new(total_size);
@@ -3825,9 +4433,15 @@
             h.nonce = nonce;
             h.namespace = namespace;
             h.size = total_size as u32;
-            if let Some(offer) = offer {
+            // The serving replica's own progress travels with every descriptor,
+            // available or not: it is what lets a receiver refuse an offer from
+            // a replica that knows less than it does.
+            h.view = descriptor.view;
+            h.commit_max = descriptor.commit_max;
+            h.unavailable_transient = u8::from(descriptor.transient);
+            if let Some((_, commit_op)) = descriptor.offer {
                 h.available = 1;
-                h.commit_op = offer.commit_op;
+                h.commit_op = commit_op;
             }
         });
         let _ = self
@@ -3874,7 +4488,7 @@
 
     /// Serve one `RequestStateTransfer`: build a fresh offer (or refuse),
     /// cache it for the chunk pulls, and answer with the descriptor.
-    #[allow(clippy::future_not_send)]
+    #[allow(clippy::future_not_send, clippy::too_many_lines)]
     async fn on_request_state_transfer(&self, msg: &Message<RequestStateTransferHeader>)
     where
         B: MessageBus,
@@ -3888,12 +4502,17 @@
     {
         let header = *msg.header();
         let planes = self.plane.inner();
+        let metadata_frame = planes
+            .0
+            .consensus
+            .as_ref()
+            .is_some_and(|consensus| consensus.namespace() == header.namespace);
+        if !metadata_frame {
+            return self.on_partition_request_state_transfer(msg).await;
+        }
         let Some(ref consensus) = planes.0.consensus else {
             return;
         };
-        if consensus.namespace() != header.namespace {
-            return;
-        }
         let cluster = consensus.cluster();
         let self_id = consensus.replica();
 
@@ -3905,17 +4524,20 @@
         // and it re-requests an empty tail forever. Re-answering with the SAME
         // offer is also what makes the retry idempotent.
         let cached = self
-            .metadata_transfer_offers
+            .state_transfer_offers
             .borrow_mut()
-            .get_mut(&header.replica)
+            .get_mut(&(header.namespace, header.replica))
             .filter(|served| served.nonce == header.nonce)
-            .map(|served| {
+            .and_then(|served| {
+                let ServedOffer::Metadata(offer) = &served.offer else {
+                    return None;
+                };
                 // A descriptor retry proves the requester is alive and still
                 // wants THIS offer, so it counts as liveness: without the reset
                 // the offer could age out mid-retry and the rebuild that
                 // replaced it is exactly what first-wins exists to prevent.
                 served.idle_ticks = 0;
-                Rc::clone(&served.offer)
+                Some(Rc::clone(offer))
             });
         if let Some(offer) = cached {
             tracing::debug!(
@@ -3929,7 +4551,12 @@
                 header.replica,
                 header.nonce,
                 header.namespace,
-                Some(&offer),
+                TransferDescriptor::available(
+                    &offer.manifest(),
+                    offer.commit_op,
+                    consensus.view(),
+                    consensus.commit_max(),
+                ),
             )
             .await;
             return;
@@ -3952,14 +4579,19 @@
                     header.replica,
                     header.nonce,
                     header.namespace,
-                    Some(&offer),
+                    TransferDescriptor::available(
+                        &offer.manifest(),
+                        offer.commit_op,
+                        consensus.view(),
+                        consensus.commit_max(),
+                    ),
                 )
                 .await;
-                self.metadata_transfer_offers.borrow_mut().insert(
-                    header.replica,
+                self.state_transfer_offers.borrow_mut().insert(
+                    (header.namespace, header.replica),
                     ServedStateTransfer {
                         nonce: header.nonce,
-                        offer,
+                        offer: ServedOffer::Metadata(offer),
                         idle_ticks: 0,
                         fully_served: false,
                     },
@@ -3982,7 +4614,11 @@
                     header.replica,
                     header.nonce,
                     header.namespace,
-                    None,
+                    TransferDescriptor::unavailable(
+                        false,
+                        consensus.view(),
+                        consensus.commit_max(),
+                    ),
                 )
                 .await;
             }
@@ -3994,7 +4630,8 @@
     #[allow(clippy::future_not_send, clippy::too_many_lines)]
     async fn on_state_transfer_target(&self, msg: &Message<StateTransferTargetHeader>)
     where
-        B: MessageBus,
+        B: MessageBus + 'static,
+        T: ShardsTable,
         MJ: JournalHandle,
         <MJ as JournalHandle>::Target: Journal<
                 <MJ as JournalHandle>::Storage,
@@ -4017,12 +4654,17 @@
 
         let header = *msg.header();
         let planes = self.plane.inner();
+        let metadata_frame = planes
+            .0
+            .consensus
+            .as_ref()
+            .is_some_and(|consensus| consensus.namespace() == header.namespace);
+        if !metadata_frame {
+            return self.on_partition_state_transfer_target(msg).await;
+        }
         let Some(ref consensus) = planes.0.consensus else {
             return;
         };
-        if consensus.namespace() != header.namespace {
-            return;
-        }
         let session_matches = self
             .metadata_transfer
             .borrow()
@@ -4139,7 +4781,7 @@
             {
                 session.artifacts = manifest
                     .iter()
-                    .map(|&entry| ArtifactProgress {
+                    .map(|&entry| consensus::ArtifactProgress {
                         entry,
                         buf: Vec::with_capacity(entry.len as usize),
                     })
@@ -4183,17 +4825,9 @@
                 if !session.target_accepted {
                     return None;
                 }
-                let (index, artifact) = session
-                    .artifacts
-                    .iter()
-                    .enumerate()
-                    .find(|(_, artifact)| !artifact.complete())?;
-                let offset = artifact.buf.len() as u64;
-                let remaining = artifact.entry.len - offset;
-                #[allow(clippy::cast_possible_truncation)]
-                let len = remaining.min(chunk_len_max) as u32;
-                #[allow(clippy::cast_possible_truncation)]
-                Some((session.nonce, session.peer, index as u32, offset, len))
+                consensus::next_pending_chunk(&session.artifacts, chunk_len_max).map(
+                    |(artifact, offset, len)| (session.nonce, session.peer, artifact, offset, len),
+                )
             })
         };
         if let Some((nonce, peer, artifact, offset, len)) = request {
@@ -4229,6 +4863,9 @@
             nonce,
             peer,
             commit_op: 0,
+            // Set when a descriptor is accepted; a session with no accepted
+            // descriptor never reaches the install path that reads it.
+            generation: 0,
             artifacts: Vec::new(),
             target_accepted: false,
             idle_ticks: 0,
@@ -4308,12 +4945,17 @@
     {
         let header = *msg.header();
         let planes = self.plane.inner();
+        let metadata_frame = planes
+            .0
+            .consensus
+            .as_ref()
+            .is_some_and(|consensus| consensus.namespace() == header.namespace);
+        if !metadata_frame {
+            return self.on_partition_request_state_chunk(msg).await;
+        }
         let Some(ref consensus) = planes.0.consensus else {
             return;
         };
-        if consensus.namespace() != header.namespace {
-            return;
-        }
         let cluster = consensus.cluster();
         let self_id = consensus.replica();
 
@@ -4327,60 +4969,66 @@
         // RefCell borrow must not cross an await on the shard).
         // Out-of-bounds requests are dropped silently inside the block.
         let reply = {
-            let mut offers = self.metadata_transfer_offers.borrow_mut();
+            let mut offers = self.state_transfer_offers.borrow_mut();
             let served = offers
-                .get_mut(&header.replica)
+                .get_mut(&(header.namespace, header.replica))
                 .filter(|served| served.nonce == header.nonce);
-            served.map_or(Some(ChunkReply::UnknownOffer), |served| {
-                // Manifest-index addressing: an index past the offer is a
-                // requester bug (or a stale frame) and is dropped below.
-                let last_artifact = served.offer.len().saturating_sub(1);
-                let artifact_bytes = served.offer.payload(header.artifact as usize)?;
-                let start = header.offset as usize;
-                // A request AT the end of an artifact has nothing left to serve.
-                // Answering it with `Some(&[])` -- which `get(len..len)` happily
-                // returns -- would extend nothing on the receiver, reset both
-                // sides' idle counters, and be re-requested at the same offset
-                // forever: an unbounded empty-frame ping-pong with the rejoining
-                // replica withholding `PrepareOk` for the life of the process.
-                // Reachable when a rebuilt offer is SHORTER than the manifest the
-                // receiver accepted (a client logged out between the two builds).
-                if start >= artifact_bytes.len() {
-                    return None;
-                }
-                let end = start
-                    .saturating_add((header.len as usize).min(chunk_len_max))
-                    .min(artifact_bytes.len());
-                let payload = artifact_bytes.get(start..end)?;
-                // Only now that bytes are actually going out: an out-of-bounds or
-                // stale frame must not flip a live offer onto the short expiry.
-                // Tail of the final artifact means the receiver holds everything
-                // the manifest promised, so the offer only has to outlive a
-                // possible re-request of this very chunk.
-                if header.artifact as usize == last_artifact && end >= artifact_bytes.len() {
-                    served.fully_served = true;
-                }
-                // Serving a chunk is the only liveness signal the offer gets;
-                // the expiry sweep drops it once these stop arriving. Set here
-                // rather than on entry so a request that serves NOTHING cannot
-                // keep an abandoned offer alive.
-                served.idle_ticks = 0;
-                let total_size = size_of::<StateChunkHeader>() + payload.len();
-                let mut chunk = Message::<StateChunkHeader>::new(total_size);
-                chunk.as_mut_slice()[size_of::<StateChunkHeader>()..].copy_from_slice(payload);
-                Some(ChunkReply::Chunk(chunk.transmute_header(
-                    |_, h: &mut StateChunkHeader| {
-                        h.command = Command2::StateChunk;
-                        h.cluster = cluster;
-                        h.replica = self_id;
-                        h.nonce = header.nonce;
-                        h.namespace = header.namespace;
-                        h.artifact = header.artifact;
-                        h.offset = header.offset;
-                        h.size = total_size as u32;
-                    },
-                )))
-            })
+            served.map_or(
+                Some(ChunkReply::Unavailable { transient: true }),
+                |served| {
+                    let ServedOffer::Metadata(offer) = &served.offer else {
+                        return Some(ChunkReply::Unavailable { transient: true });
+                    };
+                    // Manifest-index addressing: an index past the offer is a
+                    // requester bug (or a stale frame) and is dropped below.
+                    let last_artifact = offer.len().saturating_sub(1);
+                    let artifact_bytes = offer.payload(header.artifact as usize)?;
+                    let start = header.offset as usize;
+                    // A request AT the end of an artifact has nothing left to serve.
+                    // Answering it with `Some(&[])` -- which `get(len..len)` happily
+                    // returns -- would extend nothing on the receiver, reset both
+                    // sides' idle counters, and be re-requested at the same offset
+                    // forever: an unbounded empty-frame ping-pong with the rejoining
+                    // replica withholding `PrepareOk` for the life of the process.
+                    // Reachable when a rebuilt offer is SHORTER than the manifest the
+                    // receiver accepted (a client logged out between the two builds).
+                    if start >= artifact_bytes.len() {
+                        return None;
+                    }
+                    let end = start
+                        .saturating_add((header.len as usize).min(chunk_len_max))
+                        .min(artifact_bytes.len());
+                    let payload = artifact_bytes.get(start..end)?;
+                    // Only now that bytes are actually going out: an out-of-bounds or
+                    // stale frame must not flip a live offer onto the short expiry.
+                    // Tail of the final artifact means the receiver holds everything
+                    // the manifest promised, so the offer only has to outlive a
+                    // possible re-request of this very chunk.
+                    if header.artifact as usize == last_artifact && end >= artifact_bytes.len() {
+                        served.fully_served = true;
+                    }
+                    // Serving a chunk is the only liveness signal the offer gets;
+                    // the expiry sweep drops it once these stop arriving. Set here
+                    // rather than on entry so a request that serves NOTHING cannot
+                    // keep an abandoned offer alive.
+                    served.idle_ticks = 0;
+                    let total_size = size_of::<StateChunkHeader>() + payload.len();
+                    let mut chunk = Message::<StateChunkHeader>::new(total_size);
+                    chunk.as_mut_slice()[size_of::<StateChunkHeader>()..].copy_from_slice(payload);
+                    Some(ChunkReply::Chunk(chunk.transmute_header(
+                        |_, h: &mut StateChunkHeader| {
+                            h.command = Command2::StateChunk;
+                            h.cluster = cluster;
+                            h.replica = self_id;
+                            h.nonce = header.nonce;
+                            h.namespace = header.namespace;
+                            h.artifact = header.artifact;
+                            h.offset = header.offset;
+                            h.size = total_size as u32;
+                        },
+                    )))
+                },
+            )
         };
         match reply {
             Some(ChunkReply::Chunk(chunk)) => {
@@ -4389,10 +5037,11 @@
                     .send_to_replica(header.replica, chunk.into_generic().into_frozen())
                     .await;
             }
-            Some(ChunkReply::UnknownOffer) => {
+            Some(ChunkReply::Unavailable { transient }) => {
                 tracing::info!(
                     shard = self.id,
                     requester = header.replica,
+                    transient,
                     "state chunk request for an unknown offer; telling requester to restart"
                 );
                 self.send_state_transfer_target(
@@ -4401,7 +5050,11 @@
                     header.replica,
                     header.nonce,
                     header.namespace,
-                    None,
+                    TransferDescriptor::unavailable(
+                        transient,
+                        consensus.view(),
+                        consensus.commit_max(),
+                    ),
                 )
                 .await;
             }
@@ -4422,7 +5075,7 @@
     #[allow(clippy::future_not_send, clippy::too_many_lines)]
     async fn on_state_chunk(&self, msg: &Message<StateChunkHeader>)
     where
-        B: MessageBus,
+        B: MessageBus + 'static,
         MJ: JournalHandle,
         <MJ as JournalHandle>::Target: Journal<
                 <MJ as JournalHandle>::Storage,
@@ -4430,14 +5083,17 @@
                 Header = PrepareHeader,
             >,
         M: RestorableMetadataStm,
+        T: ShardsTable,
     {
         let header = *msg.header();
         let planes = self.plane.inner();
-        let Some(ref consensus) = planes.0.consensus else {
-            return;
-        };
-        if consensus.namespace() != header.namespace {
-            return;
+        let metadata_frame = planes
+            .0
+            .consensus
+            .as_ref()
+            .is_some_and(|consensus| consensus.namespace() == header.namespace);
+        if !metadata_frame {
+            return self.on_partition_state_chunk(msg).await;
         }
 
         {
@@ -4448,34 +5104,17 @@
             if session.nonce != header.nonce || !session.target_accepted {
                 return;
             }
-            let Some(artifact) = session.artifacts.get_mut(header.artifact as usize) else {
-                return;
-            };
             let payload = &msg.as_slice()[size_of::<StateChunkHeader>()..header.size as usize];
-            // Chunks are pulled sequentially with one in flight; anything
-            // else is a duplicate or reorder and is dropped (the stall retry
-            // re-requests from the current frontier).
-            if header.offset != artifact.buf.len() as u64 {
+            // Sequential-offset, overrun, and zero-byte-payload guards live in
+            // the shared session math so both planes keep the exact invariants.
+            if !consensus::append_chunk(
+                &mut session.artifacts,
+                header.artifact,
+                header.offset,
+                payload,
+            ) {
                 return;
             }
-            if artifact.buf.len() as u64 + payload.len() as u64 > artifact.entry.len {
-                tracing::warn!(
-                    shard = self.id,
-                    artifact = header.artifact,
-                    "state chunk overruns the declared artifact length; dropping frame"
-                );
-                return;
-            }
-            // A zero-byte payload is not progress: it extends nothing and the
-            // same offset is re-requested immediately. Resetting the liveness
-            // counters on one is what turned a short rebuilt offer into an
-            // unbounded empty-frame ping-pong. The serving side refuses to
-            // produce these now; the guard stays because a peer running an
-            // older build still can.
-            if payload.is_empty() {
-                return;
-            }
-            artifact.buf.extend_from_slice(payload);
             session.idle_ticks = 0;
         }
         self.note_metadata_transfer_progress();
@@ -4529,9 +5168,10 @@
         let complete = {
             let session = self.metadata_transfer.borrow();
             match session.as_ref() {
-                Some(session) if session.target_accepted => {
-                    session.artifacts.iter().all(ArtifactProgress::complete)
-                }
+                Some(session) if session.target_accepted => session
+                    .artifacts
+                    .iter()
+                    .all(consensus::ArtifactProgress::complete),
                 _ => return,
             }
         };
@@ -4548,6 +5188,12 @@
             .expect("session checked above");
         let peer = session.peer;
         let commit_op = session.commit_op;
+        // From the ACCEPTED descriptor, not re-derived from the artifacts: a
+        // scan that aborts before the snapshot entry (unknown kind first, or a
+        // checksum mismatch ahead of it) would leave nothing to charge, and an
+        // uncharged decode failure re-arms the same peer for the same manifest
+        // forever.
+        let generation = session.generation;
 
         // Per-artifact integrity, then pick the pieces this plane installs.
         // Unknown kinds are refused rather than skipped: an artifact the
@@ -4555,14 +5201,8 @@
         // install would otherwise be silently dropped.
         let mut snapshot: Option<Vec<u8>> = None;
         let mut table: Option<(Vec<u8>, u64)> = None;
-        // Captured before the integrity checks so a damaged pull still knows
-        // which generation to charge the decode budget against.
-        let mut generation: Option<u64> = None;
         let mut damaged = false;
         for (index, artifact) in session.artifacts.into_iter().enumerate() {
-            if artifact.entry.kind == consensus::artifact_kind::METADATA_SNAPSHOT {
-                generation = Some(artifact.entry.frontier);
-            }
             let actual = consensus::state_artifact_checksum(&artifact.buf);
             if actual != artifact.entry.checksum {
                 tracing::error!(
@@ -4631,9 +5271,7 @@
             // failures are charged per snapshot generation instead: a
             // generation past its budget is refused at descriptor time until
             // the peer checkpoints a new one.
-            let exhausted =
-                generation.is_some_and(|generation| self.burn_decode_failure(generation));
-            if exhausted {
+            if self.burn_decode_failure(generation) {
                 tracing::warn!(
                     shard = self.id,
                     peer,
@@ -4714,6 +5352,7 @@
 
     /// Tick partition consensuses. Loop partitions. No partitions-plane journal.
     #[allow(clippy::future_not_send)]
+    #[allow(clippy::too_many_lines)]
     pub async fn tick_partitions(&self)
     where
         B: MessageBus,
@@ -4736,6 +5375,56 @@
         // still pays one Vec per heartbeat.
         let namespaces: Vec<_> = partitions.namespaces().copied().collect();
 
+        // Pre-pass: issue every group's pending superblock persist
+        // CONCURRENTLY. A cluster-wide view change makes every group on
+        // this shard need one in the same tick, and each `atomic_replace`
+        // is a create + write + 2 fsyncs; run serially, a few hundred
+        // groups on ordinary storage exceed the 5s view-change escalation
+        // and loop elections. The persists are independent (each group owns
+        // its store, lock, and failure bookkeeping, all behind `&self`),
+        // and the per-group loop below re-checks the gate on its lock-free
+        // fast path, so gating semantics are unchanged.
+        let pending_persists: Vec<_> = namespaces
+            .iter()
+            .copied()
+            .filter(|namespace| {
+                partitions
+                    .get_by_ns(namespace)
+                    .is_some_and(|partition| partition.consensus().needs_superblock_persist())
+            })
+            .map(|namespace| async move {
+                if let Some(partition) = partitions.get_by_ns(&namespace) {
+                    // The only dropped durability verdict in the tree: this pre-pass
+                    // exists to coalesce the writes, and the per-group loop below re-runs
+                    // the same gate on its lock-free fast path and withholds every
+                    // view-scoped send when it fails, so the verdict here is redundant
+                    // rather than ignored.
+                    let _ = partition.persist_superblock_if_needed().await;
+                }
+            })
+            .collect();
+        // Capped fan-out: each persist is a create + write + 2 fsyncs, and a
+        // node-wide view change over many partitions must not dump an
+        // unbounded fd/fsync burst onto the reactor in one tick.
+        let mut pending_persists = pending_persists.into_iter();
+        loop {
+            let chunk: Vec<_> = pending_persists.by_ref().take(16).collect();
+            if chunk.is_empty() {
+                break;
+            }
+            futures::future::join_all(chunk).await;
+        }
+
+        // Counted at most ONCE per sweep and only if a re-arm actually fires,
+        // then tracked locally as arms land. Counting per namespace is a full
+        // scan per partition, so with per-partition groups the sweep would be
+        // O(P^2) exactly when every group is re-arming at once (node-wide view
+        // change or rejoin) -- and counting eagerly every tick pays that scan on
+        // every quiet tick too, since the re-arm branch is rare. A slot freed
+        // mid-sweep is seen on the next tick, the same latency a capped arm
+        // already accepts.
+        let mut transfers_inflight: Option<usize> = None;
+
         for namespace in namespaces {
             let Some(partition) = partitions.get_by_ns(&namespace) else {
                 continue;
@@ -4743,8 +5432,17 @@
 
             let consensus = partition.consensus();
             let actions = consensus.tick(PlaneKind::Partitions);
-            dispatch_vsr_actions::<B, _, MJ>(consensus, None, &actions).await;
-            dispatch_partition_journal_actions(consensus, partition, &actions).await;
+            // The tick emits view-scoped sends (heartbeats, view-change
+            // retransmits), so it persists first like every dispatch site;
+            // it is also what retries a persist an earlier site withheld on.
+            let (local_actions, wire_actions) = split_local_actions(actions);
+            // Locals to the partition dispatcher only; see the view-change
+            // sites for the rationale.
+            dispatch_partition_journal_actions(consensus, partition, &local_actions).await;
+            if partition.persist_superblock_if_needed().await {
+                dispatch_vsr_actions::<B, _, MJ>(consensus, None, &wire_actions).await;
+                dispatch_partition_journal_actions(consensus, partition, &wire_actions).await;
+            }
 
             // Stall retry: repair frames are fire-and-forget, so a lost
             // frame (or a peer that went silent mid-stream) would leave the
@@ -4800,6 +5498,93 @@
                 )
                 .await;
             }
+
+            // Transfer stall retry: descriptor and chunk frames are
+            // fire-and-forget, so a lost one must not wedge the session (and
+            // the rejoin behind it) forever. Budget-bounded: a peer that died
+            // mid-transfer is abandoned back to journal repair, which
+            // re-targets the current primary.
+            let transfer_stalled = {
+                let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+                    continue;
+                };
+                partition.transfer.as_mut().and_then(|session| {
+                    session.idle_ticks += 1;
+                    if session.idle_ticks < repair_retry_ticks {
+                        return None;
+                    }
+                    session.idle_ticks = 0;
+                    Some((session.peer, session.nonce, session.target_accepted))
+                })
+            };
+            if let Some((peer, nonce, target_accepted)) = transfer_stalled {
+                let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+                    continue;
+                };
+                if partition.burn_transfer_attempt() {
+                    tracing::warn!(
+                        shard = self.id,
+                        namespace_raw = namespace.inner(),
+                        peer,
+                        "partition state transfer stalled past its retry budget; abandoning with a backed-off re-arm"
+                    );
+                    // Staging files are KEPT: a later attempt adopts every
+                    // segment whose manifest entry still matches. The shared
+                    // path charges the failure, rotates the peer, schedules
+                    // the re-arm, and re-arms journal repair meanwhile.
+                    self.abandon_or_rearm_partition_transfer(partition, peer)
+                        .await;
+                } else if target_accepted {
+                    self.request_pending_partition_chunk(namespace.inner())
+                        .await;
+                } else {
+                    self.send_request_state_transfer(partition.consensus(), peer, nonce)
+                        .await;
+                }
+            }
+
+            // Scheduled transfer re-arm: count the backoff down and fire
+            // once nothing else recovered the partition in the meantime (a
+            // live session or a non-Idle stage owns the slot; the pending
+            // entry is then dropped as superseded).
+            let rearm_peer = {
+                let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+                    continue;
+                };
+                match partition.transfer_rearm.as_mut() {
+                    Some(pending) if pending.after_ticks > 0 => {
+                        pending.after_ticks -= 1;
+                        None
+                    }
+                    Some(pending) => {
+                        let peer = pending.peer;
+                        partition.transfer_rearm = None;
+                        if partition.transfer.is_none()
+                            && partition.consensus().state_transfer_stage()
+                                == consensus::StateTransferStage::Idle
+                        {
+                            Some(peer)
+                        } else {
+                            None
+                        }
+                    }
+                    None => None,
+                }
+            };
+            if let Some(peer) = rearm_peer {
+                // Counted here, before the `&mut partition` below exists: the
+                // scan takes shared borrows of every partition.
+                let inflight =
+                    *transfers_inflight.get_or_insert_with(|| self.partition_transfers_inflight());
+                let Some(partition) = partitions.get_mut_by_ns(&namespace) else {
+                    continue;
+                };
+                partition.consensus().begin_state_transfer_await();
+                let armed = self.arm_partition_transfer(partition, peer, inflight).await;
+                if armed {
+                    transfers_inflight = Some(inflight + 1);
+                }
+            }
         }
     }
 
@@ -4836,23 +5621,1597 @@
         }
     }
 
+    /// Whether this shard may build a partition offer for `namespace` without
+    /// pushing the served-payload working set past its byte budget.
+    ///
+    /// Counts DISTINCT groups rather than requesters: the payload cache is
+    /// content-addressed, so every requester pulling one group's offer shares
+    /// one resident copy, and it is the group count that decides how many
+    /// segments must be resident at once. A group already being served always
+    /// passes, so admission cannot revoke a transfer midway.
+    ///
+    /// BOTH inputs are the configured ones. Dividing by the compile-time
+    /// segment ceiling instead of the deployed `system.segment.size` would make
+    /// the numerator the only thing an operator controls: on a 64 MiB-segment
+    /// deployment the same budget holds sixteen times as many payloads as a cap
+    /// derived from the 1 GiB ceiling would admit, and rejoins serialise for no
+    /// reason.
+    ///
+    /// The divisor is the size a SEALED segment actually reaches, not the
+    /// configured target: rotation fires after the append that crosses it, so a
+    /// sealed segment runs up to one maximum batch past `segment.size`. Dividing
+    /// by the bare target says two payloads fit a two-target budget when they do
+    /// not, and `ServedSegmentCache::insert` then evicts one per chunk -- the
+    /// thrash this cap exists to prevent, reintroduced through the arithmetic.
+    ///
+    /// That size is `partition_artifact_len_max`, which the config validator
+    /// floors at `segment.size` plus the CONFIGURED `message_bus.max_message_size`.
+    /// The compile-time [`SEGMENT_SIZE_OVERSHOOT_BYTES`] only tracks the shipped
+    /// bus cap, so using it would restore the same thrash on any deployment that
+    /// raised that knob: the sealed segment grows with the bus cap while the
+    /// divisor would not. It is kept as a floor for the case where an operator
+    /// sets the artifact ceiling below what a segment can reach.
+    ///
+    /// At least one is always admitted, since refusing every rejoin is worse
+    /// than re-reading for a single one; the quotient rather than the divisor
+    /// carries that clamp, so a zero segment size fails CLOSED at one slot
+    /// instead of disabling admission control.
+    fn partition_transfer_admission_cap(&self) -> usize {
+        let segment_size = self.plane.partitions().config().segment_size.as_bytes_u64();
+        let resident_len = self
+            .partition_artifact_len_max
+            .get()
+            .max(segment_size.saturating_add(SEGMENT_SIZE_OVERSHOOT_BYTES));
+        let slots = self
+            .served_segment_cache_bytes_max
+            .get()
+            .checked_div(resident_len)
+            .unwrap_or(1);
+        usize::try_from(slots).unwrap_or(usize::MAX).max(1)
+    }
+
+    fn may_serve_another_partition_transfer(&self, namespace: u64) -> bool {
+        let builds = self.partition_offer_builds.borrow();
+        if builds.contains_key(&namespace) {
+            return true;
+        }
+        let offers = self.state_transfer_offers.borrow();
+        let mut served: Vec<u64> = offers
+            .iter()
+            .filter(|(_, served)| matches!(served.offer, ServedOffer::Partition(_)))
+            .map(|((offer_namespace, _), _)| *offer_namespace)
+            .collect();
+        if served.contains(&namespace) {
+            return true;
+        }
+        // Builds count too. A multi-round checksum pass holds no offer yet, so
+        // counting only completed offers admitted every requester's whole
+        // in-flight set at once and let each run its own pass: the frame bodies
+        // stay bounded, but the pump carries N budgets per round-cycle and
+        // every other frame, produce included, queues behind them.
+        served.extend(builds.keys().copied());
+        served.sort_unstable();
+        served.dedup();
+        served.len() < self.partition_transfer_admission_cap()
+    }
+
+    /// Serve one partition `RequestStateTransfer`: build (or re-serve) this
+    /// group's offer and answer with the descriptor.
+    #[allow(clippy::future_not_send, clippy::too_many_lines)]
+    async fn on_partition_request_state_transfer(&self, msg: &Message<RequestStateTransferHeader>)
+    where
+        B: MessageBus,
+    {
+        let header = *msg.header();
+        // Before anything keyed by the requester: the offer map's documented
+        // bound is the replica count, and an unvalidated id makes it 256 entries
+        // per served group, each pinning an offer for its full expiry.
+        if !self.peer_is_known(header.replica, "RequestStateTransfer") {
+            return;
+        }
+        let planes = self.plane.inner();
+        let config = planes.1.0.config().clone();
+        let Some(partition) = planes
+            .1
+            .0
+            .get_mut_by_ns(&IggyNamespace::from_raw(header.namespace))
+        else {
+            return;
+        };
+        let cluster = partition.consensus().cluster();
+        let self_id = partition.consensus().replica();
+        // First-wins per (requester, nonce), exactly as the metadata arm: a
+        // stall retry reuses the nonce, and rebuilding under it could hand
+        // the receiver chunks from a different offer than the manifest it
+        // accepted. Re-answering with the SAME offer keeps the retry
+        // idempotent.
+        let cached = self
+            .state_transfer_offers
+            .borrow_mut()
+            .get_mut(&(header.namespace, header.replica))
+            .filter(|served| served.nonce == header.nonce)
+            .and_then(|served| {
+                let ServedOffer::Partition(offer) = &served.offer else {
+                    return None;
+                };
+                served.idle_ticks = 0;
+                Some(Rc::clone(offer))
+            });
+
+        // One resolve, one send: the three outcomes differ only in the
+        // descriptor they produce, and duplicating the send made it possible for
+        // them to drift on the progress they advertise.
+        let offer = match cached {
+            Some(offer) => {
+                tracing::debug!(
+                    shard = self.id,
+                    namespace_raw = header.namespace,
+                    requester = header.replica,
+                    "re-answering a partition state transfer request from the offer \
+                     already served"
+                );
+                Some(offer)
+            }
+            None if !self.may_serve_another_partition_transfer(header.namespace) => {
+                // Admission control, because the served-payload budget is a
+                // BYTE budget and the pulls that overrun it do not degrade
+                // gracefully. Each concurrent pull holds a different segment
+                // resident, so admitting more distinct groups than the budget
+                // has max-size slots makes them evict each other on every
+                // chunk: every request then re-reads and re-hashes a whole
+                // segment to serve one 256 KiB range, and a per-chunk serve
+                // that outruns the requester's stall interval exhausts its
+                // retry budget, so the pull rotates peers and never converges.
+                // Refusing the surplus is what makes the admitted ones finish.
+                tracing::info!(
+                    shard = self.id,
+                    namespace_raw = header.namespace,
+                    requester = header.replica,
+                    "already serving as many partition transfers as the served-payload \
+                     budget holds; refusing until one completes"
+                );
+                let (view, commit_max) = serving_progress(partition);
+                self.send_state_transfer_target(
+                    cluster,
+                    self_id,
+                    header.replica,
+                    header.nonce,
+                    header.namespace,
+                    TransferDescriptor::unavailable(true, view, commit_max),
+                )
+                .await;
+                return;
+            }
+            None => {
+                // Claim the admission slot for the whole build, not just for a
+                // completed offer: the checksum pass runs over several rounds
+                // and holds nothing in the offers map meanwhile.
+                self.partition_offer_builds
+                    .borrow_mut()
+                    .insert(header.namespace, 0);
+                match partition.state_transfer_offer(&config).await {
+                    Ok(offer) => {
+                        self.partition_offer_builds
+                            .borrow_mut()
+                            .remove(&header.namespace);
+                        tracing::info!(
+                            shard = self.id,
+                            namespace_raw = header.namespace,
+                            requester = header.replica,
+                            commit_op = offer.commit_op,
+                            artifacts = offer.artifact_count(),
+                            total_len = offer.total_len(),
+                            "serving partition state transfer"
+                        );
+                        self.state_transfer_offers.borrow_mut().insert(
+                            (header.namespace, header.replica),
+                            ServedStateTransfer {
+                                nonce: header.nonce,
+                                offer: ServedOffer::Partition(Rc::clone(&offer)),
+                                idle_ticks: 0,
+                                fully_served: false,
+                            },
+                        );
+                        Some(offer)
+                    }
+                    Err(reason) => {
+                        // The ACTUAL reason: "not the caught-up primary" is routine
+                        // (the requester re-targets), an unreadable segment is an
+                        // operator-visible fault on THIS node. The requester cannot
+                        // see the reason, only whether it was transient, which is
+                        // what keeps a routine refusal from charging its failure
+                        // count.
+                        //
+                        // The slot survives ONLY a budget-exhausted round, which is
+                        // a build that will resume; every other refusal abandons
+                        // the build and must not keep the group admitted.
+                        let building = matches!(
+                        reason,
+                        partitions::state_transfer::PartitionTransferUnavailable::OfferBuildInProgress { .. }
+                    );
+                        if !building {
+                            self.partition_offer_builds
+                                .borrow_mut()
+                                .remove(&header.namespace);
+                        }
+                        let transient = reason.transient();
+                        tracing::info!(
+                            shard = self.id,
+                            namespace_raw = header.namespace,
+                            requester = header.replica,
+                            transient,
+                            %reason,
+                            "cannot serve partition state transfer; requester falls back"
+                        );
+                        let (view, commit_max) = serving_progress(partition);
+                        self.send_state_transfer_target(
+                            cluster,
+                            self_id,
+                            header.replica,
+                            header.nonce,
+                            header.namespace,
+                            TransferDescriptor::unavailable(transient, view, commit_max),
+                        )
+                        .await;
+                        return;
+                    }
+                }
+            }
+        };
+        let Some(offer) = offer else {
+            return;
+        };
+        // Sampled AFTER any build: that build force-flushes and hashes a
+        // budgeted slice of the un-memoized segments (a first multi-GiB serve
+        // spans several rounds before an offer exists) while reading
+        // `commit_op` post-flush, so a pre-build sample could advertise a
+        // `commit_max` below the descriptor's own `commit_op` -- which only
+        // makes the receiver's gate refuse, and refusals feed a backoff.
+        let (view, commit_max) = serving_progress(partition);
+        self.send_state_transfer_target(
+            cluster,
+            self_id,
+            header.replica,
+            header.nonce,
+            header.namespace,
+            TransferDescriptor::available(&offer.manifest(), offer.commit_op, view, commit_max),
+        )
+        .await;
+    }
+
+    /// Serve one partition chunk. Segment payloads are loaded from disk on
+    /// demand into the shard-wide [`ServedSegmentCache`] (content-addressed,
+    /// so simultaneous rejoiners share one resident copy); a load or
+    /// re-verification failure (GC unlinked the file, bytes changed) evicts
+    /// the offer and tells the requester to restart with a fresh one --
+    /// which then reflects the current segment set, so the retry converges.
+    #[allow(
+        clippy::future_not_send,
+        clippy::cast_possible_truncation,
+        clippy::too_many_lines
+    )]
+    async fn on_partition_request_state_chunk(&self, msg: &Message<RequestStateChunkHeader>)
+    where
+        B: MessageBus,
+    {
+        // Pass 1 inside the borrow decides; a segment artifact that is not
+        // resident exits with its path and is loaded OUTSIDE the borrow (a
+        // RefCell borrow must not be held across an await), then pass 2
+        // stores and serves it.
+        enum ChunkAttempt {
+            Reply(Option<ChunkReply>),
+            Load {
+                log_path: String,
+                entry: consensus::StateArtifact,
+            },
+        }
+
+        let header = *msg.header();
+        // The requester id keys the offer map, whose bound is the replica count.
+        if !self.peer_is_known(header.replica, "RequestStateChunk") {
+            return;
+        }
+        let planes = self.plane.inner();
+        let Some(partition) = planes
+            .1
+            .0
+            .get_by_ns(&IggyNamespace::from_raw(header.namespace))
+        else {
+            return;
+        };
+        let cluster = partition.consensus().cluster();
+        let self_id = partition.consensus().replica();
+        let serving_view = partition.consensus().view();
+        let serving_commit_max = partition.consensus().commit_max();
+        let chunk_len_max = self.state_chunk_len_max();
+        let reply = loop {
+            let attempt = 'attempt: {
+                let mut offers = self.state_transfer_offers.borrow_mut();
+                let served = offers
+                    .get_mut(&(header.namespace, header.replica))
+                    .filter(|served| served.nonce == header.nonce);
+                let Some(served) = served else {
+                    break 'attempt ChunkAttempt::Reply(Some(ChunkReply::Unavailable {
+                        transient: true,
+                    }));
+                };
+                let ServedOffer::Partition(offer) = &served.offer else {
+                    break 'attempt ChunkAttempt::Reply(Some(ChunkReply::Unavailable {
+                        transient: true,
+                    }));
+                };
+                let last_artifact = offer.artifact_count().saturating_sub(1);
+                let artifact = header.artifact as usize;
+                // Keeps a segment payload alive past the cache borrow below.
+                let segment_payload: Rc<Vec<u8>>;
+                let artifact_bytes: &[u8] = match offer.artifact_at(artifact) {
+                    Some(partitions::state_transfer::PartitionArtifactSource::Offsets(bytes)) => {
+                        bytes
+                    }
+                    Some(partitions::state_transfer::PartitionArtifactSource::Segment(source)) => {
+                        match self
+                            .served_segment_cache
+                            .borrow_mut()
+                            .get(header.namespace, source.entry.checksum)
+                        {
+                            Some(payload) => {
+                                segment_payload = payload;
+                                &segment_payload
+                            }
+                            None => {
+                                break 'attempt ChunkAttempt::Load {
+                                    log_path: source.log_path.clone(),
+                                    entry: source.entry,
+                                };
+                            }
+                        }
+                    }
+                    // Index past the manifest: requester bug or stale frame.
+                    None => break 'attempt ChunkAttempt::Reply(None),
+                };
+                let start = header.offset as usize;
+                // `start >= len` is the empty-chunk livelock refusal; see the
+                // metadata arm for the full story.
+                if start >= artifact_bytes.len() {
+                    break 'attempt ChunkAttempt::Reply(None);
+                }
+                let end = start
+                    .saturating_add((header.len as usize).min(chunk_len_max))
+                    .min(artifact_bytes.len());
+                let Some(payload) = artifact_bytes.get(start..end) else {
+                    break 'attempt ChunkAttempt::Reply(None);
+                };
+                if artifact == last_artifact && end >= artifact_bytes.len() && !served.fully_served
+                {
+                    served.fully_served = true;
+                    // Once per transfer, at the last byte of the last artifact.
+                    // The descriptor log only proves a REQUEST arrived; this is
+                    // the serving side's proof that the pull ran to completion.
+                    tracing::info!(
+                        shard = self.id,
+                        namespace_raw = header.namespace,
+                        requester = header.replica,
+                        "partition state transfer fully served"
+                    );
+                }
+                served.idle_ticks = 0;
+                let total_size = size_of::<StateChunkHeader>() + payload.len();
+                let mut chunk = Message::<StateChunkHeader>::new(total_size);
+                chunk.as_mut_slice()[size_of::<StateChunkHeader>()..].copy_from_slice(payload);
+                ChunkAttempt::Reply(Some(ChunkReply::Chunk(chunk.transmute_header(
+                    |_, h: &mut StateChunkHeader| {
+                        h.command = Command2::StateChunk;
+                        h.cluster = cluster;
+                        h.replica = self_id;
+                        h.nonce = header.nonce;
+                        h.namespace = header.namespace;
+                        h.artifact = header.artifact;
+                        h.offset = header.offset;
+                        h.size = total_size as u32;
+                    },
+                ))))
+            };
+            match attempt {
+                ChunkAttempt::Reply(reply) => break reply,
+                ChunkAttempt::Load { log_path, entry } => {
+                    // Chunked read + incremental hash: this runs on the pump to
+                    // answer ONE 256 KiB chunk request, so a whole-file read
+                    // plus a single hash pass over up to a segment would be one
+                    // long uninterruptible CPU+IO block. The chunking keeps the
+                    // REACTOR moving; this shard's consensus ticks are a sibling
+                    // select arm of the same task and stay frozen either way.
+                    let loaded = partitions::state_transfer::load_verified_segment_artifact(
+                        &log_path, &entry,
+                    )
+                    .await;
+                    let reason = match loaded {
+                        Ok(bytes) => {
+                            self.served_segment_cache.borrow_mut().insert(
+                                header.namespace,
+                                entry.checksum,
+                                Rc::new(bytes),
+                                self.served_segment_cache_bytes_max.get(),
+                            );
+                            continue;
+                        }
+                        Err(reason) => reason,
+                    };
+                    // The CAUSE decides what the requester is told: a racing GC
+                    // or a stale offer is transient and costs it nothing, while
+                    // an unreadable device is this node's fault and must charge,
+                    // or a dying disk reads as a momentary blip forever.
+                    let transient = reason.transient();
+                    tracing::warn!(
+                        shard = self.id,
+                        namespace_raw = header.namespace,
+                        artifact = header.artifact,
+                        path = %log_path,
+                        transient,
+                        %reason,
+                        "cannot serve the requested segment; evicting the offer"
+                    );
+                    self.state_transfer_offers
+                        .borrow_mut()
+                        .remove(&(header.namespace, header.replica));
+                    // The builder cache too: it is keyed by commit_op alone,
+                    // and GC unlinks files WITHOUT a commit, so the restarted
+                    // requester would otherwise be handed the same offer with
+                    // the same dead path, forever.
+                    partition.clear_state_transfer_offer_cache();
+                    break Some(ChunkReply::Unavailable { transient });
+                }
+            }
+        };
+        match reply {
+            Some(ChunkReply::Chunk(chunk)) => {
+                let _ = self
+                    .bus
+                    .send_to_replica(header.replica, chunk.into_generic().into_frozen())
+                    .await;
+            }
+            Some(ChunkReply::Unavailable { transient }) => {
+                tracing::info!(
+                    shard = self.id,
+                    namespace_raw = header.namespace,
+                    requester = header.replica,
+                    transient,
+                    "partition chunk request for an unknown offer; telling requester to restart"
+                );
+                self.send_state_transfer_target(
+                    cluster,
+                    self_id,
+                    header.replica,
+                    header.nonce,
+                    header.namespace,
+                    // Usually TRANSIENT -- retention GC'd a served segment, or
+                    // the offer aged out between two chunks, and the restarted
+                    // session converges -- but a load that failed on a local
+                    // fault says so, or a dying disk would read as a momentary
+                    // blip forever.
+                    TransferDescriptor::unavailable(transient, serving_view, serving_commit_max),
+                )
+                .await;
+            }
+            None => {
+                tracing::warn!(
+                    shard = self.id,
+                    namespace_raw = header.namespace,
+                    requester = header.replica,
+                    artifact = header.artifact,
+                    offset = header.offset,
+                    "partition chunk request out of artifact bounds; ignoring"
+                );
+            }
+        }
+    }
+
+    /// Sanity cap across a partition manifest. Segment artifacts spill to
+    /// disk as they complete, so this bounds corruption, not memory.
+    const PARTITION_TRANSFER_TOTAL_LEN_MAX: u64 = 1 << 40;
+
+    /// Alloc cap for the `CONSUMER_OFFSETS` artifact, which accumulates whole
+    /// in `ArtifactProgress::buf` before decode can reject it. Its decoder
+    /// ceilings imply ~24 MiB (two sections of 2^20 12-byte entries); this
+    /// leaves headroom without letting a hostile manifest stage gigabytes.
+    const CONSUMER_OFFSETS_ARTIFACT_LEN_MAX: u64 = 32 << 20;
+
+    /// Concurrent partition transfers this shard will run as a RECEIVER. A
+    /// whole-node rejoin arms one per lagging partition; unbounded, the sum
+    /// of in-flight buffers and staging writes is partitions x segment
+    /// size. Capped-out arms retry via the scheduled re-arm sweep.
+    const PARTITION_TRANSFERS_INFLIGHT_MAX: usize = 4;
+
+    /// Whether arming a transfer for `namespace` is even possible right now.
+    ///
+    /// Takes a SHARED borrow and drops it before returning, so a caller may form
+    /// its `&mut partition` afterwards. The point is to keep the in-flight scan
+    /// -- which borrows every partition on the shard -- off frames that cannot
+    /// arm anything: a namespace this shard does not own, and the ordinary case
+    /// of a group that is neither awaiting a transfer nor idle-with-no-re-arm.
+    fn may_arm_partition_transfer(partitions: &IggyPartitions<B, SB>, namespace_raw: u64) -> bool
+    where
+        B: MessageBus,
+    {
+        partitions
+            .get_by_ns(&IggyNamespace::from_raw(namespace_raw))
+            .is_some_and(|partition| {
+                partition.transfer.is_none()
+                    && matches!(
+                        partition.consensus().state_transfer_stage(),
+                        consensus::StateTransferStage::AwaitingTarget
+                            | consensus::StateTransferStage::Idle
+                    )
+            })
+    }
+
+    /// Receiving-side transfers currently in flight on this shard.
+    ///
+    /// One scan per call, so callers hoist it: with per-partition groups a
+    /// per-namespace call inside the tick sweep is O(P^2) exactly during a
+    /// node-wide view change or rejoin, and capped arms reschedule on the flat
+    /// retry interval, so the losers stay phase-locked and the sweep repeats
+    /// every interval for the whole rejoin.
+    fn partition_transfers_inflight(&self) -> usize {
+        let partitions = self.plane.partitions();
+        let namespaces: Vec<_> = partitions.namespaces().copied().collect();
+        namespaces
+            .iter()
+            .filter(|namespace| {
+                partitions
+                    .get_by_ns(namespace)
+                    .is_some_and(|partition| partition.transfer.is_some())
+            })
+            .count()
+    }
+
+    /// Drop every trace of `namespace`'s current bytes from the serving side:
+    /// the partition's own offer cache, this shard's cached offers, and the
+    /// resident payloads behind them.
+    ///
+    /// Called wherever a partition's segments stop being the bytes an offer
+    /// describes -- retention cleaning, a committed truncate, a purge. None of
+    /// the caches can detect that themselves: the builder cache is keyed on
+    /// `commit_op` (which a metadata-plane truncate never moves), the shard's
+    /// offers on the requester, and the payloads on a checksum over the bytes
+    /// that just went away -- so a puller mid-transfer keeps receiving deleted
+    /// data and keeps both expiry clocks reset while doing it.
+    pub(crate) fn drop_partition_transfer_state(
+        &self,
+        namespace: IggyNamespace,
+        partition: &IggyPartition<B, SB>,
+    ) where
+        B: MessageBus,
+    {
+        partition.clear_state_transfer_offer_cache();
+        self.drop_served_state_for(namespace.inner());
+    }
+
+    fn drop_served_state_for(&self, namespace: u64) {
+        // Including the build slot: the bytes a partial checksum pass covered are
+        // gone with the chain, so the slot behind it is no longer resumable
+        // work and must stop counting against other namespaces' admission.
+        self.partition_offer_builds.borrow_mut().remove(&namespace);
+        self.state_transfer_offers
+            .borrow_mut()
+            .retain(|(served_namespace, _), _| *served_namespace != namespace);
+        self.served_segment_cache
+            .borrow_mut()
+            .evict_namespace(namespace);
+    }
+
+    /// Whether a peer-supplied source replica id names a replica of this
+    /// cluster.
+    ///
+    /// `header.replica` arrives unvalidated on every frame, and the partition
+    /// transfer paths turn it into ring arithmetic ([`next_transfer_peer`], where
+    /// id 255 panics in debug and wraps to replica 0 in release -- a silent
+    /// retarget) and into the served-offer map key, whose documented bound is the
+    /// replica count rather than 256 entries per served group. One check at the
+    /// frame's ingress closes both.
+    fn peer_is_known(&self, replica: u8, frame: &'static str) -> bool {
+        let replica_count = self.partition_consensus.replica_count;
+        if replica < replica_count {
+            return true;
+        }
+        tracing::warn!(
+            shard = self.id,
+            frame,
+            replica,
+            replica_count,
+            "dropping a partition frame whose source replica is outside this cluster"
+        );
+        false
+    }
+
+    /// Fence one partition for rebuild: quarantine its segment files, drop it
+    /// from routing, and queue the retirement the reconciler re-materialises
+    /// from committed metadata.
+    ///
+    /// Used wherever a partition is left without a serviceable segment chain (a
+    /// failed state-transfer install whose convergence also failed, a purge that
+    /// could not plant its replacement segment): the next append or poll would
+    /// panic on `active_segment()`'s expect.
+    ///
+    /// `IggyPartitions` mandates external removals go through `ConfirmRemove` --
+    /// a direct `remove()` would invalidate the `&mut` the caller still holds --
+    /// but the tombstone and the routing row drop SYNCHRONOUSLY here, because
+    /// the tombstone is the only gate in `get_mut_by_ns` and the queue does not
+    /// drain until the end of the pump iteration.
+    ///
+    /// `intended_frontier` is the offset frontier the caller knows the group is
+    /// at, for the paths where the LIVE counter is not it. A failed install
+    /// under an advancing purge generation leaves the counter at the pre-purge
+    /// value while the group restarted its offset space lower, and the
+    /// advancing write would stamp that stale counter over the reset the
+    /// install just made, then quarantine the segments that would have
+    /// contradicted it. `None` where the counter is authoritative.
+    #[allow(clippy::future_not_send)]
+    async fn fence_partition_for_rebuild(
+        &self,
+        namespace: IggyNamespace,
+        partition: &IggyPartition<B, SB>,
+        intended_frontier: Option<u64>,
+    ) where
+        B: MessageBus + 'static,
+        T: ShardsTable,
+    {
+        // BEFORE the quarantine: it moves away the segments that are this
+        // partition's only other witness to the offset frontier, and the
+        // rebuild's sole anchor is then the durable record.
+        // Ungated by the write backoff on purpose: this is a one-shot write
+        // ahead of an irreversible quarantine, not a retry loop, so a skipped
+        // attempt is the last chance gone rather than deferred work.
+        let recorded = partition
+            .record_frontier_before_quarantine(intended_frontier)
+            .await;
+        if !recorded {
+            tracing::error!(
+                shard = self.id,
+                namespace_raw = namespace.inner(),
+                intended_frontier,
+                "could not record the fenced partition's offset frontier before quarantining \
+                 its segments; the rebuild will re-seed from whatever the record still holds"
+            );
+        }
+        match partition.quarantine_partition_dir().await {
+            Ok(Some(fenced_dir)) => tracing::error!(
+                shard = self.id,
+                namespace_raw = namespace.inner(),
+                fenced_dir,
+                "quarantined the fenced partition's segment files; they are kept for \
+                 inspection and never read again"
+            ),
+            Ok(None) => {}
+            Err(error) => {
+                // NO rebuild: `build_partition_fresh` plants segment 0 with
+                // `file_exists = false`, truncating whatever the failed
+                // quarantine left, so a rebuild here eats the chain one segment
+                // per attempt. Tombstone and stop -- the bytes stay for an
+                // operator, and the boot path makes the same call. The
+                // partition stays unreachable until it is dealt with; that is
+                // the intended fence, not a wait.
+                tracing::error!(
+                    shard = self.id,
+                    namespace_raw = namespace.inner(),
+                    %error,
+                    "failed to quarantine the fenced partition's segment files; leaving it \
+                     tombstoned rather than rebuilding over them"
+                );
+                self.plane.partitions().tombstone(namespace);
+                self.shards_table.remove(&namespace);
+                return;
+            }
+        }
+        self.plane.partitions().tombstone(namespace);
+        self.shards_table.remove(&namespace);
+        self.enqueue_reconcile_op(ReconcileOp::ConfirmRemove { namespace });
+        self.signal_reconcile_wake();
+    }
+
+    /// Arm a fresh partition transfer session against `peer` and request its
+    /// descriptor. Every partition arming site goes through here. Drops any
+    /// repair session (transfer supersedes repair; a transfer-unavailable
+    /// fallback re-arms repair fresh) and leaves the stage to its callers (they
+    /// own the `AwaitingTarget` transition).
+    ///
+    /// Refuses past [`Self::PARTITION_TRANSFERS_INFLIGHT_MAX`]: the arm
+    /// converts to a scheduled re-arm (no failure charged -- the local slot
+    /// shortage is not the peer's fault) and the stage returns to Idle so
+    /// journal repair keeps the gap visible meanwhile.
+    ///
+    /// `transfers_inflight` is computed by the caller BEFORE it formed its
+    /// `&mut partition`: the counting scan takes shared borrows of every
+    /// partition, and deriving a sibling `&` to the element the caller's
+    /// protected `&mut` points at is UB under both stacked and tree borrows,
+    /// however innocuous the generated code is today. Returns whether a session
+    /// was armed, so a caller sweeping many groups can carry the count forward
+    /// instead of re-scanning per group.
+    #[allow(clippy::future_not_send)]
+    async fn arm_partition_transfer(
+        &self,
+        partition: &mut IggyPartition<B, SB>,
+        peer: u8,
+        transfers_inflight: usize,
+    ) -> bool
+    where
+        B: MessageBus,
+    {
+        if partition.transfer.is_none()
+            && transfers_inflight >= Self::PARTITION_TRANSFERS_INFLIGHT_MAX
+        {
+            tracing::info!(
+                shard = self.id,
+                namespace_raw = partition.consensus().namespace(),
+                cap = Self::PARTITION_TRANSFERS_INFLIGHT_MAX,
+                "partition transfer slots exhausted; deferring this arm"
+            );
+            let consensus = partition.consensus();
+            if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
+                consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
+            }
+            partition.note_transfer_rearm_scheduled();
+            partition.transfer_rearm = Some(partitions::state_transfer::PendingTransferRearm {
+                peer,
+                after_ticks: self.repair_retry_ticks.get(),
+            });
+            return false;
+        }
+        partition.repair = None;
+        let nonce = iggy_common::random_id::get_uuid();
+        let armed = partition.transfer.is_none();
+        partition.transfer = Some(partitions::state_transfer::PartitionTransferSession {
+            nonce,
+            peer,
+            commit_op: 0,
+            artifacts: Vec::new(),
+            target_accepted: false,
+            idle_ticks: 0,
+        });
+        self.send_request_state_transfer(partition.consensus(), peer, nonce)
+            .await;
+        armed
+    }
+
+    /// Arm partition journal repair when this replica is lagging its group
+    /// and nothing else is recovering it. The idempotence guards mirror
+    /// `maybe_request_metadata_repair`: no-op unless Normal, not
+    /// transferring, behind the frontier, and no session live.
+    #[allow(clippy::future_not_send)]
+    async fn maybe_request_partition_repair(&self, partition: &mut IggyPartition<B, SB>, peer: u8)
+    where
+        B: MessageBus,
+    {
+        let consensus = partition.consensus();
+        if !consensus.is_normal()
+            || consensus.is_transferring()
+            || consensus.commit_min() >= consensus.commit_max()
+            || partition.repair.is_some()
+        {
+            return;
+        }
+        let nonce = iggy_common::random_id::get_uuid();
+        let from_op = consensus.commit_min() + 1;
+        let to_op = consensus.commit_max();
+        let cluster = consensus.cluster();
+        let self_id = consensus.replica();
+        let namespace = consensus.namespace();
+        partition.repair = Some(partitions::RepairSession {
+            nonce,
+            to_op,
+            floor: None,
+            peer,
+            first_batch_offset: None,
+            idle_ticks: 0,
+        });
+        tracing::info!(
+            shard = self.id,
+            namespace_raw = namespace,
+            from_op,
+            to_op,
+            "partition behind the group frontier; requesting repair"
+        );
+        self.send_request_prepares(cluster, self_id, peer, nonce, from_op, to_op, namespace)
+            .await;
+    }
+
+    /// Receiver side of a partition descriptor: accept the manifest, adopt
+    /// any reusable staged segments from an earlier attempt, and start
+    /// pulling, or fall back to journal repair when the peer cannot serve.
+    #[allow(clippy::future_not_send, clippy::too_many_lines)]
+    async fn on_partition_state_transfer_target(&self, msg: &Message<StateTransferTargetHeader>)
+    where
+        B: MessageBus + 'static,
+        T: ShardsTable,
+        M: StreamsFrontend,
+    {
+        let header = *msg.header();
+        // The peer id reaches `next_transfer_peer`'s ring arithmetic through the
+        // re-arm below, so it is validated before anything uses it.
+        if !self.peer_is_known(header.replica, "StateTransferTarget") {
+            return;
+        }
+        let planes = self.plane.inner();
+        let Some(partition) = planes
+            .1
+            .0
+            .get_mut_by_ns(&IggyNamespace::from_raw(header.namespace))
+        else {
+            return;
+        };
+        let session_matches = partition
+            .transfer
+            .as_ref()
+            .is_some_and(|session| session.nonce == header.nonce);
+        if !session_matches {
+            return;
+        }
+        if header.available == 0 {
+            // A refusal the peer marked transient (it is momentarily not the
+            // caught-up primary, which `is_caught_up_primary` makes frequent
+            // under produce load) must not charge the consecutive-failure count:
+            // that count is reset only by a completed install, so ten routine
+            // refusals pin the re-arm backoff at its 1024x ceiling while nothing
+            // else recovers the partition -- repair keeps hitting the refused
+            // floor and will not arm while a re-arm is pending. A hard refusal
+            // (unreadable segment, failed flush) still charges.
+            let transient = header.unavailable_transient == 1;
+            tracing::info!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                peer = header.replica,
+                transient,
+                "partition transfer peer cannot serve; backing off before re-arming"
+            );
+            if transient {
+                // The peer that refused is the node that would otherwise serve,
+                // and on the partition arm only a caught-up primary can. Keep
+                // asking it unless it is not the primary this replica knows: a
+                // rotation spends the next round on a backup that can only
+                // refuse, and the serving side's partial offer-build progress
+                // is memoized per node, so that round advances no hashing.
+                let primary = {
+                    let consensus = partition.consensus();
+                    consensus.primary_index(consensus.view())
+                };
+                self.rearm_partition_transfer_after_refusal(
+                    partition,
+                    header.replica,
+                    header.replica != primary,
+                )
+                .await;
+            } else {
+                self.abandon_or_rearm_partition_transfer(partition, header.replica)
+                    .await;
+            }
+            return;
+        }
+        // The serving replica's own progress, carried by every descriptor: an
+        // offer from a replica that knows LESS than this one does is the phantom
+        // view-0 primary signature (a group whose directory vanished boots
+        // `init()`, comes up Normal at view 0, and an empty log is trivially
+        // caught up). Installing it would unlink a chain this replica already
+        // holds; nonce match alone cannot tell the two apart.
+        let local_view = partition.consensus().view();
+        let local_commit_max = partition.consensus().commit_max();
+        // `commit_op` past the sender's OWN `commit_max` is self-contradictory:
+        // the offer cannot be built past the frontier its builder had. Nothing
+        // downstream bounds it above -- the install only refuses values BELOW
+        // the local floor, and the offsets-artifact cross-check compares two
+        // numbers the same peer chose -- so without this a peer offering
+        // `commit_op = u64::MAX` drives this replica's commit floor, sequencer
+        // and `commit_max` there and it reports itself fully committed.
+        if header.commit_op > header.commit_max {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                peer = header.replica,
+                serving_commit_op = header.commit_op,
+                serving_commit_max = header.commit_max,
+                "refusing a partition transfer offer whose commit_op exceeds the sender's \
+                 own commit frontier"
+            );
+            self.abandon_or_rearm_partition_transfer(partition, header.replica)
+                .await;
+            return;
+        }
+        if header.view < local_view || header.commit_max < local_commit_max {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                peer = header.replica,
+                serving_view = header.view,
+                serving_commit_max = header.commit_max,
+                local_view,
+                local_commit_max,
+                "refusing a partition transfer offer from a replica behind this one"
+            );
+            // ALWAYS rotate: this refusal is evidence about the peer, not about
+            // its timing, so re-asking it is the one thing that cannot help.
+            self.rearm_partition_transfer_after_refusal(partition, header.replica, true)
+                .await;
+            return;
+        }
+        let manifest_bytes =
+            &msg.as_slice()[size_of::<StateTransferTargetHeader>()..header.size as usize];
+        let entries = match consensus::decode_state_manifest(manifest_bytes) {
+            Ok(entries) => entries,
+            Err(error) => {
+                tracing::warn!(
+                    shard = self.id,
+                    namespace_raw = header.namespace,
+                    %error,
+                    "partition transfer manifest rejected"
+                );
+                return;
+            }
+        };
+        // Saturating: 65k entries of hostile lengths must refuse, not
+        // overflow-panic the debug-build sum before the cap check fires.
+        let total_len = entries
+            .iter()
+            .fold(0u64, |total, entry| total.saturating_add(entry.len));
+        // Per-KIND ceilings: only SEGMENT_LOG artifacts spill to disk as
+        // they complete, so anything else accumulates whole in memory and
+        // must be bounded by what its decoder could ever accept, not by the
+        // segment cap. An unknown kind is refused here rather than pulled:
+        // the install cannot represent it anyway.
+        let kind_capped = entries.iter().all(|entry| match entry.kind {
+            consensus::artifact_kind::SEGMENT_LOG => {
+                entry.len <= self.partition_artifact_len_max.get()
+            }
+            consensus::artifact_kind::CONSUMER_OFFSETS => {
+                entry.len <= Self::CONSUMER_OFFSETS_ARTIFACT_LEN_MAX
+            }
+            _ => false,
+        });
+        if !kind_capped || total_len > Self::PARTITION_TRANSFER_TOTAL_LEN_MAX {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                total_len,
+                "partition transfer manifest exceeds artifact caps; refusing descriptor"
+            );
+            return;
+        }
+        if partition
+            .transfer
+            .as_ref()
+            .is_some_and(|session| session.target_accepted)
+        {
+            // A crossed stall-retry descriptor: first-wins already served the
+            // same offer, and re-accepting would discard in-flight progress
+            // and re-run the staging scan for nothing.
+            return;
+        }
+        let reused = partition.reuse_staged_segments(&entries).await;
+        if !reused.is_empty() {
+            tracing::info!(
+                shard = self.id,
+                namespace_raw = header.namespace,
+                peer = header.replica,
+                adopted = reused.len(),
+                artifacts = entries.len(),
+                "adopted staged segments from an earlier transfer attempt"
+            );
+        }
+        // Re-check the nonce AFTER the await: the staging scan yields, and a
+        // session re-minted underneath it must not get stamped with this
+        // (now stale) offer's commit_op and manifest.
+        let Some(session) = partition
+            .transfer
+            .as_mut()
+            .filter(|session| session.nonce == header.nonce)
+        else {
+            return;
+        };
+        session.target_accepted = true;
+        session.commit_op = header.commit_op;
+        // No reservation here: the manifest's total is bounded only by
+        // `PARTITION_TRANSFER_TOTAL_LEN_MAX` (1 TiB), so reserving every
+        // artifact up front is an eager address-space commit of the whole
+        // manifest -- times the in-flight cap -- which turns fatal under strict
+        // overcommit, `RLIMIT_AS`, or cgroup accounting, and contradicts the
+        // session's own promise to bound receiver memory to ONE in-flight
+        // artifact. Artifacts adopted by the reuse scan below would also be
+        // reserved and then overwritten with `Staged`, making the retry path's
+        // reservation pure waste. `append_chunk` reserves the declared length on
+        // an artifact's FIRST chunk instead, and only ever for the artifact the
+        // cursor is actually pulling.
+        session.artifacts = entries
+            .iter()
+            .map(|&entry| {
+                TransferArtifact::Pending(consensus::ArtifactProgress {
+                    entry,
+                    buf: Vec::new(),
+                })
+            })
+            .collect();
+        session.idle_ticks = 0;
+        for (index, meta) in reused {
+            session.artifacts[index as usize] = TransferArtifact::Staged(meta);
+        }
+        let consensus = partition.consensus();
+        if consensus.state_transfer_stage() == consensus::StateTransferStage::AwaitingTarget {
+            consensus.set_state_transfer_stage(consensus::StateTransferStage::Fetching);
+        }
+        self.on_partition_transfer_progress(header.namespace).await;
+    }
+
+    /// Receive one partition chunk; spill a completed segment artifact, and
+    /// on the last artifact verify + install + hand the tail to repair.
+    #[allow(clippy::future_not_send)]
+    async fn on_partition_state_chunk(&self, msg: &Message<StateChunkHeader>)
+    where
+        B: MessageBus + 'static,
+        T: ShardsTable,
+        M: StreamsFrontend,
+    {
+        let header = *msg.header();
+        let planes = self.plane.inner();
+        let Some(partition) = planes
+            .1
+            .0
+            .get_mut_by_ns(&IggyNamespace::from_raw(header.namespace))
+        else {
+            return;
+        };
+        {
+            let Some(session) = partition.transfer.as_mut() else {
+                return;
+            };
+            if session.nonce != header.nonce || !session.target_accepted {
+                return;
+            }
+            // The sender too, not the nonce alone. The other three
+            // partition-transfer handlers all validate theirs; this one
+            // authenticated payload bytes by a 128-bit capability only, which
+            // is thin but real once a peer has seen one frame -- a rotated-away
+            // peer still holds the nonce until the session is re-minted. Its
+            // own `if` because folded into the condition above, clippy's
+            // `suspicious_operation_groupings` reads the operand asymmetry as a
+            // typo and proposes a `header.peer` that does not exist.
+            if session.peer != header.replica {
+                return;
+            }
+            let payload = &msg.as_slice()[size_of::<StateChunkHeader>()..header.size as usize];
+            if !consensus::append_chunk(
+                &mut session.artifacts,
+                header.artifact,
+                header.offset,
+                payload,
+            ) {
+                return;
+            }
+            session.idle_ticks = 0;
+        }
+        partition.note_transfer_progress();
+        self.on_partition_transfer_progress(header.namespace).await;
+    }
+
+    /// Drive an in-flight partition transfer: spill newly completed segment
+    /// artifacts, request the next missing chunk, or -- with everything
+    /// complete -- install and hand the tail to journal repair.
+    #[allow(clippy::future_not_send, clippy::too_many_lines)]
+    async fn on_partition_transfer_progress(&self, namespace: u64)
+    where
+        B: MessageBus + 'static,
+        T: ShardsTable,
+        M: StreamsFrontend,
+    {
+        let planes = self.plane.inner();
+        let config = planes.1.0.config().clone();
+        let target_namespace = IggyNamespace::from_raw(namespace);
+        let Some(partition) = planes.1.0.get_mut_by_ns(&target_namespace) else {
+            return;
+        };
+        // Stage/session desync bail: the probe-exhausted election fallback in
+        // core/consensus clears the stage without being able to reach this
+        // session; completing into an illegal Idle -> Installing transition
+        // would assert. Detect the out-from-under abandon and drop the
+        // session here (staging files are KEPT for reuse).
+        if partition.consensus().state_transfer_stage() != consensus::StateTransferStage::Fetching {
+            if partition.transfer.is_some() {
+                tracing::info!(
+                    shard = self.id,
+                    namespace_raw = namespace,
+                    "partition state transfer was abandoned out from under its session; dropping it"
+                );
+                partition.transfer = None;
+            }
+            return;
+        }
+        let Some(session) = partition.transfer.as_ref() else {
+            return;
+        };
+        if !session.target_accepted {
+            return;
+        }
+
+        // Spill any segment artifact that just completed, freeing its buffer.
+        let spill_candidate = session
+            .artifacts
+            .iter()
+            .enumerate()
+            .find_map(|(index, artifact)| {
+                artifact
+                    .pending()
+                    .is_some_and(|progress| {
+                        progress.entry.kind == consensus::artifact_kind::SEGMENT_LOG
+                            && progress.complete()
+                    })
+                    .then_some(index)
+            });
+        if let Some(index) = spill_candidate {
+            let (entry, bytes, peer, nonce) = {
+                let Some(session) = partition.transfer.as_mut() else {
+                    return;
+                };
+                let Some(progress) = session.artifacts[index].pending_mut() else {
+                    return;
+                };
+                let bytes = std::mem::take(&mut progress.buf);
+                (progress.entry, bytes, session.peer, session.nonce)
+            };
+            match partition.spill_transfer_segment(&entry, bytes).await {
+                Ok(meta) => {
+                    // Nonce re-check across the spill await: a session
+                    // re-minted underneath it has a fresh (possibly empty)
+                    // artifact vec, and the stale index would panic. Pump-
+                    // serial today, but nothing enforces that.
+                    if let Some(session) = partition
+                        .transfer
+                        .as_mut()
+                        .filter(|session| session.nonce == nonce)
+                    {
+                        session.artifacts[index] = TransferArtifact::Staged(meta);
+                    }
+                }
+                Err(reason) => {
+                    tracing::warn!(
+                        shard = self.id,
+                        namespace_raw = namespace,
+                        artifact = index,
+                        %reason,
+                        "partition transfer segment failed validation at spill"
+                    );
+                    self.abandon_or_rearm_partition_transfer(partition, peer)
+                        .await;
+                    return;
+                }
+            }
+            // Tail-call for the next candidate / chunk request.
+            return Box::pin(self.on_partition_transfer_progress(namespace)).await;
+        }
+
+        let Some(session) = partition.transfer.as_ref() else {
+            return;
+        };
+        let all_done = session.artifacts.iter().all(ChunkProgress::complete);
+        if !all_done {
+            self.request_pending_partition_chunk(namespace).await;
+            return;
+        }
+
+        // Everything present: verify + decode the offsets artifact, install.
+        let Some(session) = partition.transfer.take() else {
+            return;
+        };
+        // `commit_op`, NOT a "generation": in this file that word means the
+        // committed PURGE generation, and the callee's parameter is `commit_op`.
+        let commit_op = session.commit_op;
+        let peer = session.peer;
+        let mut offsets_bytes: Option<Vec<u8>> = None;
+        let mut offsets_frontier: Option<u64> = None;
+        let mut damaged = false;
+        let mut staged = Vec::new();
+        for artifact in session.artifacts {
+            let progress = match artifact {
+                TransferArtifact::Staged(meta) => {
+                    staged.push(meta);
+                    continue;
+                }
+                TransferArtifact::Pending(progress) => progress,
+            };
+            match progress.entry.kind {
+                consensus::artifact_kind::CONSUMER_OFFSETS
+                    if consensus::verify_state_artifact(&progress.entry, &progress.buf) =>
+                {
+                    // Exactly one offsets table per manifest; a second one is
+                    // a peer bug and is refused, never last-wins.
+                    if offsets_bytes.is_some() {
+                        damaged = true;
+                    } else {
+                        offsets_frontier = Some(progress.entry.frontier);
+                        offsets_bytes = Some(progress.buf);
+                    }
+                }
+                // An unknown kind is refused, never skipped: skipping would
+                // install a state this build cannot fully represent.
+                _ => damaged = true,
+            }
+        }
+        // Free self-consistency check on a durable input: the builder sets the
+        // descriptor's `commit_op` and the offsets artifact's frontier from ONE
+        // binding, and `commit_op` goes on to drive `set_commit_floor`,
+        // `set_sequence`, `advance_commit_max` and the reported
+        // `applied_commit_op`, while nothing else ever reads that frontier back.
+        if let Some(frontier) = offsets_frontier
+            && frontier != commit_op
+        {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = namespace,
+                commit_op,
+                offsets_frontier = frontier,
+                "descriptor commit_op disagrees with its offsets artifact frontier;                  refusing the install"
+            );
+            damaged = true;
+        }
+        let Some(offsets_bytes) = offsets_bytes.filter(|_| !damaged) else {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = namespace,
+                "partition transfer artifacts failed verification; refusing install"
+            );
+            self.abandon_or_rearm_partition_transfer(partition, peer)
+                .await;
+            return;
+        };
+        // A peer that has NOT yet applied a committed purge offers pre-purge
+        // segments under the stale generation. The install's own generation
+        // handling only widens permission (`max`), so it would resurrect the
+        // purged data durably: the local applied value stays at the newer
+        // generation, and the reconciler's `committed > applied` gate never
+        // re-fires. Compared against the METADATA plane's committed value, not
+        // this partition's applied one -- the latter is memory-only and reads 0
+        // after every restart. Routed through the ordinary failure arm, which
+        // rotates the peer; worst case is one wasted pull.
+        let committed_purge_generation = self
+            .plane
+            .metadata()
+            .mux_stm
+            .streams()
+            .partition_purge_generation(
+                target_namespace.stream_id(),
+                target_namespace.topic_id(),
+                target_namespace.partition_id(),
+            );
+        let offered_purge_generation =
+            partitions::state_transfer::offered_purge_generation(&offsets_bytes);
+        if offered_purge_generation < committed_purge_generation {
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = namespace,
+                peer,
+                offered_purge_generation,
+                committed_purge_generation,
+                "refusing a partition transfer offer built before a committed purge;                  installing it would resurrect purged data"
+            );
+            self.abandon_or_rearm_partition_transfer(partition, peer)
+                .await;
+            return;
+        }
+        partition
+            .consensus()
+            .set_state_transfer_stage(consensus::StateTransferStage::Installing);
+        let outcome = partition
+            .install_state_transfer(
+                &config,
+                commit_op,
+                staged,
+                &offsets_bytes,
+                committed_purge_generation,
+            )
+            .await;
+        partition
+            .consensus()
+            .set_state_transfer_stage(consensus::StateTransferStage::Idle);
+        match outcome {
+            Ok(outcome) => {
+                partition.note_transfer_progress();
+                partition.note_transfer_installed();
+                partition.transfer_rearm = None;
+                if outcome.offsets_written {
+                    tracing::info!(
+                        shard = self.id,
+                        namespace_raw = namespace,
+                        applied_commit_op = outcome.applied_commit_op,
+                        "partition state transfer installed; handing tail to journal repair"
+                    );
+                } else {
+                    // Deliberately NOT prefixed with the success line's text:
+                    // specs match log substrings, and a shared prefix would
+                    // let them pass on the degraded path.
+                    tracing::warn!(
+                        shard = self.id,
+                        namespace_raw = namespace,
+                        applied_commit_op = outcome.applied_commit_op,
+                        "partition state transfer landed WITHOUT fully written consumer \
+                         offsets; the next offset commit rewrites the files"
+                    );
+                }
+                partition.commit_journal(&config).await;
+                self.maybe_request_partition_repair(partition, peer).await;
+            }
+            Err(
+                error @ partitions::state_transfer::PartitionInstallError::ConvergeFailed {
+                    frontier,
+                    ..
+                },
+            ) => {
+                // The partition holds no serviceable segment chain and its
+                // next append or poll would panic the shard. Fence exactly
+                // this group (a failed converge sweep can leave strays that
+                // `build_partition_fresh` would never clear and the boot
+                // contiguity guard would then trip on). The reconciler
+                // re-materialises a fresh partition from committed metadata;
+                // its first repair floor refusal re-arms a transfer, which
+                // re-seeds the offset frontier from the offsets artifact.
+                tracing::error!(
+                    shard = self.id,
+                    namespace_raw = namespace,
+                    %error,
+                    "partition unserviceable after failed install; fencing it for rebuild"
+                );
+                // Served state first, as the purge fence does: the quarantine
+                // below moves the chain those offers and cached payloads
+                // describe into `.fenced.N`, and a requester holding one would
+                // otherwise pull bytes that no longer exist.
+                self.drop_partition_transfer_state(IggyNamespace::from_raw(namespace), partition);
+                self.fence_partition_for_rebuild(
+                    IggyNamespace::from_raw(namespace),
+                    partition,
+                    Some(frontier),
+                )
+                .await;
+            }
+            Err(error) => {
+                tracing::error!(
+                    shard = self.id,
+                    namespace_raw = namespace,
+                    %error,
+                    "partition state transfer install failed; falling back to journal repair"
+                );
+                self.abandon_or_rearm_partition_transfer(partition, peer)
+                    .await;
+            }
+        }
+    }
+
+    /// Charge one transfer failure and schedule a backed-off re-arm against
+    /// the NEXT peer in the ring. Immediate same-peer retries were a
+    /// failure amplifier: a deterministic local failure (ENOSPC, an
+    /// undecodable artifact) re-ran the full pull -- including the serving
+    /// primary's whole-segment reads -- at network round-trip rate, and the
+    /// generation-keyed budget never exhausted on a committing cluster.
+    /// Journal repair is re-armed in the meantime so the gap stays visible
+    /// and anything repairable heals without waiting out the backoff.
+    #[allow(clippy::future_not_send)]
+    async fn abandon_or_rearm_partition_transfer(
+        &self,
+        partition: &mut IggyPartition<B, SB>,
+        peer: u8,
+    ) where
+        B: MessageBus,
+    {
+        let failures = partition.record_transfer_failure();
+        let after_ticks = transfer_rearm_backoff(self.repair_retry_ticks.get(), failures);
+        self.schedule_partition_transfer_rearm(partition, peer, failures, after_ticks, true)
+            .await;
+    }
+
+    /// Re-arm after a refusal the serving peer marked TRANSIENT: schedule the
+    /// next attempt on a flat interval and charge nothing.
+    ///
+    /// "The peer is momentarily not the caught-up primary" is the common case
+    /// under produce load, and `transfer_failures` is reset only by a completed
+    /// install, so charging it turns a transient into a stall measured in re-arm
+    /// ceilings: nothing else recovers the partition meanwhile, since repair
+    /// keeps hitting the refused floor and will not arm while a re-arm is
+    /// pending.
+    ///
+    /// `rotate` belongs to the CALLER because the two refusal sites mean
+    /// opposite things by it. A peer saying "not right now" is the node that
+    /// would otherwise serve, so staying on it is right. This replica refusing
+    /// a descriptor from a peer that knows LESS than it does is the one case
+    /// where the peer is provably the wrong one, and rotating is the whole
+    /// remedy: a restarted primary comes back at `commit_max = 0` (the
+    /// partition journal is memory-only), so a rejoining backup would otherwise
+    /// pin itself to it at a flat interval until the group's next election.
+    #[allow(clippy::future_not_send)]
+    async fn rearm_partition_transfer_after_refusal(
+        &self,
+        partition: &mut IggyPartition<B, SB>,
+        peer: u8,
+        rotate: bool,
+    ) where
+        B: MessageBus,
+    {
+        // Deliberately NOT `transfer_rearm_backoff`: a flat interval, so a peer
+        // that spends a minute catching up costs a minute of retries rather than
+        // a climb to the 1024x ceiling.
+        let after_ticks = self.repair_retry_ticks.get();
+        // The flat interval means a partition can sit here for hours without
+        // charging anything, so the ONLY operator signal is this count: it
+        // escalates the log level and feeds a metric, and it never touches the
+        // backoff.
+        let refusals = partition.record_transfer_refusal();
+        self.metrics.record_partition_transfer_refusal();
+        if refusals >= TRANSFER_REFUSALS_BEFORE_ESCALATION
+            && refusals.is_multiple_of(TRANSFER_REFUSALS_BEFORE_ESCALATION)
+        {
+            // Deliberately not phrased as "not rejoining": a serving primary
+            // building a large offer refuses one round per budget slice, so a
+            // healthy multi-GiB rejoin reaches this count while progressing
+            // normally. The descriptor carries no reason code, so this side
+            // cannot tell the two apart; the serving node's own logs can.
+            tracing::warn!(
+                shard = self.id,
+                namespace_raw = partition.consensus().namespace(),
+                peer,
+                refusals,
+                "partition state transfer has been refused {refusals} times in a row; the peer \
+                 may be building a large offer or rate-limiting concurrent transfers, or it may \
+                 be unable to serve at all -- check its logs before intervening"
+            );
+        }
+        self.schedule_partition_transfer_rearm(partition, peer, 0, after_ticks, rotate)
+            .await;
+    }
+
+    /// Drop the session, pick the next peer, and schedule the re-arm; shared by
+    /// the charged and uncharged paths.
+    ///
+    /// `rotate` is false where the refusing peer is the only one that could
+    /// have served: only a caught-up primary passes `is_caught_up_primary`, so
+    /// rotating off it asks a backup that can answer nothing but another
+    /// refusal, and the serving side's partial offer-build progress is memoized
+    /// PER NODE, so the round spent on the backup also advances no hashing.
+    #[allow(clippy::future_not_send)]
+    async fn schedule_partition_transfer_rearm(
+        &self,
+        partition: &mut IggyPartition<B, SB>,
+        peer: u8,
+        failures: u32,
+        after_ticks: u32,
+        rotate: bool,
+    ) where
+        B: MessageBus,
+    {
+        partition.transfer = None;
+        let consensus = partition.consensus();
+        if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
+            consensus.set_state_transfer_stage(consensus::StateTransferStage::Idle);
+        }
+        let next_peer = if rotate {
+            next_transfer_peer(
+                consensus.replica(),
+                peer,
+                consensus.replica_count(),
+                consensus.primary_index(consensus.view()),
+            )
+        } else {
+            peer
+        };
+        tracing::info!(
+            shard = self.id,
+            namespace_raw = partition.consensus().namespace(),
+            failures,
+            next_peer,
+            after_ticks,
+            "partition transfer did not land; scheduling a re-arm"
+        );
+        // The stall budget belongs to ONE attempt: carried across, an exhausted
+        // count left every later session a single retry-interval window to land
+        // its first response, against a backoff climbing to 1024x. Livelock
+        // across attempts is bounded by `transfer_failures` and that backoff.
+        partition.note_transfer_rearm_scheduled();
+        partition.transfer_rearm = Some(partitions::state_transfer::PendingTransferRearm {
+            peer: next_peer,
+            after_ticks,
+        });
+        let config = self.plane.partitions().config().clone();
+        partition.commit_journal(&config).await;
+        self.maybe_request_partition_repair(partition, peer).await;
+    }
+
+    /// Ask for the next missing partition chunk (first unspilled, incomplete
+    /// artifact in manifest order).
+    ///
+    /// LOCKSTEP by design: one chunk in flight, re-driven per reply, so transfer
+    /// throughput is `state_chunk_len_max / RTT` -- roughly 26 MB/s at a 10 ms
+    /// link, about 41 s for a 1 GiB segment. `state_chunk_len_max` only clamps
+    /// downward, so no operator knob raises that ceiling; it is worth knowing
+    /// when sizing `segment.size` and retention, since rejoin time scales with
+    /// retained bytes per partition. A small in-flight window would lift it, but
+    /// it has to grow `[partition] transfer_served_cache_bytes_max` in step -- that
+    /// budget is sized for exactly the concurrent lockstep pulls the in-flight
+    /// cap allows.
+    #[allow(clippy::future_not_send)]
+    async fn request_pending_partition_chunk(&self, namespace: u64)
+    where
+        B: MessageBus,
+    {
+        let planes = self.plane.inner();
+        let chunk_len_max = self.state_chunk_len_max() as u64;
+        let Some(partition) = planes
+            .1
+            .0
+            .get_mut_by_ns(&IggyNamespace::from_raw(namespace))
+        else {
+            return;
+        };
+        let request = partition.transfer.as_ref().and_then(|session| {
+            if !session.target_accepted {
+                return None;
+            }
+            let (index, offset, len) =
+                consensus::next_pending_chunk(&session.artifacts, chunk_len_max)?;
+            Some((session.nonce, session.peer, index, offset, len))
+        });
+        let consensus_ids = {
+            let consensus = partition.consensus();
+            (consensus.cluster(), consensus.replica())
+        };
+        if let Some((nonce, peer, artifact, offset, len)) = request {
+            self.send_request_state_chunk(
+                consensus_ids.0,
+                consensus_ids.1,
+                peer,
+                nonce,
+                namespace,
+                artifact,
+                offset,
+                len,
+            )
+            .await;
+        }
+    }
+
     /// Drop serving-side state-transfer offers that stopped being pulled.
     ///
-    /// Each offer owns a whole snapshot plus the encoded client table, and the
-    /// protocol has no completion frame (a receiver installs and goes quiet), so
-    /// without this a primary that ever served a transfer pins that memory for
-    /// the rest of the process. Generous relative to the chunk cadence: a live
-    /// puller resets the counter on every chunk it fetches, so only an abandoned
-    /// or finished transfer ages out.
+    /// An offer pins its plane's payload for as long as it lives -- the metadata
+    /// snapshot plus the encoded client table, or a partition manifest and the
+    /// resident segment payloads behind it -- and the protocol has no completion
+    /// frame (a receiver installs and goes quiet), so without this a primary
+    /// that ever served a transfer holds that memory for the rest of the
+    /// process. Generous relative to the chunk cadence: a live puller resets the
+    /// counter on every chunk it fetches, so only an abandoned or finished
+    /// transfer ages out.
     fn expire_idle_state_transfer_offers(&self) {
+        // Same clock the offers below age on: `retry_ticks * MULTIPLE` ticks,
+        // and this sweep runs once per tick.
+        let payload_idle_sweeps = u64::from(self.repair_retry_ticks.get().max(1))
+            * u64::from(STATE_TRANSFER_OFFER_EXPIRY_MULTIPLE);
+        self.served_segment_cache
+            .borrow_mut()
+            .expire_idle(payload_idle_sweeps);
         // `max(1)`: the retry interval is operator-configurable, and a zero would
         // make the expiry zero, dropping every offer on the tick after it was
         // built and breaking transfers outright.
         let retry_ticks = self.repair_retry_ticks.get().max(1);
         let idle_expiry_ticks = retry_ticks.saturating_mul(STATE_TRANSFER_OFFER_EXPIRY_MULTIPLE);
         let served_expiry_ticks = retry_ticks.saturating_mul(STATE_TRANSFER_SERVED_EXPIRY_MULTIPLE);
-        let mut offers = self.metadata_transfer_offers.borrow_mut();
-        offers.retain(|requester, served| {
+        // A build slot is released by the round that completes the offer, so a
+        // requester that walked away mid-build would otherwise hold admission
+        // forever. Same idle window as an abandoned offer.
+        self.partition_offer_builds
+            .borrow_mut()
+            .retain(|namespace, idle_ticks| {
+                *idle_ticks += 1;
+                let live = *idle_ticks < idle_expiry_ticks;
+                if !live {
+                    tracing::debug!(
+                        shard = self.id,
+                        namespace_raw = namespace,
+                        "dropping an abandoned partition offer build slot"
+                    );
+                }
+                live
+            });
+        let mut offers = self.state_transfer_offers.borrow_mut();
+        let namespaces_before: Vec<u64> = offers.keys().map(|(namespace, _)| *namespace).collect();
+        offers.retain(|(namespace, requester), served| {
             served.idle_ticks += 1;
             // A fully-served offer only has to outlive a re-request of its last
             // chunk, so it goes on the short clock; anything else is an
@@ -4866,6 +7225,7 @@
             if !live {
                 tracing::debug!(
                     shard = self.id,
+                    namespace_raw = namespace,
                     requester,
                     fully_served = served.fully_served,
                     "dropping a state-transfer offer"
@@ -4873,10 +7233,32 @@
             }
             live
         });
-        // Nobody is pulling: release the cached snapshot copy too, rather than
-        // pinning it for the life of the process.
-        if offers.is_empty() {
-            self.plane.metadata().clear_state_transfer_offer_cache();
+        // Nobody is pulling from the metadata plane: release its cached
+        // snapshot copy too, rather than pinning it for the life of the
+        // process. Runs on every shard, but only shard 0 ever populates the
+        // metadata cache, so it is a no-op elsewhere.
+        let metadata = self.plane.metadata();
+        let metadata_served = metadata.consensus.as_ref().is_some_and(|consensus| {
+            offers
+                .keys()
+                .any(|(namespace, _)| *namespace == consensus.namespace())
+        });
+        if !metadata_served {
+            metadata.clear_state_transfer_offer_cache();
+        }
+        // Partition offer caches: release each namespace whose LAST offer
+        // just aged out, so a served-once partition does not pin its offer
+        // (manifest + offsets table) for the process lifetime.
+        let mut vanished = namespaces_before;
+        vanished.retain(|namespace| !offers.keys().any(|(live, _)| live == namespace));
+        vanished.sort_unstable();
+        vanished.dedup();
+        drop(offers);
+        let partitions = self.plane.partitions();
+        for namespace in vanished {
+            if let Some(partition) = partitions.get_by_ns(&IggyNamespace::from_raw(namespace)) {
+                partition.clear_state_transfer_offer_cache();
+            }
         }
     }
 
@@ -4906,8 +7288,10 @@
 
         let actions = consensus.tick(PlaneKind::Metadata);
 
+        let (local_actions, wire_actions) = split_local_actions(actions);
+        dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &local_actions).await;
         if metadata.persist_superblock_if_needed(consensus).await {
-            dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &actions).await;
+            dispatch_vsr_actions(consensus, metadata.journal.as_ref(), &wire_actions).await;
         }
 
         // Repair a lost primary self-ack: `RetransmitPrepares` to self is a
@@ -4925,8 +7309,6 @@
         // nothing is stranded.
         metadata.resume_stranded_commits().await;
 
-        self.expire_idle_state_transfer_offers();
-
         // Stall retry for an in-flight state transfer: descriptor or chunk
         // frames are fire-and-forget, so a lost one must not wedge the
         // session (and the boot flow behind it) forever.
@@ -4955,8 +7337,7 @@
                     shard = self.id,
                     peer,
                     attempts,
-                    "metadata state transfer stalled past its retry budget; \
-                     abandoning and falling back to journal repair"
+                    "metadata state transfer stalled past its retry budget; abandoning and falling back to journal repair"
                 );
                 *self.metadata_transfer.borrow_mut() = None;
                 if consensus.state_transfer_stage() != consensus::StateTransferStage::Idle {
@@ -5125,36 +7506,35 @@
     // Centralized durable-before-send tripwire: a view-scoped message must never
     // advertise a (view, log_view) the superblock has not recorded, or a crash could
     // recover an older view than one a peer already saw, splitting the brain or
-    // losing a commit. Every metadata caller persists first (the view-change dispatch
-    // sites and the on_replicate / on_commit send gates), so this asserts they did
-    // rather than letting a future bypass through silently. Metadata plane only:
-    // partition consensus has no superblock to record a view in, so it is exempt and
-    // the namespace test below is what does the work (its `needs_superblock_persist`
-    // is not a stand-in: that predicate reads clean at view 0, which is where a
-    // partition group spends most of its life). `RequestStartView` is exempt too,
-    // being a probe that asks to LEARN the view rather than advertise it.
+    // losing a commit. Every caller on BOTH planes persists first (the view-change
+    // dispatch sites, the tick, and each plane's PrepareOk send gate), so this
+    // asserts they did rather than letting a future bypass through silently.
+    // `RequestStartView` is exempt, being a probe that asks to LEARN the view rather
+    // than advertise it. Partitions without an attached superblock (in-memory,
+    // simulated) pass vacuously: their persist gate records "durable = current"
+    // instead of writing, precisely so this assert stays meaningful for the groups
+    // that do have a store.
     #[cfg(debug_assertions)]
-    if consensus.namespace() == METADATA_CONSENSUS_NAMESPACE {
-        for action in actions {
-            let advertises_view = matches!(
-                action,
-                VsrAction::SendStartViewChange { .. }
-                    | VsrAction::SendDoViewChange { .. }
-                    | VsrAction::SendStartView { .. }
-                    | VsrAction::SendPrepareOk { .. }
-                    // A backup drops a Commit whose view differs from its own, and a
-                    // primary answers an older-view one with a StartView, so the
-                    // heartbeat advertises a view like the rest. Gated today only
-                    // because its sole emitter rides the tick, which persists first.
-                    | VsrAction::SendCommit { .. }
-            );
-            debug_assert!(
-                !advertises_view || !consensus.needs_superblock_persist(),
-                "durable-before-send violated: dispatching a view-scoped metadata action \
-                 while the superblock is behind the in-memory view {}",
-                consensus.view(),
-            );
-        }
+    for action in actions {
+        let advertises_view = matches!(
+            action,
+            VsrAction::SendStartViewChange { .. }
+                | VsrAction::SendDoViewChange { .. }
+                | VsrAction::SendStartView { .. }
+                | VsrAction::SendPrepareOk { .. }
+                // A backup drops a Commit whose view differs from its own, and a
+                // primary answers an older-view one with a StartView, so the
+                // heartbeat advertises a view like the rest. Gated today only
+                // because its sole emitter rides the tick, which persists first.
+                | VsrAction::SendCommit { .. }
+        );
+        debug_assert!(
+            !advertises_view || !consensus.needs_superblock_persist(),
+            "durable-before-send violated: dispatching a view-scoped action for \
+             namespace {} while the superblock is behind the in-memory view {}",
+            consensus.namespace(),
+            consensus.view(),
+        );
     }
 
     for action in actions {
@@ -5379,9 +7759,9 @@
     clippy::too_many_lines,
     clippy::cast_possible_truncation
 )]
-async fn dispatch_partition_journal_actions<B, P>(
+async fn dispatch_partition_journal_actions<B, P, SB>(
     consensus: &VsrConsensus<B, P>,
-    partition: &IggyPartition<B>,
+    partition: &IggyPartition<B, SB>,
     actions: &[VsrAction],
 ) where
     B: MessageBus,
@@ -5400,6 +7780,22 @@
         }
     };
 
+    // Same durable-before-send tripwire as `dispatch_vsr_actions`: this
+    // dispatcher emits view-scoped `SendPrepareOk` too, and all callers are
+    // persist-gated today -- assert it so a future bypass cannot slip
+    // through the partition plane's own dispatcher silently.
+    #[cfg(debug_assertions)]
+    for action in actions {
+        debug_assert!(
+            !matches!(action, VsrAction::SendPrepareOk { .. })
+                || !consensus.needs_superblock_persist(),
+            "durable-before-send violated: dispatching a view-scoped action for \
+             namespace {} while the superblock is behind the in-memory view {}",
+            consensus.namespace(),
+            consensus.view(),
+        );
+    }
+
     for action in actions {
         match action {
             VsrAction::SendPrepareOk {
@@ -5512,3 +7908,58 @@
         }
     }
 }
+
+#[cfg(test)]
+mod persist_gate_tests {
+    use super::*;
+
+    fn rebuild() -> VsrAction {
+        VsrAction::RebuildPipeline {
+            from_op: 3,
+            to_op: 9,
+        }
+    }
+
+    #[test]
+    fn given_view_change_actions_when_split_should_keep_locals_out_of_the_gate() {
+        // The exact action shape `complete_view_change_as_primary` emits
+        // after it already flipped status/log_view and cleared its pipeline.
+        // The regression: a failed superblock persist used to drop the whole
+        // vec, and losing `RebuildPipeline` leaves a primary that discards
+        // every backup PrepareOk for the orphaned window as UnknownPrepare.
+        let actions = vec![
+            VsrAction::SendStartView {
+                view: 4,
+                op: 9,
+                commit: 3,
+                incarnation: 0,
+                target: None,
+                namespace: 7,
+            },
+            VsrAction::CommitJournal,
+            rebuild(),
+        ];
+        let (local, wire) = split_local_actions(actions);
+        assert!(
+            local.iter().all(|action| matches!(
+                action,
+                VsrAction::CommitJournal | VsrAction::RebuildPipeline { .. }
+            )),
+            "locals must hold exactly the act-side actions"
+        );
+        assert_eq!(local.len(), 2, "both act-side actions survive the gate");
+        assert_eq!(wire.len(), 1, "only the send is fenced by the persist");
+        assert!(matches!(wire[0], VsrAction::SendStartView { .. }));
+    }
+
+    #[test]
+    fn given_send_only_actions_when_split_should_leave_locals_empty() {
+        let actions = vec![VsrAction::SendStartViewChange {
+            view: 2,
+            namespace: 7,
+        }];
+        let (local, wire) = split_local_actions(actions);
+        assert!(local.is_empty());
+        assert_eq!(wire.len(), 1);
+    }
+}
diff --git a/core/shard/src/metrics.rs b/core/shard/src/metrics.rs
index 5b8fa34..883eefe 100644
--- a/core/shard/src/metrics.rs
+++ b/core/shard/src/metrics.rs
@@ -186,6 +186,7 @@
     partitions_materialised_total: Counter,
     partitions_removed_total: Counter,
     partitions_reconcile_failures_total: Counter,
+    partition_transfer_refusals_total: Counter,
     partition_frames_rejected_stale_total: Counter,
     partition_frames_rejected_ahead_total: Counter,
     partition_requests_denied_transient_total: Counter,
@@ -218,6 +219,7 @@
             partitions_materialised_total: Counter::default(),
             partitions_removed_total: Counter::default(),
             partitions_reconcile_failures_total: Counter::default(),
+            partition_transfer_refusals_total: Counter::default(),
             partition_frames_rejected_stale_total: Counter::default(),
             partition_frames_rejected_ahead_total: Counter::default(),
             partition_requests_denied_transient_total: Counter::default(),
@@ -263,6 +265,26 @@
         self.partitions_reconcile_failures_total.inc();
     }
 
+    /// Bumped every time a serving peer refuses a partition state transfer.
+    ///
+    /// Transient refusals re-arm on a flat interval and charge no failure
+    /// count -- deliberately, since the alternative routes through a 1024x
+    /// backoff cap that pins a partition for ~17 minutes after the peer has
+    /// already caught up -- which also means a partition stuck rejoining for
+    /// hours produces no signal of its own. This counter plus the escalating
+    /// log level at the refusal site is that signal.
+    pub fn record_partition_transfer_refusal(&self) {
+        self.partition_transfer_refusals_total.inc();
+    }
+
+    /// Test-only read, mirroring the siblings; the production scrape goes
+    /// through the prometheus registry.
+    #[cfg(any(test, feature = "simulator"))]
+    #[must_use]
+    pub fn partition_transfer_refusals_value(&self) -> u64 {
+        self.partition_transfer_refusals_total.get()
+    }
+
     /// Bumped when a parked partition frame is answered instead of served
     /// because it was addressed to an incarnation this shard no longer holds
     /// (delete + recreate recycled the namespace's slab keys). Serving it would
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index bc7370a..9553532 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -24,6 +24,7 @@
 use crossfire::TrySendError;
 use futures::FutureExt;
 use iggy_binary_protocol::{ConsensusHeader, GenericHeader, Operation, PrepareHeader};
+use journal::superblock::SuperblockStore;
 use journal::{Journal, JournalHandle};
 use message_bus::{ConnectionInstaller, MessageBus, ReplicaHandshakeDoneFn};
 use server_common::sharding::{IggyNamespace, METADATA_CONSENSUS_NAMESPACE};
@@ -112,10 +113,11 @@
 /// through the channel into the target shard's message pump.  This ensures
 /// that every mutation on a shard is serialized through a single point (the
 /// pump), preventing concurrent access from independent async tasks.
-impl<B, MJ, S, M, T> IggyShard<B, MJ, S, M, T>
+impl<B, MJ, S, M, T, SB> IggyShard<B, MJ, S, M, T, SB>
 where
     B: MessageBus + ConnectionInstaller + Clone + 'static,
     T: ShardsTable,
+    SB: SuperblockStore,
 {
     /// Network-receive entry point. Classifies the raw
     /// `Message<GenericHeader>` and routes it to the owning shard via
@@ -345,6 +347,11 @@
                     // partition-ref-across-`.await` UB this fold closed.
                     self.tick_metadata().await;
                     self.tick_partitions().await;
+                    // Runs here, not inside `tick_metadata`: that early-returns
+                    // on shards without metadata consensus, and partition-plane
+                    // offers live on every shard that hosts a serving group --
+                    // parked behind the shard-0 gate they would never expire.
+                    self.expire_idle_state_transfer_offers();
                     // While a cooperative revocation is pending, wake the
                     // reconciler each tick so the handoff completes within ~one
                     // tick of the partition draining, not the periodic pass.
@@ -617,6 +624,14 @@
                         .clean_expired_segments(now, message_expiry, max_bytes)
                         .await;
                     if segments > 0 {
+                        // Any unlink invalidates what this shard is SERVING:
+                        // the offer names files that are gone and the payload
+                        // cache can answer from RAM without touching disk, so a
+                        // puller would install deleted messages. Neither cache
+                        // can notice on its own -- one is keyed on the
+                        // partition's commit_op, which retention does not move,
+                        // the other on a checksum over the deleted bytes.
+                        self.drop_partition_transfer_state(namespace, partition);
                         tracing::debug!(
                             shard = self.id,
                             namespace_raw = namespace.inner(),
@@ -638,6 +653,11 @@
                     let (segments, messages) =
                         partition.remove_sealed_segments_up_to(up_to_offset).await;
                     if segments > 0 {
+                        // See the cleaner arm: a truncate commits on the
+                        // METADATA plane, so this partition's commit_op never
+                        // moves and the cached offer stays a hit over unlinked
+                        // files.
+                        self.drop_partition_transfer_state(namespace, partition);
                         tracing::debug!(
                             shard = self.id,
                             namespace_raw = namespace.inner(),
@@ -662,19 +682,70 @@
                     && partition.applied_purge_generation() < generation
                 {
                     match partition.purge(&config, generation).await {
-                        Ok(()) => tracing::debug!(
-                            shard = self.id,
-                            namespace_raw = namespace.inner(),
-                            generation,
-                            "purge-partition reset partition to empty"
-                        ),
-                        Err(error) => tracing::error!(
-                            shard = self.id,
-                            namespace_raw = namespace.inner(),
-                            generation,
-                            %error,
-                            "purge-partition failed to reset partition"
-                        ),
+                        Ok(()) => {
+                            // The purge unlinked the very bytes this shard is
+                            // serving: the cached offer still advertises the
+                            // pre-purge manifest and the payload cache can
+                            // answer chunk requests for it without touching
+                            // disk, so a puller would install purged data. Both
+                            // are keyed on pre-purge content, so neither can
+                            // notice on its own.
+                            self.drop_partition_transfer_state(namespace, partition);
+                            tracing::debug!(
+                                shard = self.id,
+                                namespace_raw = namespace.inner(),
+                                generation,
+                                "purge-partition reset partition to empty"
+                            );
+                        }
+                        Err(partitions::PurgeError::FrontierNotRecorded) => {
+                            // NOT fenced: nothing was mutated, so the chain is
+                            // whole and `applied_purge_generation` is unmoved,
+                            // which means the reconciler's `committed > applied`
+                            // gate still sees this purge as outstanding.
+                            // Fencing here would quarantine live data, and the
+                            // fence's own frontier write would first stamp the
+                            // pre-purge counter the purge was about to reset.
+                            //
+                            // NOT woken: staging a purge counts as work in the
+                            // pass, which keeps the fast-skip disarmed, so the
+                            // ordinary periodic pass re-issues until one lands
+                            // and stops once `applied` catches `committed`. An
+                            // eager wake here closes a loop with no pacing in
+                            // it at all -- pass, stage, defer, wake -- and on a
+                            // disk that refuses instantly that is a full O(N)
+                            // reconcile scan and a real `atomic_replace`
+                            // attempt per turn, holding the partition write
+                            // lock each time.
+                            tracing::warn!(
+                                shard = self.id,
+                                namespace_raw = namespace.inner(),
+                                generation,
+                                "purge-partition deferred: could not record the frontier reset; \
+                                 the reconciler re-issues it while the generation stays unapplied"
+                            );
+                        }
+                        Err(error @ partitions::PurgeError::Unserviceable(_)) => {
+                            // Past the drain, so this group has no serviceable
+                            // chain and the next append panics on
+                            // `active_segment()`. Fence it for rebuild, exactly
+                            // as a failed state-transfer convergence does. The
+                            // counters were already reset to 0 before the
+                            // fallible plant, so the fence's advancing write
+                            // records the post-purge frontier.
+                            tracing::error!(
+                                shard = self.id,
+                                namespace_raw = namespace.inner(),
+                                generation,
+                                %error,
+                                "purge-partition failed to reset partition; fencing it for rebuild"
+                            );
+                            // Fenced, but the caches still describe the
+                            // pre-purge bytes until the rebuild lands.
+                            self.drop_partition_transfer_state(namespace, partition);
+                            self.fence_partition_for_rebuild(namespace, partition, None)
+                                .await;
+                        }
                     }
                 }
             }
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index c682125..64aecc4 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -34,7 +34,6 @@
 use executor::{DetExecutor, RunOutcome, TaskId};
 use iggy_binary_protocol::{GenericHeader, ReplyHeader};
 use iggy_common::IggyError;
-use journal::superblock::DynSuperblockStore;
 use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
 use metadata::impls::metadata::StreamsFrontend;
 use network::Network;
@@ -48,7 +47,8 @@
 use server_common::sharding::{IggyNamespace, PartitionLocation, ShardId};
 use shard::CONSENSUS_TICK_INTERVAL;
 use shard::shards_table::{ShardsTable, calculate_shard_assignment};
-use std::collections::HashSet;
+use std::cell::RefCell;
+use std::collections::{HashMap, HashSet};
 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
 use std::rc::Rc;
 
@@ -86,6 +86,14 @@
     /// yet the run stays byte-identical on replay. See
     /// `VsrConsensus::set_incarnation`.
     pub metadata_incarnation: u128,
+    /// One durable superblock per partition group this replica has materialised,
+    /// harness-owned for the same reason as the metadata one: the bytes survive
+    /// the shards being dropped and rebuilt, so a re-materialised group recovers
+    /// the `(view, log_view)` it recorded instead of re-entering view 0. Without
+    /// a store the persist gate marks every view durable without writing, which
+    /// leaves the gate, its write-failure fence, and view recovery all
+    /// unexercised.
+    pub partition_superblocks: RefCell<HashMap<IggyNamespace, Rc<SimSuperblock>>>,
     /// Keeps each pump's stop channel alive; dropping one would end that
     /// pump gracefully, which is reserved for future shutdown/restart
     /// tests (crash uses `DetExecutor::abort` instead).
@@ -280,9 +288,8 @@
                     .expect("mesh yields exactly one inbox per shard");
                 // Only shard 0 owns metadata consensus, so only it carries the
                 // superblock. Peer shards persist nothing.
-                let shard_superblock: Option<Rc<dyn DynSuperblockStore>> = if shard_idx == 0 {
-                    let sb: Rc<dyn DynSuperblockStore> = superblock.clone();
-                    Some(sb)
+                let shard_superblock = if shard_idx == 0 {
+                    Some(superblock.clone())
                 } else {
                     None
                 };
@@ -327,6 +334,7 @@
                 superblock,
                 metadata_journal,
                 metadata_incarnation,
+                partition_superblocks: RefCell::new(HashMap::new()),
                 _stop_txs: stop_txs,
                 pump_tasks,
             });
@@ -360,29 +368,11 @@
     /// mesh construction caps it at `u16`).
     #[allow(clippy::cast_possible_truncation)]
     pub fn init_partition(&mut self, namespace: IggyNamespace) {
-        for (i, replica) in self.replicas.iter_mut().enumerate() {
+        for (i, replica) in self.replicas.iter().enumerate() {
             if self.crashed.contains(&(i as u8)) {
                 continue;
             }
-            let shard_count = u32::try_from(replica.shards.len()).expect("shard count fits u32");
-            let owner = calculate_shard_assignment(&namespace, shard_count);
-            replica.shards[usize::from(owner)].init_partition(namespace);
-            // Commit the namespace before stamping the rows: a partition the
-            // metadata plane never heard of is a shape production cannot
-            // produce, and the shard refuses to serve client traffic whose
-            // routing-row epoch it cannot match against a committed
-            // `created_revision`.
-            let streams = replica.shards[0].plane.metadata().mux_stm.streams();
-            streams.seed_namespace(namespace, namespace.inner());
-            let epoch = streams
-                .created_revision_for_namespace(namespace)
-                .expect("namespace committed by the seed above");
-            for shard in &replica.shards {
-                shard.shards_table().insert(
-                    namespace,
-                    PartitionLocation::new(ShardId::new(owner), epoch),
-                );
-            }
+            materialise_partition(replica, namespace);
         }
     }
 
@@ -751,6 +741,11 @@
         // incarnation and still in flight is ignored. Deterministic, so replay stays
         // byte-identical.
         let metadata_incarnation = self.replicas[idx].metadata_incarnation + 1;
+        // Partition superblocks carry forward too: a group re-materialised after
+        // the restart must recover its recorded view from the same store, exactly
+        // as a rebooted server-ng partition reads the record in its directory.
+        let partition_superblocks =
+            std::mem::take(&mut *self.replicas[idx].partition_superblocks.borrow_mut());
 
         // Recover the durable VSR state from the retained superblock before the
         // rebuild, as production reads it in restore_metadata_consensus.
@@ -771,9 +766,8 @@
             let inbox = inboxes[usize::from(shard_idx)]
                 .take()
                 .expect("mesh yields exactly one inbox per shard");
-            let shard_superblock: Option<Rc<dyn DynSuperblockStore>> = if shard_idx == 0 {
-                let sb: Rc<dyn DynSuperblockStore> = superblock.clone();
-                Some(sb)
+            let shard_superblock = if shard_idx == 0 {
+                Some(superblock.clone())
             } else {
                 None
             };
@@ -814,10 +808,30 @@
             superblock,
             metadata_journal,
             metadata_incarnation,
+            partition_superblocks: RefCell::new(partition_superblocks),
             _stop_txs: stop_txs,
             pump_tasks,
         };
 
+        // Re-materialise every group this replica had before the crash, as a
+        // rebooted server-ng re-opens every partition directory it owns. This
+        // is what makes the carried-forward superblock load-bearing: the group
+        // recovers the `(view, log_view)` it recorded instead of re-entering
+        // view 0.
+        // SORTED: `HashMap` iteration order is seeded per process, and
+        // materialisation order is observable (shard init order, routing-row
+        // stamps), so replay would stop being byte-identical.
+        let mut materialised: Vec<IggyNamespace> = self.replicas[idx]
+            .partition_superblocks
+            .borrow()
+            .keys()
+            .copied()
+            .collect();
+        materialised.sort_unstable_by_key(IggyNamespace::inner);
+        for namespace in materialised {
+            materialise_partition(&self.replicas[idx], namespace);
+        }
+
         // Reconnect to the network and mark the replica live again.
         self.network
             .process_enable(ProcessId::Replica(replica_index));
@@ -927,6 +941,46 @@
     }
 }
 
+/// Materialises `namespace` on its hash-owning shard of one replica and stamps
+/// the routing row on every shard of that replica.
+///
+/// Shared by [`SimCluster::init_partition`] and the restart path: a rebooted
+/// server-ng re-opens every partition directory it owns, so the sim has to
+/// re-materialise too, otherwise the superblock a restart carries forward is
+/// never read back and the recovered-view branch is dead code.
+fn materialise_partition(replica: &SimReplica, namespace: IggyNamespace) {
+    let shard_count = u32::try_from(replica.shards.len()).expect("shard count fits u32");
+    let owner = calculate_shard_assignment(&namespace, shard_count);
+    // One store per group, minted on first materialisation and reused on every
+    // later one, so the recorded view survives a replica restart.
+    let superblock = Rc::clone(
+        replica
+            .partition_superblocks
+            .borrow_mut()
+            .entry(namespace)
+            .or_default(),
+    );
+    let recovered_state = superblock
+        .read_latest_sync()
+        .and_then(|bytes| VsrState::try_from(bytes.as_slice()).ok());
+    replica.shards[usize::from(owner)].init_partition(namespace, Some(superblock), recovered_state);
+    // Commit the namespace before stamping the rows: a partition the metadata
+    // plane never heard of is a shape production cannot produce, and the shard
+    // refuses to serve client traffic whose routing-row epoch it cannot match
+    // against a committed `created_revision`.
+    let streams = replica.shards[0].plane.metadata().mux_stm.streams();
+    streams.seed_namespace(namespace, namespace.inner());
+    let epoch = streams
+        .created_revision_for_namespace(namespace)
+        .expect("namespace committed by the seed above");
+    for shard in &replica.shards {
+        shard.shards_table().insert(
+            namespace,
+            PartitionLocation::new(ShardId::new(owner), epoch),
+        );
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/core/simulator/src/replica.rs b/core/simulator/src/replica.rs
index ace319b..9abf73c 100644
--- a/core/simulator/src/replica.rs
+++ b/core/simulator/src/replica.rs
@@ -16,13 +16,13 @@
 // under the License.
 
 use crate::bus::{SharedSimOutbox, SimOutbox};
+use crate::deps::SimSuperblock;
 use crate::deps::{MemStorage, SimJournal, SimMuxStateMachine, SimSnapshot};
 use configs::server::PersonalAccessTokenConfig;
 use configs::server_ng::NgSystemConfig;
 use consensus::{ConsensusClock, LocalPipeline, Sequencer, VsrConsensus, VsrState};
 use iggy_common::IggyByteSize;
 use iggy_common::variadic;
-use journal::superblock::DynSuperblockStore;
 use metadata::stm::mux::WithFactory;
 use metadata::stm::stream::{Streams, StreamsInner};
 use metadata::stm::user::{Users, UsersInner};
@@ -63,6 +63,7 @@
     SimSnapshot,
     SimMuxStateMachine,
     PapayaShardsTable,
+    SimSuperblock,
 >;
 
 /// Read-side handoff bundle for the metadata STM.
@@ -120,7 +121,7 @@
     clock: ConsensusClock,
     shell: bool,
     reader_bundle: Option<SimMetadataBundle>,
-    superblock: Option<Rc<dyn DynSuperblockStore>>,
+    superblock: Option<Rc<SimSuperblock>>,
     metadata_journal: Option<Rc<SimJournal<MemStorage>>>,
     recovered_state: Option<VsrState>,
     incarnation: u128,
@@ -295,8 +296,12 @@
 
     // The deferred handlers upgrade this weak self-reference per frame; it
     // stays `None` until the shard is built and downgraded into it below.
-    let shard_handle: ShellShardHandle<SharedSimOutbox, Rc<SimJournal<MemStorage>>, SimSnapshot> =
-        Rc::new(RefCell::new(None));
+    let shard_handle: ShellShardHandle<
+        SharedSimOutbox,
+        Rc<SimJournal<MemStorage>>,
+        SimSnapshot,
+        SimSuperblock,
+    > = Rc::new(RefCell::new(None));
     let ShellHandlers {
         on_replica_message,
         on_client_request,
diff --git a/examples/node/package-lock.json b/examples/node/package-lock.json
index 4d9c99e..3fd722f 100644
--- a/examples/node/package-lock.json
+++ b/examples/node/package-lock.json
@@ -15,7 +15,7 @@
       "devDependencies": {
         "@types/debug": "^4.1.12",
         "@types/node": "^22.9.3",
-        "eslint": "^10.7.0",
+        "eslint": "^10.8.0",
         "jiti": "^2.7.0",
         "tsx": "^4.23.1",
         "typescript-eslint": "^8.47.0"
@@ -23,7 +23,7 @@
     },
     "../../foreign/node": {
       "name": "apache-iggy",
-      "version": "0.8.1-edge.2",
+      "version": "0.9.0-edge.1",
       "license": "Apache-2.0",
       "dependencies": {
         "debug": "4.4.3",
@@ -33,11 +33,11 @@
       "devDependencies": {
         "@commitlint/cli": "21.2.1",
         "@commitlint/config-conventional": "21.2.0",
-        "@cucumber/cucumber": "13.0.0",
-        "@swc-node/register": "1.11.1",
+        "@cucumber/cucumber": "13.2.0",
+        "@swc-node/register": "1.12.1",
         "@types/debug": "4.1.13",
         "@types/node": "26.1.1",
-        "c8": "^11.0.0",
+        "c8": "^12.0.0",
         "husky": "9.1.7",
         "typescript": "6.0.3",
         "typescript-eslint": "^8.47.0"
@@ -555,9 +555,9 @@
       }
     },
     "node_modules/@eslint/config-helpers": {
-      "version": "0.6.0",
-      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
-      "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+      "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -1096,9 +1096,9 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.7.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
-      "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
+      "version": "10.8.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
+      "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
       "dev": true,
       "license": "MIT",
       "workspaces": [
@@ -1108,7 +1108,7 @@
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.2",
         "@eslint/config-array": "^0.23.5",
-        "@eslint/config-helpers": "^0.6.0",
+        "@eslint/config-helpers": "^0.7.0",
         "@eslint/core": "^1.2.1",
         "@eslint/plugin-kit": "^0.7.2",
         "@humanfs/node": "^0.16.6",
@@ -1132,7 +1132,7 @@
         "imurmurhash": "^0.1.4",
         "is-glob": "^4.0.0",
         "json-stable-stringify-without-jsonify": "^1.0.1",
-        "minimatch": "^10.2.4",
+        "minimatch": "^10.2.5",
         "natural-compare": "^1.4.0",
         "optionator": "^0.9.3"
       },
diff --git a/examples/node/package.json b/examples/node/package.json
index eb1334b..42a20c2 100644
--- a/examples/node/package.json
+++ b/examples/node/package.json
@@ -39,7 +39,7 @@
   "devDependencies": {
     "@types/debug": "^4.1.12",
     "@types/node": "^22.9.3",
-    "eslint": "^10.7.0",
+    "eslint": "^10.8.0",
     "jiti": "^2.7.0",
     "tsx": "^4.23.1",
     "typescript-eslint": "^8.47.0"
diff --git a/foreign/cpp/MODULE.bazel b/foreign/cpp/MODULE.bazel
index 6724065..3142ca5 100644
--- a/foreign/cpp/MODULE.bazel
+++ b/foreign/cpp/MODULE.bazel
@@ -24,7 +24,7 @@
 bazel_dep(name = "platforms", version = "1.1.0")
 bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
 bazel_dep(name = "cucumber-cpp", version = "0.8.0.bcr.1")
-bazel_dep(name = "rules_rust", version = "0.71.3")
+bazel_dep(name = "rules_rust", version = "0.72.0")
 
 rust_host_tools = use_extension("@rules_rust//rust:extensions.bzl", "rust_host_tools")
 rust_host_tools.host_tools(
diff --git a/foreign/cpp/MODULE.bazel.lock b/foreign/cpp/MODULE.bazel.lock
index 0ad4eed..7061c20 100644
--- a/foreign/cpp/MODULE.bazel.lock
+++ b/foreign/cpp/MODULE.bazel.lock
@@ -188,8 +188,8 @@
     "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
     "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8",
     "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32",
-    "https://bcr.bazel.build/modules/rules_rust/0.71.3/MODULE.bazel": "e2390c96f77d65f00c769bf665678c5424188e9c777239cfaae2a8d2dde7b981",
-    "https://bcr.bazel.build/modules/rules_rust/0.71.3/source.json": "5eb5d8068571725bc893045f8137ed7937988f23d73c53ea443470e8047598ad",
+    "https://bcr.bazel.build/modules/rules_rust/0.72.0/MODULE.bazel": "e49a6d6525cf5a28d52afe17e4b12e62498652b40ab30cec26be39eb4f0303c7",
+    "https://bcr.bazel.build/modules/rules_rust/0.72.0/source.json": "a3871759cac97efb50ea066bcf6487ed639c181e71aa22d26aaa658890703bc2",
     "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
     "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
     "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b",
diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json
index 7cf29b8..11295a8 100644
--- a/foreign/node/package-lock.json
+++ b/foreign/node/package-lock.json
@@ -19,7 +19,7 @@
         "@cucumber/cucumber": "13.2.0",
         "@swc-node/register": "1.12.1",
         "@types/debug": "4.1.13",
-        "@types/node": "26.1.1",
+        "@types/node": "26.1.2",
         "c8": "^12.0.0",
         "husky": "9.1.7",
         "typescript": "6.0.3",
@@ -1868,9 +1868,9 @@
       "license": "MIT"
     },
     "node_modules/@types/node": {
-      "version": "26.1.1",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
-      "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+      "version": "26.1.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+      "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
diff --git a/foreign/node/package.json b/foreign/node/package.json
index 62e4712..7f4bcb6 100644
--- a/foreign/node/package.json
+++ b/foreign/node/package.json
@@ -61,7 +61,7 @@
     "@cucumber/cucumber": "13.2.0",
     "@swc-node/register": "1.12.1",
     "@types/debug": "4.1.13",
-    "@types/node": "26.1.1",
+    "@types/node": "26.1.2",
     "c8": "^12.0.0",
     "husky": "9.1.7",
     "typescript": "6.0.3",
diff --git a/web/package-lock.json b/web/package-lock.json
index 5279865..45768cf 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -25,11 +25,11 @@
         "@eslint/js": "^10.0.1",
         "@sveltejs/adapter-node": "^5.5.7",
         "@sveltejs/adapter-static": "^3.0.10",
-        "@sveltejs/kit": "^2.70.1",
+        "@sveltejs/kit": "^2.70.2",
         "@sveltejs/vite-plugin-svelte": "^7.2.0",
         "@types/d3-interpolate": "^3.0.4",
         "@types/json-bigint": "^1.0.4",
-        "@types/node": "^26.1.1",
+        "@types/node": "^26.1.2",
         "autoprefixer": "^10.5.4",
         "esbuild": "^0.28.1",
         "eslint": "^10.8.0",
@@ -37,18 +37,18 @@
         "eslint-plugin-svelte": "^3.22.0",
         "globals": "^17.8.0",
         "jwt-decode": "^4.0.0",
-        "postcss": "^8.5.23",
+        "postcss": "^8.5.25",
         "prettier": "^3.9.6",
         "prettier-plugin-svelte": "^4.1.1",
         "svelte": "^5.56.8",
-        "svelte-check": "^4.7.3",
+        "svelte-check": "^4.7.4",
         "sveltekit-superforms": "^2.30.2",
         "tailwindcss": "^4.2.4",
         "ts-toolbelt": "^9.6.0",
         "tslib": "^2.8.1",
         "typescript": "^6.0.3",
         "typescript-eslint": "^8.65.0",
-        "vite": "^8.1.5",
+        "vite": "^8.2.0",
         "zod": "^4.4.3"
       }
     },
@@ -94,40 +94,6 @@
         "node": ">=6.9.0"
       }
     },
-    "node_modules/@emnapi/core": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
-      "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/wasi-threads": "1.2.2",
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
-      "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@emnapi/wasi-threads": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
-      "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.28.1",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@@ -868,32 +834,10 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
-    "node_modules/@napi-rs/wasm-runtime": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
-      "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@tybys/wasm-util": "^0.10.3"
-      },
-      "engines": {
-        "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Brooooooklyn"
-      },
-      "peerDependencies": {
-        "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
-        "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
-      }
-    },
     "node_modules/@oxc-project/types": {
-      "version": "0.139.0",
-      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
-      "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+      "version": "0.143.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+      "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
       "dev": true,
       "license": "MIT",
       "funding": {
@@ -927,9 +871,9 @@
       "optional": true
     },
     "node_modules/@rolldown/binding-android-arm64": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
-      "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+      "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
       "cpu": [
         "arm64"
       ],
@@ -944,9 +888,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-arm64": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
-      "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+      "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
       "cpu": [
         "arm64"
       ],
@@ -961,9 +905,9 @@
       }
     },
     "node_modules/@rolldown/binding-darwin-x64": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
-      "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+      "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
       "cpu": [
         "x64"
       ],
@@ -978,9 +922,9 @@
       }
     },
     "node_modules/@rolldown/binding-freebsd-x64": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
-      "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+      "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
       "cpu": [
         "x64"
       ],
@@ -995,9 +939,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
-      "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+      "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
       "cpu": [
         "arm"
       ],
@@ -1012,9 +956,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-gnu": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
-      "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+      "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
       "cpu": [
         "arm64"
       ],
@@ -1032,9 +976,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-arm64-musl": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
-      "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+      "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
       "cpu": [
         "arm64"
       ],
@@ -1052,9 +996,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-ppc64-gnu": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
-      "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+      "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
       "cpu": [
         "ppc64"
       ],
@@ -1072,9 +1016,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-s390x-gnu": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
-      "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+      "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
       "cpu": [
         "s390x"
       ],
@@ -1092,9 +1036,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-gnu": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
-      "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+      "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
       "cpu": [
         "x64"
       ],
@@ -1112,9 +1056,9 @@
       }
     },
     "node_modules/@rolldown/binding-linux-x64-musl": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
-      "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+      "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
       "cpu": [
         "x64"
       ],
@@ -1132,9 +1076,9 @@
       }
     },
     "node_modules/@rolldown/binding-openharmony-arm64": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
-      "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+      "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
       "cpu": [
         "arm64"
       ],
@@ -1148,29 +1092,10 @@
         "node": "^20.19.0 || >=22.12.0"
       }
     },
-    "node_modules/@rolldown/binding-wasm32-wasi": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
-      "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
-      "cpu": [
-        "wasm32"
-      ],
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "@emnapi/core": "1.11.1",
-        "@emnapi/runtime": "1.11.1",
-        "@napi-rs/wasm-runtime": "^1.1.6"
-      },
-      "engines": {
-        "node": "^20.19.0 || >=22.12.0"
-      }
-    },
     "node_modules/@rolldown/binding-win32-arm64-msvc": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
-      "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+      "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
       "cpu": [
         "arm64"
       ],
@@ -1185,9 +1110,9 @@
       }
     },
     "node_modules/@rolldown/binding-win32-x64-msvc": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
-      "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+      "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
       "cpu": [
         "x64"
       ],
@@ -1747,9 +1672,9 @@
       }
     },
     "node_modules/@sveltejs/kit": {
-      "version": "2.70.1",
-      "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
-      "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
+      "version": "2.70.2",
+      "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
+      "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -1789,9 +1714,9 @@
       }
     },
     "node_modules/@sveltejs/load-config": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz",
-      "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==",
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz",
+      "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -2146,17 +2071,6 @@
         "tailwindcss": "4.3.3"
       }
     },
-    "node_modules/@tybys/wasm-util": {
-      "version": "0.10.3",
-      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
-      "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
     "node_modules/@types/cookie": {
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -2209,9 +2123,9 @@
       "license": "MIT"
     },
     "node_modules/@types/node": {
-      "version": "26.1.1",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
-      "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+      "version": "26.1.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+      "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -4219,9 +4133,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.5.23",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
-      "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+      "version": "8.5.25",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+      "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
       "funding": [
         {
           "type": "opencollective",
@@ -4473,13 +4387,13 @@
       }
     },
     "node_modules/rolldown": {
-      "version": "1.1.5",
-      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
-      "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+      "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "@oxc-project/types": "=0.139.0",
+        "@oxc-project/types": "=0.143.0",
         "@rolldown/pluginutils": "^1.0.0"
       },
       "bin": {
@@ -4489,21 +4403,20 @@
         "node": "^20.19.0 || >=22.12.0"
       },
       "optionalDependencies": {
-        "@rolldown/binding-android-arm64": "1.1.5",
-        "@rolldown/binding-darwin-arm64": "1.1.5",
-        "@rolldown/binding-darwin-x64": "1.1.5",
-        "@rolldown/binding-freebsd-x64": "1.1.5",
-        "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
-        "@rolldown/binding-linux-arm64-gnu": "1.1.5",
-        "@rolldown/binding-linux-arm64-musl": "1.1.5",
-        "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
-        "@rolldown/binding-linux-s390x-gnu": "1.1.5",
-        "@rolldown/binding-linux-x64-gnu": "1.1.5",
-        "@rolldown/binding-linux-x64-musl": "1.1.5",
-        "@rolldown/binding-openharmony-arm64": "1.1.5",
-        "@rolldown/binding-wasm32-wasi": "1.1.5",
-        "@rolldown/binding-win32-arm64-msvc": "1.1.5",
-        "@rolldown/binding-win32-x64-msvc": "1.1.5"
+        "@rolldown/binding-android-arm64": "1.2.3",
+        "@rolldown/binding-darwin-arm64": "1.2.3",
+        "@rolldown/binding-darwin-x64": "1.2.3",
+        "@rolldown/binding-freebsd-x64": "1.2.3",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+        "@rolldown/binding-linux-arm64-gnu": "1.2.3",
+        "@rolldown/binding-linux-arm64-musl": "1.2.3",
+        "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+        "@rolldown/binding-linux-s390x-gnu": "1.2.3",
+        "@rolldown/binding-linux-x64-gnu": "1.2.3",
+        "@rolldown/binding-linux-x64-musl": "1.2.3",
+        "@rolldown/binding-openharmony-arm64": "1.2.3",
+        "@rolldown/binding-win32-arm64-msvc": "1.2.3",
+        "@rolldown/binding-win32-x64-msvc": "1.2.3"
       }
     },
     "node_modules/rollup": {
@@ -4683,14 +4596,14 @@
       }
     },
     "node_modules/svelte-check": {
-      "version": "4.7.3",
-      "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz",
-      "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==",
+      "version": "4.7.4",
+      "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz",
+      "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@jridgewell/trace-mapping": "^0.3.25",
-        "@sveltejs/load-config": "^0.2.0",
+        "@sveltejs/load-config": "^0.2.1",
         "chokidar": "^4.0.1",
         "fdir": "^6.2.0",
         "picocolors": "^1.0.0",
@@ -4704,7 +4617,7 @@
       },
       "peerDependencies": {
         "svelte": "^4.0.0 || ^5.0.0-next.0",
-        "typescript": ">=5.0.0"
+        "typescript": "^5.0.0 || ^6.0.0"
       }
     },
     "node_modules/svelte-eslint-parser": {
@@ -5206,16 +5119,16 @@
       }
     },
     "node_modules/vite": {
-      "version": "8.1.5",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
-      "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+      "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "lightningcss": "^1.32.0",
+        "lightningcss": "^1.33.0",
         "picomatch": "^4.0.5",
-        "postcss": "^8.5.17",
-        "rolldown": "~1.1.5",
+        "postcss": "^8.5.23",
+        "rolldown": "~1.2.0",
         "tinyglobby": "^0.2.17"
       },
       "bin": {
@@ -5232,7 +5145,7 @@
       },
       "peerDependencies": {
         "@types/node": "^20.19.0 || >=22.12.0",
-        "@vitejs/devtools": "^0.3.0",
+        "@vitejs/devtools": "^0.4.0",
         "esbuild": "^0.27.0 || ^0.28.0",
         "jiti": ">=1.21.0",
         "less": "^4.0.0",
@@ -5283,6 +5196,279 @@
         }
       }
     },
+    "node_modules/vite/node_modules/lightningcss": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+      "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.33.0",
+        "lightningcss-darwin-arm64": "1.33.0",
+        "lightningcss-darwin-x64": "1.33.0",
+        "lightningcss-freebsd-x64": "1.33.0",
+        "lightningcss-linux-arm-gnueabihf": "1.33.0",
+        "lightningcss-linux-arm64-gnu": "1.33.0",
+        "lightningcss-linux-arm64-musl": "1.33.0",
+        "lightningcss-linux-x64-gnu": "1.33.0",
+        "lightningcss-linux-x64-musl": "1.33.0",
+        "lightningcss-win32-arm64-msvc": "1.33.0",
+        "lightningcss-win32-x64-msvc": "1.33.0"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-android-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+      "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+      "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+      "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+      "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+      "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+      "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+      "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+      "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+      "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+      "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.33.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+      "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
     "node_modules/vitefu": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz",
diff --git a/web/package.json b/web/package.json
index 647031a..23b95a2 100644
--- a/web/package.json
+++ b/web/package.json
@@ -18,11 +18,11 @@
     "@eslint/js": "^10.0.1",
     "@sveltejs/adapter-node": "^5.5.7",
     "@sveltejs/adapter-static": "^3.0.10",
-    "@sveltejs/kit": "^2.70.1",
+    "@sveltejs/kit": "^2.70.2",
     "@sveltejs/vite-plugin-svelte": "^7.2.0",
     "@types/d3-interpolate": "^3.0.4",
     "@types/json-bigint": "^1.0.4",
-    "@types/node": "^26.1.1",
+    "@types/node": "^26.1.2",
     "autoprefixer": "^10.5.4",
     "esbuild": "^0.28.1",
     "eslint": "^10.8.0",
@@ -30,18 +30,18 @@
     "eslint-plugin-svelte": "^3.22.0",
     "globals": "^17.8.0",
     "jwt-decode": "^4.0.0",
-    "postcss": "^8.5.23",
+    "postcss": "^8.5.25",
     "prettier": "^3.9.6",
     "prettier-plugin-svelte": "^4.1.1",
     "svelte": "^5.56.8",
-    "svelte-check": "^4.7.3",
+    "svelte-check": "^4.7.4",
     "sveltekit-superforms": "^2.30.2",
     "tailwindcss": "^4.2.4",
     "ts-toolbelt": "^9.6.0",
     "tslib": "^2.8.1",
     "typescript": "^6.0.3",
     "typescript-eslint": "^8.65.0",
-    "vite": "^8.1.5",
+    "vite": "^8.2.0",
     "zod": "^4.4.3"
   },
   "dependencies": {