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

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 19603861d feat: add core COW rewrite primitive (#2752)
19603861d is described below

commit 19603861d545f8edbc5016e45cb1bacb0579cbf8
Author: kid <[email protected]>
AuthorDate: Thu Oct 1 14:54:21 2026 +0000

    feat: add core COW rewrite primitive (#2752)
    
    * feat: add core COW rewrite primitive
    
    Add a core copy-on-write rewrite primitive that plans candidate data
    files, applies a caller-provided RecordBatch rewriter, writes
    replacement data files, and returns removed/added/unchanged file lists
    for future overwrite-style commit actions.
    
    - Plan candidates through the existing scan path so manifest pruning
      and delete-file application stay consistent with normal reads; the
      row predicate is cleared only for the full-file rewrite input.
    - Stream rewritten batches to the replacement writer: buffer only the
      prefix before the first changed batch, then open the writer lazily;
      unchanged files open no writer, fully-deleted files produce no
      replacement.
    - Bind the replacement writer and partition key to the planned
      snapshot's schema so rewrites survive schema evolution.
    - Build the replacement parquet writer via
      ParquetWriterBuilder::from_table_properties so replacement files
      honor the table's write.parquet.* properties, carry the table's
      encryption manager so encrypted tables are not downgraded to
      plaintext, and refuse to run when write.format.default is not
      parquet.
    - Keep the planner internal; CowRewriteBuilder is the only public
      entry and CowRewriteFile fields sit behind accessors.
    
    Cover delete-style, update-style, no-op, full-file delete, no-match
    delete, delete-file planning, writer edge cases, schema evolution,
    multi-source replacement path uniqueness, and partition-value
    preservation (including the null partition) end-to-end.
    
    Part of #2269.
    
    Co-Authored-By: Claude <[email protected]>
    
    * fix: address COW rewrite review comments
    
    - derive the effective change flag as `changed || output.is_none()` so a
      rewriter returning {changed: false, output: None} cannot silently drop
      rows while leaving the file marked unchanged; add regression tests
    - treat a candidate whose visible rows are all removed by delete files as
      removed with no replacement, so it can be compacted away with its
      delete files instead of landing in unchanged_data_files forever
    - honor write.object-storage.enabled when building replacement writers
      (add the missing table property) so object-storage-layout tables keep
      hash-entropy paths
    - count output_rows only for rows actually written to replacement files
    - derive Debug for CowBatchRewrite
    - document the sync rewriter contract, the read-only CowRewriteFile
      surface, and the prefix buffer's whole-file worst case
    - drop avoidable FileScanTask/DataFile clones and replace the writer
      expect() with a let-else invariant
    
    * fix(cow_rewrite): flush kept prefix after a dropped batch
    
    A dropped batch could mark a file changed while skipping the Some(output) 
branch that initialized the writer and flushed kept rows. Open the writer 
whenever a changed file has buffered or current output, then flush the prefix 
independently of the current batch output. Cover the keep-first/drop-second 
data-loss case and full coverage by a real position-delete file; clarify 
delete-file and schema contracts.
    
    * fix(cow_rewrite): guard partition preservation and document rewrite() 
trade-offs
    
    Address the three non-blocking follow-ups from the approval review of
    apache/iceberg-rust#2752:
    
    - Surface the prefix-buffer memory ceiling in the public rewrite()
      rustdoc: a file that never changes (or whose first change is at EOF)
      buffers its entire decoded contents, one file at a time, which can be
      several GB for a compaction-sized file.
    - Add a debug-only partition guard: replacement files record the source
      partition verbatim, so a rewriter that mutates a partition-source
      column would silently write rows under a partition they do not belong
      to. In debug builds every emitted batch is checked against the source
      file's partition; release builds are unchanged.
    - Document in the module docs that keeping the source partition spec and
      staying unsorted is, like the snapshot-schema choice, a deliberate
      divergence from Java's RewriteDataFiles / Spark DML.
    
    * fix(cow_rewrite): use relocated scan test fixture
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 crates/iceberg/public-api.txt               |   57 +
 crates/iceberg/src/cow_rewrite/mod.rs       | 1667 +++++++++++++++++++++++++++
 crates/iceberg/src/cow_rewrite/plan.rs      |  161 +++
 crates/iceberg/src/cow_rewrite/rewriter.rs  |   56 +
 crates/iceberg/src/cow_rewrite/writer.rs    |  447 +++++++
 crates/iceberg/src/lib.rs                   |    1 +
 crates/iceberg/src/scan/context.rs          |   17 +
 crates/iceberg/src/scan/mod.rs              |   54 +-
 crates/iceberg/src/scan/task.rs             |   10 +
 crates/iceberg/src/spec/table_properties.rs |   12 +
 10 files changed, 2468 insertions(+), 14 deletions(-)

diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index da0b6b191..bb4d350e5 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -175,6 +175,60 @@ impl serde_core::ser::Serialize for 
iceberg::compression::CompressionCodec
 pub fn iceberg::compression::CompressionCodec::serialize<S: 
serde_core::ser::Serializer>(&self, serializer: S) -> core::result::Result<<S 
as serde_core::ser::Serializer>::Ok, <S as serde_core::ser::Serializer>::Error>
 impl<'de> serde_core::de::Deserialize<'de> for 
iceberg::compression::CompressionCodec
 pub fn iceberg::compression::CompressionCodec::deserialize<D: 
serde_core::de::Deserializer<'de>>(deserializer: D) -> 
core::result::Result<Self, <D as serde_core::de::Deserializer>::Error>
+pub mod iceberg::cow_rewrite
+pub struct iceberg::cow_rewrite::CowBatchRewrite
+pub iceberg::cow_rewrite::CowBatchRewrite::changed: bool
+pub iceberg::cow_rewrite::CowBatchRewrite::output: 
core::option::Option<arrow_array::record_batch::RecordBatch>
+impl core::fmt::Debug for iceberg::cow_rewrite::CowBatchRewrite
+pub fn iceberg::cow_rewrite::CowBatchRewrite::fmt(&self, f: &mut 
core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct iceberg::cow_rewrite::CowRewriteBuilder<'a>
+impl<'a> iceberg::cow_rewrite::CowRewriteBuilder<'a>
+pub fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::new(table: &'a 
iceberg::table::Table) -> Self
+pub async fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::rewrite(self) -> 
iceberg::Result<iceberg::cow_rewrite::CowRewriteResult>
+pub fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::with_batch_size(self, 
batch_size: usize) -> Self
+pub fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::with_case_sensitive(self, 
case_sensitive: bool) -> Self
+pub fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::with_predicate(self, 
predicate: iceberg::expr::Predicate) -> Self
+pub fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::with_rewriter(self, 
rewriter: alloc::sync::Arc<dyn iceberg::cow_rewrite::CowBatchRewriter>) -> Self
+pub fn iceberg::cow_rewrite::CowRewriteBuilder<'a>::with_snapshot_id(self, 
snapshot_id: i64) -> Self
+pub struct iceberg::cow_rewrite::CowRewriteFile
+impl iceberg::cow_rewrite::CowRewriteFile
+pub fn iceberg::cow_rewrite::CowRewriteFile::old_data_file(&self) -> 
&iceberg::spec::DataFile
+pub fn iceberg::cow_rewrite::CowRewriteFile::scan_task(&self) -> 
&iceberg::scan::FileScanTask
+impl core::clone::Clone for iceberg::cow_rewrite::CowRewriteFile
+pub fn iceberg::cow_rewrite::CowRewriteFile::clone(&self) -> 
iceberg::cow_rewrite::CowRewriteFile
+impl core::fmt::Debug for iceberg::cow_rewrite::CowRewriteFile
+pub fn iceberg::cow_rewrite::CowRewriteFile::fmt(&self, f: &mut 
core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct iceberg::cow_rewrite::CowRewriteResult
+pub iceberg::cow_rewrite::CowRewriteResult::added_data_files: 
alloc::vec::Vec<iceberg::spec::DataFile>
+pub iceberg::cow_rewrite::CowRewriteResult::removed_data_files: 
alloc::vec::Vec<iceberg::spec::DataFile>
+pub iceberg::cow_rewrite::CowRewriteResult::stats: 
iceberg::cow_rewrite::CowRewriteStats
+pub iceberg::cow_rewrite::CowRewriteResult::unchanged_data_files: 
alloc::vec::Vec<iceberg::spec::DataFile>
+impl iceberg::cow_rewrite::CowRewriteResult
+pub fn iceberg::cow_rewrite::CowRewriteResult::has_changes(&self) -> bool
+impl core::default::Default for iceberg::cow_rewrite::CowRewriteResult
+pub fn iceberg::cow_rewrite::CowRewriteResult::default() -> 
iceberg::cow_rewrite::CowRewriteResult
+impl core::fmt::Debug for iceberg::cow_rewrite::CowRewriteResult
+pub fn iceberg::cow_rewrite::CowRewriteResult::fmt(&self, f: &mut 
core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct iceberg::cow_rewrite::CowRewriteStats
+pub iceberg::cow_rewrite::CowRewriteStats::candidate_files: usize
+pub iceberg::cow_rewrite::CowRewriteStats::changed_batches: u64
+pub iceberg::cow_rewrite::CowRewriteStats::input_rows: u64
+pub iceberg::cow_rewrite::CowRewriteStats::output_rows: u64
+pub iceberg::cow_rewrite::CowRewriteStats::rewritten_files: usize
+pub iceberg::cow_rewrite::CowRewriteStats::unchanged_files: usize
+impl core::clone::Clone for iceberg::cow_rewrite::CowRewriteStats
+pub fn iceberg::cow_rewrite::CowRewriteStats::clone(&self) -> 
iceberg::cow_rewrite::CowRewriteStats
+impl core::cmp::Eq for iceberg::cow_rewrite::CowRewriteStats
+impl core::cmp::PartialEq for iceberg::cow_rewrite::CowRewriteStats
+pub fn iceberg::cow_rewrite::CowRewriteStats::eq(&self, other: 
&iceberg::cow_rewrite::CowRewriteStats) -> bool
+impl core::default::Default for iceberg::cow_rewrite::CowRewriteStats
+pub fn iceberg::cow_rewrite::CowRewriteStats::default() -> 
iceberg::cow_rewrite::CowRewriteStats
+impl core::fmt::Debug for iceberg::cow_rewrite::CowRewriteStats
+pub fn iceberg::cow_rewrite::CowRewriteStats::fmt(&self, f: &mut 
core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::marker::Copy for iceberg::cow_rewrite::CowRewriteStats
+impl core::marker::StructuralPartialEq for 
iceberg::cow_rewrite::CowRewriteStats
+pub trait iceberg::cow_rewrite::CowBatchRewriter: core::marker::Send + 
core::marker::Sync
+pub fn iceberg::cow_rewrite::CowBatchRewriter::rewrite_batch(&self, batch: 
arrow_array::record_batch::RecordBatch) -> 
iceberg::Result<iceberg::cow_rewrite::CowBatchRewrite>
 pub mod iceberg::encryption
 pub mod iceberg::encryption::kms
 pub struct iceberg::encryption::kms::GeneratedKey
@@ -2868,6 +2922,8 @@ pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_UUID: &'static str
 pub const iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_DATA_LOCATION: 
&'static str
 pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_FOLDER_STORAGE_LOCATION: 
&'static str
 pub const iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_METADATA_PATH: 
&'static str
+pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_OBJECT_STORAGE_ENABLED: 
&'static str
+pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_OBJECT_STORAGE_ENABLED_DEFAULT:
 bool
 pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_OBJECT_STORAGE_LOCATION: 
&'static str
 pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS:
 &'static str
 pub const 
iceberg::spec::TableProperties<'_>::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS_DEFAULT:
 bool
@@ -2905,6 +2961,7 @@ pub fn 
iceberg::spec::TableProperties<'properties>::write_datafusion_fanout_enab
 pub fn 
iceberg::spec::TableProperties<'properties>::write_folder_storage_location(&self)
 -> iceberg::Result<core::option::Option<alloc::string::String>>
 pub fn 
iceberg::spec::TableProperties<'properties>::write_format_default(&self) -> 
iceberg::Result<alloc::string::String>
 pub fn iceberg::spec::TableProperties<'properties>::write_metadata_path(&self) 
-> iceberg::Result<core::option::Option<alloc::string::String>>
+pub fn 
iceberg::spec::TableProperties<'properties>::write_object_storage_enabled(&self)
 -> iceberg::Result<bool>
 pub fn 
iceberg::spec::TableProperties<'properties>::write_object_storage_location(&self)
 -> iceberg::Result<core::option::Option<alloc::string::String>>
 pub fn 
iceberg::spec::TableProperties<'properties>::write_object_storage_partitioned_paths(&self)
 -> iceberg::Result<bool>
 pub fn 
iceberg::spec::TableProperties<'properties>::write_target_file_size_bytes(&self)
 -> iceberg::Result<usize>
diff --git a/crates/iceberg/src/cow_rewrite/mod.rs 
b/crates/iceberg/src/cow_rewrite/mod.rs
new file mode 100644
index 000000000..25d5fb4b8
--- /dev/null
+++ b/crates/iceberg/src/cow_rewrite/mod.rs
@@ -0,0 +1,1667 @@
+// 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.
+
+//! Copy-on-write rewrite primitives.
+//!
+//! This module plans candidate data files, reads their visible rows, applies a
+//! caller-provided batch rewriter, and writes replacement data files. It 
returns
+//! old and new file sets that can be committed by an overwrite-style 
transaction
+//! action.
+//!
+//! The primitive does not parse SQL and does not commit metadata by itself.
+//! Rewriters must emit batches compatible with the schema rows were read in
+//! (the planned snapshot's schema) and must preserve each source file's
+//! partition values; this primitive does not repartition rewritten rows.
+//!
+//! Replacement files deliberately mirror their source file's layout instead of
+//! re-optimizing it — a deliberate divergence from Java's RewriteDataFiles and
+//! Spark DML: they are written in the planned snapshot's schema rather than
+//! promoted to the table's current schema, they keep the source file's
+//! partition spec rather than being repartitioned into the table's default
+//! spec, and they stay unsorted since no sort order is applied. An opt-in
+//! promote-to-current-schema rewrite is left for follow-up work.
+//!
+//! The result carries data files only. A commit adapter consuming these file
+//! lists may remove position delete files and deletion vectors only when they
+//! exclusively reference removed data files. Equality delete files must be
+//! retained while they can still apply to other live data files by sequence
+//! number.
+//!
+//! ```rust,no_run
+//! # use std::sync::Arc;
+//! # use arrow_array::RecordBatch;
+//! # use iceberg::cow_rewrite::{CowBatchRewrite, CowBatchRewriter, 
CowRewriteBuilder};
+//! # use iceberg::table::Table;
+//! # use iceberg::Result;
+//! struct KeepAll;
+//!
+//! impl CowBatchRewriter for KeepAll {
+//!     fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+//!         Ok(CowBatchRewrite {
+//!             output: Some(batch),
+//!             changed: false,
+//!         })
+//!     }
+//! }
+//!
+//! # async fn example(table: &Table) -> Result<()> {
+//! let result = CowRewriteBuilder::new(table)
+//!     .with_rewriter(Arc::new(KeepAll))
+//!     .rewrite()
+//!     .await?;
+//!
+//! assert!(!result.has_changes());
+//! # Ok(())
+//! # }
+//! ```
+
+mod plan;
+mod rewriter;
+pub(crate) mod writer;
+
+use std::sync::Arc;
+
+use arrow_array::RecordBatch;
+use futures::TryStreamExt;
+pub use plan::CowRewriteFile;
+pub use rewriter::{CowBatchRewrite, CowBatchRewriter};
+
+use crate::expr::Predicate;
+use crate::scan::FileScanTaskStream;
+use crate::spec::{DataFile, PartitionKey};
+use crate::table::Table;
+use crate::{Error, ErrorKind, Result};
+
+/// Counters produced by a copy-on-write rewrite.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct CowRewriteStats {
+    /// Number of candidate files selected by planning.
+    pub candidate_files: usize,
+    /// Number of old files that have replacement output or are fully removed.
+    pub rewritten_files: usize,
+    /// Number of candidate files that did not change after row rewriting.
+    pub unchanged_files: usize,
+    /// Visible input row count read from candidate files.
+    pub input_rows: u64,
+    /// Output row count written to replacement files.
+    ///
+    /// Rows the rewriter emitted for files that turned out unchanged are not
+    /// counted, so this always matches the row counts of `added_data_files`.
+    pub output_rows: u64,
+    /// Number of input batches that changed, including batches the rewriter
+    /// dropped entirely (`output: None`) even if it did not flag them.
+    pub changed_batches: u64,
+}
+
+/// Result of a copy-on-write rewrite operation.
+#[derive(Debug, Default)]
+pub struct CowRewriteResult {
+    /// Old data files that should be removed by the commit action.
+    pub removed_data_files: Vec<DataFile>,
+    /// New data files that should be added by the commit action.
+    pub added_data_files: Vec<DataFile>,
+    /// Candidate files that were read and left unchanged.
+    ///
+    /// Files whose visible rows were all removed by delete files are NOT
+    /// included here: they read as zero rows and are reported in
+    /// `removed_data_files` with no replacement. A commit adapter may remove
+    /// position deletes and deletion vectors that exclusively reference these
+    /// removed files, but must retain equality deletes that can still apply to
+    /// other live files.
+    pub unchanged_data_files: Vec<DataFile>,
+    /// Rewrite counters.
+    pub stats: CowRewriteStats,
+}
+
+impl CowRewriteResult {
+    /// Returns true if the rewrite produced any table changes.
+    pub fn has_changes(&self) -> bool {
+        !self.removed_data_files.is_empty() || 
!self.added_data_files.is_empty()
+    }
+}
+
+/// Builder for orchestrating copy-on-write data file rewrites.
+pub struct CowRewriteBuilder<'a> {
+    table: &'a Table,
+    predicate: Predicate,
+    snapshot_id: Option<i64>,
+    batch_size: Option<usize>,
+    case_sensitive: bool,
+    rewriter: Option<Arc<dyn CowBatchRewriter>>,
+}
+
+impl<'a> CowRewriteBuilder<'a> {
+    /// Creates a copy-on-write rewrite builder for `table`.
+    pub fn new(table: &'a Table) -> Self {
+        Self {
+            table,
+            predicate: Predicate::AlwaysTrue,
+            snapshot_id: None,
+            batch_size: None,
+            case_sensitive: true,
+            rewriter: None,
+        }
+    }
+
+    /// Sets the row predicate used to plan candidate files.
+    pub fn with_predicate(mut self, predicate: Predicate) -> Self {
+        self.predicate = predicate;
+        self
+    }
+
+    /// Sets the snapshot id used to plan candidate files.
+    pub fn with_snapshot_id(mut self, snapshot_id: i64) -> Self {
+        self.snapshot_id = Some(snapshot_id);
+        self
+    }
+
+    /// Sets the Arrow reader batch size.
+    pub fn with_batch_size(mut self, batch_size: usize) -> Self {
+        self.batch_size = Some(batch_size);
+        self
+    }
+
+    /// Sets the case sensitivity used to bind the planning predicate.
+    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
+        self.case_sensitive = case_sensitive;
+        self
+    }
+
+    /// Sets the record batch rewriter.
+    pub fn with_rewriter(mut self, rewriter: Arc<dyn CowBatchRewriter>) -> 
Self {
+        self.rewriter = Some(rewriter);
+        self
+    }
+
+    /// Plans, reads, rewrites, and writes replacement data files.
+    ///
+    /// # Memory usage
+    ///
+    /// Batches read before a file's first changed batch are buffered so that
+    /// no replacement file is emitted for a source file that turns out
+    /// unchanged. A file that never changes — or whose first change sits at
+    /// its very end — therefore buffers its entire decoded contents in
+    /// memory. Files are processed sequentially, so peak usage is one decoded
+    /// source file at a time, but that can still be several GB for a
+    /// compaction-sized file. A size-capped fallback that starts writing the
+    /// replacement once the buffer crosses a threshold is left for follow-up
+    /// work.
+    pub async fn rewrite(self) -> Result<CowRewriteResult> {
+        let rewriter = self.rewriter.ok_or_else(|| {
+            Error::new(
+                ErrorKind::PreconditionFailed,
+                "COW rewrite requires a batch rewriter",
+            )
+        })?;
+        let files = plan::plan_cow_rewrite_files(
+            self.table,
+            Some(self.predicate),
+            self.snapshot_id,
+            self.case_sensitive,
+        )
+        .await?;
+
+        let mut result = CowRewriteResult {
+            stats: CowRewriteStats {
+                candidate_files: files.len(),
+                ..CowRewriteStats::default()
+            },
+            ..CowRewriteResult::default()
+        };
+
+        for file in files {
+            let CowRewriteFile {
+                old_data_file,
+                scan_task,
+            } = file;
+            // Schema the rows are read in (the planned snapshot's schema). The
+            // replacement files must be written with this schema so that 
batches
+            // remain compatible when the table's current schema has evolved 
past
+            // the snapshot the source files belong to. This preserves the 
older
+            // schema in replacement files rather than promoting them to the
+            // table's current schema during this rewrite.
+            let write_schema = scan_task.schema_ref();
+            let has_delete_files = !scan_task.deletes().is_empty();
+
+            // Batches produced before the first changed batch. They are 
buffered
+            // rather than written immediately because the primitive must not
+            // emit a replacement file for a source file that turns out to be
+            // unchanged. Once a changed batch is observed the buffered prefix 
is
+            // flushed to the writer and all subsequent batches stream straight
+            // through.
+            //
+            // Worst-case footprint: for a file that never changes (or whose
+            // first change sits at its very end) the prefix holds the entire
+            // decoded source file in memory. Files are processed sequentially,
+            // so peak usage is one file at a time, but that can still be 
several
+            // GB for a compaction-sized file. A size-capped fallback that 
starts
+            // writing the replacement once the buffer crosses a threshold is
+            // left for follow-up work.
+            let mut prefix: Vec<RecordBatch> = Vec::new();
+            let mut file_changed = false;
+            let mut file_input_rows = 0_u64;
+            let mut file_output_rows = 0_u64;
+            let mut writer: Option<Box<dyn crate::writer::IcebergWriter>> = 
None;
+            // Debug-only tripwire for the "preserve partition values"
+            // contract; see `DebugPartitionGuard`.
+            #[cfg(debug_assertions)]
+            let partition_guard =
+                DebugPartitionGuard::new(self.table, &old_data_file, 
&write_schema)?;
+
+            // Planning already cleared the row predicate (see
+            // `ManifestEntryContext::into_cow_rewrite_file`), so this task
+            // reads every row of the source file.
+            let tasks = Box::pin(futures::stream::iter(vec![Ok(scan_task)])) 
as FileScanTaskStream;
+
+            // Each candidate file gets its own reader so the per-file prefix
+            // and lazy-writer semantics stay intact; the delete-file cache is
+            // therefore also per file, and equality deletes shared by several
+            // candidates are fetched once per file.
+            let mut reader_builder = self.table.reader_builder();
+            if let Some(batch_size) = self.batch_size {
+                reader_builder = reader_builder.with_batch_size(batch_size);
+            }
+
+            let mut batches = reader_builder.build().read(tasks)?.stream();
+            while let Some(batch) = batches.try_next().await? {
+                result.stats.input_rows += batch.num_rows() as u64;
+                file_input_rows += batch.num_rows() as u64;
+
+                let rewrite = rewriter.rewrite_batch(batch)?;
+                // `output: None` means the batch is fully removed, which is
+                // itself a change. Derive the effective flag instead of
+                // trusting every rewriter to keep `changed` consistent with
+                // `output` — otherwise a `{changed: false, output: None}`
+                // batch would silently drop its rows while leaving the file
+                // marked unchanged.
+                let changed = rewrite.changed || rewrite.output.is_none();
+                if changed {
+                    file_changed = true;
+                    result.stats.changed_batches += 1;
+                }
+
+                // The guard must see every emitted batch, including ones that
+                // are only buffered into the prefix before the file flips to
+                // changed.
+                #[cfg(debug_assertions)]
+                if let (Some(guard), Some(output)) = (&partition_guard, 
&rewrite.output) {
+                    guard.check(output)?;
+                }
+
+                // A dropped batch can be the first change. Flush any kept
+                // prefix even when this batch has no output; defer opening the
+                // writer only if there are no rows to preserve yet.
+                if file_changed
+                    && writer.is_none()
+                    && (!prefix.is_empty() || rewrite.output.is_some())
+                {
+                    let partition_key =
+                        source_partition_key(self.table, &old_data_file, 
&write_schema)?;
+                    writer = Some(
+                        writer::build_replacement_writer(
+                            self.table,
+                            write_schema.clone(),
+                            Some(partition_key),
+                        )
+                        .await?,
+                    );
+                }
+
+                if let Some(writer) = writer.as_mut() {
+                    for prefix_batch in prefix.drain(..) {
+                        writer.write(prefix_batch).await?;
+                    }
+                    if let Some(output) = rewrite.output {
+                        file_output_rows += output.num_rows() as u64;
+                        writer.write(output).await?;
+                    }
+                } else if let Some(output) = rewrite.output {
+                    file_output_rows += output.num_rows() as u64;
+                    prefix.push(output);
+                }
+            }
+
+            // A candidate whose visible rows were all removed by its delete
+            // files reads as zero rows and the loop above never runs. Treat it
+            // the same as a rewriter that dropped every batch — changed with
+            // no replacement — so the file and its delete files can be
+            // compacted away instead of being pinned in the table forever.
+            let fully_removed_by_deletes =
+                !file_changed && file_input_rows == 0 && has_delete_files;
+
+            if file_changed || fully_removed_by_deletes {
+                result.stats.rewritten_files += 1;
+                result.stats.output_rows += file_output_rows;
+                result.removed_data_files.push(old_data_file);
+
+                if let Some(mut writer) = writer {
+                    let added_data_files = writer.close().await?;
+                    result.added_data_files.extend(added_data_files);
+                }
+                // If `writer` is `None`, no visible rows remain after delete
+                // files and batch rewriting, so no replacement is written.
+            } else {
+                result.stats.unchanged_files += 1;
+                result.unchanged_data_files.push(old_data_file);
+                // `prefix` is dropped here; no replacement file was written.
+            }
+        }
+
+        Ok(result)
+    }
+}
+
+fn source_partition_key(
+    table: &Table,
+    data_file: &DataFile,
+    schema: &crate::spec::SchemaRef,
+) -> Result<PartitionKey> {
+    let spec = table
+        .metadata()
+        .partition_spec_by_id(data_file.partition_spec_id)
+        .ok_or_else(|| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "Missing partition spec {} for COW rewrite source file",
+                    data_file.partition_spec_id
+                ),
+            )
+        })?
+        .as_ref()
+        .clone();
+    // `PartitionKey::new` does not bind the spec to the schema, so validate 
the
+    // binding here: a spec that is incompatible with the planned snapshot
+    // schema should fail with a clear error instead of producing a bad
+    // partition path when the writer later calls `PartitionKey::to_path`.
+    spec.partition_type(schema).map_err(|err| {
+        Error::new(
+            ErrorKind::DataInvalid,
+            format!(
+                "Cannot bind partition spec {} to the planned snapshot schema 
for COW rewrite",
+                data_file.partition_spec_id
+            ),
+        )
+        .with_source(err)
+    })?;
+
+    Ok(PartitionKey::new(
+        spec,
+        schema.clone(),
+        data_file.partition().clone(),
+    ))
+}
+
+/// Debug-only tripwire for the rewriter contract that output batches preserve
+/// the source file's partition values: replacement files record the source
+/// partition verbatim, so a rewriter that mutates a partition-source column
+/// would silently write rows under a partition they do not belong to. Compiled
+/// out in release builds, where per-row partition calculation would be too
+/// expensive for the rewrite hot path.
+#[cfg(debug_assertions)]
+struct DebugPartitionGuard {
+    calculator: crate::arrow::PartitionValueCalculator,
+    partition_type: crate::spec::StructType,
+    expected: crate::spec::Struct,
+}
+
+#[cfg(debug_assertions)]
+impl DebugPartitionGuard {
+    /// Builds a guard for one candidate file, or `None` when the source
+    /// file's partition spec is unpartitioned. Mirrors the spec lookup and
+    /// schema binding of [`source_partition_key`].
+    fn new(
+        table: &Table,
+        data_file: &DataFile,
+        schema: &crate::spec::SchemaRef,
+    ) -> Result<Option<Self>> {
+        let spec = table
+            .metadata()
+            .partition_spec_by_id(data_file.partition_spec_id)
+            .ok_or_else(|| {
+                Error::new(
+                    ErrorKind::DataInvalid,
+                    format!(
+                        "Missing partition spec {} for COW rewrite source 
file",
+                        data_file.partition_spec_id
+                    ),
+                )
+            })?;
+        if spec.is_unpartitioned() {
+            return Ok(None);
+        }
+
+        Ok(Some(Self {
+            calculator: crate::arrow::PartitionValueCalculator::try_new(spec, 
schema)?,
+            partition_type: spec.partition_type(schema)?,
+            expected: data_file.partition().clone(),
+        }))
+    }
+
+    /// Asserts that every row in `output` computes to the source file's
+    /// partition under the source spec.
+    fn check(&self, output: &RecordBatch) -> Result<()> {
+        let partition_array = self.calculator.calculate(output)?;
+        let values = crate::arrow::arrow_struct_to_literal(&partition_array, 
&self.partition_type)?;
+        for value in values {
+            debug_assert_eq!(
+                value,
+                Some(crate::spec::Literal::Struct(self.expected.clone())),
+                "COW rewriter must preserve the source file's partition 
values: \
+                 replacement files record the source partition verbatim, so \
+                 mutating a partition-source column writes rows under a \
+                 partition they do not belong to"
+            );
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::{HashMap, HashSet};
+    use std::sync::Arc;
+
+    use arrow_array::{Array, ArrayRef, BooleanArray, Int32Array, RecordBatch};
+    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
+    use futures::TryStreamExt;
+    use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
+    use tempfile::TempDir;
+
+    use crate::cow_rewrite::{CowBatchRewrite, CowBatchRewriter, 
CowRewriteBuilder};
+    use crate::io::LocalFsStorageFactory;
+    use crate::memory::{MEMORY_CATALOG_WAREHOUSE, MemoryCatalogBuilder};
+    use crate::scan::{FileScanTask, FileScanTaskStream};
+    use crate::spec::{
+        DataFile, Literal, NestedField, PrimitiveType, Schema, Struct, 
TableProperties, Transform,
+        Type,
+    };
+    use crate::table::Table;
+    use crate::test_utils::scan::TableTestFixture;
+    use crate::transaction::{AddColumn, ApplyTransactionAction, Transaction};
+    use crate::{Catalog, CatalogBuilder, Error, ErrorKind, NamespaceIdent, 
Result, TableCreation};
+
+    struct KeepAll;
+
+    impl CowBatchRewriter for KeepAll {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            Ok(CowBatchRewrite {
+                output: Some(batch),
+                changed: false,
+            })
+        }
+    }
+
+    struct DeleteEvenIds;
+
+    impl CowBatchRewriter for DeleteEvenIds {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            let ids = batch
+                .column_by_name("id")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id 
column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be 
Int32"))?;
+
+            let keep =
+                BooleanArray::from_iter((0..ids.len()).map(|row| 
Some(ids.value(row) % 2 != 0)));
+            let filtered = arrow_select::filter::filter_record_batch(&batch, 
&keep)
+                .map_err(|err| Error::new(ErrorKind::Unexpected, 
err.to_string()))?;
+
+            Ok(CowBatchRewrite {
+                changed: filtered.num_rows() != batch.num_rows(),
+                output: (filtered.num_rows() > 0).then_some(filtered),
+            })
+        }
+    }
+
+    struct IncrementValueForEvenIds;
+
+    impl CowBatchRewriter for IncrementValueForEvenIds {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            let ids = batch
+                .column_by_name("id")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id 
column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be 
Int32"))?;
+            let values = batch
+                .column_by_name("value")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing 
value column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "value must 
be Int32"))?;
+
+            let mut changed = false;
+            let updated_values = 
Int32Array::from_iter((0..values.len()).map(|row| {
+                let value = values.value(row);
+                if ids.value(row) % 2 == 0 {
+                    changed = true;
+                    Some(value + 10)
+                } else {
+                    Some(value)
+                }
+            }));
+            let output = RecordBatch::try_new(batch.schema(), vec![
+                batch.column(0).clone(),
+                Arc::new(updated_values),
+            ])
+            .map_err(|err| Error::new(ErrorKind::Unexpected, 
err.to_string()))?;
+
+            Ok(CowBatchRewrite {
+                output: Some(output),
+                changed,
+            })
+        }
+    }
+
+    struct CowRewriteFixture {
+        _temp_dir: TempDir,
+        table: Table,
+    }
+
+    async fn test_table_with_ids(ids: Vec<i32>) -> Result<CowRewriteFixture> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_rewrite_fixture".to_string())
+                    .schema(schema)
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let batch = RecordBatch::try_new(arrow_schema, vec![
+            Arc::new(Int32Array::from(ids)) as ArrayRef
+        ])?;
+        let data_files = super::writer::write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+
+        let tx = Transaction::new(&table);
+        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
+        let table = tx.commit(&catalog).await?;
+
+        Ok(CowRewriteFixture {
+            _temp_dir: temp_dir,
+            table,
+        })
+    }
+
+    async fn test_table_with_id_batches(batches: Vec<Vec<i32>>) -> 
Result<CowRewriteFixture> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_rewrite_fixture".to_string())
+                    .schema(schema)
+                    .properties(HashMap::from([(
+                        
TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES.to_string(),
+                        "1".to_string(),
+                    )]))
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let input = batches.into_iter().map(|ids| {
+            Ok(RecordBatch::try_new(arrow_schema.clone(), vec![
+                Arc::new(Int32Array::from(ids)) as ArrayRef,
+            ])?)
+        });
+        let data_files = super::writer::write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(input),
+        )
+        .await?;
+
+        let tx = Transaction::new(&table);
+        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
+        let table = tx.commit(&catalog).await?;
+
+        Ok(CowRewriteFixture {
+            _temp_dir: temp_dir,
+            table,
+        })
+    }
+
+    async fn test_table_with_id_value_rows(rows: Vec<(i32, i32)>) -> 
Result<CowRewriteFixture> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::required(2, "value", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_rewrite_fixture".to_string())
+                    .schema(schema)
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+            Field::new("value", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "2".to_string(),
+            )])),
+        ]));
+        let ids = rows.iter().map(|(id, _)| *id).collect::<Vec<_>>();
+        let values = rows.iter().map(|(_, value)| *value).collect::<Vec<_>>();
+        let batch = RecordBatch::try_new(arrow_schema, vec![
+            Arc::new(Int32Array::from(ids)) as ArrayRef,
+            Arc::new(Int32Array::from(values)) as ArrayRef,
+        ])?;
+        let data_files = super::writer::write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+
+        let tx = Transaction::new(&table);
+        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
+        let table = tx.commit(&catalog).await?;
+
+        Ok(CowRewriteFixture {
+            _temp_dir: temp_dir,
+            table,
+        })
+    }
+
+    async fn read_ids(table: &Table, files: &[DataFile]) -> Result<Vec<i32>> {
+        let schema = table.metadata().current_schema().clone();
+        let project_field_ids = schema
+            .as_struct()
+            .fields()
+            .iter()
+            .map(|field| field.id)
+            .collect::<Vec<_>>();
+        let tasks = files
+            .iter()
+            .map(|data_file| {
+                FileScanTask::builder()
+                    .with_file_size_in_bytes(data_file.file_size_in_bytes())
+                    .with_start(0)
+                    .with_length(data_file.file_size_in_bytes())
+                    .with_record_count(Some(data_file.record_count()))
+                    .with_data_file_path(data_file.file_path().to_string())
+                    .with_data_file_format(data_file.file_format())
+                    .with_schema(schema.clone())
+                    .with_project_field_ids(project_field_ids.clone())
+                    .with_case_sensitive(true)
+                    .build()
+            })
+            .collect::<Vec<_>>();
+        let task_stream = Box::pin(futures::stream::iter(tasks)) as 
FileScanTaskStream;
+        let batches = table
+            .reader_builder()
+            .build()
+            .read(task_stream)?
+            .stream()
+            .try_collect::<Vec<_>>()
+            .await?;
+
+        let mut ids = Vec::new();
+        for batch in batches {
+            let column = batch
+                .column_by_name("id")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id 
column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be 
Int32"))?;
+            ids.extend((0..column.len()).map(|row| column.value(row)));
+        }
+        ids.sort_unstable();
+        Ok(ids)
+    }
+
+    async fn read_ids_and_values(table: &Table, files: &[DataFile]) -> 
Result<Vec<(i32, i32)>> {
+        let schema = table.metadata().current_schema().clone();
+        let project_field_ids = schema
+            .as_struct()
+            .fields()
+            .iter()
+            .map(|field| field.id)
+            .collect::<Vec<_>>();
+        let tasks = files
+            .iter()
+            .map(|data_file| {
+                FileScanTask::builder()
+                    .with_file_size_in_bytes(data_file.file_size_in_bytes())
+                    .with_start(0)
+                    .with_length(data_file.file_size_in_bytes())
+                    .with_record_count(Some(data_file.record_count()))
+                    .with_data_file_path(data_file.file_path().to_string())
+                    .with_data_file_format(data_file.file_format())
+                    .with_schema(schema.clone())
+                    .with_project_field_ids(project_field_ids.clone())
+                    .with_case_sensitive(true)
+                    .build()
+            })
+            .collect::<Vec<_>>();
+        let task_stream = Box::pin(futures::stream::iter(tasks)) as 
FileScanTaskStream;
+        let batches = table
+            .reader_builder()
+            .build()
+            .read(task_stream)?
+            .stream()
+            .try_collect::<Vec<_>>()
+            .await?;
+
+        let mut rows = Vec::new();
+        for batch in batches {
+            let ids = batch
+                .column_by_name("id")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id 
column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be 
Int32"))?;
+            let values = batch
+                .column_by_name("value")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing 
value column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "value must 
be Int32"))?;
+            rows.extend((0..ids.len()).map(|row| (ids.value(row), 
values.value(row))));
+        }
+        rows.sort_unstable_by_key(|(id, _)| *id);
+        Ok(rows)
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_keep_all_produces_no_changes() -> Result<()> {
+        let fixture = test_table_with_ids(vec![1, 2, 3]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(KeepAll))
+            .rewrite()
+            .await?;
+
+        assert!(!result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 0);
+        assert_eq!(result.added_data_files.len(), 0);
+        assert_eq!(result.unchanged_data_files.len(), 1);
+        assert_eq!(result.stats.candidate_files, 1);
+        assert_eq!(result.stats.unchanged_files, 1);
+        assert_eq!(result.stats.input_rows, 3);
+        // Nothing was written, so rows emitted for the unchanged file are not
+        // counted as output.
+        assert_eq!(result.stats.output_rows, 0);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_requires_rewriter() -> Result<()> {
+        let fixture = test_table_with_ids(vec![1]).await?;
+
+        let err = CowRewriteBuilder::new(&fixture.table)
+            .rewrite()
+            .await
+            .expect_err("missing rewriter should fail");
+
+        assert_eq!(err.kind(), ErrorKind::PreconditionFailed);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_delete_rows_removes_old_file_and_adds_replacement() 
-> Result<()> {
+        let fixture = test_table_with_ids(vec![1, 2, 3, 4]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(DeleteEvenIds))
+            .rewrite()
+            .await?;
+
+        assert!(result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.added_data_files.len(), 1);
+        assert_eq!(result.stats.input_rows, 4);
+        assert_eq!(result.stats.output_rows, 2);
+
+        let ids = read_ids(&fixture.table, &result.added_data_files).await?;
+        assert_eq!(ids, vec![1, 3]);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_update_rows_rewrites_file_with_updated_values() -> 
Result<()> {
+        let fixture =
+            test_table_with_id_value_rows(vec![(1, 10), (2, 20), (3, 30), (4, 
40)]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(IncrementValueForEvenIds))
+            .rewrite()
+            .await?;
+
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.added_data_files.len(), 1);
+        assert_eq!(result.stats.input_rows, 4);
+        assert_eq!(result.stats.output_rows, 4);
+
+        let rows = read_ids_and_values(&fixture.table, 
&result.added_data_files).await?;
+        assert_eq!(rows, vec![(1, 10), (2, 30), (3, 30), (4, 50)]);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn 
cow_rewrite_full_file_delete_removes_old_file_without_replacement() -> 
Result<()> {
+        let fixture = test_table_with_ids(vec![2, 4]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(DeleteEvenIds))
+            .rewrite()
+            .await?;
+
+        assert!(result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.added_data_files.len(), 0);
+        assert_eq!(result.stats.input_rows, 2);
+        assert_eq!(result.stats.output_rows, 0);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_removes_file_fully_covered_by_position_deletes() -> 
Result<()> {
+        let mut fixture = TableTestFixture::new();
+        let positions = (0..300).collect::<Vec<i64>>();
+        fixture.setup_multi_row_group_manifest(&positions).await;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(KeepAll))
+            .rewrite()
+            .await?;
+
+        assert_eq!(result.stats.candidate_files, 1);
+        assert_eq!(result.stats.rewritten_files, 1);
+        assert_eq!(result.stats.input_rows, 0);
+        assert_eq!(result.stats.output_rows, 0);
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert!(result.added_data_files.is_empty());
+        assert!(result.unchanged_data_files.is_empty());
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_delete_no_matching_rows_keeps_old_file() -> 
Result<()> {
+        let fixture = test_table_with_ids(vec![1, 3]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(DeleteEvenIds))
+            .rewrite()
+            .await?;
+
+        assert!(!result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 0);
+        assert_eq!(result.added_data_files.len(), 0);
+        assert_eq!(result.unchanged_data_files.len(), 1);
+        assert_eq!(result.stats.input_rows, 2);
+        assert_eq!(result.stats.output_rows, 0);
+
+        Ok(())
+    }
+
+    /// Drops every batch while reporting `changed: false`, violating the
+    /// documented contract. The orchestrator must derive the change from
+    /// `output: None` itself, otherwise the file would be kept as "unchanged"
+    /// while its rows were meant to be dropped.
+    struct SilentFullDrop;
+
+    impl CowBatchRewriter for SilentFullDrop {
+        fn rewrite_batch(&self, _batch: RecordBatch) -> 
Result<CowBatchRewrite> {
+            Ok(CowBatchRewrite {
+                output: None,
+                changed: false,
+            })
+        }
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_none_output_implies_change() -> Result<()> {
+        let fixture = test_table_with_ids(vec![1, 2]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(SilentFullDrop))
+            .rewrite()
+            .await?;
+
+        assert!(result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.added_data_files.len(), 0);
+        assert!(result.unchanged_data_files.is_empty());
+        assert_eq!(result.stats.rewritten_files, 1);
+        assert_eq!(result.stats.input_rows, 2);
+        assert_eq!(result.stats.output_rows, 0);
+        assert_eq!(result.stats.changed_batches, 1);
+
+        Ok(())
+    }
+
+    /// Returns `output: None` with `changed: false` for the first batch, then
+    /// flags the second batch as changed. The replacement must contain exactly
+    /// the second batch's rows — the silently dropped first batch must not
+    /// resurrect, and the original file must be removed.
+    struct DropFirstBatchSilently {
+        batches_seen: std::sync::atomic::AtomicUsize,
+    }
+
+    impl CowBatchRewriter for DropFirstBatchSilently {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            let seen = self
+                .batches_seen
+                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+            if seen == 0 {
+                Ok(CowBatchRewrite {
+                    output: None,
+                    changed: false,
+                })
+            } else {
+                Ok(CowBatchRewrite {
+                    output: Some(batch),
+                    changed: true,
+                })
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_silent_drop_before_change_loses_no_rows() -> 
Result<()> {
+        let fixture = test_table_with_ids(vec![1, 2, 3, 4]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_batch_size(2)
+            .with_rewriter(Arc::new(DropFirstBatchSilently {
+                batches_seen: std::sync::atomic::AtomicUsize::new(0),
+            }))
+            .rewrite()
+            .await?;
+
+        assert!(result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.added_data_files.len(), 1);
+        assert_eq!(result.stats.input_rows, 4);
+        assert_eq!(result.stats.output_rows, 2);
+
+        let ids = read_ids(&fixture.table, &result.added_data_files).await?;
+        assert_eq!(ids, vec![3, 4]);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_rewrite_kept_prefix_survives_later_full_batch_delete() -> 
Result<()> {
+        let fixture = test_table_with_ids(vec![1, 3, 2, 4]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_batch_size(2)
+            .with_rewriter(Arc::new(DeleteEvenIds))
+            .rewrite()
+            .await?;
+
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.stats.input_rows, 4);
+        assert_eq!(result.stats.output_rows, 2);
+        assert_eq!(result.added_data_files.len(), 1);
+        assert!(result.unchanged_data_files.is_empty());
+        assert_eq!(
+            read_ids(&fixture.table, &result.added_data_files).await?,
+            vec![1, 3]
+        );
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn 
cow_rewrite_uses_unique_replacement_paths_for_multiple_source_files() -> 
Result<()> {
+        let fixture = test_table_with_id_batches(vec![vec![1, 2], vec![3, 
4]]).await?;
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(DeleteEvenIds))
+            .rewrite()
+            .await?;
+
+        assert_eq!(result.stats.candidate_files, 2);
+        assert_eq!(result.removed_data_files.len(), 2);
+        assert_eq!(result.added_data_files.len(), 2);
+
+        let added_paths = result
+            .added_data_files
+            .iter()
+            .map(|file| file.file_path().to_string())
+            .collect::<HashSet<_>>();
+        let removed_paths = result
+            .removed_data_files
+            .iter()
+            .map(|file| file.file_path().to_string())
+            .collect::<HashSet<_>>();
+
+        assert_eq!(added_paths.len(), result.added_data_files.len());
+        assert!(added_paths.is_disjoint(&removed_paths));
+
+        let ids = read_ids(&fixture.table, &result.added_data_files).await?;
+        assert_eq!(ids, vec![1, 3]);
+
+        Ok(())
+    }
+
+    #[test]
+    fn cow_batch_rewriter_is_object_safe() {
+        let _rewriter: Arc<dyn CowBatchRewriter> = Arc::new(KeepAll);
+    }
+
+    #[test]
+    fn cow_rewrite_result_reports_no_changes() {
+        let result = crate::cow_rewrite::CowRewriteResult {
+            removed_data_files: vec![],
+            added_data_files: vec![],
+            unchanged_data_files: vec![],
+            stats: crate::cow_rewrite::CowRewriteStats {
+                candidate_files: 0,
+                rewritten_files: 0,
+                unchanged_files: 0,
+                input_rows: 0,
+                output_rows: 0,
+                changed_batches: 0,
+            },
+        };
+
+        assert!(!result.has_changes());
+        assert_eq!(result.stats.candidate_files, 0);
+    }
+
+    /// `DeleteIfModThree` keeps rows whose `id` is not divisible by 3.
+    struct DeleteIfModThree;
+
+    impl CowBatchRewriter for DeleteIfModThree {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            let ids = batch
+                .column_by_name("id")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing id 
column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "id must be 
Int32"))?;
+
+            let keep =
+                BooleanArray::from_iter((0..ids.len()).map(|row| 
Some(ids.value(row) % 3 != 0)));
+            let filtered = arrow_select::filter::filter_record_batch(&batch, 
&keep)
+                .map_err(|err| Error::new(ErrorKind::Unexpected, 
err.to_string()))?;
+
+            Ok(CowBatchRewrite {
+                changed: filtered.num_rows() != batch.num_rows(),
+                output: (filtered.num_rows() > 0).then_some(filtered),
+            })
+        }
+    }
+
+    /// After adding an optional column (`value`) to the schema, a COW rewrite
+    /// of the pre-existing data must not fail. The replacement writer must use
+    /// the schema the batches were read in (the snapshot schema), not the
+    /// table's evolved current schema, otherwise the parquet writer rejects
+    /// the input batch whose column set does not include `value`.
+    #[tokio::test]
+    async fn cow_rewrite_after_optional_column_add() -> Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog: Arc<dyn Catalog> = Arc::new(
+            MemoryCatalogBuilder::default()
+                .with_storage_factory(Arc::new(LocalFsStorageFactory))
+                .load(
+                    "memory",
+                    HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+                )
+                .await?,
+        );
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("evolved".to_string())
+                    .schema(schema)
+                    .build(),
+            )
+            .await?;
+
+        // Write the original data file with the {id}-only schema.
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let batch = RecordBatch::try_new(arrow_schema, 
vec![Arc::new(Int32Array::from(vec![
+            1, 2, 3, 4,
+        ])) as ArrayRef])?;
+        let data_files = super::writer::write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+        let tx = Transaction::new(&table);
+        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
+        let table = tx.commit(&*catalog).await?;
+
+        // Evolve the schema: add an optional `value` column.
+        let tx = Transaction::new(&table);
+        let tx = tx
+            .update_schema()
+            .add_column(AddColumn::optional(
+                "value",
+                Type::Primitive(PrimitiveType::Int),
+            ))
+            .apply(tx)?;
+        let table = tx.commit(&*catalog).await?;
+
+        // The current schema now has {id, value} but the current snapshot's
+        // schema is still the original {id} schema.
+        let current_snapshot = table.metadata().current_snapshot().unwrap();
+        assert_ne!(
+            table.metadata().current_schema_id(),
+            current_snapshot.schema_id().unwrap()
+        );
+
+        // Before the fix this fails when the parquet writer rejects the 
{id}-only
+        // batch while configured with the {id, value} current schema.
+        let result = CowRewriteBuilder::new(&table)
+            .with_predicate(crate::expr::Predicate::AlwaysTrue)
+            .with_rewriter(Arc::new(DeleteIfModThree))
+            .rewrite()
+            .await?;
+
+        // id 3 is divisible by 3 and is dropped; remaining ids are 1, 2, 4.
+        assert!(result.has_changes());
+        assert_eq!(result.removed_data_files.len(), 1);
+        assert_eq!(result.added_data_files.len(), 1);
+
+        let ids = read_ids(&table, &result.added_data_files).await?;
+        assert_eq!(ids, vec![1, 2, 4]);
+
+        Ok(())
+    }
+
+    /// Builds a two-file table whose files have disjoint id ranges so 
predicate
+    /// metrics pruning can exclude one file entirely.
+    async fn two_file_table_disjoint_ids() -> Result<CowRewriteFixture> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("disjoint".to_string())
+                    .schema(schema)
+                    .properties(HashMap::from([(
+                        
TableProperties::PROPERTY_WRITE_TARGET_FILE_SIZE_BYTES.to_string(),
+                        "1".to_string(),
+                    )]))
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        // First batch ids 1..=4, second batch ids 100..=103. Force a split via
+        // tiny target file size so each batch lands in its own data file.
+        let input = vec![vec![1, 2, 3, 4], vec![100, 101, 102, 103]]
+            .into_iter()
+            .map(|ids| {
+                Ok(RecordBatch::try_new(arrow_schema.clone(), vec![
+                    Arc::new(Int32Array::from(ids)) as ArrayRef,
+                ])?)
+            });
+        let data_files = super::writer::write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(input),
+        )
+        .await?;
+
+        let tx = Transaction::new(&table);
+        let tx = tx.fast_append().add_data_files(data_files).apply(tx)?;
+        let table = tx.commit(&catalog).await?;
+
+        Ok(CowRewriteFixture {
+            _temp_dir: temp_dir,
+            table,
+        })
+    }
+
+    /// Predicate-based planning must exclude files whose metrics cannot match
+    /// the predicate. With `id > 50`, the file holding ids 1..=4 must never be
+    /// planned as a candidate.
+    #[tokio::test]
+    async fn cow_rewrite_predicate_prunes_non_matching_files() -> Result<()> {
+        let fixture = two_file_table_disjoint_ids().await?;
+
+        let predicate = 
crate::expr::Reference::new("id").greater_than(crate::spec::Datum::int(50));
+
+        let result = CowRewriteBuilder::new(&fixture.table)
+            .with_predicate(predicate)
+            .with_rewriter(Arc::new(KeepAll))
+            .rewrite()
+            .await?;
+
+        // Only the high-id file should be a candidate.
+        assert_eq!(result.stats.candidate_files, 1);
+        // KeepAll does not change anything, so the single candidate is 
unchanged.
+        assert_eq!(result.removed_data_files.len(), 0);
+        assert_eq!(result.added_data_files.len(), 0);
+        assert_eq!(result.unchanged_data_files.len(), 1);
+
+        // The surviving candidate must be the high-id file (ids 100..=103).
+        let ids = read_ids(&fixture.table, 
&result.unchanged_data_files).await?;
+        assert_eq!(ids, vec![100, 101, 102, 103]);
+
+        Ok(())
+    }
+
+    /// Rewrites every batch without altering rows, so every candidate file is
+    /// replaced. Useful for exercising the replacement-writing path itself.
+    struct TouchEveryBatch;
+
+    impl CowBatchRewriter for TouchEveryBatch {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            Ok(CowBatchRewrite {
+                output: Some(batch),
+                changed: true,
+            })
+        }
+    }
+
+    /// An end-to-end rewrite on an identity-partitioned table must preserve
+    /// each source file's partition: the replacement file carries the same
+    /// partition values and partition spec id, and lands under the same
+    /// partition directory — including the null partition.
+    #[tokio::test]
+    async fn cow_rewrite_preserves_partitions_end_to_end() -> Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::optional(2, "value", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let partition_spec = 
crate::spec::PartitionSpec::builder(Arc::new(schema.clone()))
+            .add_partition_field("value", "value", Transform::Identity)?
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("partitioned".to_string())
+                    .schema(schema)
+                    .partition_spec(partition_spec.into_unbound())
+                    .build(),
+            )
+            .await?;
+
+        // Two source files: ids 1, 2 in the `value=1` partition and ids 3, 4
+        // in the null partition.
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+            Field::new("value", DataType::Int32, 
true).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "2".to_string(),
+            )])),
+        ]));
+        let current_schema = table.metadata().current_schema().clone();
+        let default_spec = 
table.metadata().default_partition_spec().as_ref().clone();
+
+        let batch_one = RecordBatch::try_new(arrow_schema.clone(), vec![
+            Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
+            Arc::new(Int32Array::from(vec![1, 1])),
+        ])?;
+        let one_partition = crate::spec::PartitionKey::new(
+            default_spec.clone(),
+            current_schema.clone(),
+            Struct::from_iter([Some(Literal::int(1))]),
+        );
+        let files_one = super::writer::write_replacement_batches(
+            &table,
+            current_schema.clone(),
+            Some(one_partition),
+            futures::stream::iter(vec![Ok(batch_one)]),
+        )
+        .await?;
+
+        let batch_null = RecordBatch::try_new(arrow_schema.clone(), vec![
+            Arc::new(Int32Array::from(vec![3, 4])) as ArrayRef,
+            Arc::new(Int32Array::from(vec![None::<i32>, None])),
+        ])?;
+        let null_partition = crate::spec::PartitionKey::new(
+            default_spec.clone(),
+            current_schema.clone(),
+            Struct::from_iter([None::<Literal>]),
+        );
+        let files_null = super::writer::write_replacement_batches(
+            &table,
+            current_schema.clone(),
+            Some(null_partition),
+            futures::stream::iter(vec![Ok(batch_null)]),
+        )
+        .await?;
+
+        let tx = Transaction::new(&table);
+        let tx = tx
+            .fast_append()
+            .add_data_files([files_one, files_null].concat())
+            .apply(tx)?;
+        let table = tx.commit(&catalog).await?;
+
+        let result = CowRewriteBuilder::new(&table)
+            .with_rewriter(Arc::new(TouchEveryBatch))
+            .rewrite()
+            .await?;
+
+        assert_eq!(result.stats.candidate_files, 2);
+        assert_eq!(result.stats.rewritten_files, 2);
+        assert_eq!(result.removed_data_files.len(), 2);
+        assert_eq!(result.added_data_files.len(), 2);
+        assert!(result.unchanged_data_files.is_empty());
+
+        // Partition values survive the rewrite unchanged.
+        let one_struct = Struct::from_iter([Some(Literal::int(1))]);
+        let null_struct = Struct::from_iter([None::<Literal>]);
+        let mut removed_partitions = result
+            .removed_data_files
+            .iter()
+            .map(|file| file.partition().clone())
+            .collect::<Vec<_>>();
+        let mut added_partitions = result
+            .added_data_files
+            .iter()
+            .map(|file| file.partition().clone())
+            .collect::<Vec<_>>();
+        removed_partitions.sort_by_key(|partition| 
partition.is_null_at_index(0));
+        added_partitions.sort_by_key(|partition| 
partition.is_null_at_index(0));
+        assert_eq!(removed_partitions, added_partitions);
+        assert_eq!(added_partitions[0], one_struct);
+        assert_eq!(added_partitions[1], null_struct);
+
+        for file in &result.added_data_files {
+            assert_eq!(file.partition_spec_id, 0);
+            if file.partition() == &one_struct {
+                assert!(file.file_path().contains("value=1"), "{}", 
file.file_path());
+            } else {
+                assert!(
+                    file.file_path().contains("value=null"),
+                    "{}",
+                    file.file_path()
+                );
+            }
+        }
+
+        // Replacement content matches the source rows.
+        let ids = read_ids(&table, &result.added_data_files).await?;
+        assert_eq!(ids, vec![1, 2, 3, 4]);
+
+        Ok(())
+    }
+
+    /// Mutates the identity-partitioned `value` column while flagging the
+    /// batch as changed — a violation of the partition-preservation contract.
+    /// In debug builds the partition guard must trip instead of letting the
+    /// replacement file record a partition that disagrees with its rows.
+    #[cfg(debug_assertions)]
+    struct MutatePartitionColumn;
+
+    #[cfg(debug_assertions)]
+    impl CowBatchRewriter for MutatePartitionColumn {
+        fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite> 
{
+            let values = batch
+                .column_by_name("value")
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "missing 
value column"))?
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .ok_or_else(|| Error::new(ErrorKind::DataInvalid, "value must 
be Int32"))?;
+            let updated =
+                Int32Array::from_iter((0..values.len()).map(|row| 
Some(values.value(row) + 100)));
+            let output = RecordBatch::try_new(batch.schema(), vec![
+                batch.column(0).clone(),
+                Arc::new(updated),
+            ])
+            .map_err(|err| Error::new(ErrorKind::Unexpected, 
err.to_string()))?;
+
+            Ok(CowBatchRewrite {
+                output: Some(output),
+                changed: true,
+            })
+        }
+    }
+
+    #[cfg(debug_assertions)]
+    #[tokio::test]
+    #[should_panic(expected = "must preserve the source file's partition 
values")]
+    async fn cow_rewrite_partition_column_mutation_trips_debug_guard() {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await
+            .unwrap();
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog
+            .create_namespace(&namespace, HashMap::new())
+            .await
+            .unwrap();
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::required(2, "value", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()
+            .unwrap();
+        let partition_spec = 
crate::spec::PartitionSpec::builder(Arc::new(schema.clone()))
+            .add_partition_field("value", "value", Transform::Identity)
+            .unwrap()
+            .build()
+            .unwrap();
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("partition_guard".to_string())
+                    .schema(schema)
+                    .partition_spec(partition_spec.into_unbound())
+                    .build(),
+            )
+            .await
+            .unwrap();
+
+        // One source file whose rows all sit in the `value=1` partition.
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+            Field::new("value", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "2".to_string(),
+            )])),
+        ]));
+        let batch = RecordBatch::try_new(arrow_schema, vec![
+            Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
+            Arc::new(Int32Array::from(vec![1, 1])),
+        ])
+        .unwrap();
+        let partition_key = crate::spec::PartitionKey::new(
+            table.metadata().default_partition_spec().as_ref().clone(),
+            table.metadata().current_schema().clone(),
+            Struct::from_iter([Some(Literal::int(1))]),
+        );
+        let data_files = super::writer::write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            Some(partition_key),
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await
+        .unwrap();
+
+        let tx = Transaction::new(&table);
+        let tx = tx
+            .fast_append()
+            .add_data_files(data_files)
+            .apply(tx)
+            .unwrap();
+        let table = tx.commit(&catalog).await.unwrap();
+
+        // The rewriter bumps every `value` by 100, so the output batch
+        // computes to a different partition than the source file records.
+        // The debug guard must panic here; without it the rewrite would
+        // silently commit a replacement file under `value=1` holding rows
+        // with `value=101`.
+        let _ = CowRewriteBuilder::new(&table)
+            .with_rewriter(Arc::new(MutatePartitionColumn))
+            .rewrite()
+            .await;
+    }
+}
diff --git a/crates/iceberg/src/cow_rewrite/plan.rs 
b/crates/iceberg/src/cow_rewrite/plan.rs
new file mode 100644
index 000000000..b96aea635
--- /dev/null
+++ b/crates/iceberg/src/cow_rewrite/plan.rs
@@ -0,0 +1,161 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use futures::TryStreamExt;
+
+use crate::scan::FileScanTask;
+use crate::spec::DataFile;
+
+/// A data file selected for COW rewrite.
+///
+/// This is a read-only output type: the planning entry points that produce
+/// candidates are crate-internal and reached through
+/// [`crate::cow_rewrite::CowRewriteBuilder`], and the fields are exposed
+/// through accessors only. Follow-up commit-adapter work (overwrite and
+/// row-delta actions) consumes planned candidates through those accessors;
+/// construction from outside the crate is intentionally not exposed yet and
+/// can be added when an adapter actually needs it.
+#[derive(Debug, Clone)]
+pub struct CowRewriteFile {
+    /// Original data file from the manifest entry.
+    pub(crate) old_data_file: DataFile,
+    /// Full-read task for visible rows in the original file.
+    pub(crate) scan_task: FileScanTask,
+}
+
+impl CowRewriteFile {
+    /// Original data file from the manifest entry.
+    pub fn old_data_file(&self) -> &DataFile {
+        &self.old_data_file
+    }
+
+    /// Full-read task for visible rows in the original file.
+    pub fn scan_task(&self) -> &FileScanTask {
+        &self.scan_task
+    }
+}
+
+/// Plan data files that may need copy-on-write rewrite.
+pub(crate) async fn plan_cow_rewrite_files(
+    table: &crate::table::Table,
+    predicate: Option<crate::expr::Predicate>,
+    snapshot_id: Option<i64>,
+    case_sensitive: bool,
+) -> crate::Result<Vec<CowRewriteFile>> {
+    let mut scan_builder = table
+        .scan()
+        .select_all()
+        .with_case_sensitive(case_sensitive);
+
+    if let Some(predicate) = predicate {
+        scan_builder = scan_builder.with_filter(predicate);
+    }
+
+    if let Some(snapshot_id) = snapshot_id {
+        scan_builder = scan_builder.snapshot_id(snapshot_id);
+    }
+
+    scan_builder
+        .build()?
+        .plan_cow_rewrite_files()
+        .await?
+        .try_collect()
+        .await
+}
+
+#[cfg(test)]
+mod tests {
+    use futures::TryStreamExt;
+
+    use crate::Result;
+    use crate::expr::Predicate;
+    use crate::test_utils::scan::TableTestFixture;
+
+    #[tokio::test]
+    async fn cow_planner_returns_old_file_and_full_read_task() -> Result<()> {
+        let mut fixture = TableTestFixture::new();
+        fixture.setup_manifest_files().await;
+
+        let mut files =
+            super::plan_cow_rewrite_files(&fixture.table, 
Some(Predicate::AlwaysTrue), None, true)
+                .await?;
+
+        assert_eq!(files.len(), 2);
+
+        files.sort_by_key(|file| file.old_data_file.file_path().to_string());
+        assert_eq!(
+            files[0].old_data_file.file_path(),
+            format!("{}/1.parquet", fixture.table_location)
+        );
+        assert_eq!(
+            files[1].old_data_file.file_path(),
+            format!("{}/3.parquet", fixture.table_location)
+        );
+
+        for file in files {
+            assert_eq!(
+                file.old_data_file.file_path(),
+                file.scan_task.data_file_path()
+            );
+            assert!(file.scan_task.predicate().is_none());
+            assert_eq!(
+                Some(file.old_data_file.record_count()),
+                file.scan_task.record_count()
+            );
+            assert_eq!(0, file.scan_task.start());
+            assert_eq!(
+                file.old_data_file.file_size_in_bytes(),
+                file.scan_task.length()
+            );
+        }
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_planner_preserves_delete_files() -> Result<()> {
+        let mut fixture = TableTestFixture::new();
+        fixture.setup_deadlock_manifests().await;
+
+        let scan = fixture
+            .table
+            .scan()
+            .select_all()
+            .with_concurrency_limit(1)
+            .build()?;
+
+        let files = tokio::time::timeout(std::time::Duration::from_secs(5), 
async {
+            scan.plan_cow_rewrite_files()
+                .await?
+                .try_collect::<Vec<_>>()
+                .await
+        })
+        .await
+        .expect("COW planning should not deadlock")?;
+
+        assert_eq!(files.len(), 10);
+        for file in files {
+            assert_eq!(file.scan_task.deletes().len(), 1);
+            assert_eq!(
+                file.scan_task.deletes()[0].file_path,
+                format!("{}/del.parquet", fixture.table_location)
+            );
+        }
+
+        Ok(())
+    }
+}
diff --git a/crates/iceberg/src/cow_rewrite/rewriter.rs 
b/crates/iceberg/src/cow_rewrite/rewriter.rs
new file mode 100644
index 000000000..4ed5e3402
--- /dev/null
+++ b/crates/iceberg/src/cow_rewrite/rewriter.rs
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use arrow_array::RecordBatch;
+
+use crate::Result;
+
+/// Result of rewriting a single record batch.
+#[derive(Debug)]
+pub struct CowBatchRewrite {
+    /// Rewritten output batch, or `None` when the input batch is fully 
removed.
+    ///
+    /// Output batches must use the same schema as their input batch — the
+    /// planned snapshot's schema, which may be older than the table's current
+    /// schema. Rewriters must also preserve each source file's partition
+    /// values: this primitive writes replacements into the source file's
+    /// partition and does not repartition rows.
+    pub output: Option<RecordBatch>,
+    /// Whether the rewrite changed the input batch contents.
+    ///
+    /// Set this to `true` whenever `output` differs from the input batch,
+    /// including filtered rows, updated values, reordered rows, or `None`.
+    pub changed: bool,
+}
+
+/// Rewrites record batches for copy-on-write operations.
+///
+/// `rewrite_batch` is synchronous: it runs on the async runtime thread that
+/// drives the read/write pipeline, so implementations must not perform
+/// blocking work — async I/O such as catalog enrichment or cross-table
+/// lookups is not supported in this contract. The method stays sync (rather
+/// than returning a boxed future) so the trait remains object safe for
+/// `Arc<dyn CowBatchRewriter>`.
+pub trait CowBatchRewriter: Send + Sync {
+    /// Rewrites a record batch and reports whether it changed.
+    ///
+    /// `output: None` means the batch is fully removed, which is itself a
+    /// change: the orchestrator treats it as changed regardless of the
+    /// `changed` flag, so a rewriter cannot accidentally keep dropped rows
+    /// alive by reporting `changed: false` alongside a `None` output.
+    fn rewrite_batch(&self, batch: RecordBatch) -> Result<CowBatchRewrite>;
+}
diff --git a/crates/iceberg/src/cow_rewrite/writer.rs 
b/crates/iceberg/src/cow_rewrite/writer.rs
new file mode 100644
index 000000000..ce235e889
--- /dev/null
+++ b/crates/iceberg/src/cow_rewrite/writer.rs
@@ -0,0 +1,447 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::str::FromStr;
+
+use uuid::Uuid;
+
+use crate::Result;
+#[cfg(test)]
+use crate::spec::DataFile;
+use crate::spec::{DataFileFormat, PartitionKey, SchemaRef};
+use crate::table::Table;
+use crate::writer::IcebergWriterBuilder;
+use crate::writer::base_writer::data_file_writer::DataFileWriterBuilder;
+use crate::writer::file_writer::ParquetWriterBuilder;
+use crate::writer::file_writer::location_generator::{
+    DefaultFileNameGenerator, DefaultLocationGenerator, LocationGenerator,
+    ObjectStorageLocationGenerator,
+};
+use crate::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
+
+/// Builds a boxed replacement-data-file writer.
+///
+/// `write_schema` is the schema the input batches are encoded in. It must 
match
+/// the schema the rows were read in (the planned snapshot's schema), not the
+/// table's possibly-evolved current schema; otherwise the parquet writer will
+/// reject batches that lack columns added after the source files were written.
+///
+/// Building the writer is cheap: no physical file is opened until the first
+/// batch is written, so it is safe to construct one optimistically and only
+/// write to it once a source file is known to have changed.
+///
+/// The location layout follows `write.object-storage.enabled`: tables that opt
+/// into the object-storage layout get hash-entropy paths from
+/// [`ObjectStorageLocationGenerator`], everything else gets the default 
layout.
+pub(crate) async fn build_replacement_writer(
+    table: &Table,
+    write_schema: SchemaRef,
+    partition_key: Option<PartitionKey>,
+) -> Result<Box<dyn crate::writer::IcebergWriter>> {
+    let table_props = table.metadata().table_properties();
+    // The replacement writer only produces parquet today; refuse to run on a
+    // table configured for another format instead of silently writing parquet
+    // against `write.format.default`.
+    let write_format_default = table_props.write_format_default()?;
+    if DataFileFormat::from_str(&write_format_default)? != 
DataFileFormat::Parquet {
+        return Err(crate::Error::new(
+            crate::ErrorKind::FeatureUnsupported,
+            format!("File format {write_format_default} is not supported for 
COW rewrite yet!"),
+        ));
+    }
+    let mut parquet_builder =
+        ParquetWriterBuilder::from_table_properties(&table_props, 
write_schema)?;
+    // Replacement files must not silently downgrade an encrypted table to
+    // plaintext: source rows were read through decryption, so re-encrypt the
+    // rewritten rows when the table carries an encryption manager.
+    if let Some(encryption_manager) = table.encryption_manager() {
+        parquet_builder = 
parquet_builder.with_encryption_manager(encryption_manager.clone());
+    }
+
+    if table_props.write_object_storage_enabled()? {
+        let location_generator = 
ObjectStorageLocationGenerator::new(table.metadata())?;
+        build_data_file_writer(table, parquet_builder, location_generator, 
partition_key).await
+    } else {
+        let location_generator = 
DefaultLocationGenerator::new(table.metadata())?;
+        build_data_file_writer(table, parquet_builder, location_generator, 
partition_key).await
+    }
+}
+
+async fn build_data_file_writer<L: LocationGenerator>(
+    table: &Table,
+    parquet_builder: ParquetWriterBuilder,
+    location_generator: L,
+    partition_key: Option<PartitionKey>,
+) -> Result<Box<dyn crate::writer::IcebergWriter>> {
+    let file_name_generator = DefaultFileNameGenerator::new(
+        format!("cow-rewrite-{}", Uuid::now_v7()),
+        None,
+        DataFileFormat::Parquet,
+    );
+    let table_props = table.metadata().table_properties();
+    let rolling_builder = RollingFileWriterBuilder::new(
+        parquet_builder,
+        table_props.write_target_file_size_bytes()?,
+        table.file_io().clone(),
+        location_generator,
+        file_name_generator,
+    );
+    let data_writer_builder = DataFileWriterBuilder::new(rolling_builder);
+    Ok(Box::new(data_writer_builder.build(partition_key).await?))
+}
+
+/// Writes replacement record batches as Iceberg data files.
+///
+/// This is a convenience wrapper around [`build_replacement_writer`] that
+/// consumes a stream end-to-end. Empty input or all-zero-row batches produce 
no
+/// data files.
+#[cfg(test)]
+pub(crate) async fn write_replacement_batches<S>(
+    table: &Table,
+    write_schema: SchemaRef,
+    partition_key: Option<PartitionKey>,
+    mut batches: S,
+) -> Result<Vec<DataFile>>
+where
+    S: futures::Stream<Item = Result<arrow_array::RecordBatch>> + Unpin,
+{
+    use futures::TryStreamExt as _;
+
+    let mut writer = build_replacement_writer(table, write_schema, 
partition_key).await?;
+
+    let mut wrote_rows = false;
+    while let Some(batch) = batches.try_next().await? {
+        if batch.num_rows() == 0 {
+            continue;
+        }
+        wrote_rows = true;
+        writer.write(batch).await?;
+    }
+
+    if !wrote_rows {
+        return Ok(vec![]);
+    }
+
+    writer.close().await
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::HashMap;
+    use std::sync::Arc;
+
+    use arrow_array::{ArrayRef, Int32Array, RecordBatch};
+    use arrow_schema::{DataType, Field, Schema as ArrowSchema};
+    use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
+    use tempfile::TempDir;
+
+    use super::write_replacement_batches;
+    use crate::io::LocalFsStorageFactory;
+    use crate::memory::{MEMORY_CATALOG_WAREHOUSE, MemoryCatalogBuilder};
+    use crate::spec::{
+        DataContentType, NestedField, PartitionKey, PrimitiveType, Schema, 
Struct, Transform, Type,
+    };
+    use crate::{Catalog, CatalogBuilder, NamespaceIdent, Result, 
TableCreation};
+
+    #[tokio::test]
+    async fn cow_replacement_writer_outputs_data_files() -> Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_writer".to_string())
+                    .schema(schema)
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let batch = RecordBatch::try_new(arrow_schema, 
vec![Arc::new(Int32Array::from(vec![
+            1, 2, 3,
+        ])) as ArrayRef])?;
+
+        let data_files = write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+
+        assert_eq!(data_files.len(), 1);
+        assert_eq!(data_files[0].record_count(), 3);
+        assert_eq!(data_files[0].content_type(), DataContentType::Data);
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_replacement_writer_skips_empty_input() -> Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_empty_writer".to_string())
+                    .schema(schema)
+                    .build(),
+            )
+            .await?;
+
+        let data_files = write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![]),
+        )
+        .await?;
+
+        assert!(data_files.is_empty());
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_replacement_writer_skips_zero_row_batches() -> Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_zero_row_writer".to_string())
+                    .schema(schema)
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let batch =
+            RecordBatch::try_new(arrow_schema, vec![
+                Arc::new(Int32Array::from(Vec::<i32>::new())) as ArrayRef,
+            ])?;
+
+        let data_files = write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+
+        assert!(data_files.is_empty());
+
+        Ok(())
+    }
+
+    /// With `write.object-storage.enabled` the replacement file must land in
+    /// the hash-entropy object storage layout instead of the flat default one.
+    #[tokio::test]
+    async fn cow_replacement_writer_honors_object_storage_layout() -> 
Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_object_storage_writer".to_string())
+                    .schema(schema)
+                    .properties(HashMap::from([(
+                        
crate::spec::TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_ENABLED
+                            .to_string(),
+                        "true".to_string(),
+                    )]))
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let batch = RecordBatch::try_new(arrow_schema, 
vec![Arc::new(Int32Array::from(vec![
+            1, 2, 3,
+        ])) as ArrayRef])?;
+
+        let data_files = write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            None,
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+
+        assert_eq!(data_files.len(), 1);
+        // Object storage layout: `{data_location}/{entropy dirs}/{file name}`,
+        // where the entropy dirs are binary strings of 4, 4, 4, and 8 chars.
+        let path = data_files[0].file_path();
+        let after_data = path
+            .split("/data/")
+            .nth(1)
+            .unwrap_or_else(|| panic!("expected /data/ in {path}"));
+        let segments = after_data.split('/').collect::<Vec<_>>();
+        assert_eq!(segments.len(), 5, "{path}");
+        for (segment, len) in segments[..4].iter().zip([4, 4, 4, 8]) {
+            assert_eq!(segment.len(), len, "{path}");
+            assert!(segment.chars().all(|c| c == '0' || c == '1'), "{path}");
+        }
+        assert!(segments[4].starts_with("cow-rewrite-"), "{path}");
+
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn cow_replacement_writer_preserves_partition_key() -> Result<()> {
+        let temp_dir = TempDir::new().unwrap();
+        let warehouse = format!("file://{}", 
temp_dir.path().join("warehouse").display());
+        let catalog = MemoryCatalogBuilder::default()
+            .with_storage_factory(Arc::new(LocalFsStorageFactory))
+            .load(
+                "memory",
+                HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), 
warehouse)]),
+            )
+            .await?;
+        let namespace = NamespaceIdent::new("ns".to_string());
+        catalog.create_namespace(&namespace, HashMap::new()).await?;
+
+        let schema = Schema::builder()
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+            ])
+            .build()?;
+        let partition_spec = 
crate::spec::PartitionSpec::builder(Arc::new(schema.clone()))
+            .add_partition_field("id", "id", Transform::Identity)?
+            .build()?;
+        let table = catalog
+            .create_table(
+                &namespace,
+                TableCreation::builder()
+                    .name("cow_partitioned_writer".to_string())
+                    .schema(schema)
+                    .partition_spec(partition_spec.clone().into_unbound())
+                    .build(),
+            )
+            .await?;
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, 
false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+        let batch = RecordBatch::try_new(arrow_schema, 
vec![Arc::new(Int32Array::from(vec![
+            1, 1, 1,
+        ])) as ArrayRef])?;
+        let partition_key = PartitionKey::new(
+            table.metadata().default_partition_spec().as_ref().clone(),
+            table.metadata().current_schema().clone(),
+            Struct::from_iter([Some(crate::spec::Literal::int(1))]),
+        );
+
+        let data_files = write_replacement_batches(
+            &table,
+            table.metadata().current_schema().clone(),
+            Some(partition_key),
+            futures::stream::iter(vec![Ok(batch)]),
+        )
+        .await?;
+
+        assert_eq!(data_files.len(), 1);
+        assert_eq!(data_files[0].partition_spec_id, 0);
+        assert_eq!(
+            data_files[0].partition(),
+            &Struct::from_iter([Some(crate::spec::Literal::int(1))])
+        );
+
+        Ok(())
+    }
+}
diff --git a/crates/iceberg/src/lib.rs b/crates/iceberg/src/lib.rs
index bccf82e1f..9733fac32 100644
--- a/crates/iceberg/src/lib.rs
+++ b/crates/iceberg/src/lib.rs
@@ -83,6 +83,7 @@ pub mod table;
 mod avro;
 pub mod cache;
 pub mod compression;
