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

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


The following commit(s) were added to refs/heads/master by this push:
     new ad9e5ddce test(integration): catch on-disk storage format breaks 
pre-merge (#4003)
ad9e5ddce is described below

commit ad9e5ddce23ad5570b8b432c0f02ab19c9982ef0
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Wed Sep 2 11:46:23 2026 +0200

    test(integration): catch on-disk storage format breaks pre-merge (#4003)
---
 .config/nextest.toml                               |    6 +
 .github/actions/rust/pre-merge/action.yml          |   33 +-
 .../actions/utils/setup-rust-with-cache/action.yml |    7 +-
 .github/config/components.yml                      |   37 +
 .github/workflows/_test.yml                        |   52 +-
 .github/workflows/pre-merge.yml                    |    2 +
 core/integration/src/harness/handle/server.rs      |   11 +
 core/integration/tests/data_integrity/mod.rs       |    5 +
 .../tests/data_integrity/storage_compat.rs         | 1473 ++++++++++++++++++++
 scripts/ci/storage-compat.sh                       |  270 ++++
 10 files changed, 1886 insertions(+), 10 deletions(-)

diff --git a/.config/nextest.toml b/.config/nextest.toml
index 3070df8bd..1d9889433 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -15,6 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
+# Floor for the flags CI and scripts/ci/storage-compat.sh pass: --no-tests
+# (0.9.75), --run-ignored only (0.9.76) and --ignore-default-filter (0.9.77).
+# nextest refuses to run below it, and setup-rust-with-cache replaces a
+# cache-restored binary that fails `cargo nextest show-config version`.
+nextest-version = { required = "0.9.77" }
+
 [[profile.default.overrides]]
 # This is a solution (or actually a workaround) for the problem that nextest 
does not support
 # #[serial] macro which shall enforce sequential execution of the test case.
diff --git a/.github/actions/rust/pre-merge/action.yml 
b/.github/actions/rust/pre-merge/action.yml
index 97efa4d3a..204811ebc 100644
--- a/.github/actions/rust/pre-merge/action.yml
+++ b/.github/actions/rust/pre-merge/action.yml
@@ -20,7 +20,7 @@ description: Rust pre-merge testing and linting github iggy 
actions
 
 inputs:
   task:
-    description: "Task to run (check, check-msrv, fmt, clippy, sort, machete, 
doctest, verify-publish, test-1, test-2, test-3, miri)"
+    description: "Task to run (check, check-msrv, fmt, clippy, sort, machete, 
doctest, verify-publish, test-1, test-2, test-3, test-storage-compat, miri)"
     required: true
   component:
     description: "Component name (for context)"
@@ -84,19 +84,22 @@ runs:
     # a subset of crates changed.
     # Safety: cargo check/clippy run on the full workspace separately, 
catching all
     # compilation errors. This only scopes test BUILD and EXECUTION.
+    # test-storage-compat is excluded from the DAG and coverage setup below:
+    # storage-compat.sh builds two explicit binaries and drives its own nextest
+    # run, and reads none of the /tmp plan files or the llvm-cov environment.
     - name: Fetch base branch for DAG analysis
-      if: startsWith(inputs.task, 'test-')
+      if: startsWith(inputs.task, 'test-') && inputs.task != 
'test-storage-compat'
       run: git fetch origin master --depth=1 2>/dev/null || true
       shell: bash
 
     - name: Install cargo-rail
-      if: startsWith(inputs.task, 'test-')
+      if: startsWith(inputs.task, 'test-') && inputs.task != 
'test-storage-compat'
       uses: taiki-e/install-action@v2
       with:
         tool: cargo-rail
 
     - name: Compute affected crates (cargo-rail)
-      if: startsWith(inputs.task, 'test-')
+      if: startsWith(inputs.task, 'test-') && inputs.task != 
'test-storage-compat'
       run: |
         METADATA_JSON=$(cargo metadata --format-version 1 --no-deps 
2>/dev/null || echo "{}")
         TOTAL_CRATES=$(echo "$METADATA_JSON" | jq '.workspace_members | 
length' 2>/dev/null || echo "?")
@@ -202,18 +205,25 @@ runs:
       shell: bash
 
     - name: Install cargo-llvm-cov
-      if: startsWith(inputs.task, 'test-')
+      if: startsWith(inputs.task, 'test-') && inputs.task != 
'test-storage-compat'
       uses: taiki-e/install-action@v2
       with:
         tool: cargo-llvm-cov
 
     - name: Build and test with coverage
-      if: startsWith(inputs.task, 'test-')
+      # test-storage-compat drives its own nextest invocation from
+      # scripts/ci/storage-compat.sh. Without this exclusion it would also run
+      # here with an empty PARTITION_FLAG, i.e. a fourth *unpartitioned* copy
+      # of the whole suite on top of test-1/2/3.
+      if: startsWith(inputs.task, 'test-') && inputs.task != 
'test-storage-compat'
       run: |
         # Parse partition index from task name (test-1 -> hash:1/3, test-2 -> 
hash:2/3, ...).
         # TEST_PARTITIONS must match the number of test-N tasks in
         # .github/config/components.yml. Cluster bootstrap makes each test
         # CPU-heavy, so partitions stay small.
+        # The regex is numeric-only on purpose: non-numeric test-* tasks
+        # (test-storage-compat) opt out of partitioning rather than producing
+        # an out-of-range hash:N/3.
         TEST_PARTITIONS=3
         TASK="${{ inputs.task }}"
         PARTITION_FLAG=""
@@ -383,6 +393,17 @@ runs:
         ls -la codecov.json
       shell: bash
 
+    # On-disk format backwards compatibility. The script builds an iggy-server
+    # from the master tip under the checked-out merge ref (origin/master off a
+    # pull_request run) and one from HEAD, seeds a data directory with the
+    # former and reads it back with the latter. It does its own shallow fetch
+    # of the baseline commit and exits non-zero if it cannot resolve one, so
+    # the default depth-1 checkout is sufficient here.
+    - name: Storage format backwards compatibility
+      if: inputs.task == 'test-storage-compat'
+      run: ./scripts/ci/storage-compat.sh
+      shell: bash
+
     # Miri (UB detector) on the unsafe-heavy crates that don't pull tokio /
     # compio. Pinned nightly so MIRIFLAGS behavior is stable across CI runs;
     # bump the date quarterly. Tree-borrows is the future-default aliasing
diff --git a/.github/actions/utils/setup-rust-with-cache/action.yml 
b/.github/actions/utils/setup-rust-with-cache/action.yml
index 82867a968..3ca1dffc0 100644
--- a/.github/actions/utils/setup-rust-with-cache/action.yml
+++ b/.github/actions/utils/setup-rust-with-cache/action.yml
@@ -125,9 +125,12 @@ runs:
     - name: Install cargo-nextest
       if: runner.os == 'Linux' && inputs.install-nextest == 'true'
       run: |
-        if command -v cargo-nextest &> /dev/null; then
+        # A binary restored from the cargo cache can predate the floor in
+        # .config/nextest.toml. `show-config version` exits non-zero below it,
+        # and on a nextest too old to know the subcommand at all, so both fall
+        # through to a fresh install instead of aborting the lane later.
+        if command -v cargo-nextest &> /dev/null && cargo nextest show-config 
version; then
           echo "cargo-nextest already installed"
-          cargo nextest --version
           exit 0
         fi
 
diff --git a/.github/config/components.yml b/.github/config/components.yml
index 082aec83b..d63cdd54a 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -135,6 +135,43 @@ components:
       - "core/message_bus/**"
       - "core/partitions/**"
 
+  # On-disk format backwards compatibility. Boots a server built from the
+  # master tip the PR merges onto, seeds a data directory, then swaps in a
+  # HEAD-built binary and asserts it reads everything back. Paths are the
+  # crates that own the on-disk representation plus the boot path that reads
+  # it back; a change to any of them can silently break rollforward for
+  # existing deployments, which no other check covers. Matching is literal
+  # glob with no crate-graph expansion, so every crate has to be spelled out:
+  # `core/server/**` does not cover `core/server_common/**`.
+  # `breaking:storage` on the PR skips it (see .github/workflows/_test.yml).
+  rust-storage-compat:
+    depends_on:
+      - "rust-workspace"
+      - "rust-configs" # partition/metadata on-disk schema knobs and their 
defaults
+      - "ci-infrastructure" # action.yml/workflow edits should re-run the check
+    paths:
+      - "core/server/**"
+      - "core/server_common/**" # per-message segment record + segment 
readers/writers
+      - "core/binary_protocol/**"
+      - "core/common/**"
+      - "core/consensus/**" # VsrState is the superblock payload
+      - "core/journal/**"
+      - "core/metadata/**"
+      - "core/partitions/**"
+      - "core/shard/**" # drives the shutdown flush and boot-time partition 
recovery
+      - "scripts/ci/storage-compat.sh"
+      # The check itself, so weakening it re-runs it. Scoped to the test, the
+      # module wiring that keeps it compiled in, the crate manifest and the
+      # harness it drives rather than `core/integration/**`, which would fire
+      # this lane on every unrelated integration test change.
+      - "core/integration/Cargo.toml"
+      - "core/integration/tests/mod.rs"
+      - "core/integration/tests/data_integrity/mod.rs"
+      - "core/integration/tests/data_integrity/storage_compat.rs"
+      - "core/integration/src/harness/**"
+    tasks:
+      - "test-storage-compat"
+
   # Standalone simulation tool, does NOT affect server binary or foreign SDKs.
   # Split from rust-cluster to avoid triggering SDK tests on simulator-only 
changes.
   rust-simulator:
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index eb0483efb..82a70444b 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -33,6 +33,7 @@ on:
 
 permissions:
   contents: read
+  issues: read # listLabelsOnIssue, for the breaking:storage escape hatch
   security-events: write
   pull-requests: write
 
@@ -55,15 +56,62 @@ jobs:
         run: echo "No changes detected, skipping tests"
 
       # Rust