+pub mod cow_rewrite;
 pub mod io;
 pub mod spec;
 
diff --git a/crates/iceberg/src/scan/context.rs 
b/crates/iceberg/src/scan/context.rs
index 4635325fb..d17835afa 100644
--- a/crates/iceberg/src/scan/context.rs
+++ b/crates/iceberg/src/scan/context.rs
@@ -172,6 +172,23 @@ impl ManifestEntryContext {
             .with_sort_order(self.sort_order)
             .build()
     }
+
+    /// Consume this `ManifestEntryContext`, returning a COW rewrite file 
candidate.
+    ///
+    /// The scan task keeps everything planned for a normal read (projection,
+    /// delete files, partition metadata, ...) except the row predicate: the
+    /// candidate was already selected by that predicate during planning, and
+    /// the rewrite must read the whole file.
+    pub(crate) async fn into_cow_rewrite_file(self) -> 
Result<crate::cow_rewrite::CowRewriteFile> {
+        let old_data_file = self.manifest_entry.data_file().clone();
+        let mut scan_task = self.into_file_scan_task().await?;
+        scan_task.clear_predicate();
+
+        Ok(crate::cow_rewrite::CowRewriteFile {
+            old_data_file,
+            scan_task,
+        })
+    }
 }
 
 /// PlanContext wraps a [`SnapshotRef`] alongside all the other