+      # `breaking:storage` is the documented escape hatch for a deliberate
+      # on-disk format change, which the compat check cannot pass by
+      # construction. The label has to be read live: pre-merge.yml does not
+      # subscribe to `labeled` (pr-triage-apply.yml writes S-* labels, so every
+      # triage write would re-run the whole gate), and a re-run replays the
+      # webhook-frozen `pull_request.labels` from the last push. The operator
+      # flow is "check fails, apply label, re-run", which the frozen payload
+      # cannot see. Paginated so a PR with more than 100 labels still resolves.
+      - name: Resolve breaking:storage escape hatch
+        id: storage_hatch
+        if: >-
+          startsWith(inputs.component, 'rust') &&
+          inputs.task == 'test-storage-compat' &&
+          github.event.pull_request.number
+        uses: actions/github-script@v9
+        with:
+          script: |
+            let skip = false;
+            try {
+              const labels = await github.paginate(
+                github.rest.issues.listLabelsOnIssue,
+                {
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: context.payload.pull_request.number,
+                  per_page: 100,
+                },
+              );
+              skip = labels.some(l => l.name === 'breaking:storage');
+            } catch (e) {
+              // Fail toward running. A rate limit or a narrowed token must not
+              // wave an on-disk format break through as a green check.
+              core.warning(`listLabelsOnIssue failed (${e.status ?? e.code ?? 
'network'}), running the check`);
+            }
+            core.info(`breaking:storage escape hatch: skip=${skip}`);
+            core.setOutput('skip', String(skip));
+
       - name: Run Rust task
-        if: startsWith(inputs.component, 'rust')
+        # Skipping the step (not the job) keeps the leg green so the pre-merge
+        # status roll-up stays reportable instead of pending.
+        if: >-
+          startsWith(inputs.component, 'rust') &&
+          steps.storage_hatch.outputs.skip != 'true'
         uses: ./.github/actions/rust/pre-merge
         with:
           task: ${{ inputs.task }}
           component: ${{ inputs.component }}
 
       - name: Upload coverage to Codecov
-        if: startsWith(inputs.component, 'rust') && startsWith(inputs.task, 
'test-')
+        # test-storage-compat runs its own nextest invocation without llvm-cov,
+        # so it emits no codecov.json. Uploading an empty report under the
+        # `rust` flag would read as a coverage drop on the PR.
+        if: >-
+          startsWith(inputs.component, 'rust') &&
+          startsWith(inputs.task, 'test-') &&
+          inputs.task != 'test-storage-compat'
         uses: codecov/[email protected]
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml
index edc9452f5..aae32a00c 100644
--- a/.github/workflows/pre-merge.yml
+++ b/.github/workflows/pre-merge.yml
@@ -31,6 +31,8 @@ concurrency:
 
 permissions:
   contents: read
+  # listLabelsOnIssue in _test.yml, for the breaking:storage escape hatch
+  issues: read
   security-events: write
   pull-requests: write
 
diff --git a/core/integration/src/harness/handle/server.rs 
b/core/integration/src/harness/handle/server.rs
index da6b75af3..bac9f6cb2 100644
--- a/core/integration/src/harness/handle/server.rs
+++ b/core/integration/src/harness/handle/server.rs
@@ -726,6 +726,17 @@ impl ServerHandle {
         self.test_transport = Some(transport);
     }
 
+    /// Point the next `start()` at a different server binary.
+    ///
+    /// `start()` re-reads `config.executable_path` on every call, so a test
+    /// can boot one build, then restart the SAME data directory under another
+    /// one. `None` restores the cargo-built binary of the crate under test,
+    /// which is why this takes an explicit `Option` rather than
+    /// `impl Into<String>`.
+    pub fn set_executable_path(&mut self, path: Option<String>) {
+        self.config.executable_path = path;
+    }
+
     /// Configure MCP server for this iggy server.
     pub fn set_mcp_config(&mut self, config: McpConfig) {
         self.mcp = Some(McpHandle::with_server_id(
diff --git a/core/integration/tests/data_integrity/mod.rs 
b/core/integration/tests/data_integrity/mod.rs
index 2418ef39b..3386feccb 100644
--- a/core/integration/tests/data_integrity/mod.rs
+++ b/core/integration/tests/data_integrity/mod.rs
@@ -39,3 +39,8 @@ mod verify_cluster_replica_data_identical;
 // Auto-commit offset replication is inherently a multi-node (VSR) property: 
the
 // backup only holds the offset if the poll's auto-commit rode consensus.
 mod verify_auto_commit_offset_replicates;
+
+// On-disk format compatibility across a binary swap. `#[ignore]`d: it needs a
+// baseline `iggy-server` built from the merge base, which only
+// `scripts/ci/storage-compat.sh` provides.
+mod storage_compat;
diff --git a/core/integration/tests/data_integrity/storage_compat.rs 
b/core/integration/tests/data_integrity/storage_compat.rs
new file mode 100644
index 000000000..5c7ce6647
--- /dev/null
+++ b/core/integration/tests/data_integrity/storage_compat.rs
@@ -0,0 +1,1473 @@
+// 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.
+
+//! On-disk format backwards compatibility.
+//!
+//! A server built from the master tip the change merges onto (the BASELINE)
+//! writes a rich data directory. The same directory is then restarted under
+//! the build under test, and everything must read back unchanged:
+//!
+//! - A checkpointed metadata snapshot holding topics with every option key,
+//!   partitions with a deletion watermark and a purge generation, consumer
+//!   groups, users with permissions and personal access tokens, all created
+//!   BEFORE the checkpoint fires, plus an uncheckpointed WAL tail holding one
+//!   more of each, created after it. The snapshot and the WAL encode the same
+//!   types differently, and either can break on its own.
+//! - Sealed and active segments in a non-zero partition, holding messages
+//!   with explicit ids, typed user headers and payload lengths that vary
+//!   inside every batch, and a partial segment deletion.
+//! - Individual and group consumer offsets, and a purge generation with
+//!   messages appended past it.
+//!
+//! The swap works because `ServerHandle::start` re-reads
+//! `config.executable_path` on every call while the data directory, the
+//! ports and the harness paths all survive a `stop`.
+//!
+//! # Why every assertion here is about DATA
+//!
+//! Three failure modes reach exit code 0 with a server that boots and serves:
+//!
+//! - A partition whose durable state cannot be read is TOMBSTONED and boot
+//!   continues. Only the log says so.
+//! - A topic loses a single storage option, per key, with no log line at
+//!   all: `TopicCreateOptions::parse_committed` deliberately discards
+//!   per-entry parse errors so one unreadable key cannot drop the rest.
+//! - A segment misparse can truncate a torn tail while the messages a test
+//!   happens to poll still read back fine, because segment batch headers
+//!   carry no magic and no version.
+//!
+//! So the test asserts the tombstone marker is absent, re-reads every seeded
+//! option value AND its provenance flag, compares every stored message field
+//! against a read taken from the baseline, and byte-compares the segment
+//! files across the swap. It stops producing MID-segment on purpose: boot
+//! unseals the last segment, so a chain that ends on a rotation boundary
+//! would only ever hand that path an empty file.
+//!
+//! # Structural false positive to avoid
+//!
+//! The harness forwards every parent `IGGY_*` variable to the server child,
+//! and the server treats an unknown `IGGY_*` name as a `debug_assert`. A pull
+//! request that adds a config leaf AND a test that sets it therefore makes
+//! the BASELINE binary die on startup, which looks like a compatibility
+//! break. Nothing here detects that: `resolve_config_paths` validates the
+//! name against the catalog of the build under test, which is the wrong side
+//! of the swap, so it only proves the name exists on HEAD. Keeping every
+//! override to a name that already exists on the merge base stays a rule the
+//! author has to follow by hand.
+//!
+//! `IGGY_TEST_VERBOSE` makes the harness inherit the server's stdout instead
+//! of capturing it, which would make the tombstone check vacuous. The
+//! graceful-shutdown assertion below is taken on the same log and fails
+//! loudly in that case rather than passing on an empty file.
+
+use bytes::Bytes;
+use iggy::prelude::*;
+use integration::harness::{
+    TestHarness, TestServerConfig, USER_PASSWORD, disk, resolve_config_paths,
+};
+use serial_test::parallel;
+use std::collections::{BTreeMap, HashMap};
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+use tokio::time::sleep;
+
+/// Absolute path to the baseline `iggy-server` binary. See
+/// [`baseline_server_binary`] for why a relative value is refused.
+///
+/// NOT `IGGY_`-prefixed on purpose: the harness forwards every parent
+/// `IGGY_*` variable to the server child, where an unknown name trips a
+/// `debug_assert` and kills the debug build this test runs against.
+const BASELINE_SERVER_ENV: &str = "COMPAT_BASELINE_SERVER";
+
+/// `metadata.journal_slots` floor for `prepare_queue_depth = 32`
+/// (`4 * max(64, 32)`). With the 64-op checkpoint margin a checkpoint fires
+/// once the journal holds 192 committed ops.
+const JOURNAL_SLOTS: &str = "256";
+
+/// Committed metadata ops between forced checkpoints at [`JOURNAL_SLOTS`].
+const CHECKPOINT_EVERY: u32 = 192;
+
+/// Stream creates that drive the metadata plane past its first checkpoint.
+/// Everything seeded before them lands in the snapshot.
+const SEED_STREAMS: u32 = 250;
+
+/// Stream creates issued after the checkpoint, so recovery must fold a
+/// snapshot AND replay a WAL suffix on top of it.
+const WAL_TAIL_STREAMS: u32 = 5;
+
+const DATA_STREAM: &str = "compat-data";
+const DATA_TOPIC: &str = "data";
+/// Created after the checkpoint, so its options only exist as a WAL record.
+const WAL_TAIL_TOPIC: &str = "tail";
+const PURGE_STREAM: &str = "compat-purge";
+const PURGE_TOPIC: &str = "purged";
+const OFFSET_CONSUMER: &str = "compat-offset-consumer";
+const READBACK_CONSUMER: &str = "compat-readback";
+const OFFSET_GROUP: &str = "compat-offset-group";
+const TRANSIENT_GROUP: &str = "compat-transient-group";
+const COMPAT_USER: &str = "compat-user";
+const COMPAT_PAT: &str = "compat-pat";
+const WAL_TAIL_USER: &str = "compat-tail-user";
+const WAL_TAIL_PAT: &str = "compat-tail-pat";
+
+/// Partitions per seeded topic. Non-default (the default is 1), so the count
+/// is itself a recovered value, and more than one so [`SEEDED_PARTITION`]
+/// can be non-zero.
+const PARTITIONS_COUNT: u32 = 2;
+
+/// The partition every message, offset and purge check targets. Non-zero on
+/// purpose: the segment batch header carries the partition id, and a field
+/// that decoded as 0 is indistinguishable from a correct one when the only
+/// seeded partition is 0. Partition 0 stays empty.
+const SEEDED_PARTITION: u32 = 1;
+
+/// Exactly `MIN_TOPIC_SEGMENT_SIZE`, and a 512-byte multiple, so it passes
+/// create-time validation while keeping the produced data small.
+const SEGMENT_SIZE_BYTES: u64 = 1024 * 1024;
+
+/// Byte flush threshold. Together with `messages_required_to_save = 1` it
+/// forces every committed message onto a segment file, instead of leaving it
+/// in the in-memory journal until the shutdown flush.
+const FLUSH_SIZE_BYTES: u64 = 4096;
+
+/// Seeded `message_expiry`, far enough out that nothing expires mid-run.
+///
+/// The value matters only in that it is NOT the default (`NeverExpire`): a key
+/// lost across the swap is re-derived at its default, so seeding the default
+/// would make the assertion pass either way.
+const MESSAGE_EXPIRY_SECS: u64 = 30 * 24 * 60 * 60;
+const WAL_TAIL_MESSAGE_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
+
+/// Seeded `max_topic_size`. Non-default for the same reason (the default is
+/// `Unlimited`), and far above the few MiB this test produces so retention
+/// never trims a segment.
+const MAX_TOPIC_SIZE_BYTES: u64 = 512 * 1024 * 1024;
+const WAL_TAIL_MAX_TOPIC_SIZE_BYTES: u64 = 256 * 1024 * 1024;
+
+/// Base payload length; [`payload_for`] adds [`PAYLOAD_STEP`] per position
+/// inside a batch.
+const PAYLOAD_SIZE: usize = 16 * 1024;
+const PAYLOAD_STEP: usize = 64;
+
+/// Messages a segment holds before the append that crosses
+/// [`SEGMENT_SIZE_BYTES`] seals it. One [`SEND_BATCH`] costs 272304 bytes on
+/// disk: a 256-byte batch header, then per message a 48-byte frame header,
+/// the 91 bytes of [`user_headers_for`] and the payload, whose lengths sum
+/// to 269824 over the 16 positions of [`payload_for`]. Three batches stay
+/// under 1 MiB, the fourth crosses it.
+const MESSAGES_PER_SEGMENT: u64 = 4 * SEND_BATCH;
+
+/// Sealed segments the seed leaves behind, before the deletion in step 3.
+const SEALED_SEGMENTS: u64 = 5;
+
+/// Messages left in the ACTIVE segment once production stops.
+///
+/// Non-zero on purpose. Boot unseals the last segment, and a run that stopped
+/// on a segment boundary reopens an EMPTY file, so neither the unseal nor a
+/// torn-tail truncation of a partially written segment is ever exercised.
+/// Not a multiple of [`SEND_BATCH`] either, so the final batch is short.
+const ACTIVE_SEGMENT_MESSAGES: u64 = 10;
+
+const PRODUCED_MESSAGES: u64 = SEALED_SEGMENTS * MESSAGES_PER_SEGMENT + 
ACTIVE_SEGMENT_MESSAGES;
+
+const SEND_BATCH: u64 = 16;
+
+/// Segment logs (the sealed ones plus the active one) the seed waits for
+/// before deleting one, so a sealed segment is still left behind.
+const MIN_SEGMENTS_BEFORE_DELETE: usize = 4;
+
+/// Messages produced into the topic that is purged afterwards.
+const PURGED_MESSAGES: u64 = 8;
+
+/// Messages appended to the purged topic AFTER the purge, at generation 1.
+///
+/// Without them the purge check is vacuous. An unreadable `purge.gen` reads
+/// as 0 on purpose (the absent-or-torn sentinel), the reconciler then
+/// re-purges the partition, and an empty topic passes a `messages_count == 0`
+/// assertion whether or not the generation decoded. Only messages that must
+/// SURVIVE the swap can tell the two apart.
+const POST_PURGE_MESSAGES: u64 = 8;
+
+/// Seeded personal access token expiry. Non-default (the default is
+/// `NeverExpire`, which reads back as `None`) so a dropped or misread field
+/// cannot pass as the default.
+const PAT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60;
+const WAL_TAIL_PAT_EXPIRY_SECS: u64 = 3 * 24 * 60 * 60;
+
+/// Contiguous messages re-polled after the swap.
+const READBACK_COUNT: u32 = 16;
+
+/// Bound on every [`wait_until`] probe loop.
+///
+/// Kept small because the whole test has to finish inside nextest's 300s hard
+/// kill (`slow-timeout` x `terminate-after` in `.config/nextest.toml`, and the
+/// driving script deliberately runs without `--profile ci`). Two boots at 60s
+/// plus two stops at 5s plus seven of these waits is 200s. A SIGKILL past
+/// that budget would take the named `wait_until` message with it, losing the
+/// diagnostic in exactly the run that needed it. At a 200ms
+/// [`POLL_INTERVAL`] this is still ~50 probes per wait.
+const SETTLE_TIMEOUT: Duration = Duration::from_secs(10);
+const POLL_INTERVAL: Duration = Duration::from_millis(200);
+
+/// Substring shared by all three tombstone log lines in
+/// `core/server/src/bootstrap.rs`. Matching the full sentence of any one of
+/// them would miss the others, and a segment layout change lands on the
+/// first arm, not the consensus-state one.
+///
+/// Being shorter than a whole word is what makes it match every arm, and also
+/// what makes it collide with [`TOMBSTONE_FIELD`]. Strip that before matching
+/// rather than lengthening this.
+const TOMBSTONE_MARKER: &str = "tombston";
+
+/// The one tracing FIELD in the server named for the tombstone state
+/// (`partition_reconciler.rs`, the parked-frame discard). Fields render as
+/// `key=value`, so a shard that is merely not the namespace's owner logs
+/// `tombstoned=false`, which contains [`TOMBSTONE_MARKER`] on a HEALTHY run.
+///
+/// Removed from the log before the absence check. Only this exact field form
+/// goes: every real arm reads `tombstoning it`, `tombstoned rather than` or
+/// `tombstoning the`, and none of them emits a `tombstoned` field, so their
+/// detection is untouched.
+///
+/// The collision is dormant at the default `info` level, but the line is
+/// `debug!`, and two independent variables reach the child: the harness
+/// forwards every `IGGY_*` name, and `RUST_LOG` is inherited by any process
+/// and OUTRANKS the configured level. Filtering the text keeps the check
+/// working under both instead of refusing to run.
+const TOMBSTONE_FIELD: &str = "tombstoned=";
+
+/// Logged by `iggy-server`'s `main` once shutdown ran to completion. A stop
+/// that escalated to `SIGKILL` leaves crash-recovery state behind, which
+/// would make the next boot exercise crash recovery and compatibility at the
+/// same time.
+const GRACEFUL_SHUTDOWN_MARKER: &str = "server shutdown complete";
+
+#[tokio::test]
+#[parallel]
+#[ignore = "needs a baseline iggy-server built from master; run via 
scripts/ci/storage-compat.sh"]
+async fn should_read_back_a_data_directory_written_by_the_baseline_server() {
+    let baseline = baseline_server_binary();
+    let envs = resolve_config_paths(&HashMap::from([(
+        "metadata.journal_slots".to_string(),
+        JOURNAL_SLOTS.to_string(),
+    )]))
+    .expect("metadata.journal_slots resolves against the live config catalog");
+
+    let mut harness = TestHarness::builder()
+        .cluster_nodes(1)
+        .server(
+            TestServerConfig::builder()
+                .executable_path(baseline.clone())
+                .extra_envs(envs)
+                .build(),
+        )
+        .build()
+        .unwrap();
+    harness.start().await.unwrap();
+
+    let data_path = harness.server().data_path();
+    let client = harness.tcp_root_client().await.unwrap();
+
+    // 1. The topic carrying the segment chain. The flush thresholds are load
+    //    bearing: without them committed messages stay in the journal and the
+    //    partition directory looks empty until shutdown.
+    let data_stream_details = client.create_stream(DATA_STREAM).await.unwrap();
+    let data_stream = Identifier::numeric(data_stream_details.id).unwrap();
+    let data_topic_details = client
+        .create_topic(&data_stream, DATA_TOPIC, &data_topic_options())
+        .await
+        .unwrap();
+    let data_topic = Identifier::numeric(data_topic_details.id).unwrap();
+    let partition = partition_dir(
+        &data_path,
+        data_stream_details.id,
+        data_topic_details.id,
+        SEEDED_PARTITION,
+    );
+
+    // 2. Produce past [`SEALED_SEGMENTS`] rotations and then stop MID-segment.
+    //    A segment rolls on the append that crosses the target, so the
+    //    crossing batch lands whole and the tail batch stays in the active 
one.
+    let mut produced = 0u64;
+    while produced < PRODUCED_MESSAGES {
+        let batch_end = (produced + SEND_BATCH).min(PRODUCED_MESSAGES);
+        let mut batch: Vec<IggyMessage> = (produced..batch_end)
+            .map(|offset| seeded_message(offset, payload_for(offset)))
+            .collect();
+        client
+            .send_messages(
+                &data_stream,
+                &data_topic,
+                &Partitioning::partition_id(SEEDED_PARTITION),
+                &mut batch,
+            )
+            .await
+            .unwrap_or_else(|error| panic!("send messages 
{produced}..{batch_end}: {error}"));
+        produced = batch_end;
+    }
+
+    wait_until(
+        "the produced messages to seal enough segments",
+        async || {
+            let logs = segment_logs(&partition);
+            if logs.len() >= MIN_SEGMENTS_BEFORE_DELETE {
+                Ok(())
+            } else {
+                Err(format!(
+                    "{} holds {} segment log(s) ({logs:?}), need 
{MIN_SEGMENTS_BEFORE_DELETE}",
+                    partition.display(),
+                    logs.len()
+                ))
+            }
+        },
+    )
+    .await;
+    let before_delete = segment_logs(&partition);
+
+    // 3. Delete the oldest sealed segment, BEFORE any consumer offset exists:
+    //    a committed offset is a retention barrier the removal stops at. The
+    //    ack only records the truncation watermark; the reconciler removes the
+    //    files on a later commit-driven pass.
+    client
+        .delete_segments(&data_stream, &data_topic, SEEDED_PARTITION, 1)
+        .await
+        .unwrap();
+    let deleted_segment = before_delete[0].clone();
+    wait_until("the deleted segment to leave disk", async || {
+        let logs = segment_logs(&partition);
+        if logs.contains(&deleted_segment) {
+            Err(format!(
+                "{deleted_segment} is still present in {} ({logs:?})",
+                partition.display()
+            ))
+        } else {
+            Ok(())
+        }
+    })
+    .await;
+
+    let retained = segment_logs(&partition);
+    assert!(
+        retained.len() >= 2,
+        "the seed must keep at least one sealed segment beside the active one, 
got {retained:?}"
+    );
+    let first_retained_offset = base_offset_of(&retained[0]);
+    assert!(
+        first_retained_offset > 0,
+        "deleting the oldest segment must move the partition's first offset 
off zero, \
+         segments before={before_delete:?} after={retained:?}"
+    );
+
+    // 4. Both offset flavours, so `offsets/consumers/` and `offsets/groups/`
+    //    are populated.
+    let offset_consumer = 
Consumer::new(Identifier::named(OFFSET_CONSUMER).unwrap());
+    client
+        .store_consumer_offset(
+            &offset_consumer,
+            &data_stream,
+            &data_topic,
+            Some(SEEDED_PARTITION),
+            first_retained_offset,
+        )
+        .await
+        .unwrap();
+
+    client
+        .create_consumer_group(&data_stream, &data_topic, OFFSET_GROUP)
+        .await
+        .unwrap();
+    // Storing a GROUP offset is a partition op gated on membership: a caller
+    // that only created the group does not own the partition at the current
+    // generation, so the namespace resolve fails and the server replies
+    // ResourceNotFound with an empty body.
+    client
+        .join_consumer_group(
+            &data_stream,
+            &data_topic,
+            &Identifier::named(OFFSET_GROUP).unwrap(),
+        )
+        .await
+        .unwrap();
+    let offset_group = 
Consumer::group(Identifier::named(OFFSET_GROUP).unwrap());
+    client
+        .store_consumer_offset(
+            &offset_group,
+            &data_stream,
+            &data_topic,
+            Some(SEEDED_PARTITION),
+            first_retained_offset,
+        )
+        .await
+        .unwrap();
+
+    for kind in ["consumers", "groups"] {
+        let dir = partition.join("offsets").join(kind);
+        wait_until(&format!("the {kind} offset to reach disk"), async || {
+            let count = fs::read_dir(&dir)
+                .map(|entries| entries.flatten().count())
+                .unwrap_or(0);
+            if count > 0 {
+                Ok(())
+            } else {
+                Err(format!("{} holds no offset file", dir.display()))
+            }
+        })
+        .await;
+    }
+
+    // 5. A purge on a SEPARATE topic: it resets the partition and empties its
+    //    segment chain, so it must not touch the one above.
+    let purge_stream_details = 
client.create_stream(PURGE_STREAM).await.unwrap();
+    let purge_stream = Identifier::numeric(purge_stream_details.id).unwrap();
+    let purge_topic_details = client
+        .create_topic(&purge_stream, PURGE_TOPIC, &purge_topic_options())
+        .await
+        .unwrap();
+    let purge_topic = Identifier::numeric(purge_topic_details.id).unwrap();
+    let mut purged_batch: Vec<IggyMessage> = (0..PURGED_MESSAGES)
+        .map(|index| seeded_message(index, 
Bytes::from(format!("compat-purged-{index}"))))
+        .collect();
+    client
+        .send_messages(
+            &purge_stream,
+            &purge_topic,
+            &Partitioning::partition_id(SEEDED_PARTITION),
+            &mut purged_batch,
+        )
+        .await
+        .unwrap();
+    client
+        .purge_topic(&purge_stream, &purge_topic)
+        .await
+        .unwrap();
+
+    let purge_generation = partition_dir(
+        &data_path,
+        purge_stream_details.id,
+        purge_topic_details.id,
+        SEEDED_PARTITION,
+    )
+    .join("purge.gen");
+    wait_until("the purge generation to reach disk", async || {
+        if purge_generation.is_file() {
+            Ok(())
+        } else {
+            Err(format!("{} does not exist", purge_generation.display()))
+        }
+    })
+    .await;
+
+    // Appended once the generation is durable, so they sit at offset 0 of the
+    // reset chain and only survive a boot that decodes `purge.gen`.
+    let mut post_purge_batch: Vec<IggyMessage> = (0..POST_PURGE_MESSAGES)
+        .map(|index| seeded_message(index, post_purge_payload(index)))
+        .collect();
+    client
+        .send_messages(
+            &purge_stream,
+            &purge_topic,
+            &Partitioning::partition_id(SEEDED_PARTITION),
+            &mut post_purge_batch,
+        )
+        .await
+        .unwrap();
+
+    // 6. A user with permissions at every level, and a personal access token.
+    let permissions = seeded_permissions(data_stream_details.id, 
data_topic_details.id);
+    client
+        .create_user(
+            COMPAT_USER,
+            USER_PASSWORD,
+            UserStatus::Active,
+            Some(permissions.clone()),
+        )
+        .await
+        .unwrap();
+    let seeded_user = client
+        .get_user(&Identifier::named(COMPAT_USER).unwrap())
+        .await
+        .unwrap()
+        .expect("the baseline lists the user it just created");
+    assert_eq!(
+        seeded_user.permissions,
+        Some(permissions.clone()),
+        "the baseline must report the permissions exactly as seeded, or a 
post-swap mismatch \
+         could not be attributed to the swap"
+    );
+    let pat = create_token(&client, COMPAT_PAT, PAT_EXPIRY_SECS).await;
+
+    // 7. Drive the metadata plane past its first forced checkpoint. Everything
+    //    above is now encoded in `snapshot.bin`, which the second boot must
+    //    fold in as its recovery floor; everything below only exists as WAL
+    //    records replayed on top of it.
+    for index in 0..SEED_STREAMS {
+        client
+            .create_stream(&format!("compat-seed-{index}"))
+            .await
+            .unwrap_or_else(|error| panic!("create seed stream {index}: 
{error}"));
+    }
+
+    let snapshot_path = data_path.join("metadata").join("snapshot.bin");
+    wait_until(
+        "the metadata checkpoint to persist a snapshot",
+        async || match fs::metadata(&snapshot_path).map(|meta| meta.len()) {
+            Ok(len) if len > 0 => Ok(()),
+            other => Err(format!(
+                "{SEED_STREAMS} committed stream creates must cross the 
{CHECKPOINT_EVERY}-op \
+                 checkpoint and persist a non-empty snapshot at {}, got 
{other:?}",
+                snapshot_path.display()
+            )),
+        },
+    )
+    .await;
+
+    // 8. One more of each rich type past the checkpoint, so the WAL encodings
+    //    are exercised as well: a topic with every option key, a user with
+    //    permissions, a token, a group whose membership churned, and streams.
+    let tail_topic_details = client
+        .create_topic(&data_stream, WAL_TAIL_TOPIC, &wal_tail_topic_options())
+        .await
+        .unwrap();
+    let tail_permissions = seeded_permissions(data_stream_details.id, 
tail_topic_details.id);
+    client
+        .create_user(
+            WAL_TAIL_USER,
+            USER_PASSWORD,
+            UserStatus::Active,
+            Some(tail_permissions.clone()),
+        )
+        .await
+        .unwrap();
+    let tail_pat = create_token(&client, WAL_TAIL_PAT, 
WAL_TAIL_PAT_EXPIRY_SECS).await;
+    client
+        .create_consumer_group(&data_stream, &data_topic, TRANSIENT_GROUP)
+        .await
+        .unwrap();
+    let transient_group = Identifier::named(TRANSIENT_GROUP).unwrap();
+    client
+        .join_consumer_group(&data_stream, &data_topic, &transient_group)
+        .await
+        .unwrap();
+    client
+        .leave_consumer_group(&data_stream, &data_topic, &transient_group)
+        .await
+        .unwrap();
+
+    for index in 0..WAL_TAIL_STREAMS {
+        client
+            .create_stream(&format!("compat-tail-{index}"))
+            .await
+            .unwrap_or_else(|error| panic!("create tail stream {index}: 
{error}"));
+    }
+
+    // 9. What the baseline serves, captured for a field-by-field comparison
+    //    after the swap. Checked against the seed first, so a post-swap
+    //    mismatch can be attributed to the swap.
+    let expected_streams = stream_catalog(&client).await;
+    assert_eq!(
+        expected_streams.len(),
+        (SEED_STREAMS + WAL_TAIL_STREAMS + 2) as usize,
+        "the seed must have created every stream before the swap"
+    );
+    let readback = 
Consumer::new(Identifier::named(READBACK_CONSUMER).unwrap());
+    let last_offset = PRODUCED_MESSAGES - 1;
+    let retained_before = poll_window(
+        &client,
+        &data_stream,
+        &data_topic,
+        &readback,
+        first_retained_offset,
+        READBACK_COUNT,
+    )
+    .await;
+    assert_seeded_messages(
+        "the retained window",
+        &retained_before,
+        first_retained_offset,
+        READBACK_COUNT as usize,
+        payload_for,
+    );
+    let tail_before = poll_window(
+        &client,
+        &data_stream,
+        &data_topic,
+        &readback,
+        last_offset,
+        1,
+    )
+    .await;
+    assert_seeded_messages(
+        "the last produced message",
+        &tail_before,
+        last_offset,
+        1,
+        payload_for,
+    );
+    let post_purge_before = poll_window(
+        &client,
+        &purge_stream,
+        &purge_topic,
+        &readback,
+        0,
+        READBACK_COUNT,
+    )
+    .await;
+    assert_seeded_messages(
+        "the post-purge messages",
+        &post_purge_before,
+        0,
+        POST_PURGE_MESSAGES as usize,
+        post_purge_payload,
+    );
+
+    drop(client);
+    harness.stop().await.unwrap();
+    assert_graceful_shutdown(&harness, "the baseline server");
+
+    let baseline_files = disk::collect_comparable_files(&data_path, false);
+    assert!(
+        baseline_files
+            .keys()
+            .any(|rel| rel.starts_with("streams/") && rel.ends_with(".log")),
+        "the baseline wrote no segment .log under {}, so the byte comparison 
would be vacuous \
+         (found: {:?})",
+        data_path.display(),
+        baseline_files.keys().collect::<Vec<_>>()
+    );
+
+    // The active segment must be PARTIALLY filled: anything at or above the
+    // target is a sealed one. Boot unseals the last segment, so an empty
+    // active segment leaves both the reopen and any torn-tail truncation the
+    // comparison below would catch unreachable.
+    let active_segment = retained.last().expect("the retained chain is 
non-empty");
+    let active_relative = partition
+        .strip_prefix(&data_path)
+        .expect("the partition directory sits under the data directory")
+        .join(active_segment)
+        .to_string_lossy()
+        .replace('\\', "/");
+    let active_bytes = baseline_files
+        .get(&active_relative)
+        .unwrap_or_else(|| panic!("the baseline wrote no {active_relative}"))
+        .len() as u64;
+    assert!(
+        active_bytes > 0 && active_bytes < SEGMENT_SIZE_BYTES,
+        "{active_segment} holds {active_bytes} byte(s), so it is not a 
partially filled active \
+         segment. Production must stop between two rotations: 
PRODUCED_MESSAGES \
+         ({PRODUCED_MESSAGES}) must not be a multiple of MESSAGES_PER_SEGMENT \
+         ({MESSAGES_PER_SEGMENT}), and the on-disk batch cost the latter is 
derived from must \
+         still hold. Segments: {retained:?}"
+    );
+
+    // The swap: `None` selects the cargo-built binary of the crate under test.
+    harness.server_mut().set_executable_path(None);
+    harness.restart_server().await.unwrap();
+
+    // `tcp_root_client` hands out a client the harness does not own, so the
+    // reconnect loop inside `restart_server` never touched it. Take a fresh 
one.
+    let client = harness.tcp_root_client().await.unwrap();
+
+    // Ids AND names: later reads resolve by numeric id, so a corrupted name
+    // would pass a bare count.
+    assert_eq!(
+        stream_catalog(&client).await,
+        expected_streams,
+        "streams written by the baseline must all recover with their ids and 
names, both the \
+         checkpointed prefix and the WAL tail"
+    );
+
+    assert_topic_recovered(&client, &data_stream, DATA_TOPIC, 
&data_topic_options()).await;
+    assert_topic_recovered(
+        &client,
+        &data_stream,
+        WAL_TAIL_TOPIC,
+        &wal_tail_topic_options(),
+    )
+    .await;
+    assert_topic_recovered(&client, &purge_stream, PURGE_TOPIC, 
&purge_topic_options()).await;
+
+    assert_eq!(
+        segment_logs(&partition),
+        retained,
+        "the segment chain must recover exactly as the baseline left it"
+    );
+
+    assert_messages_identical(
+        "the retained window",
+        &retained_before,
+        &poll_window(
+            &client,
+            &data_stream,
+            &data_topic,
+            &readback,
+            first_retained_offset,
+            READBACK_COUNT,
+        )
+        .await,
+    );
+    assert_messages_identical(
+        "the last produced message",
+        &tail_before,
+        &poll_window(
+            &client,
+            &data_stream,
+            &data_topic,
+            &readback,
+            last_offset,
+            1,
+        )
+        .await,
+    );
+
+    let stored = client
+        .get_consumer_offset(
+            &offset_consumer,
+            &data_stream,
+            &data_topic,
+            Some(SEEDED_PARTITION),
+        )
+        .await
+        .unwrap()
+        .expect("the individual consumer offset survives the swap");
+    assert_eq!(
+        stored.stored_offset, first_retained_offset,
+        "individual consumer offset"
+    );
+    let stored_group = client
+        .get_consumer_offset(
+            &offset_group,
+            &data_stream,
+            &data_topic,
+            Some(SEEDED_PARTITION),
+        )
+        .await
+        .unwrap()
+        .expect("the group consumer offset survives the swap");
+    assert_eq!(
+        stored_group.stored_offset, first_retained_offset,
+        "group consumer offset"
+    );
+    assert_eq!(
+        disk::read_replicated_consumer_offset(&data_path),
+        Some(first_retained_offset),
+        "the on-disk offset record must decode to the offset the baseline 
stored"
+    );
+
+    let purged = client
+        .get_topic(&purge_stream, &purge_topic)
+        .await
+        .unwrap()
+        .expect("the purged topic survives the swap");
+    assert_eq!(
+        purged.messages_count, POST_PURGE_MESSAGES,
+        "the purged topic must hold exactly the messages appended after the 
purge: fewer means \
+         `purge.gen` read back as 0 and the partition was purged again, more 
means the purge \
+         itself was lost"
+    );
+    assert_messages_identical(
+        "the post-purge messages",
+        &post_purge_before,
+        &poll_window(
+            &client,
+            &purge_stream,
+            &purge_topic,
+            &readback,
+            0,
+            READBACK_COUNT,
+        )
+        .await,
+    );
+
+    // A generation that decoded ABOVE the committed one passes both checks
+    // above, nothing was re-purged, while parking the partition past every
+    // purge the topic will ever commit: the reconciler stages a reset only
+    // for `committed > applied`. So a purge issued under the build under test
+    // must still take effect.
+    client
+        .purge_topic(&purge_stream, &purge_topic)
+        .await
+        .unwrap();
+    wait_until("the post-swap purge to empty the topic", async || {
+        let topic = client
+            .get_topic(&purge_stream, &purge_topic)
+            .await
+            .map_err(|error| error.to_string())?
+            .ok_or_else(|| "the purged topic is gone".to_string())?;
+        if topic.messages_count == 0 {
+            Ok(())
+        } else {
+            Err(format!(
+                "messages_count is still {}: the reconciler staged no reset, 
so the recovered \
+                 generation must sit at or above the newly committed one",
+                topic.messages_count
+            ))
+        }
+    })
+    .await;
+    let after_purge = poll_window(
+        &client,
+        &purge_stream,
+        &purge_topic,
+        &readback,
+        0,
+        READBACK_COUNT,
+    )
+    .await;
+    assert!(
+        after_purge.is_empty(),
+        "offset 0 of the purged topic must be empty after the post-swap purge, 
got {} message(s)",
+        after_purge.len()
+    );
+
+    assert_user_recovered(&harness, &client, COMPAT_USER, &permissions).await;
+    assert_user_recovered(&harness, &client, WAL_TAIL_USER, 
&tail_permissions).await;
+    assert_token_recovered(&harness, &client, COMPAT_PAT, &pat).await;
+    assert_token_recovered(&harness, &client, WAL_TAIL_PAT, &tail_pat).await;
+
+    let groups = client
+        .get_consumer_groups(&data_stream, &data_topic)
+        .await
+        .unwrap();
+    let group_names: Vec<&str> = groups.iter().map(|group| 
group.name.as_str()).collect();
+    assert!(
+        group_names.contains(&OFFSET_GROUP) && 
group_names.contains(&TRANSIENT_GROUP),
+        "both consumer groups must survive the swap, got {group_names:?}"
+    );
+
+    drop(client);
+    harness.stop().await.unwrap();
+
+    // Taken after the readback so the non-blocking stdout appender has had the
+    // whole run to drain, and after a positive marker so an uncaptured log
+    // fails loudly instead of passing the absence check vacuously.
+    assert_graceful_shutdown(&harness, "the server under test");
+    // `stdout_plain` returns the same ANSI-stripped text `stdout_contains`
+    // matches against, so filtering it costs no matching power.
+    let stdout = harness.server().stdout_plain();
+    assert!(
+        !stdout
+            .replace(TOMBSTONE_FIELD, "")
+            .contains(TOMBSTONE_MARKER),
+        "the server under test tombstoned a partition rather than reading the 
baseline's \
+         durable state; boot still exits 0, so only the log says so. Server 
stdout:\n{stdout}"
+    );
+
+    // The purged topic was rewritten by the post-swap purge on purpose, so
+    // only the rest of the tree is held to byte identity.
+    assert_segments_identical(
+        &baseline_files,
+        &disk::collect_comparable_files(&data_path, false),
+        &topic_prefix(purge_stream_details.id, purge_topic_details.id),
+    );
+}
+
+/// Resolve the baseline binary to an absolute, existing path.
+///
+/// The absolute requirement is not cosmetic. `ServerHandle::start` only spawns
+/// the configured path directly when it has more than one component or already
+/// exists; a bare name that does not exist falls through to
+/// `Command::cargo_bin`, which resolves the binary of the crate under test. So
+/// a single-component value would boot the HEAD build while the test reported
+/// it as the baseline, comparing master against master and passing forever.
+/// The same silent-green family as running a stale binary.
+fn baseline_server_binary() -> String {
+    let raw = std::env::var(BASELINE_SERVER_ENV).unwrap_or_else(|_| {
+        panic!(
+            "{BASELINE_SERVER_ENV} is unset. It must be an ABSOLUTE path to 
the iggy-server \
+             binary built from master. Without it this test would boot the 
build under test \
+             twice and prove nothing, so it refuses to run. Use 
scripts/ci/storage-compat.sh, \
+             which builds the baseline and exports the variable."
+        )
+    });
+
+    let path = PathBuf::from(&raw);
+    assert!(
+        path.is_absolute(),
+        "{BASELINE_SERVER_ENV}={raw:?} is relative. It must be an ABSOLUTE 
path: a bare binary \
+         name that does not exist on disk is silently resolved as the build 
under test, which \
+         would compare master against master and report green forever."
+    );
+    let resolved = fs::canonicalize(&path).unwrap_or_else(|error| {
+        panic!(
+            "{BASELINE_SERVER_ENV}={raw:?} does not resolve: {error}. It must 
point at an \
+             iggy-server binary built from master."
+        )
+    });
+    assert!(
+        resolved.is_file(),
+        "{BASELINE_SERVER_ENV}={raw:?} resolves to {}, which is not a file.",
+        resolved.display()
+    );
+    resolved.display().to_string()
+}
+
+/// Options of the topic that carries the segment chain. Every key but
+/// `compression_algorithm` is sent, so that one must come back derived while
+/// the rest come back explicit.
+fn data_topic_options() -> TopicCreateOptions {
+    TopicCreateOptions {
+        partitions_count: Some(PARTITIONS_COUNT),
+        message_expiry: 
Some(IggyExpiry::ExpireDuration(IggyDuration::new_from_secs(
+            MESSAGE_EXPIRY_SECS,
+        ))),
+        max_topic_size: Some(MaxTopicSize::Custom(IggyByteSize::from(
+            MAX_TOPIC_SIZE_BYTES,
+        ))),
+        segment_size: Some(IggyByteSize::from(SEGMENT_SIZE_BYTES)),
+        enforce_fsync: Some(true),
+        messages_required_to_save: Some(1),
+        size_of_messages_required_to_save: 
Some(IggyByteSize::from(FLUSH_SIZE_BYTES)),
+        // Left at the default, so only its provenance flag can tell a
+        // surviving key from a re-derived one. Turning it on would reserve
+        // the whole segment up front, which changes the shape of the active
+        // segment the byte comparison certifies.
+        preallocate_segments: Some(false),
+        ..TopicCreateOptions::default()
+    }
+}
+
+/// Options of the topic created after the checkpoint. Different values from
+/// [`data_topic_options`] where a key has a usable one, so a record attributed
+/// to the wrong topic cannot pass, and a different unsent key
+/// (`enforce_fsync`) for the provenance check.
+fn wal_tail_topic_options() -> TopicCreateOptions {
+    TopicCreateOptions {
+        partitions_count: Some(PARTITIONS_COUNT),
+        compression_algorithm: Some(CompressionAlgorithm::Gzip),
+        message_expiry: 
Some(IggyExpiry::ExpireDuration(IggyDuration::new_from_secs(
+            WAL_TAIL_MESSAGE_EXPIRY_SECS,
+        ))),
+        max_topic_size: Some(MaxTopicSize::Custom(IggyByteSize::from(
+            WAL_TAIL_MAX_TOPIC_SIZE_BYTES,
+        ))),
+        segment_size: Some(IggyByteSize::from(2 * SEGMENT_SIZE_BYTES)),
+        messages_required_to_save: Some(1),
+        size_of_messages_required_to_save: Some(IggyByteSize::from(2 * 
FLUSH_SIZE_BYTES)),
+        preallocate_segments: Some(false),
+        ..TopicCreateOptions::default()
+    }
+}
+
+/// Options of the topic that is purged. `compression_algorithm` is stored
+/// topic metadata only (the server compresses nothing), so Gzip is just a
+/// non-default value that must round-trip.
+fn purge_topic_options() -> TopicCreateOptions {
+    TopicCreateOptions {
+        partitions_count: Some(PARTITIONS_COUNT),
+        compression_algorithm: Some(CompressionAlgorithm::Gzip),
+        messages_required_to_save: Some(1),
+        ..TopicCreateOptions::default()
+    }
+}
+
+/// Deterministic message for `offset`: an explicit id, typed user headers and
+/// the given payload, so a readback can name exactly which message diverged.
+fn seeded_message(offset: u64, payload: Bytes) -> IggyMessage {
+    IggyMessage::builder()
+        .id(message_id_for(offset))
+        .payload(payload)
+        .user_headers(user_headers_for(offset))
+        .build()
+        .unwrap()
+}
+
+/// Both 64-bit halves non-zero and unequal, so a swapped, truncated or zeroed
+/// half cannot pass. The builder's default id is 0.
+fn message_id_for(offset: u64) -> u128 {
+    ((u128::from(offset) + 1) << 64) | u128::from(u64::MAX - offset)
+}
+
+/// One value of each of four kinds. Fixed width, so every message costs the
+/// same header bytes and the segment math in [`MESSAGES_PER_SEGMENT`] holds.
+fn user_headers_for(offset: u64) -> BTreeMap<HeaderKey, HeaderValue> {
+    BTreeMap::from([
+        (
+            HeaderKey::try_from("offset").unwrap(),
+            HeaderValue::from(offset),
+        ),
+        (
+            HeaderKey::try_from("origin").unwrap(),
+            HeaderValue::try_from(format!("compat-{offset:06}")).unwrap(),
+        ),
+        (
+            HeaderKey::try_from("even").unwrap(),
+            HeaderValue::from(offset.is_multiple_of(2)),
+        ),
+        (
+            HeaderKey::try_from("ratio").unwrap(),
+            HeaderValue::from(1.0 / (offset as f64 + 1.0)),
+        ),
+    ])
+}
+
+/// Prefixed with its own offset, then padded to a length that varies with
+/// the position inside the batch, so a decoder that reused one message's
+/// `payload_length` for the next cannot pass. The pattern repeats every
+/// [`SEND_BATCH`], so every batch costs the same on disk.
+fn payload_for(offset: u64) -> Bytes {
+    let mut bytes = format!("compat-message-{offset:06}").into_bytes();
+    bytes.resize(
+        PAYLOAD_SIZE + (offset % SEND_BATCH) as usize * PAYLOAD_STEP,
+        b'.',
+    );
+    Bytes::from(bytes)
+}
+
+fn post_purge_payload(index: u64) -> Bytes {
+    Bytes::from(format!("compat-post-purge-{index}"))
+}
+
+/// Alternating bits at every level. All-true (the harness default) reads back
+/// identical under any field permutation, and a level left `None` cannot tell
+/// a dropped map from one never seeded.
+fn seeded_permissions(stream_id: u32, topic_id: u32) -> Permissions {
+    Permissions {
+        global: GlobalPermissions {
+            manage_servers: false,
+            read_servers: true,
+            manage_users: false,
+            read_users: true,
+            manage_streams: false,
+            read_streams: true,
+            manage_topics: false,
+            read_topics: true,
+            poll_messages: false,
+            send_messages: true,
+        },
+        streams: Some(BTreeMap::from([(
+            stream_id as usize,
+            StreamPermissions {
+                manage_stream: true,
+                read_stream: false,
+                manage_topics: true,
+                read_topics: false,
+                poll_messages: true,
+                send_messages: false,
+                topics: Some(BTreeMap::from([(
+                    topic_id as usize,
+                    TopicPermissions {
+                        manage_topic: false,
+                        read_topic: true,
+                        poll_messages: false,
+                        send_messages: true,
+                    },
+                )])),
+            },
+        )])),
+    }
+}
+
+/// A personal access token as the baseline handed it out: the raw token a
+/// later login presents, and the stored expiry a listing must reproduce.
+struct SeededToken {
+    raw: String,
+    expiry_at: IggyTimestamp,
+}
+
+async fn create_token(client: &IggyClient, name: &str, expiry_secs: u64) -> 
SeededToken {
+    let raw = client
+        .create_personal_access_token(
+            name,
+            
PersonalAccessTokenExpiry::ExpireDuration(IggyDuration::new_from_secs(expiry_secs)),
+        )
+        .await
+        .unwrap()
+        .token;
+    let expiry_at = client
+        .get_personal_access_tokens()
+        .await
+        .unwrap()
+        .into_iter()
+        .find(|token| token.name == name)
+        .and_then(|token| token.expiry_at)
+        .unwrap_or_else(|| panic!("the baseline lists {name} with its 
expiry"));
+    SeededToken { raw, expiry_at }
+}
+
+/// Stream id to name, the identity the metadata plane must recover.
+async fn stream_catalog(client: &impl StreamClient) -> BTreeMap<u32, String> {
+    client
+        .get_streams()
+        .await
+        .unwrap()
+        .into_iter()
+        .map(|stream| (stream.id, stream.name))
+        .collect()
+}
+
+/// `count` messages from `offset` in [`SEEDED_PARTITION`], without committing
+/// an offset: that would add a retention barrier and a stored record of its
+/// own.
+async fn poll_window(
+    client: &IggyClient,
+    stream: &Identifier,
+    topic: &Identifier,
+    consumer: &Consumer,
+    offset: u64,
+    count: u32,
+) -> Vec<IggyMessage> {
+    let polled = client
+        .poll_messages(
+            stream,
+            topic,
+            Some(SEEDED_PARTITION),
+            consumer,
+            &PollingStrategy::offset(offset),
+            count,
+            false,
+        )
+        .await
+        .unwrap_or_else(|error| panic!("poll {count} message(s) from offset 
{offset}: {error}"));
+    assert_eq!(
+        polled.partition_id, SEEDED_PARTITION,
+        "the poll must be served from the seeded partition"
+    );
+    polled.messages
+}
+
+/// The baseline's own readback of what it was asked to store. Verified before
+/// the swap, so the captured messages are a trustworthy oracle for the
+/// field-by-field comparison after it.
+fn assert_seeded_messages(
+    what: &str,
+    messages: &[IggyMessage],
+    first_offset: u64,
+    count: usize,
+    payload: fn(u64) -> Bytes,
+) {
+    assert_eq!(
+        messages.len(),
+        count,
+        "{what}: the baseline must serve every seeded message from offset 
{first_offset}"
+    );
+    for (index, message) in messages.iter().enumerate() {
+        let offset = first_offset + index as u64;
+        assert_eq!(message.header.offset, offset, "{what}: offset");
+        assert_eq!(
+            message.header.id,
+            message_id_for(offset),
+            "{what}: id of the message at offset {offset}"
+        );
+        assert_eq!(
+            message.user_headers_map().unwrap(),
+            Some(user_headers_for(offset)),
+            "{what}: user headers of the message at offset {offset}"
+        );
+        assert!(
+            message.payload == payload(offset),
+            "{what}: the payload of the message at offset {offset} ({} bytes) 
is not the seeded one",
+            message.payload.len()
+        );
+    }
+}
+
+/// Every stored field. The header carries the id, both timestamps, the
+/// checksum and the lengths, and a payload comparison alone would pass a
+/// misread of any of them.
+fn assert_messages_identical(what: &str, baseline: &[IggyMessage], current: 
&[IggyMessage]) {
+    assert_eq!(
+        baseline.len(),
+        current.len(),
+        "{what}: message count after the swap"
+    );
+    for (before, after) in baseline.iter().zip(current) {
+        let offset = before.header.offset;
+        assert_eq!(
+            after.header, before.header,
+            "{what}: header of the message at offset {offset} (post-swap left, 
baseline right)"
+        );
+        assert_eq!(
+            after.user_headers, before.user_headers,
+            "{what}: user headers of the message at offset {offset} (post-swap 
left, baseline \
+             right)"
+        );
+        assert!(
+            after.payload == before.payload,
+            "{what}: the payload of the message at offset {offset} differs 
after the swap ({} vs \
+             {} bytes)",
+            after.payload.len(),
+            before.payload.len()
+        );
+    }
+}
+
+/// Per-key option degradation is silent, so every seeded value is re-read,
+/// and with it the provenance flag: admission refills a key it cannot read
+/// from the built-in default and marks it derived, which the value check
+/// alone cannot catch for a key seeded at its default (`preallocate_segments`
+/// has no usable non-default value here). The unsent key must come back
+/// derived, or provenance stopped round-tripping and the explicit checks
+/// prove nothing.
+async fn assert_topic_recovered(
+    client: &IggyClient,
+    stream: &Identifier,
+    name: &str,
+    seed: &TopicCreateOptions,
+) {
+    let topic = client
+        .get_topic(stream, &Identifier::named(name).unwrap())
+        .await
+        .unwrap()
+        .unwrap_or_else(|| panic!("the topic {name} must survive the swap"));
+
+    // Masked to the keys the seed sent: `from_resource_options` fills the
+    // rest from the derived defaults, which the seed leaves `None`.
+    let recovered = TopicCreateOptions::from_resource_options(&topic.options);
+    let recovered = TopicCreateOptions {
+        partitions_count: seed.partitions_count.map(|_| 
topic.partitions_count),
+        compression_algorithm: seed
+            .compression_algorithm
+            .map(|_| topic.compression_algorithm),
+        message_expiry: seed.message_expiry.map(|_| topic.message_expiry),
+        max_topic_size: seed.max_topic_size.map(|_| topic.max_topic_size),
+        segment_size: seed.segment_size.and(recovered.segment_size),
+        enforce_fsync: seed.enforce_fsync.and(recovered.enforce_fsync),
+        messages_required_to_save: seed
+            .messages_required_to_save
+            .and(recovered.messages_required_to_save),
+        size_of_messages_required_to_save: seed
+            .size_of_messages_required_to_save
+            .and(recovered.size_of_messages_required_to_save),
+        preallocate_segments: seed
+            .preallocate_segments
+            .and(recovered.preallocate_segments),
+        raw: BTreeMap::new(),
+    };
+    assert_eq!(
+        &recovered, seed,
+        "{name}: every seeded value must read back as the baseline stored it 
(recovered left, \
+         seed right); a lost key silently reverts to the shard-wide default 
with no log line"
+    );
+
+    for (key, sent) in [
+        (
+            topic_option_keys::COMPRESSION_ALGORITHM,
+            seed.compression_algorithm.is_some(),
+        ),
+        (
+            topic_option_keys::MESSAGE_EXPIRY,
+            seed.message_expiry.is_some(),
+        ),
+        (
+            topic_option_keys::MAX_TOPIC_SIZE,
+            seed.max_topic_size.is_some(),
+        ),
+        (topic_option_keys::SEGMENT_SIZE, seed.segment_size.is_some()),
+        (
+            topic_option_keys::ENFORCE_FSYNC,
+            seed.enforce_fsync.is_some(),
+        ),
+        (
+            topic_option_keys::MESSAGES_REQUIRED_TO_SAVE,
+            seed.messages_required_to_save.is_some(),
+        ),
+        (
+            topic_option_keys::SIZE_OF_MESSAGES_REQUIRED_TO_SAVE,
+            seed.size_of_messages_required_to_save.is_some(),
+        ),
+        (
+            topic_option_keys::PREALLOCATE_SEGMENTS,
+            seed.preallocate_segments.is_some(),
+        ),
+    ] {
+        let option = topic
+            .options
+            .get(&HeaderKey::from_str(key).unwrap())
+            .unwrap_or_else(|| panic!("{name}: {key} is GONE from the topic's 
options"));
+        assert_eq!(
+            option.explicit, sent,
+            "{name}: {key} provenance. A seeded key that came back DERIVED was 
dropped and \
+             refilled from the built-in default; an unsent key that came back 
EXPLICIT means \
+             provenance no longer distinguishes a surviving option from a 
re-derived one"
+        );
+    }
+}
+
+/// Listing proves the metadata; the password hash is a separate field, and a
+/// user that lists fine but cannot log in is still locked out. The login uses
+/// a fresh, unauthenticated connection, so the root session vouches for
+/// nothing.
+async fn assert_user_recovered(
+    harness: &TestHarness,
+    client: &IggyClient,
+    name: &str,
+    permissions: &Permissions,
+) {
+    let user = client
+        .get_user(&Identifier::named(name).unwrap())
+        .await
+        .unwrap()
+        .unwrap_or_else(|| panic!("the user {name} must survive the swap"));
+    assert_eq!(user.status, UserStatus::Active, "{name}: status");
+    assert_eq!(
+        user.permissions.as_ref(),
+        Some(permissions),
+        "{name}: permissions must survive the swap bit for bit at the global, 
stream and topic \
+         level"
+    );
+    let by_password = harness.tcp_new_client().await.unwrap();
+    by_password
+        .login_user(name, USER_PASSWORD)
+        .await
+        .unwrap_or_else(|error| {
+            panic!("{name}: the seeded password must still authenticate after 
the swap: {error}")
+        });
+}
+
+/// Same split as [`assert_user_recovered`]: the token hash is not in the
+/// listing, so only a login proves it.
+async fn assert_token_recovered(
+    harness: &TestHarness,
+    client: &IggyClient,
+    name: &str,
+    seeded: &SeededToken,
+) {
+    let tokens = client.get_personal_access_tokens().await.unwrap();
+    let token = tokens
+        .iter()
+        .find(|token| token.name == name)
+        .unwrap_or_else(|| {
+            panic!(
+                "the personal access token {name} must survive the swap, got 
{:?}",
+                tokens.iter().map(|token| &token.name).collect::<Vec<_>>()
+            )
+        });
+    assert_eq!(token.expiry_at, Some(seeded.expiry_at), "{name}: expiry");
+    let by_token = harness.tcp_new_client().await.unwrap();
+    by_token
+        .login_with_personal_access_token(&seeded.raw)
+        .await
+        .unwrap_or_else(|error| {
+            panic!(
+                "{name}: the seeded personal access token must still 
authenticate after the \
+                 swap: {error}"
+            )
+        });
+}
+
+/// A topic directory relative to the data directory, in the key form of
+/// [`disk::collect_comparable_files`].
+fn topic_prefix(stream_id: u32, topic_id: u32) -> String {
+    format!("streams/{stream_id}/topics/{topic_id}/")
+}
+
+fn partition_dir(data_path: &Path, stream_id: u32, topic_id: u32, 
partition_id: u32) -> PathBuf {
+    data_path
+        .join(topic_prefix(stream_id, topic_id))
+        .join("partitions")
+        .join(partition_id.to_string())
+}
+
+/// Segment log file names in a partition directory, in offset order (they are
+/// zero-padded base offsets, so lexical order is offset order).
+fn segment_logs(partition: &Path) -> Vec<String> {
+    let mut names: Vec<String> = fs::read_dir(partition)
+        .map(|entries| {
+            entries
+                .flatten()
+                .map(|entry| entry.file_name().to_string_lossy().into_owned())
+                .filter(|name| name.ends_with(".log"))
+                .collect()
+        })
+        .unwrap_or_default();
+    names.sort();
+    names
+}
+
+fn base_offset_of(segment_log: &str) -> u64 {
+    segment_log
+        .trim_end_matches(".log")
+        .parse()
+        .unwrap_or_else(|error| {
+            panic!("segment {segment_log} is not named for a base offset: 
{error}")
+        })
+}
+
+fn assert_graceful_shutdown(harness: &TestHarness, who: &str) {
+    assert!(
+        harness.server().stdout_contains(GRACEFUL_SHUTDOWN_MARKER),
+        "{who} did not log {GRACEFUL_SHUTDOWN_MARKER:?}. Either the stop 
escalated to SIGKILL, \
+         leaving crash-recovery state that confounds the compatibility 
verdict, or stdout was \
+         not captured at all (IGGY_TEST_VERBOSE makes the harness inherit it, 
which would also \
+         make the tombstone check vacuous). Server stdout:\n{}",
+        harness.server().stdout_plain()
+    );
+}
+
+/// Byte-compare the segment files across the binary swap, except under
+/// `rewritten`, the one directory the build under test was told to change.
+///
+/// Segment batch headers carry no magic and no version, and recovery is
+/// allowed to truncate a torn tail, so a misparse can shorten a `.log` while
+/// every message a test happens to poll still reads back correctly.
+fn assert_segments_identical(
+    baseline: &BTreeMap<String, Vec<u8>>,
+    current: &BTreeMap<String, Vec<u8>>,
+    rewritten: &str,
+) {
+    assert!(
+        baseline.keys().any(|rel| rel.starts_with(rewritten)),
+        "`{rewritten}` matches nothing the baseline wrote, so the exclusion 
would hide a typo \
+         rather than the post-swap purge"
+    );
+    let mut problems = Vec::new();
+    for (rel, baseline_bytes) in baseline
+        .iter()
+        .filter(|(rel, _)| !rel.starts_with(rewritten))
+    {
+        match current.get(rel) {
+            None => problems.push(format!(
+                "`{rel}` was written by the baseline but is GONE after the 
swap"
+            )),
+            Some(bytes) if bytes != baseline_bytes => {
+                problems.push(disk::describe_mismatch(rel, 0, baseline_bytes, 
1, bytes));
+            }
+            Some(_) => {}
+        }
+    }
+    for rel in current.keys().filter(|rel| !rel.starts_with(rewritten)) {
+        if !baseline.contains_key(rel) {
+            problems.push(format!("`{rel}` appeared only after the swap"));
+        }
+    }
+    assert!(
+        problems.is_empty(),
+        "the build under test did not preserve the baseline's segment bytes \
+         ({} issue(s); node 0 = baseline, node 1 = post-swap):\n{}",
+        problems.len(),
+        problems.join("\n")
+    );
+}
+
+/// Poll `probe` until it succeeds, panicking with its last message on timeout.
+async fn wait_until(what: &str, mut probe: impl AsyncFnMut() -> Result<(), 
String>) {
+    let deadline = Instant::now() + SETTLE_TIMEOUT;
+    loop {
+        match probe().await {
+            Ok(()) => return,
+            Err(last) => {
+                assert!(
+                    Instant::now() < deadline,
+                    "timed out after {SETTLE_TIMEOUT:?} waiting for {what}: 
{last}"
+                );
+                sleep(POLL_INTERVAL).await;
+            }
+        }
+    }
+}
diff --git a/scripts/ci/storage-compat.sh b/scripts/ci/storage-compat.sh
new file mode 100755
index 000000000..b4c3a72a2
--- /dev/null
+++ b/scripts/ci/storage-compat.sh
@@ -0,0 +1,270 @@
+#!/usr/bin/env bash
+# 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.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+source "$(dirname "${BASH_SOURCE[0]}")/lib/init.sh"
+
+# storage-compat.sh -- Prove HEAD still reads a data directory written by 
master.
+#
+# Builds two iggy-server binaries and hands both to one integration test:
+#   baseline  built from --baseline-ref, copied aside. Default: on a
+#             pull_request run, the master tip under the checked-out merge ref;
+#             anywhere else, origin/master.
+#   HEAD      built from the working tree, left at target/debug/iggy-server
+# The test boots the baseline, seeds a data directory, swaps the binary to HEAD
+# and restarts against that same directory.
+#
+# Both binaries MUST be built here. `core/integration` has no dependency on the
+# `server` package; the harness only LOCATES a binary, through
+# `assert_cmd::Command::cargo_bin`, which falls back to whatever file happens 
to
+# sit at target/debug/iggy-server (assert_cmd 2.2.2 `legacy_cargo_bin`). So
+# `cargo nextest run -p integration` compiles no server at all, and a lane that
+# does not build both halves compares a stale binary against itself and reports
+# green forever.
+#
+# Exit codes: 0 = compatible, non-zero = build failure or format regression.
+
+# Empty selects the default described above once HEAD is known.
+BASELINE_REF=""
+REBUILD_BASELINE=0
+
+# The single #[ignore]d test this script exists to drive. The integration crate
+# is one binary built from tests/mod.rs, so tests are selected by module path.
+TEST_FILTER="data_integrity::storage_compat"
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --baseline-ref)
+      BASELINE_REF="${2:-}"
+      if [ -z "${BASELINE_REF}" ]; then
+        echo "--baseline-ref requires a git ref"
+        exit 1
+      fi
+      shift 2
+      ;;
+    --rebuild-baseline)
+      REBUILD_BASELINE=1
+      shift
+      ;;
+    --help|-h)
+      echo "Usage: $0 [--baseline-ref <ref>] [--rebuild-baseline]"
+      echo ""
+      echo "Options:"
+      echo "  --baseline-ref <ref>  Ref to build the baseline server from 
(default: the merge ref's"
+      echo "                        first parent on a pull_request run, 
origin/master otherwise)"
+      echo "  --rebuild-baseline    Rebuild the baseline even when its binary 
is already on disk"
+      exit 0
+      ;;
+    *)
+      echo "Unknown option: $1"
+      echo "Use --help for usage information"
+      exit 1
+      ;;
+  esac
+done
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+
+# Absolute before any cd: cargo resolves a relative CARGO_TARGET_DIR against
+# its own CWD, and the baseline below builds from a worktree elsewhere on
+# disk, so a relative value would scatter the two builds and the lookup of
+# either binary across three directories. Exported so every cargo call here,
+# nextest included, lands in the same place.
+TARGET_DIR="${CARGO_TARGET_DIR:-${REPO_ROOT}/target}"
+mkdir -p "${TARGET_DIR}"
+TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)"
+export CARGO_TARGET_DIR="${TARGET_DIR}"
+
+cd "${REPO_ROOT}"
+
+if ! command -v cargo-nextest &>/dev/null; then
+  echo "cargo-nextest is not installed"
+  echo ""
+  echo "Install with:"
+  echo "  cargo install cargo-nextest --locked"
+  exit 1
+fi
+
+HEAD_SERVER="${TARGET_DIR}/debug/iggy-server"
+
+WORKTREE_DIR=""
+
+# Every command is guarded: under `set -e` a failure inside an EXIT trap would
+# replace the script's real exit status with the trap's.
+cleanup() {
+  if [ -n "${WORKTREE_DIR}" ]; then
+    git worktree remove --force "${WORKTREE_DIR}" 2>/dev/null || rm -rf 
"${WORKTREE_DIR}" || true
+  fi
+  git worktree prune 2>/dev/null || true
+}
+trap cleanup EXIT
+
+HEAD_SHA="$(git rev-parse --verify HEAD)"
+
+# On a pull_request run actions/checkout leaves HEAD at the synthetic
+# refs/pull/N/merge commit, whose first parent is the exact master tip GitHub
+# merged the PR onto: an ancestor of HEAD by construction. Live origin/master
+# can already be newer by the time this step runs, and a baseline HEAD does
+# not contain would blame the PR for master's own changes. Read from the raw
+# object: in the depth-1 checkout the parent is a shallow boundary, so
+# `HEAD^1` does not resolve. Two parents required, so a checkout pinned to the
+# PR head instead of the merge ref falls through rather than picking the PR's
+# own parent.
+if [ -z "${BASELINE_REF}" ]; then
+  if [[ "${GITHUB_REF:-}" =~ ^refs/pull/[0-9]+/merge$ ]] \
+    && [ "$(git cat-file -p HEAD | grep -c '^parent ')" -eq 2 ]; then
+    BASELINE_REF="$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}')"
+    echo "Pull request run: baseline is the master tip under the merge ref"
+  else
+    BASELINE_REF="origin/master"
+  fi
+fi
+
+# The remote side of a fetch takes a branch or tag name (or a reachable SHA),
+# never a remote-tracking name, so drop the remote prefix before asking origin.
+#
+# --depth=1 only where the clone is already shallow (the CI checkout). On a
+# full clone that flag does not save anything, it WRITES a shallow boundary at
+# the fetched tip and grafts every older commit off the developer's history,
+# for every worktree sharing the repository.
+FETCH_DEPTH=()
+if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
+  FETCH_DEPTH=(--depth=1)
+fi
+FETCHED=0
+if git fetch --no-tags "${FETCH_DEPTH[@]}" origin "${BASELINE_REF#origin/}" 
2>/dev/null; then
+  FETCHED=1
+fi
+
+BASELINE_SHA=""
+if [ "${FETCHED}" -eq 1 ]; then
+  # FETCH_HEAD in preference to the named ref: actions/checkout narrows
+  # remote.origin.fetch on a shallow clone, so refs/remotes/origin/master can
+  # stay absent or stale straight through a successful fetch.
+  BASELINE_SHA="$(git rev-parse --verify --quiet "FETCH_HEAD^{commit}" || 
true)"
+fi
+if [ -z "${BASELINE_SHA}" ]; then
+  BASELINE_SHA="$(git rev-parse --verify --quiet "${BASELINE_REF}^{commit}" || 
true)"
+fi
+if [ -z "${BASELINE_SHA}" ]; then
+  echo "Could not resolve baseline ref '${BASELINE_REF}' locally or from 
origin"
+  exit 1
+fi
+
+echo "Baseline: ${BASELINE_SHA} (${BASELINE_REF})"
+echo "HEAD:     ${HEAD_SHA}"
+if [ "${BASELINE_SHA}" = "${HEAD_SHA}" ]; then
+  echo "WARNING: baseline and HEAD are the same commit, this run proves 
nothing"
+fi
+# Only decidable with history: a shallow clone answers "no" for every pair.
+if [ "$(git rev-parse --is-shallow-repository)" = "false" ] \
+  && ! git merge-base --is-ancestor "${BASELINE_SHA}" "${HEAD_SHA}"; then
+  echo "WARNING: baseline is not an ancestor of HEAD; a failure below may be 
master's change, not HEAD's"
+fi
+
+# Keyed by baseline commit, which also pins that commit's Cargo.lock and
+# rust-toolchain.toml. Only a developer machine ever hits this: hosted runners
+# start empty and nothing publishes a baseline binary, so CI rebuilds it on
+# every run.
+BASELINE_SERVER="${TARGET_DIR}/storage-compat/${BASELINE_SHA}/iggy-server"
+
+if [ "${REBUILD_BASELINE}" -eq 1 ]; then
+  rm -f "${BASELINE_SERVER}"
+fi
+
+if [ -x "${BASELINE_SERVER}" ]; then
+  echo "Reusing baseline server at ${BASELINE_SERVER}"
+else
+  # Outside the repo on purpose: an in-tree worktree gets swept up by the
+  # repo-wide find(1) in the lint scripts and by cargo's workspace globs.
+  # A fresh mktemp path per run cannot collide with an aborted run; the only
+  # residue such a run leaves is an admin entry under .git/worktrees, which
+  # prune clears.
+  git worktree prune
+  WORKTREE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iggy-storage-compat.XXXXXX")"
+  git worktree add --detach "${WORKTREE_DIR}" "${BASELINE_SHA}"
+
+  # Symmetric to the guard before the HEAD build below: a HEAD binary left by 
an
+  # earlier run makes cargo skip the uplift, and the cp further down would then
+  # capture that HEAD binary as the baseline, comparing HEAD against itself.
+  rm -f "${HEAD_SERVER}"
+
+  echo "Building baseline iggy-server from ${BASELINE_SHA}..."
+  # Built from the worktree as CWD so the baseline's own rust-toolchain.toml
+  # applies, into the shared CARGO_TARGET_DIR so the registry graph compiles
+  # once.
+  #
+  # Debug profile on both sides, and no --all-features: release would compile
+  # debug_assert! out of the baseline while HEAD still panics on it, and
+  # --all-features turns on the server's `disable-mimalloc`, so the two halves
+  # would differ in ways the storage format never changed.
+  (
+    cd "${WORKTREE_DIR}"
+    cargo build --locked -p server --bin iggy-server
+  )
+
+  if [ ! -x "${HEAD_SERVER}" ]; then
+    echo "Baseline build did not produce ${HEAD_SERVER}"
+    exit 1
+  fi
+
+  # Copy before HEAD builds: both trees uplift to the same target/debug path.
+  mkdir -p "$(dirname "${BASELINE_SERVER}")"
+  cp "${HEAD_SERVER}" "${BASELINE_SERVER}"
+fi
+
+# Delete the uplifted binary before building HEAD. Cargo skips re-uplifting 
when
+# the destination already looks current, and the baseline build just refreshed
+# it, so on a second run the BASELINE binary could survive at
+# target/debug/iggy-server and the test would compare master against master.
+rm -f "${HEAD_SERVER}"
+
+echo "Building HEAD iggy-server from ${HEAD_SHA}..."
+cargo build --locked -p server --bin iggy-server
+
+if [ ! -x "${HEAD_SERVER}" ]; then
+  echo "HEAD build did not produce ${HEAD_SERVER}"
+  exit 1
+fi
+
+# Absolute path: ServerHandle only treats this value as a literal path when it
+# has more than one component, otherwise it falls back to a cargo_bin lookup.
+export COMPAT_BASELINE_SERVER="${BASELINE_SERVER}"
+
+echo "Running storage compatibility test..."
+# --run-ignored only: the test is #[ignore]d, so the normal lanes skip it.
+# --no-tests=fail: a filter matching nothing has to be an error here, unlike 
the
+# main lane's --no-tests=warn, which would report a typo'd filter as green.
+# --retries 0: a read-back that only fails sometimes is exactly the signal this
+# check exists for, so no retry may hide it. Explicit rather than "not
+# --profile ci": a profile default or NEXTEST_RETRIES in the environment would
+# otherwise apply.
+# --ignore-default-filter: nextest intersects -E with the profile's
+# default-filter, so one that excluded this module would turn the run into a
+# --no-tests=fail abort instead of running the test.
+# The version floor for these flags lives in .config/nextest.toml.
+cargo nextest run --locked -p integration \
+  --run-ignored only \
+  --no-tests=fail \
+  --retries 0 \
+  --ignore-default-filter \
+  -E "test(${TEST_FILTER})"
+
+echo "Storage format compatible: ${BASELINE_SHA} -> ${HEAD_SHA}"

Reply via email to