diff --git a/crates/iceberg/src/scan/mod.rs b/crates/iceberg/src/scan/mod.rs
index 7d8140396..ccdb5c7d5 100644
--- a/crates/iceberg/src/scan/mod.rs
+++ b/crates/iceberg/src/scan/mod.rs
@@ -24,6 +24,7 @@ use context::*;
 mod task;
 
 use std::collections::HashMap;
+use std::future::Future;
 use std::sync::Arc;
 
 use arrow_array::RecordBatch;
@@ -397,6 +398,25 @@ pub struct TableScan {
 impl TableScan {
     /// Returns a stream of [`FileScanTask`]s.
     pub async fn plan_files(&self) -> Result<FileScanTaskStream> {
+        self.plan_data_files(|ctx| ctx.into_file_scan_task()).await
+    }
+
+    pub(crate) async fn plan_cow_rewrite_files(
+        &self,
+    ) -> Result<BoxStream<'static, 
Result<crate::cow_rewrite::CowRewriteFile>>> {
+        self.plan_data_files(|ctx| ctx.into_cow_rewrite_file())
+            .await
+    }
+
+    async fn plan_data_files<T, F, Fut>(
+        &self,
+        build_result: F,
+    ) -> Result<BoxStream<'static, Result<T>>>
+    where
+        T: Send + 'static,
+        F: Fn(ManifestEntryContext) -> Fut + Copy + Send + Sync + 'static,
+        Fut: Future<Output = Result<T>> + Send + 'static,
+    {
         let Some(plan_context) = self.plan_context.as_ref() else {
             return Ok(Box::pin(futures::stream::empty()));
         };
@@ -410,8 +430,8 @@ impl TableScan {
         let (manifest_entry_delete_ctx_tx, manifest_entry_delete_ctx_rx) =
             channel(concurrency_limit_manifest_files);
 
-        // used to stream the results back to the caller
-        let (file_scan_task_tx, file_scan_task_rx) = 
channel(concurrency_limit_manifest_entries);
+        // used to stream the planned data file results back to the caller
+        let (planned_file_tx, planned_file_rx) = 
channel(concurrency_limit_manifest_entries);
 
         let (delete_file_idx, delete_file_tx) = 
DeleteFileIndex::new(self.runtime.clone());
 
@@ -427,9 +447,9 @@ impl TableScan {
             manifest_entry_delete_ctx_tx,
         )?;
 
-        let mut channel_for_manifest_error = file_scan_task_tx.clone();
-        let mut channel_for_data_manifest_entry_error = 
file_scan_task_tx.clone();
-        let mut channel_for_delete_manifest_entry_error = 
file_scan_task_tx.clone();
+        let mut channel_for_manifest_error = planned_file_tx.clone();
+        let mut channel_for_data_manifest_entry_error = 
planned_file_tx.clone();
+        let mut channel_for_delete_manifest_entry_error = 
planned_file_tx.clone();
 
         let rt = self.runtime.clone();
 
@@ -486,7 +506,7 @@ impl TableScan {
             let rt_inner = rt.clone();
             rt.cpu().spawn(async move {
                 let result = manifest_entry_data_ctx_rx
-                    .map(|me_ctx| Ok((me_ctx, file_scan_task_tx.clone())))
+                    .map(|me_ctx| Ok((me_ctx, planned_file_tx.clone())))
                     .try_for_each_concurrent(
                         concurrency_limit_manifest_entries,
                         |(manifest_entry_context, tx)| {
@@ -498,6 +518,7 @@ impl TableScan {
                                         Self::process_data_manifest_entry(
                                             manifest_entry_context,
                                             tx,
+                                            build_result,
                                         )
                                         .await
                                     })
@@ -513,7 +534,7 @@ impl TableScan {
             });
         }
 
-        Ok(file_scan_task_rx.boxed())
+        Ok(planned_file_rx.boxed())
     }
 
     /// Returns an [`ArrowRecordBatchStream`].
@@ -545,10 +566,15 @@ impl TableScan {
         self.plan_context.as_ref().map(|x| &x.snapshot)
     }
 
-    async fn process_data_manifest_entry(
+    async fn process_data_manifest_entry<T, F, Fut>(
         manifest_entry_context: ManifestEntryContext,
-        mut file_scan_task_tx: Sender<Result<FileScanTask>>,
-    ) -> Result<()> {
+        mut planned_file_tx: Sender<Result<T>>,
+        build_result: F,
+    ) -> Result<()>
+    where
+        F: Fn(ManifestEntryContext) -> Fut + Send + Sync + 'static,
+        Fut: Future<Output = Result<T>> + Send + 'static,
+    {
         // skip processing this manifest entry if it has been marked as deleted
         if !manifest_entry_context.manifest_entry.is_alive() {
             return Ok(());
@@ -593,10 +619,10 @@ impl TableScan {
         }
 
         // congratulations! the manifest entry has made its way through the
-        // entire plan without getting filtered out. Create a corresponding
-        // FileScanTask and push it to the result stream
-        file_scan_task_tx
-            .send(Ok(manifest_entry_context.into_file_scan_task().await?))
+        // entire plan without getting filtered out. Build the planned file 
before
+        // sending so delete-file lookup preserves the original scan timing.
+        planned_file_tx
+            .send(Ok(build_result(manifest_entry_context).await?))
             .await?;
 
         Ok(())
diff --git a/crates/iceberg/src/scan/task.rs b/crates/iceberg/src/scan/task.rs
index 96a322e3d..97ca7e518 100644
--- a/crates/iceberg/src/scan/task.rs
+++ b/crates/iceberg/src/scan/task.rs
@@ -213,6 +213,16 @@ impl FileScanTask {
         self.predicate.as_ref()
     }
 
+    /// Clears the row predicate of this file scan task.
+    ///
+    /// The COW rewrite path uses this after candidate selection: candidates 
are
+    /// chosen with the predicate during planning, but each chosen file must 
then
+    /// be read in full so the rewrite sees every surviving row. Delete-file
+    /// application is unaffected.
+    pub(crate) fn clear_predicate(&mut self) {
+        self.predicate = None;
+    }
+
     /// Returns the delete files that may need to be applied to the data file.
     pub fn deletes(&self) -> &[FileScanTaskDeleteFile] {
         &self.deletes
diff --git a/crates/iceberg/src/spec/table_properties.rs 
b/crates/iceberg/src/spec/table_properties.rs
index 09bf71c19..f418d3a1d 100644
--- a/crates/iceberg/src/spec/table_properties.rs
+++ b/crates/iceberg/src/spec/table_properties.rs
@@ -316,6 +316,14 @@ pub struct TableProperties {
         getter
     )]
     write_object_storage_location: Option<String>,
+    /// Whether new data files use the object storage location layout, which
+    /// injects hash entropy into file paths to spread object-store prefixes.
+    #[property(
+        key = Self::PROPERTY_WRITE_OBJECT_STORAGE_ENABLED,
+        default = Self::PROPERTY_WRITE_OBJECT_STORAGE_ENABLED_DEFAULT,
+        getter
+    )]
+    write_object_storage_enabled: bool,
     /// Whether partition values are included in object storage paths.
     #[property(
         key = Self::PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS,
@@ -530,6 +538,10 @@ impl TableProperties<'_> {
     pub const PROPERTY_WRITE_FOLDER_STORAGE_LOCATION: &'static str = 
"write.folder-storage.path";
     /// Property key for deprecated object storage path, kept as a fallback 
for compatibility.
     pub const PROPERTY_WRITE_OBJECT_STORAGE_LOCATION: &'static str = 
"write.object-storage.path";
+    /// Property key for enabling the object storage location layout for new 
data files.
+    pub const PROPERTY_WRITE_OBJECT_STORAGE_ENABLED: &'static str = 
"write.object-storage.enabled";
+    /// Default value for 
[`TableProperties::PROPERTY_WRITE_OBJECT_STORAGE_ENABLED`]
+    pub const PROPERTY_WRITE_OBJECT_STORAGE_ENABLED_DEFAULT: bool = false;
     /// Property key for controlling whether partition values are included in 
object storage paths.
     pub const PROPERTY_WRITE_OBJECT_STORAGE_PARTITIONED_PATHS: &'static str =
         "write.object-storage.partitioned-paths";

Reply via email to