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

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 91132f56 [core] Complete historical write, update and reader predicate 
parity with Java (#1182)
91132f56 is described below

commit 91132f56fb7794380a81bff199f0a9daaabeed13
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 15:27:38 2026 +0800

    [core] Complete historical write, update and reader predicate parity with 
Java (#1182)
---
 bindings/python/src/predicate.rs                   |  85 +++++-
 bindings/python/src/write.rs                       | 272 ++++++++++++++++--
 crates/integrations/datafusion/src/sql_context.rs  |  14 +-
 .../datafusion/tests/time_travel_schema_tests.rs   |  85 ++++--
 crates/paimon/src/table/branch_write_tests.rs      |  28 +-
 crates/paimon/src/table/bucket_assigner_cross.rs   |   6 +
 crates/paimon/src/table/bucket_function.rs         |  75 +++++
 crates/paimon/src/table/data_evolution_reader.rs   |  63 ++--
 crates/paimon/src/table/data_file_reader.rs        |   2 +-
 crates/paimon/src/table/mod.rs                     |   6 +-
 .../src/table/postpone_fixed_bucket_write.rs       |  27 +-
 crates/paimon/src/table/read_builder.rs            |  48 ++--
 crates/paimon/src/table/shard_table_updater.rs     |   5 +-
 .../paimon/src/table/shard_table_updater/tests.rs  | 148 ++++++++++
 crates/paimon/src/table/table_commit.rs            |  64 +++++
 crates/paimon/src/table/table_read.rs              |  31 +-
 crates/paimon/src/table/table_update.rs            |  14 +-
 crates/paimon/src/table/time_travel.rs             |  97 +++++--
 crates/paimon/src/table/write_builder.rs           |  23 +-
 crates/paimon/tests/cross_partition_write_test.rs  |  53 ++++
 .../paimon/tests/filtered_write_validation_test.rs |  62 ++++
 .../paimon/tests/historical_table_update_test.rs   | 272 +++++++++++++++---
 .../paimon/tests/historical_write_commit_test.rs   | 319 +++++++++++++++++++++
 crates/paimon/tests/partitioned_blob_read_test.rs  | 280 ++++++++++++++++++
 crates/paimon/tests/postpone_native_write_test.rs  |  43 +++
 .../paimon/tests/precomputed_bucket_write_test.rs  |  50 ++++
 crates/paimon/tests/preplanned_row_id_read_test.rs | 250 ++++++++++++++++
 crates/paimon/tests/rest_catalog/merge_sources.rs  | 266 +++++++++++++++++
 crates/paimon/tests/table_predicate_delete_test.rs |  11 +-
 crates/paimon/tests/table_update_test.rs           |  12 +-
 30 files changed, 2492 insertions(+), 219 deletions(-)

diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index cb7726f7..9656dd26 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -19,7 +19,7 @@ use crate::error::to_py_err;
 use paimon::spec::{
     DataField, DataType, Datum, DecimalType, Predicate, PredicateBuilder, 
TableSchema,
 };
-use pyo3::exceptions::{PyNotImplementedError, PyValueError};
+use pyo3::exceptions::{PyAttributeError, PyNotImplementedError, PyValueError};
 use pyo3::prelude::*;
 use pyo3::types::PyTzInfoAccess;
 use pyo3::types::{
@@ -200,6 +200,22 @@ fn micros_to_millis_nanos(micros: i64) -> (i64, i32) {
     )
 }
 
+fn submicrosecond_nanos(value: &Bound<'_, PyAny>) -> PyResult<i32> {
+    // datetime itself has no nanosecond attribute. Arrow's pandas.Timestamp
+    // subclass carries the remaining three digits outside its microseconds.
+    let nanos = match value.getattr("nanosecond") {
+        Ok(nanos) => nanos.extract::<i32>()?,
+        Err(error) if error.is_instance_of::<PyAttributeError>(value.py()) => 
0,
+        Err(error) => return Err(error),
+    };
+    if !(0..1_000).contains(&nanos) {
+        return Err(PyValueError::new_err(
+            "datetime nanosecond remainder must be between 0 and 999",
+        ));
+    }
+    Ok(nanos)
+}
+
 /// Convert a naive `datetime.datetime` into `Datum::Timestamp`.
 ///
 /// TIMESTAMP (without time zone) is timezone-less, so timezone-aware datetimes
@@ -219,7 +235,8 @@ fn timestamp_datum(value: &Bound<'_, PyAny>) -> 
PyResult<Datum> {
     let naive: chrono::NaiveDateTime = value.extract().map_err(|_| {
         PyValueError::new_err("expected a datetime.datetime literal for 
Timestamp field")
     })?;
-    let (millis, nanos) = 
micros_to_millis_nanos(naive.and_utc().timestamp_micros());
+    let (millis, micros_nanos) = 
micros_to_millis_nanos(naive.and_utc().timestamp_micros());
+    let nanos = micros_nanos + submicrosecond_nanos(value)?;
     Ok(Datum::Timestamp { millis, nanos })
 }
 
@@ -254,7 +271,8 @@ fn local_zoned_timestamp_datum(value: &Bound<'_, PyAny>) -> 
PyResult<Datum> {
     let utc = PyTzInfo::utc(py)?;
     let as_utc = dt.call_method1(pyo3::intern!(py, "astimezone"), (utc,))?;
     let instant: chrono::DateTime<chrono::Utc> = as_utc.extract()?;
-    let (millis, nanos) = micros_to_millis_nanos(instant.timestamp_micros());
+    let (millis, micros_nanos) = 
micros_to_millis_nanos(instant.timestamp_micros());
+    let nanos = micros_nanos + submicrosecond_nanos(value)?;
     Ok(Datum::LocalZonedTimestamp { millis, nanos })
 }
 
@@ -1468,6 +1486,67 @@ mod tests {
         });
     }
 
+    #[test]
+    fn timestamp_literals_preserve_nanosecond_tails() {
+        Python::attach(|py| {
+            // Arrow's pandas.Timestamp is a datetime subclass with a
+            // nanosecond remainder. Keep this unit independent of pandas.
+            let module = pyo3::types::PyModule::from_code(
+                py,
+                c"import datetime
+def stamp(ticks, hours):
+    class NanosecondDateTime(datetime.datetime):
+        @property
+        def nanosecond(self):
+            return ticks % 1000
+    base = datetime.datetime(1970, 1, 1) + 
datetime.timedelta(microseconds=ticks // 1000)
+    value = NanosecondDateTime(base.year, base.month, base.day, base.hour, 
base.minute, base.second, base.microsecond)
+    if hours is not None:
+        value = 
value.replace(tzinfo=datetime.timezone.utc).astimezone(datetime.timezone(datetime.timedelta(hours=hours)))
+    return value
+",
+                c"ns_literal.py",
+                c"ns_literal",
+            )
+            .unwrap();
+            for ticks in [
+                i64::MIN + 1,
+                i64::MIN + 807,
+                1,
+                2,
+                -1,
+                -1_000_001,
+                999_999,
+                1_000_001,
+                i64::MAX,
+            ] {
+                for hours in [None, Some(0), Some(-5), Some(8)] {
+                    let value = module
+                        .getattr("stamp")
+                        .unwrap()
+                        .call1((ticks, hours))
+                        .unwrap();
+                    let millis = ticks.div_euclid(1_000_000);
+                    let nanos = ticks.rem_euclid(1_000_000) as i32;
+                    let (data_type, expected) = if hours.is_some() {
+                        (
+                            DataType::LocalZonedTimestamp(
+                                
paimon::spec::LocalZonedTimestampType::new(9).unwrap(),
+                            ),
+                            Datum::LocalZonedTimestamp { millis, nanos },
+                        )
+                    } else {
+                        (
+                            
DataType::Timestamp(paimon::spec::TimestampType::new(9).unwrap()),
+                            Datum::Timestamp { millis, nanos },
+                        )
+                    };
+                    assert_eq!(py_to_datum(&value, &data_type).unwrap(), 
expected);
+                }
+            }
+        });
+    }
+
     #[test]
     fn timestamp_field_rejects_aware_datetime() {
         Python::attach(|py| {
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 7d850a81..e0dce175 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -327,8 +327,10 @@ impl WriteTarget {
                 writer.with_blob_consumer(consumer)?;
                 Ok(())
             }
-            // Like Java AbstractFileStoreWrite, PK-only writers ignore it.
-            Self::PostponeFixed(_) => Ok(()),
+            Self::PostponeFixed(writer) => {
+                writer.with_blob_consumer(consumer)?;
+                Ok(())
+            }
         }
     }
 
@@ -609,6 +611,21 @@ impl UpdateContext {
         })
     }
 
+    fn merge_source_schema(
+        &self,
+        source: &paimon::table::MergeSource,
+    ) -> PyResult<Arc<ArrowSchema>> {
+        let table = match source {
+            paimon::table::MergeSource::Batches(batches) => return 
Ok(batches[0].schema()),
+            paimon::table::MergeSource::Table(table) => table,
+            paimon::table::MergeSource::SelfTable => &self.table,
+        };
+        // Compile conditions against the same metadata schema that core uses
+        // when projecting a table source, including independently pinned 
views.
+        
paimon::arrow::build_target_arrow_schema(&table.schema().fields_with_read_metadata())
+            .map_err(to_py_err)
+    }
+
     fn merge_into(
         &self,
         py: Python<'_>,
@@ -624,24 +641,7 @@ impl UpdateContext {
             },
             None => paimon::table::MergeSource::SelfTable,
         };
-        let source_schema = match &source {
-            paimon::table::MergeSource::Batches(batches) => 
batches[0].schema(),
-            paimon::table::MergeSource::Table(table) => {
-                
paimon::arrow::build_target_arrow_schema(table.schema().fields())
-                    .map_err(to_py_err)?
-            }
-            paimon::table::MergeSource::SelfTable => {
-                let schema = 
paimon::arrow::build_target_arrow_schema(self.table.schema().fields())
-                    .map_err(to_py_err)?;
-                let mut fields = schema.fields().to_vec();
-                fields.push(Arc::new(arrow::datatypes::Field::new(
-                    "_ROW_ID",
-                    arrow::datatypes::DataType::Int64,
-                    false,
-                )));
-                Arc::new(ArrowSchema::new(fields))
-            }
-        };
+        let source_schema = self.merge_source_schema(&source)?;
         let errors = Arc::new(Mutex::new(None));
         let (matched, not_matched) = crate::merge_into::from_python(
             py,
@@ -1684,9 +1684,239 @@ mod tests {
     use super::*;
     use paimon::catalog::Identifier;
     use paimon::io::FileIOBuilder;
-    use paimon::spec::{IntType, Schema, TableSchema, VarCharType};
+    use paimon::spec::{BigIntType, IntType, Schema, TableSchema, VarCharType};
     use pyo3::ffi::c_str;
 
+    #[test]
+    fn merge_table_source_conditions_resolve_read_metadata() {
+        Python::attach(|py| {
+            for metadata in ["_ROW_ID", "_SEQUENCE_NUMBER"] {
+                let schema = Schema::builder()
+                    .column("id", DataType::BigInt(BigIntType::new()))
+                    .column("value", DataType::Int(IntType::new()))
+                    .option("row-tracking.enabled", "true")
+                    .option("data-evolution.enabled", "true")
+                    .build()
+                    .unwrap();
+                let file_io = FileIOBuilder::new("memory").build().unwrap();
+                let table = Arc::new(Table::new(
+                    file_io.clone(),
+                    Identifier::new("default", "metadata_merge_target"),
+                    "memory:/metadata-merge-target".into(),
+                    TableSchema::new(0, &schema),
+                    None,
+                ));
+                let source = Arc::new(Table::new(
+                    file_io,
+                    Identifier::new("default", "metadata_merge_source"),
+                    "memory:/metadata-merge-source".into(),
+                    TableSchema::new(0, &schema),
+                    None,
+                ));
+                let source = Py::new(py, 
crate::table::PyTable::new(source)).unwrap();
+                let context = 
UpdateContext::new(&WriteContext::new(table)).unwrap();
+                let locals = PyDict::new(py);
+                locals.set_item("metadata", metadata).unwrap();
+                py.run(
+                    c_str!(
+                        r#"
+condition = dict(sql='"s.' + metadata + '" >= 0')
+matched = [dict(assignments=[('value', 'source', 'value')], 
condition=condition, delete=False)]
+not_matched = [dict(assignments=[('id', 'source', metadata), ('value', 
'source', 'value')],
+                    condition=condition)]
+"#
+                    ),
+                    Some(&locals),
+                    Some(&locals),
+                )
+                .unwrap();
+                let messages = context
+                    .merge_into(
+                        py,
+                        Some(source.bind(py).as_any()),
+                        vec![("id".into(), metadata.into())],
+                        &locals.get_item("matched").unwrap().unwrap(),
+                        &locals.get_item("not_matched").unwrap().unwrap(),
+                    )
+                    .unwrap();
+                // Conditions must compile even when both snapshots are empty;
+                // empty input prepares no mutation files or synthetic rows.
+                assert!(messages.is_empty());
+            }
+        });
+    }
+
+    #[test]
+    fn merge_table_source_metadata_conditions_update_and_insert() {
+        use arrow::array::{Array, Int32Array, Int64Array};
+        use futures::TryStreamExt;
+
+        async fn seed(table: &Table, ids: Vec<i64>, values: Vec<i32>) {
+            let batch = RecordBatch::try_new(
+                
paimon::arrow::build_target_arrow_schema(table.schema().fields()).unwrap(),
+                vec![
+                    Arc::new(Int64Array::from(ids)),
+                    Arc::new(Int32Array::from(values)),
+                ],
+            )
+            .unwrap();
+            let builder = table.new_write_builder();
+            let mut writer = builder.new_write().unwrap();
+            writer.write_arrow_batch(&batch).await.unwrap();
+            builder
+                .new_commit()
+                .commit(writer.prepare_commit().await.unwrap())
+                .await
+                .unwrap();
+            writer.close().await;
+        }
+
+        async fn read(table: &Table, projection: &[&str]) -> Vec<RecordBatch> {
+            let mut builder = table.new_read_builder();
+            builder.with_projection(projection).unwrap();
+            let plan = builder.new_scan().plan().await.unwrap();
+            builder
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap()
+        }
+
+        Python::attach(|py| {
+            for metadata in ["_ROW_ID", "_SEQUENCE_NUMBER"] {
+                let schema = Schema::builder()
+                    .column("id", DataType::BigInt(BigIntType::new()))
+                    .column("value", DataType::Int(IntType::new()))
+                    .option("row-tracking.enabled", "true")
+                    .option("data-evolution.enabled", "true")
+                    .build()
+                    .unwrap();
+                let file_io = FileIOBuilder::new("memory").build().unwrap();
+                let target = Arc::new(Table::new(
+                    file_io.clone(),
+                    Identifier::new("default", "metadata_data_target"),
+                    "memory:/metadata-data-target".into(),
+                    TableSchema::new(0, &schema),
+                    None,
+                ));
+                let source = Arc::new(Table::new(
+                    file_io,
+                    Identifier::new("default", "metadata_data_source"),
+                    "memory:/metadata-data-source".into(),
+                    TableSchema::new(0, &schema),
+                    None,
+                ));
+                let (first, second) = py.detach(|| {
+                    runtime().block_on(async {
+                        seed(&source, vec![9], vec![91]).await;
+                        seed(&source, vec![8], vec![82]).await;
+                        let mut identities = HashMap::new();
+                        for batch in read(&source, &[metadata, "value"]).await 
{
+                            let keys = batch
+                                .column(0)
+                                .as_any()
+                                .downcast_ref::<Int64Array>()
+                                .unwrap();
+                            let values = batch
+                                .column(1)
+                                .as_any()
+                                .downcast_ref::<Int32Array>()
+                                .unwrap();
+                            assert_eq!(keys.null_count(), 0);
+                            for row in 0..batch.num_rows() {
+                                identities.insert(values.value(row), 
keys.value(row));
+                            }
+                        }
+                        let first = identities[&91];
+                        let second = identities[&82];
+                        assert_ne!(first, second);
+                        seed(&target, vec![first, first + 100], vec![10, 
20]).await;
+                        (first, second)
+                    })
+                });
+                let source = Py::new(py, 
crate::table::PyTable::new(source)).unwrap();
+                let context = 
UpdateContext::new(&WriteContext::new(target.clone())).unwrap();
+                let locals = PyDict::new(py);
+                locals.set_item("metadata", metadata).unwrap();
+                locals.set_item("first", first).unwrap();
+                locals.set_item("second", second).unwrap();
+                py.run(
+                    c_str!(
+                        r#"
+matched = [dict(assignments=[('value', 'source', 'value')],
+                condition=dict(sql='"s.' + metadata + '" = ' + str(first)), 
delete=False)]
+not_matched = [dict(assignments=[('id', 'source', metadata), ('value', 
'source', 'value')],
+                    condition=dict(sql='"s.' + metadata + '" = ' + 
str(second)))]
+"#
+                    ),
+                    Some(&locals),
+                    Some(&locals),
+                )
+                .unwrap();
+                let messages = context
+                    .merge_into(
+                        py,
+                        Some(source.bind(py).as_any()),
+                        vec![("id".into(), metadata.into())],
+                        &locals.get_item("matched").unwrap().unwrap(),
+                        &locals.get_item("not_matched").unwrap().unwrap(),
+                    )
+                    .unwrap();
+                assert!(messages
+                    .iter()
+                    .any(|message| message.inner.check_from_snapshot == 
Some(1)));
+                assert!(messages.iter().any(|message| {
+                    message.inner.check_from_snapshot.is_none()
+                        && message
+                            .inner
+                            .new_files
+                            .iter()
+                            .all(|file| file.first_row_id.is_none())
+                }));
+                let rows = py.detach(|| {
+                    runtime().block_on(async {
+                        target
+                            .new_write_builder()
+                            .new_commit()
+                            .commit(messages.into_iter().map(|message| 
message.inner).collect())
+                            .await
+                            .unwrap();
+                        let mut rows = Vec::new();
+                        for batch in read(&target, &["id", "value", 
"_ROW_ID"]).await {
+                            let ids = batch
+                                .column(0)
+                                .as_any()
+                                .downcast_ref::<Int64Array>()
+                                .unwrap();
+                            let values = batch
+                                .column(1)
+                                .as_any()
+                                .downcast_ref::<Int32Array>()
+                                .unwrap();
+                            let row_ids = batch
+                                .column(2)
+                                .as_any()
+                                .downcast_ref::<Int64Array>()
+                                .unwrap();
+                            for row in 0..batch.num_rows() {
+                                rows.push((ids.value(row), values.value(row), 
row_ids.value(row)));
+                            }
+                        }
+                        rows.sort_by_key(|row| row.2);
+                        rows
+                    })
+                });
+                assert_eq!(
+                    rows,
+                    [(first, 91, 0), (first + 100, 20, 1), (second, 82, 2)]
+                );
+            }
+        });
+    }
+
     #[test]
     fn update_inputs_preserve_source_nulls_until_core_selects_payloads() {
         use arrow::array::{Array, Int32Array};
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index bad1203f..450a0c19 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -1607,15 +1607,13 @@ impl SQLContext {
         ok_result(&self.ctx)
     }
 
-    /// Reject write statements while a session-level time-travel selector is
-    /// active.
+    /// Reject latest-table statement executors while a session-level
+    /// time-travel selector is active.
     ///
-    /// Writes always operate on the latest table state, but in the same
-    /// session reads resolve through the time-travelled snapshot schema (and
-    /// INSERT through the provider is rejected by the write builder), so
-    /// silently ignoring the selector here would be inconsistent. Failing
-    /// with a clear message is safer than writing against a different schema
-    /// than concurrent reads observe.
+    /// These executors load the latest catalog table, while session reads
+    /// resolve the selected snapshot schema. Silently ignoring the selector
+    /// here would be inconsistent. Ordinary INSERT uses the resolved provider
+    /// and its selected schema, so it does not need this guard.
     fn ensure_no_time_travel_for_write(&self, operation: &str) -> DFResult<()> 
{
         use paimon::spec::{
             SCAN_SNAPSHOT_ID_OPTION, SCAN_TAG_NAME_OPTION, 
SCAN_TIMESTAMP_MILLIS_OPTION,
diff --git a/crates/integrations/datafusion/tests/time_travel_schema_tests.rs 
b/crates/integrations/datafusion/tests/time_travel_schema_tests.rs
index d5cfed6f..6801a700 100644
--- a/crates/integrations/datafusion/tests/time_travel_schema_tests.rs
+++ b/crates/integrations/datafusion/tests/time_travel_schema_tests.rs
@@ -225,21 +225,24 @@ async fn test_session_scan_version_uses_snapshot_schema() 
{
     assert_eq!(column_names(&batches), vec!["id", "name"]);
     assert_eq!(total_rows(&batches), 3);
 
-    // Writing through a time-travelled table is rejected.
-    let result = sql_context
+    // Ordinary INSERT retains the selected schema, while its commit publishes
+    // to the latest table state. The session's historical reader stays fixed.
+    sql_context
         .sql("INSERT INTO paimon.default.t VALUES (6, 'f')")
-        .await;
-    let err = match result {
-        Err(e) => e.to_string(),
-        Ok(df) => match df.collect().await {
-            Err(e) => e.to_string(),
-            Ok(_) => panic!("INSERT into a time-travelled table should fail"),
-        },
-    };
-    assert!(
-        err.contains("time-travel option"),
-        "error should mention time travel: {err}"
-    );
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let historical = sql_context
+        .sql("SELECT * FROM paimon.default.t")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_names(&historical), vec!["id", "name"]);
+    assert_eq!(total_rows(&historical), 3);
 
     // Other write statements are rejected up front instead of silently
     // ignoring the active selector and writing to the latest state.
@@ -261,8 +264,8 @@ async fn test_session_scan_version_uses_snapshot_schema() {
         );
     }
 
-    // A selector resolving to a snapshot with the current schema id pins the
-    // read just the same, so INSERT stays rejected.
+    // A selector resolving to the current schema also permits INSERT without
+    // making newly published rows visible through the pinned read.
     sql_context
         .sql("SET 'paimon.scan.version' = '2'")
         .await
@@ -270,20 +273,22 @@ async fn test_session_scan_version_uses_snapshot_schema() 
{
         .collect()
         .await
         .unwrap();
-    let result = sql_context
-        .sql("INSERT INTO paimon.default.t VALUES (6, 'f', 16)")
-        .await;
-    let err = match result {
-        Err(e) => e.to_string(),
-        Ok(df) => match df.collect().await {
-            Err(e) => e.to_string(),
-            Ok(_) => panic!("INSERT should fail while scan.version pins a 
same-schema snapshot"),
-        },
-    };
-    assert!(
-        err.contains("time-travel option"),
-        "error should mention time travel: {err}"
-    );
+    sql_context
+        .sql("INSERT INTO paimon.default.t VALUES (7, 'g', 17)")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let pinned = sql_context
+        .sql("SELECT * FROM paimon.default.t")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_names(&pinned), vec!["id", "name", "age"]);
+    assert_eq!(total_rows(&pinned), 5);
 
     sql_context
         .sql("RESET 'paimon.scan.version'")
@@ -300,6 +305,26 @@ async fn test_session_scan_version_uses_snapshot_schema() {
         .await
         .unwrap();
     assert_eq!(column_names(&batches), vec!["id", "name", "age"]);
+    assert_eq!(total_rows(&batches), 7);
+    let appended = sql_context
+        .sql("SELECT id, age FROM paimon.default.t WHERE id >= 6 ORDER BY id")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let ids = appended[0]
+        .column(0)
+        .as_any()
+        .downcast_ref::<datafusion::arrow::array::Int32Array>()
+        .unwrap();
+    let ages = appended[0]
+        .column(1)
+        .as_any()
+        .downcast_ref::<datafusion::arrow::array::Int32Array>()
+        .unwrap();
+    assert_eq!(ids.iter().collect::<Vec<_>>(), vec![Some(6), Some(7)]);
+    assert_eq!(ages.iter().collect::<Vec<_>>(), vec![None, Some(17)]);
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/table/branch_write_tests.rs 
b/crates/paimon/src/table/branch_write_tests.rs
index c1538848..0adbcee1 100644
--- a/crates/paimon/src/table/branch_write_tests.rs
+++ b/crates/paimon/src/table/branch_write_tests.rs
@@ -462,25 +462,31 @@ async fn 
branch_write_missing_schema_never_creates_a_branch() {
 }
 
 #[tokio::test]
-async fn branch_write_historical_handles_remain_read_only() {
+async fn branch_write_historical_handles_retain_their_read_view() {
     let (_dir, main) = table(false, &[]).await;
     append(&main, "seed", batch(vec![1], vec![10])).await;
     let branch = tagged_branch(&main).await;
     let snapshot = latest(&branch).await;
     let mut historical = branch.copy_with_pinned_snapshot(Some(&snapshot));
-    // The pinned snapshot remains read-only even with no selector in its 
options.
+    // A captured view is independent of the writer's publication state, even
+    // when the corresponding selector is absent from its options.
     historical.schema = historical
         .schema
         .copy_with_replaced_options(branch.schema().options().clone());
-    assert!(historical.new_write_builder().new_write().is_err());
-    assert!(historical.new_write_builder().try_new_commit().is_err());
-    assert!(historical
-        .new_write_builder()
-        .new_commit()
-        .commit(vec![])
-        .await
-        .is_err());
-    assert_eq!(latest(&branch).await, snapshot);
+    append(&historical, "historical", batch(vec![2], vec![20])).await;
+    assert_eq!(latest(&branch).await.id(), snapshot.id() + 1);
+    assert_eq!(
+        table_write::tests::read_id_value_rows(&historical).await,
+        vec![(1, 10)]
+    );
+    assert_eq!(
+        table_write::tests::read_id_value_rows(&branch).await,
+        vec![(1, 10), (2, 20)]
+    );
+    assert_eq!(
+        table_write::tests::read_id_value_rows(&main).await,
+        vec![(1, 10)]
+    );
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/table/bucket_assigner_cross.rs 
b/crates/paimon/src/table/bucket_assigner_cross.rs
index 5abb9c33..b3324e49 100644
--- a/crates/paimon/src/table/bucket_assigner_cross.rs
+++ b/crates/paimon/src/table/bucket_assigner_cross.rs
@@ -81,6 +81,12 @@ impl GlobalPartitionIndex {
         // The cross-partition index reads every primary key.
         
crate::spec::CoreOptions::new(table.schema().options()).ensure_read_authorized()?;
 
+        // Read selectors describe the caller's query view, not the state that
+        // the next write extends. Java IndexBootstrap forces a latest scan 
too.
+        // Pin that state while retaining this writer's schema and field IDs.
+        let snapshot = table.snapshot_manager().get_latest_snapshot().await?;
+        let table = table.copy_with_pinned_snapshot(snapshot.as_ref());
+
         let mut key_to_location: HashMap<Vec<u8>, (Vec<u8>, i32)> = 
HashMap::new();
         let mut partition_bucket_counts: HashMap<Vec<u8>, BTreeMap<i32, i64>> 
= HashMap::new();
 
diff --git a/crates/paimon/src/table/bucket_function.rs 
b/crates/paimon/src/table/bucket_function.rs
index b21082f5..599032bd 100644
--- a/crates/paimon/src/table/bucket_function.rs
+++ b/crates/paimon/src/table/bucket_function.rs
@@ -315,6 +315,81 @@ mod tests {
         );
     }
 
+    #[test]
+    fn mod_bucket_preserves_integer_boundaries_and_null_slots() {
+        let int_type = DataType::Int(IntType::new());
+        let long_type = DataType::BigInt(crate::spec::BigIntType::new());
+        for (value, expected) in [(i32::MIN, 2), (-17, 3), (-1, 4), (0, 0), 
(i32::MAX, 2)] {
+            assert_eq!(
+                bucket_for_datums(
+                    &[(Some(&Datum::Int(value)), &int_type)],
+                    BucketFunctionType::Mod,
+                    5,
+                )
+                .unwrap(),
+                expected
+            );
+        }
+        for (value, expected) in [(i64::MIN, 2), (-17, 3), (-1, 4), (0, 0), 
(i64::MAX, 2)] {
+            assert_eq!(
+                bucket_for_datums(
+                    &[(Some(&Datum::Long(value)), &long_type)],
+                    BucketFunctionType::Mod,
+                    5,
+                )
+                .unwrap(),
+                expected
+            );
+        }
+        for data_type in [&int_type, &long_type] {
+            assert_eq!(
+                bucket_for_datums(&[(None, data_type)], 
BucketFunctionType::Mod, 5).unwrap(),
+                0
+            );
+        }
+    }
+
+    #[test]
+    fn timestamp_nanoseconds_use_java_default_bucket_ids() {
+        for (ticks, expected) in [
+            (i64::MIN + 1, 4),
+            (i64::MIN + 807, 0),
+            (-1_000_001_i64, 3),
+            (-1, 0),
+            (0, 4),
+            (1, 2),
+            (2, 3),
+            (999_999, 2),
+            (1_000_001, 0),
+            (i64::MAX, 4),
+        ] {
+            let millis = ticks.div_euclid(1_000_000);
+            let nanos = ticks.rem_euclid(1_000_000) as i32;
+            for (data_type, datum) in [
+                (
+                    
DataType::Timestamp(crate::spec::TimestampType::new(9).unwrap()),
+                    Datum::Timestamp { millis, nanos },
+                ),
+                (
+                    DataType::LocalZonedTimestamp(
+                        crate::spec::LocalZonedTimestampType::new(9).unwrap(),
+                    ),
+                    Datum::LocalZonedTimestamp { millis, nanos },
+                ),
+            ] {
+                assert_eq!(
+                    bucket_for_datums(
+                        &[(Some(&datum), &data_type)],
+                        BucketFunctionType::Default,
+                        5
+                    )
+                    .unwrap(),
+                    expected
+                );
+            }
+        }
+    }
+
     #[test]
     fn hive_bucket_matches_java_reference_case() {
         let bool_type = DataType::Boolean(BooleanType::new());
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index da0f950c..6b0bff0c 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -335,6 +335,7 @@ impl DataEvolutionReader {
                 false,
                 None,
             )?
+            .with_partition_fields(self.partition_fields.clone())
             .with_batch_size(self.batch_size)
             .with_limit(Some(remaining))
             .with_parquet_read_budget(self.parquet_read_budget.clone())
@@ -1135,10 +1136,13 @@ impl DataEvolutionReader {
                 &prepared_group,
                 &file_infos,
                 &read_type,
-                &table_fields,
                 &blob_inline_fields,
-                row_ranges.is_some(),
-                nested_enabled,
+                SourcePlanReadContext {
+                    table_fields: &table_fields,
+                    partition_fields: &partition_fields,
+                    row_id_pushdown: row_ranges.is_some(),
+                    nested_enabled,
+                },
             )?;
 
             let active_source_indices: Vec<usize> = source_plan
@@ -2506,6 +2510,16 @@ struct SourcePlan {
     nested_plan: Vec<Option<super::data_evolution_nested::NestedFieldPlan>>,
 }
 
+/// Logical inputs needed while selecting physical column providers.
+/// Partition columns may have no provider: the merge restores them from the
+/// split, including when the declared partition type is NOT NULL.
+struct SourcePlanReadContext<'a> {
+    table_fields: &'a [DataField],
+    partition_fields: &'a [DataField],
+    row_id_pushdown: bool,
+    nested_enabled: bool,
+}
+
 #[cfg(test)]
 fn build_source_plan(
     prepared_group: &PreparedMergeGroup,
@@ -2517,10 +2531,13 @@ fn build_source_plan(
         prepared_group,
         file_infos,
         read_type,
-        read_type,
         blob_descriptor_fields,
-        false,
-        false,
+        SourcePlanReadContext {
+            table_fields: read_type,
+            partition_fields: &[],
+            row_id_pushdown: false,
+            nested_enabled: false,
+        },
     )
 }
 
@@ -2528,11 +2545,15 @@ fn build_source_plan_with_row_id_pushdown(
     prepared_group: &PreparedMergeGroup,
     file_infos: &[ResolvedFileInfo],
     read_type: &[DataField],
-    table_fields: &[DataField],
     blob_descriptor_fields: &HashSet<String>,
-    row_id_pushdown: bool,
-    nested_enabled: bool,
+    context: SourcePlanReadContext<'_>,
 ) -> crate::Result<SourcePlan> {
+    let SourcePlanReadContext {
+        table_fields,
+        partition_fields,
+        row_id_pushdown,
+        nested_enabled,
+    } = context;
     let mut sources = Vec::new();
     let mut normal_providers: HashMap<i32, usize> = HashMap::new(); // 
field_id -> source_idx
     let mut vector_field_providers: HashMap<i32, usize> = HashMap::new(); // 
field_id -> source_idx
@@ -2749,7 +2770,11 @@ fn build_source_plan_with_row_id_pushdown(
             let field_offset = 
sources[source_idx].add_read_field(source_field.clone());
             column_plan.push(Some((source_idx, field_offset)));
             nested_plan.push(None);
-        } else if !field.data_type().is_nullable() {
+        } else if !field.data_type().is_nullable()
+            && !partition_fields
+                .iter()
+                .any(|partition| partition.id() == field.id())
+        {
             return Err(Error::DataInvalid {
                 message: format!(
                     "Cannot read non-nullable field '{}' without a provider",
@@ -4571,10 +4596,13 @@ mod tests {
             &prepared_group,
             &file_infos,
             &read_type,
-            &read_type,
             &HashSet::new(),
-            true,
-            false,
+            SourcePlanReadContext {
+                table_fields: &read_type,
+                partition_fields: &[],
+                row_id_pushdown: true,
+                nested_enabled: false,
+            },
         )
         .unwrap_err();
 
@@ -4660,10 +4688,13 @@ mod tests {
             &group,
             &[resolved_info(vec![1]), resolved_info(vec![2])],
             &[selected],
-            std::slice::from_ref(&map),
             &HashSet::new(),
-            false,
-            false,
+            SourcePlanReadContext {
+                table_fields: std::slice::from_ref(&map),
+                partition_fields: &[],
+                row_id_pushdown: false,
+                nested_enabled: false,
+            },
         )
         .unwrap();
         assert_eq!(plan.column_plan, vec![Some((1, 0))]);
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 4ea4c9bd..7c47cfcd 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -626,7 +626,7 @@ impl DataFileReader {
             || (row_tracking_enabled
                 && refs
                     .iter()
-                    .any(|(name, _)| *name == 
crate::spec::SEQUENCE_NUMBER_FIELD_NAME));
+                    .any(|(name, _)| 
crate::spec::is_row_tracking_column(name)));
         if post_read_filter {
             // Read predicate-only columns without decoder filtering. Reconcile
             // old-file types and attach original row IDs before exact 
filtering.
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 80c7e396..e811ca07 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -301,9 +301,9 @@ pub struct Table {
     /// replay a grant.
     query_auth_session: Option<u64>,
     rest_env: Option<RESTEnv>,
-    /// True when this table copy was switched to a historical schema by
-    /// [`Table::copy_with_time_travel`]. Ordinary writes and publication 
reject
-    /// this copy; row-level updates may use it as their read snapshot.
+    /// True for a time-traveled or captured read view. Scans and row matching
+    /// use its selected snapshot. Writers retain its schema; commits publish
+    /// against latest state.
     time_traveled: bool,
     /// Captured snapshot or empty view, so scans don't resolve latest again.
     /// Cleared when [`Table::copy_with_options`] changes the selector.
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_write.rs 
b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
index 4f55a352..a46730c0 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
@@ -39,18 +39,35 @@ pub struct PostponeFixedBucketTableWrite {
 }
 
 impl PostponeFixedBucketTableWrite {
+    /// Configure the Blob consumer before accepting input, including input
+    /// buffered for automatic bucket planning. Primary-key writers do not call
+    /// this consumer, just as for ordinary TableWrite.
+    pub fn with_blob_consumer(
+        &mut self,
+        consumer: Option<std::sync::Arc<dyn crate::spec::BlobConsumer>>,
+    ) -> Result<&mut Self> {
+        self.ensure_configurable("with_blob_consumer")?;
+        self.inner.with_blob_consumer(consumer)?;
+        Ok(self)
+    }
+
     pub fn with_blob_uri_reader_factory(
         &mut self,
         factory: Option<std::sync::Arc<dyn crate::io::UriReaderFactory>>,
     ) -> Result<&mut Self> {
+        self.ensure_configurable("with_blob_uri_reader_factory")?;
+        self.inner.with_blob_uri_reader_factory(factory)?;
+        Ok(self)
+    }
+
+    fn ensure_configurable(&self, method: &str) -> Result<()> {
         self.ensure_writable()?;
         if self.check_from_snapshot.is_some() {
-            return Err(data_invalid(
-                "with_blob_uri_reader_factory must be called before any write 
operation",
-            ));
+            return Err(data_invalid(format!(
+                "{method} must be called before any write operation"
+            )));
         }
-        self.inner.with_blob_uri_reader_factory(factory)?;
-        Ok(self)
+        Ok(())
     }
 
     pub(crate) fn new(
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 76a0b538..275203cf 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -535,11 +535,6 @@ impl<'a> PaimonReadBuilder<'a> {
         let combined = Predicate::and(self.filter.data_predicates.clone());
         if let Some(ranges) = 
super::row_id_predicate::extract_row_id_ranges(&combined) {
             self.derived_row_ranges = Some(ranges);
-            // Ranges are a superset, so a conjunct leaves the residual only 
when
-            // they represent it exactly.
-            self.filter
-                .data_predicates
-                .retain(|conjunct| 
!super::row_id_predicate::ranges_represent_conjunct(conjunct));
         }
     }
 
@@ -652,7 +647,18 @@ impl<'a> PaimonReadBuilder<'a> {
     /// ordinary partition conjuncts for safe stats/LIMIT decisions, rebound 
from
     /// the partition row to the table layout (partition order may differ).
     fn scan_data_predicates(&self) -> Vec<Predicate> {
-        let mut predicates = self.filter.data_predicates.clone();
+        // Range extraction replaces exact scan conjuncts only. A reader can
+        // consume splits from another plan, so retain its complete predicate.
+        let mut predicates = self
+            .filter
+            .data_predicates
+            .iter()
+            .filter(|conjunct| {
+                self.derived_row_ranges.is_none()
+                    || 
!super::row_id_predicate::ranges_represent_conjunct(conjunct)
+            })
+            .cloned()
+            .collect::<Vec<_>>();
         if self.partition_filter.is_none() {
             return predicates;
         }
@@ -1307,7 +1313,7 @@ mod tests {
             Some(vec![RowRange::new(1, 1)])
         );
         assert_eq!(
-            builder.filter.data_predicates,
+            builder.scan_data_predicates(),
             vec![row_id_leaf(PredicateOperator::NotEq, 1)]
         );
 
@@ -1317,7 +1323,8 @@ mod tests {
             exact.effective_row_ranges(),
             Some(vec![RowRange::new(10, i64::MAX)])
         );
-        assert!(exact.filter.data_predicates.is_empty());
+        assert!(exact.scan_data_predicates().is_empty());
+        assert_eq!(exact.new_read().unwrap().data_predicates().len(), 1);
     }
 
     #[test]
@@ -1342,7 +1349,7 @@ mod tests {
             builder.effective_row_ranges(),
             Some(vec![RowRange::new(10, i64::MAX)])
         );
-        assert_eq!(builder.filter.data_predicates, vec![disjunction]);
+        assert_eq!(builder.scan_data_predicates(), vec![disjunction]);
     }
 
     #[test]
@@ -1363,7 +1370,8 @@ mod tests {
             builder.effective_row_ranges(),
             Some(vec![RowRange::new(7, 7)])
         );
-        assert!(builder.filter.data_predicates.is_empty());
+        assert!(builder.scan_data_predicates().is_empty());
+        assert_eq!(builder.new_read().unwrap().data_predicates().len(), 1);
     }
 
     #[test]
@@ -1413,7 +1421,7 @@ mod tests {
         ]));
 
         assert_eq!(builder.effective_row_ranges(), Some(Vec::new()));
-        assert_eq!(builder.filter.data_predicates, vec![unsatisfiable]);
+        assert_eq!(builder.scan_data_predicates(), vec![unsatisfiable]);
     }
 
     #[test]
@@ -1442,7 +1450,8 @@ mod tests {
                 Some(vec![RowRange::new(102, i64::MAX)]),
                 "row_tracking={row_tracking} data_evolution={data_evolution}"
             );
-            assert!(builder.filter.data_predicates.is_empty());
+            assert!(builder.scan_data_predicates().is_empty());
+            assert_eq!(builder.new_read().unwrap().data_predicates().len(), 1);
         }
     }
 
@@ -1456,7 +1465,7 @@ mod tests {
             row_id_leaf(PredicateOperator::Eq, 8),
         ]));
         assert!(
-            builder.filter.data_predicates.is_empty(),
+            builder.scan_data_predicates().is_empty(),
             "handed to ranges"
         );
 
@@ -2289,9 +2298,16 @@ mod tests {
         assert_eq!(ranges[0].from(), 10);
         assert_eq!(ranges[0].to(), 20);
 
-        // _ROW_ID predicates should be removed from data_predicates
-        assert!(!inner.filter.data_predicates.is_empty());
-        for p in &inner.filter.data_predicates {
+        // Only scan predicates omit exact ranges. Independent readers retain
+        // the full filter even when their supplied splits were not filtered.
+        assert!(inner
+            .filter
+            .data_predicates
+            .iter()
+            .any(super::super::row_id_predicate::references_row_id));
+        let scan_predicates = inner.scan_data_predicates();
+        assert!(!scan_predicates.is_empty());
+        for p in &scan_predicates {
             if let Predicate::Leaf { column, .. } = p {
                 assert_ne!(column, crate::spec::ROW_ID_FIELD_NAME);
             }
diff --git a/crates/paimon/src/table/shard_table_updater.rs 
b/crates/paimon/src/table/shard_table_updater.rs
index 3c03b624..3872af02 100644
--- a/crates/paimon/src/table/shard_table_updater.rs
+++ b/crates/paimon/src/table/shard_table_updater.rs
@@ -91,7 +91,7 @@ impl ShardTableUpdater {
         if write_columns.is_empty() {
             return Err(invalid("Shard update columns must not be empty"));
         }
-        let writer = DataEvolutionWriter::new(table, write_columns.clone())?;
+        let writer = DataEvolutionWriter::for_row_id(table, 
write_columns.clone())?;
         let snapshot = super::time_travel::resolve_snapshot(table).await?;
         let table = table
             .copy_with_options(HashMap::from([(
@@ -282,7 +282,8 @@ impl ShardTableUpdater {
                 ));
             }
             if self.writer.is_none() {
-                let mut writer = DataEvolutionWriter::new(&self.table, 
self.write_columns.clone())?;
+                let mut writer =
+                    DataEvolutionWriter::for_row_id(&self.table, 
self.write_columns.clone())?;
                 
writer.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
                 self.writer = Some(writer);
             }
diff --git a/crates/paimon/src/table/shard_table_updater/tests.rs 
b/crates/paimon/src/table/shard_table_updater/tests.rs
index 4f2c2ffe..4126dcc0 100644
--- a/crates/paimon/src/table/shard_table_updater/tests.rs
+++ b/crates/paimon/src/table/shard_table_updater/tests.rs
@@ -233,6 +233,154 @@ async fn 
partitioned_shard_accepts_rebatched_values_and_pins_snapshot() {
     );
 }
 
+#[tokio::test]
+async fn partitioned_shard_accepts_unchanged_partition_carriers() {
+    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+
+    for partition_only in [false, true] {
+        let table = table(true);
+        seed(&table, batch(vec!["a", "b"], vec![1, 2], vec![10, 20])).await;
+        let mut configured = table.new_write_builder().new_update().unwrap();
+        if partition_only {
+            configured.with_update_type(vec!["p".into()]).unwrap();
+        }
+        let mut update = configured.new_shard_updater(0, 1, 
None).await.unwrap();
+        let inputs: Vec<_> = 
update.arrow_reader().unwrap().try_collect().await.unwrap();
+        assert_eq!(inputs.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
+        // Rebatch across partitions so both the first writer and each writer
+        // created at a file-group boundary use the same carrier validation.
+        let input = arrow_select::concat::concat_batches(&inputs[0].schema(), 
&inputs).unwrap();
+        let output = if partition_only {
+            input.project(&[0]).unwrap()
+        } else {
+            let ids = input
+                .column_by_name("id")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            RecordBatch::try_from_iter([
+                ("p", input.column_by_name("p").unwrap().clone()),
+                ("id", input.column_by_name("id").unwrap().clone()),
+                (
+                    "value",
+                    Arc::new(Int32Array::from_iter_values(
+                        ids.values().iter().map(|id| id * 100),
+                    )) as ArrayRef,
+                ),
+            ])
+            .unwrap()
+        };
+        update.update_by_arrow_batch(output).await.unwrap();
+        let messages = update.prepare_commit().await.unwrap();
+        assert_eq!(messages.len(), 2);
+        let files = table
+            .file_io()
+            .list_status_recursive(table.location())
+            .await
+            .unwrap();
+        let mut ranges = Vec::new();
+        for message in &messages {
+            assert_eq!(message.check_from_snapshot, Some(1));
+            for file in &message.new_files {
+                assert_eq!(file.schema_id, 0);
+                assert_eq!(file.row_count, 1);
+                ranges.push(file.first_row_id.unwrap());
+                assert_eq!(file.write_cols, partition_only.then(|| 
vec!["p".into()]));
+                let path = &files
+                    .iter()
+                    .find(|status| status.path.ends_with(&file.file_name))
+                    .unwrap()
+                    .path;
+                let bytes = table
+                    .file_io()
+                    .new_input(path)
+                    .unwrap()
+                    .read()
+                    .await
+                    .unwrap();
+                let reader = 
ParquetRecordBatchReaderBuilder::try_new(bytes).unwrap();
+                let schema = reader.schema();
+                let names = schema
+                    .fields()
+                    .iter()
+                    .map(|field| field.name().as_str())
+                    .collect::<Vec<_>>();
+                assert_eq!(
+                    names,
+                    if partition_only {
+                        vec!["p"]
+                    } else {
+                        vec!["p", "id", "value"]
+                    }
+                );
+            }
+        }
+        ranges.sort_unstable();
+        assert_eq!(ranges, vec![0, 1]);
+        commit(&table, messages).await;
+        assert_eq!(
+            rows(&table).await,
+            if partition_only {
+                vec![(1, 10), (2, 20)]
+            } else {
+                vec![(1, 100), (2, 200)]
+            }
+        );
+    }
+}
+
+#[tokio::test]
+async fn partitioned_shard_rejects_moving_rows_to_another_partition() {
+    let table = table(true);
+    seed(&table, batch(vec!["a", "b"], vec![1, 2], vec![10, 20])).await;
+    let mut update = table
+        .new_write_builder()
+        .new_update()
+        .unwrap()
+        .new_shard_updater(0, 1, None)
+        .await
+        .unwrap();
+    let inputs: Vec<_> = 
update.arrow_reader().unwrap().try_collect().await.unwrap();
+    let result: crate::Result<()> = async {
+        for input in inputs {
+            let ids = input
+                .column_by_name("id")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            update
+                .update_by_arrow_batch(batch(
+                    vec!["changed"; input.num_rows()],
+                    ids.values().to_vec(),
+                    vec![99; input.num_rows()],
+                ))
+                .await?;
+        }
+        update.prepare_commit().await?;
+        Ok(())
+    }
+    .await;
+    let error = result.unwrap_err();
+    assert!(
+        error
+            .to_string()
+            .contains("Cannot change partition column 'p'"),
+        "{error}"
+    );
+    assert!(update.prepare_commit().await.is_err());
+    assert_eq!(
+        table
+            .snapshot_manager()
+            .get_latest_snapshot_id()
+            .await
+            .unwrap(),
+        Some(1)
+    );
+    assert_eq!(rows(&table).await, vec![(1, 10), (2, 20)]);
+}
+
 #[tokio::test]
 async fn deleted_rows_do_not_shift_positional_assignments() {
     let table = table(false);
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 20846ea6..ce9d8804 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -5328,6 +5328,70 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn test_explicit_index_deletes_only_exempt_the_deleted_indexes() {
+        for action in ["THROW_ERROR", "DROP_PARTITION_INDEX", "IGNORE"] {
+            for deleted in ["affected", "other", "both"] {
+                let file_io = test_file_io();
+                let path = 
format!("memory:/explicit_index_delete_{action}_{deleted}");
+                let table = test_table_with_options(
+                    &file_io,
+                    &path,
+                    
HashMap::from([("global-index.column-update-action".into(), action.into())]),
+                );
+                let commit = TableCommit::new(table, "test-user".into());
+                let previous: Vec<_> = [("affected.index", 0), ("other.index", 
1)]
+                    .into_iter()
+                    .map(|(name, id)| IndexManifestEntry {
+                        kind: FileKind::Add,
+                        partition: vec![],
+                        bucket: 0,
+                        index_file: test_global_index_file(name, id, 0, 9),
+                        version: 1,
+                    })
+                    .collect();
+                let deletions: Vec<_> = previous
+                    .iter()
+                    .filter(|entry| {
+                        deleted == "both"
+                            || entry.index_file.file_name == 
format!("{deleted}.index")
+                    })
+                    .cloned()
+                    .map(|mut entry| {
+                        entry.kind = FileKind::Delete;
+                        entry
+                    })
+                    .collect();
+                let mut update = test_data_file("update.parquet", 10);
+                update.first_row_id = Some(0);
+                update.write_cols = Some(vec!["id".into()]);
+                let entries =
+                    commit.messages_to_entries(&[CommitMessage::new(vec![], 0, 
vec![update])]);
+                let result = commit
+                    .global_index_update_entries(&previous, &entries, 
&deletions)
+                    .await;
+                if action == "THROW_ERROR" && deleted == "other" {
+                    assert!(
+                        result
+                            .unwrap_err()
+                            .to_string()
+                            .contains("globally indexed columns"),
+                        "{action}/{deleted}"
+                    );
+                } else {
+                    let automatic = result.unwrap();
+                    if action == "DROP_PARTITION_INDEX" && deleted == "other" {
+                        assert_eq!(automatic.len(), 1);
+                        assert_eq!(automatic[0].kind, FileKind::Delete);
+                        assert_eq!(automatic[0].index_file, 
previous[0].index_file);
+                    } else {
+                        assert!(automatic.is_empty(), "{action}/{deleted}");
+                    }
+                }
+            }
+        }
+    }
+
     #[tokio::test]
     async fn test_partial_update_indexed_column_rejects_by_default() {
         let file_io = test_file_io();
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index 7fe76656..39efafd6 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -3611,7 +3611,12 @@ mod tests {
 
     #[tokio::test]
     async fn test_a_row_id_filter_configured_on_the_read_is_refused() {
-        let table = crate::table::rest_query_auth_table().await;
+        let table = crate::table::rest_query_auth_table()
+            .await
+            .copy_with_options(std::collections::HashMap::from([(
+                "row-tracking.enabled".into(),
+                "true".into(),
+            )]));
         let row_id = Predicate::Leaf {
             index: 0,
             column: crate::spec::ROW_ID_FIELD_NAME.to_string(),
@@ -3619,17 +3624,21 @@ mod tests {
             op: crate::spec::PredicateOperator::GtEq,
             literals: vec![crate::spec::Datum::Long(1)],
         };
+        let mut builder = table.new_read_builder();
+        builder.with_filter(row_id.clone());
         let read = TableRead::new(&table, table.schema.fields().to_vec(), 
vec![row_id]);
-        let split = split_with_grant(Some(grant_for(&table, false)));
-        let err = match read.to_arrow(&[split]) {
-            Ok(_) => panic!("a system-column filter must be refused"),
-            Err(err) => err,
-        };
-        assert!(
-            matches!(err, crate::Error::Unsupported { ref message }
-                if message.contains("system column '_ROW_ID'")),
-            "got {err:?}"
-        );
+        for read in [read, builder.new_read().unwrap()] {
+            let split = split_with_grant(Some(grant_for(&table, false)));
+            let err = match read.to_arrow(&[split]) {
+                Ok(_) => panic!("a system-column filter must be refused"),
+                Err(err) => err,
+            };
+            assert!(
+                matches!(err, crate::Error::Unsupported { ref message }
+                    if message.contains("system column '_ROW_ID'")),
+                "got {err:?}"
+            );
+        }
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/table_update.rs 
b/crates/paimon/src/table/table_update.rs
index de07c4d3..1eac4063 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -46,10 +46,11 @@ fn invalid(message: impl Into<String>) -> crate::Error {
 /// Row-ID updates infer columns from the input unless configured with
 /// [`with_update_type`](Self::with_update_type). Key-based upserts accept a
 /// column subset including the keys and match keys within each partition.
-/// Row updates, deletes and shard updates may select a historical read view;
-/// their messages retain its snapshot as the conflict baseline. Publish those
-/// messages using a current table. Upsert and MERGE with INSERT clauses
-/// require a current table as ordinary writes do.
+/// Operations may select a historical read view. Matched updates retain its
+/// snapshot as the conflict baseline; upsert and MERGE append unmatched rows
+/// using that view's schema and assign fresh row IDs at publication. 
Publication
+/// resolves the latest snapshot and current table schema independently of the
+/// selected read view.
 #[derive(Clone)]
 pub struct TableUpdate {
     table: Table,
@@ -260,8 +261,6 @@ impl TableUpdate {
         batches: Vec<RecordBatch>,
         upsert_keys: Vec<String>,
     ) -> crate::Result<Vec<CommitMessage>> {
-        // Unlike partial row updates, an upsert may append complete rows.
-        super::write_builder::ensure_table_write_allowed(&self.table)?;
         let mut writer = super::table_upsert::TableUpsert::new(
             &self.table,
             self.commit_user.clone(),
@@ -287,9 +286,6 @@ impl TableUpdate {
         when_matched: Vec<super::WhenMatched>,
         when_not_matched: Vec<super::WhenNotMatched>,
     ) -> crate::Result<Vec<CommitMessage>> {
-        if !when_not_matched.is_empty() {
-            super::write_builder::ensure_table_write_allowed(&self.table)?;
-        }
         super::table_merge_into::merge_into(
             &self.table,
             &self.commit_user,
diff --git a/crates/paimon/src/table/time_travel.rs 
b/crates/paimon/src/table/time_travel.rs
index 245a9595..a7e2216d 100644
--- a/crates/paimon/src/table/time_travel.rs
+++ b/crates/paimon/src/table/time_travel.rs
@@ -167,7 +167,7 @@ mod tests {
     use crate::catalog::Identifier;
     use crate::io::{FileIO, FileIOBuilder};
     use crate::spec::{CommitKind, DataType, IntType, Schema, Snapshot, 
TableSchema};
-    use crate::table::{SnapshotManager, Table, TableCommit, TableWrite, 
TagManager};
+    use crate::table::{SnapshotManager, Table, TagManager};
     use arrow_array::{Int32Array, RecordBatch};
     use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema 
as ArrowSchema};
     use std::collections::HashMap;
@@ -250,11 +250,49 @@ mod tests {
     }
 
     async fn write_and_commit(table: &Table, batch: &RecordBatch) {
-        let mut write = TableWrite::new(table, 
"test-user".to_string()).unwrap();
+        let builder = table
+            .new_write_builder()
+            .with_commit_user("test-user")
+            .unwrap();
+        let mut write = builder.new_write().unwrap();
         write.write_arrow_batch(batch).await.unwrap();
         let messages = write.prepare_commit().await.unwrap();
-        let commit = TableCommit::new(table.clone(), "test-user".to_string());
-        commit.commit(messages).await.unwrap();
+        builder
+            .try_new_commit()
+            .unwrap()
+            .commit(messages)
+            .await
+            .unwrap();
+    }
+
+    async fn read_ids(table: &Table) -> Vec<i32> {
+        use futures::TryStreamExt;
+
+        let builder = table.new_read_builder();
+        let plan = builder.new_scan().plan().await.unwrap();
+        let batches: Vec<RecordBatch> = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let mut ids = batches
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column_by_name("id")
+                    .unwrap()
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .values()
+                    .to_vec()
+            })
+            .collect::<Vec<_>>();
+        ids.sort_unstable();
+        ids
     }
 
     /// Table with two schema versions and one snapshot per version:
@@ -402,12 +440,6 @@ mod tests {
                 .unwrap();
             assert_eq!(traveled.schema().id(), 0);
             assert_eq!(traveled.travel_snapshot().unwrap().id(), 1);
-            assert!(traveled.new_write_builder().new_write().is_err());
-            assert!(table
-                .copy_with_options(opts)
-                .new_write_builder()
-                .new_write()
-                .is_err());
             let builder = traveled.new_read_builder();
             let plan = builder.new_scan().plan().await.unwrap();
             let batches: Vec<RecordBatch> = builder
@@ -519,7 +551,6 @@ mod tests {
             super::resolve_snapshot(&pinned).await.unwrap().unwrap(),
             snapshot
         );
-        assert!(pinned.new_write_builder().new_write().is_err());
         assert!(table.travel_snapshot().is_none());
     }
 
@@ -589,7 +620,6 @@ mod tests {
                 .unwrap()
                 .splits()
                 .is_empty());
-            assert!(view.new_write_builder().new_write().is_err());
             assert!(view.is_time_traveled());
             assert!(!view.has_resolved_travel_snapshot());
             assert!(crate::catalog::list_partitions_from_file_system(&view)
@@ -631,13 +661,12 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn test_copy_with_time_travel_same_schema_still_rejects_write() {
+    async fn test_copy_with_time_travel_same_schema_can_append_and_commit() {
         let (file_io, table_path) = setup_evolved_table().await;
         let table = latest_table(&file_io, &table_path);
 
         // Snapshot 2 carries the current schema, so the schema is not
-        // switched — but the copy still reads a pinned snapshot, so writing
-        // through it is rejected like any other time-travelled copy.
+        // switched. Its read snapshot does not limit append publication.
         let traveled = table
             .copy_with_time_travel(options(&[("scan.version", "2")]))
             .await
@@ -645,7 +674,19 @@ mod tests {
 
         assert_eq!(traveled.schema().id(), 1);
         assert!(!traveled.is_time_traveled());
-        assert!(traveled.new_write_builder().new_write().is_err());
+        write_and_commit(&traveled, &batch_v1(vec![6], vec![60], 
vec![16])).await;
+        assert_eq!(read_ids(&table).await, vec![1, 2, 3, 4, 5, 6]);
+        assert_eq!(read_ids(&traveled).await, vec![1, 2, 3, 4, 5]);
+        assert_eq!(
+            table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .schema_id(),
+            1
+        );
     }
 
     #[tokio::test]
@@ -712,7 +753,7 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn test_time_traveled_table_rejects_write() {
+    async fn 
test_time_traveled_table_appends_with_its_schema_and_publishes_latest() {
         let (file_io, table_path) = setup_evolved_table().await;
         let table = latest_table(&file_io, &table_path);
 
@@ -721,17 +762,17 @@ mod tests {
             .await
             .unwrap();
 
-        let err = match traveled.new_write_builder().new_write() {
-            Err(e) => e,
-            Ok(_) => panic!("expected write rejection on time-travelled 
table"),
-        };
-        assert!(
-            matches!(err, crate::Error::Unsupported { ref message }
-                if message.contains("time-travel option")),
-            "expected write rejection on time-travelled table, got {err:?}"
-        );
-        // The latest table is unaffected.
-        assert!(table.new_write_builder().new_write().is_ok());
+        write_and_commit(&traveled, &batch_v0(vec![6], vec![60])).await;
+        let latest_snapshot = table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(latest_snapshot.id(), 3);
+        assert_eq!(latest_snapshot.schema_id(), 1);
+        assert_eq!(read_ids(&table).await, vec![1, 2, 3, 4, 5, 6]);
+        assert_eq!(read_ids(&traveled).await, vec![1, 2, 3]);
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/write_builder.rs 
b/crates/paimon/src/table/write_builder.rs
index 2c3744ed..ae90dc0c 100644
--- a/crates/paimon/src/table/write_builder.rs
+++ b/crates/paimon/src/table/write_builder.rs
@@ -264,8 +264,9 @@ impl<'a> PaimonWriteBuilder<'a> {
     pub fn new_update(&self) -> crate::Result<TableUpdate> {
         // Row-level updates can select an older read snapshot. Their partial
         // files retain that schema and their messages carry the snapshot used
-        // for conflict detection. Appending and publishing still require a
-        // current table, including append-capable operations on TableUpdate.
+        // for conflict detection. Upsert and MERGE append unmatched rows using
+        // the same read schema. As with ordinary writers, publication uses the
+        // latest state of the selected table and branch.
         ensure_paimon_table(self.table)?;
         if self.overwrite {
             return Err(crate::Error::Unsupported {
@@ -292,6 +293,7 @@ impl<'a> PaimonWriteBuilder<'a> {
 }
 
 pub(super) fn ensure_table_write_authorized(table: &Table) -> 
crate::Result<()> {
+    ensure_table_write_allowed(table)?;
     let options = crate::spec::CoreOptions::new(table.schema().options());
     // Query authorization protects reads, not INSERT/OVERWRITE/TRUNCATE.
     // A catalog-loaded REST table retains its publication identity and uses
@@ -304,22 +306,13 @@ pub(super) fn ensure_table_write_authorized(table: 
&Table) -> crate::Result<()>
             message: "Writing a table with 'query-auth.enabled' = true 
requires a catalog-loaded REST table with its resolved schema".into(),
         });
     }
-    ensure_table_write_allowed(table)
+    Ok(())
 }
 
 pub(super) fn ensure_table_write_allowed(table: &Table) -> crate::Result<()> {
-    ensure_paimon_table(table)?;
-    // A time-travel table may carry a historical schema.
-    let selector =
-        
crate::spec::CoreOptions::new(table.schema().options()).try_time_travel_selector();
-    if table.is_time_traveled() || !matches!(selector, Ok(None)) {
-        return Err(crate::Error::Unsupported {
-            message: "Cannot write to a table with a time-travel option set \
-                  (scan.version / scan.timestamp-millis / scan.timestamp / 
scan.watermark / scan.snapshot-id / scan.tag-name)"
-                .to_string(),
-        });
-    }
-    Ok(())
+    // Read selectors choose the read view and its schema. Java writers retain
+    // that schema but restore sequence/index state and publish against latest.
+    ensure_paimon_table(table)
 }
 
 pub(super) fn ensure_paimon_table(table: &Table) -> crate::Result<()> {
diff --git a/crates/paimon/tests/cross_partition_write_test.rs 
b/crates/paimon/tests/cross_partition_write_test.rs
index 36b82988..93edb51a 100644
--- a/crates/paimon/tests/cross_partition_write_test.rs
+++ b/crates/paimon/tests/cross_partition_write_test.rs
@@ -244,6 +244,59 @@ async fn 
merge_engines_route_updates_like_java_after_restart() {
     }
 }
 
+#[tokio::test]
+async fn historical_read_views_restore_the_latest_global_key_locations() {
+    for engine in ["deduplicate", "first-row", "partial-update", 
"aggregation"] {
+        for captured_empty in [false, true] {
+            let mut options = vec![("merge-engine", engine)];
+            if engine == "aggregation" {
+                options.push(("fields.v.aggregate-function", "sum"));
+            }
+            let table = table(true, &options).await;
+            let empty = table.copy_with_pinned_snapshot(None);
+            write_batch(&table, &batch(&[("x", "a", 1, 10)])).await;
+            let first = table
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap();
+            let historical = table.copy_with_pinned_snapshot(Some(&first));
+            // This key is absent from either historical view. Its location 
must
+            // still be restored before the next write, as in IndexBootstrap's
+            // forced latest scan rather than the writer table's read selector.
+            write_batch(&table, &batch(&[("x", "b", 2, 20)])).await;
+            let view = if captured_empty { &empty } else { &historical };
+            write_batch(view, &batch(&[("x", "c", 2, 30)])).await;
+
+            let (partition, value) = match engine {
+                "deduplicate" => ("c", 30),
+                "first-row" => ("b", 20),
+                "partial-update" => ("b", 30),
+                "aggregation" => ("b", 50),
+                _ => unreachable!(),
+            };
+            assert_eq!(
+                rows(&table).await,
+                vec![
+                    ("x".into(), "a".into(), 1, 10),
+                    ("x".into(), partition.into(), 2, value)
+                ],
+                "engine={engine}, captured_empty={captured_empty}"
+            );
+            // Restoring the writer must not mutate either public read view.
+            assert_eq!(
+                rows(view).await,
+                if captured_empty {
+                    vec![]
+                } else {
+                    vec![("x".into(), "a".into(), 1, 10)]
+                }
+            );
+        }
+    }
+}
+
 fn with_kinds(rows: &[(&str, &str, i32, i32)], kinds: &[&str]) -> RecordBatch {
     let data = batch(rows);
     let mut fields = data.schema().fields().to_vec();
diff --git a/crates/paimon/tests/filtered_write_validation_test.rs 
b/crates/paimon/tests/filtered_write_validation_test.rs
index 0a78cd0b..4d51a78e 100644
--- a/crates/paimon/tests/filtered_write_validation_test.rs
+++ b/crates/paimon/tests/filtered_write_validation_test.rs
@@ -335,3 +335,65 @@ async fn 
empty_and_filtered_rowkind_inputs_preserve_transport_validation() {
         assert!(writer.prepare_commit().await.unwrap().is_empty());
     }
 }
+
+#[tokio::test]
+async fn completely_filtered_batches_do_not_lock_writer_configuration() {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("op", DataType::VarChar(VarCharType::string_type()))
+        .option("rowkind.field", "op")
+        .option("ignore-delete", "true")
+        .build()
+        .unwrap();
+    let (io, table) = memory_table(
+        "memory:/filtered-configuration",
+        TableSchema::new(0, &schema),
+    );
+    setup_dirs(&io, table.location()).await;
+    persist_table_schema(&io, table.location(), table.schema()).await;
+    let batch = |kind| {
+        RecordBatch::try_from_iter([
+            ("id", Arc::new(Int32Array::from(vec![2])) as ArrayRef),
+            ("op", Arc::new(StringArray::from(vec![kind])) as ArrayRef),
+        ])
+        .unwrap()
+    };
+    let mut writer = table.new_write_builder().new_write().unwrap();
+    writer.write_arrow_batch(&batch("-D")).await.unwrap();
+    writer.with_write_type(vec!["id".into()]).unwrap();
+    writer.with_blob_consumer(None).unwrap();
+    writer.with_blob_uri_reader_factory(None).unwrap();
+    writer.write_arrow_batch(&batch("+I")).await.unwrap();
+    assert!(writer.with_write_type(vec!["id".into()]).is_err());
+    assert!(writer.with_blob_consumer(None).is_err());
+    assert!(writer.with_blob_uri_reader_factory(None).is_err());
+    let messages = writer.prepare_commit().await.unwrap();
+    assert_eq!(
+        messages
+            .iter()
+            .flat_map(|message| &message.new_files)
+            .map(|file| file.row_count)
+            .sum::<i64>(),
+        1
+    );
+    table
+        .new_write_builder()
+        .new_commit()
+        .commit(messages)
+        .await
+        .unwrap();
+    let reader = table.new_read_builder();
+    let plan = reader.new_scan().plan().await.unwrap();
+    let batches: Vec<_> = reader
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+    assert!(batches
+        .iter()
+        .all(|batch| batch.column_by_name("op").unwrap().is_null(0)));
+}
diff --git a/crates/paimon/tests/historical_table_update_test.rs 
b/crates/paimon/tests/historical_table_update_test.rs
index 4a2a92eb..2b9466b5 100644
--- a/crates/paimon/tests/historical_table_update_test.rs
+++ b/crates/paimon/tests/historical_table_update_test.rs
@@ -302,48 +302,254 @@ async fn historical_deletes_preserve_newer_rows() {
 }
 
 #[tokio::test]
-async fn historical_updater_cannot_append_or_publish() {
-    let (latest, historical) = 
evolved_table("memory:/historical_write_guards").await;
-    assert!(historical.new_write_builder().new_write().is_err());
-    assert!(historical.new_write_builder().try_new_commit().is_err());
-    assert!(historical.new_write_builder().new_delete().is_err());
-    assert!(historical
-        .new_write_builder()
-        .new_data_evolution_writer(vec!["value".into()])
-        .is_err());
-    let update = historical.new_write_builder().new_update().unwrap();
-    let upsert = update
-        .upsert_by_arrow_with_key(
-            vec![batch(
-                &["id", "value", "keep"],
-                &[vec![1], vec![11], vec![100]],
-            )],
-            vec!["id".into()],
-        )
+async fn historical_table_can_publish_ordinary_append_with_old_schema() {
+    let (latest, historical) = 
evolved_table("memory:/historical_ordinary_append").await;
+    let builder = historical.new_write_builder();
+    let mut write = builder.new_write().unwrap();
+    write
+        .write_arrow_batch(&batch(
+            &["id", "value", "keep"],
+            &[vec![4], vec![40], vec![400]],
+        ))
         .await
-        .unwrap_err();
-    assert!(upsert.to_string().contains("time-travel option"));
-    let merge = update
-        .merge_into(
-            MergeSource::Batches(Vec::new()),
-            Vec::new(),
-            Vec::new(),
-            vec![WhenNotMatched {
-                condition: None,
-                assignments: Vec::new(),
-            }],
-        )
+        .unwrap();
+    let messages = write.prepare_commit().await.unwrap();
+    assert!(messages
+        .iter()
+        .flat_map(|message| &message.new_files)
+        .all(|file| file.schema_id == historical.schema().id()));
+    builder
+        .try_new_commit()
+        .unwrap()
+        .commit(messages)
         .await
-        .unwrap_err();
-    assert!(merge.to_string().contains("time-travel option"));
+        .unwrap();
+    assert_eq!(
+        latest
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .unwrap()
+            .schema_id(),
+        latest.schema().id()
+    );
     assert_eq!(
         rows(&latest).await,
         vec![
             vec![Some(1), Some(10), Some(100), None],
             vec![Some(2), Some(20), Some(200), None],
             vec![Some(3), Some(30), Some(300), Some(999)],
+            vec![Some(4), Some(40), Some(400), None],
         ]
     );
+    assert_eq!(
+        rows(&historical).await,
+        vec![
+            vec![Some(1), Some(10), Some(100)],
+            vec![Some(2), Some(20), Some(200)],
+        ]
+    );
+}
+
+async fn append_mutation(table: &Table, operation: &str, input: RecordBatch) 
-> Vec<CommitMessage> {
+    let mut update = table.new_write_builder().new_update().unwrap();
+    update.with_update_type(vec!["value".into()]).unwrap();
+    if operation == "upsert" {
+        update
+            .upsert_by_arrow_with_key(vec![input], vec!["id".into()])
+            .await
+            .unwrap()
+    } else {
+        update
+            .merge_into(
+                MergeSource::Batches(vec![input]),
+                vec![("id".into(), "id".into())],
+                vec![WhenMatched {
+                    condition: None,
+                    delete: false,
+                    assignments: vec![(
+                        "value".into(),
+                        MergeAssignment::SourceColumn("value".into()),
+                    )],
+                }],
+                vec![WhenNotMatched {
+                    condition: None,
+                    assignments: ["id", "value", "keep"]
+                        .into_iter()
+                        .map(|name| (name.into(), 
MergeAssignment::SourceColumn(name.into())))
+                        .collect(),
+                }],
+            )
+            .await
+            .unwrap()
+    }
+}
+
+#[tokio::test]
+async fn historical_upsert_and_merge_append_with_the_read_schema() {
+    for operation in ["upsert", "merge"] {
+        for only_matched in [false, true] {
+            let (latest, historical) = evolved_table(&format!(
+                "memory:/historical_append_{operation}_{only_matched}"
+            ))
+            .await;
+            let input = || {
+                if only_matched {
+                    batch(&["id", "value", "keep"], &[vec![1], vec![11], 
vec![101]])
+                } else {
+                    batch(
+                        &["id", "value", "keep"],
+                        &[vec![1, 4], vec![11, 40], vec![101, 400]],
+                    )
+                }
+            };
+            let messages = append_mutation(&historical, operation, 
input()).await;
+            assert_eq!(messages.len(), if only_matched { 1 } else { 2 });
+            for message in &messages {
+                for file in &message.new_files {
+                    assert_eq!(file.schema_id, 0);
+                    if message.check_from_snapshot.is_some() {
+                        assert_eq!(message.check_from_snapshot, Some(1));
+                        assert_eq!(file.first_row_id, Some(0));
+                        assert_eq!(file.write_cols, 
Some(vec!["value".into()]));
+                    } else {
+                        // Only publication assigns an appended row's new 
identity.
+                        assert_eq!(file.first_row_id, None);
+                    }
+                }
+            }
+            commit(&latest, messages).await;
+            let mut expected = vec![
+                vec![Some(1), Some(11), Some(100), None],
+                vec![Some(2), Some(20), Some(200), None],
+                vec![Some(3), Some(30), Some(300), Some(999)],
+            ];
+            if !only_matched {
+                expected.push(vec![Some(4), Some(40), Some(400), None]);
+            }
+            assert_eq!(rows(&latest).await, expected);
+            let stale = append_mutation(&historical, operation, input()).await;
+            let error = latest
+                .new_write_builder()
+                .new_commit()
+                .commit(stale)
+                .await
+                .unwrap_err();
+            assert!(
+                error.to_string().to_lowercase().contains("conflict"),
+                "{error}"
+            );
+            assert_eq!(rows(&latest).await, expected);
+        }
+    }
+}
+
+#[tokio::test]
+async fn captured_empty_upsert_and_merge_only_append() {
+    for operation in ["upsert", "merge"] {
+        let (latest, historical) =
+            
evolved_table(&format!("memory:/captured_empty_append_{operation}")).await;
+        let empty = historical.copy_with_pinned_snapshot(None);
+        let messages = append_mutation(
+            &empty,
+            operation,
+            batch(&["id", "value", "keep"], &[vec![4], vec![40], vec![400]]),
+        )
+        .await;
+        assert!(!messages.is_empty());
+        assert!(messages
+            .iter()
+            .all(|message| message.check_from_snapshot.is_none()));
+        assert!(messages
+            .iter()
+            .flat_map(|message| &message.new_files)
+            .all(|file| file.schema_id == 0 && file.first_row_id.is_none()));
+        commit(&latest, messages).await;
+        assert_eq!(
+            rows(&latest).await,
+            vec![
+                vec![Some(1), Some(10), Some(100), None],
+                vec![Some(2), Some(20), Some(200), None],
+                vec![Some(3), Some(30), Some(300), Some(999)],
+                vec![Some(4), Some(40), Some(400), None],
+            ]
+        );
+    }
+}
+
+#[tokio::test]
+async fn historical_append_mutations_keep_original_field_ids() {
+    for operation in ["upsert", "merge"] {
+        for change in ["rename", "drop_readd", "widen"] {
+            let (table, historical) = evolved_table(&format!(
+                "memory:/historical_append_fields_{operation}_{change}"
+            ))
+            .await;
+            let mut schema = serde_json::to_value(table.schema()).unwrap();
+            schema["id"] = 2.into();
+            match change {
+                "rename" => schema["fields"][1]["name"] = "renamed".into(),
+                "drop_readd" => {
+                    schema["fields"][1]["id"] = 4.into();
+                    schema["highestFieldId"] = 4.into();
+                }
+                "widen" => schema["fields"][1]["type"] = "BIGINT".into(),
+                _ => unreachable!(),
+            }
+            let schema: TableSchema = serde_json::from_value(schema).unwrap();
+            persist_table_schema(table.file_io(), table.location(), 
&schema).await;
+            let latest = table.copy_with_resolved_schema(schema, 
"main").unwrap();
+            let messages = append_mutation(
+                &historical,
+                operation,
+                batch(
+                    &["id", "value", "keep"],
+                    &[vec![1, 4], vec![11, 40], vec![101, 400]],
+                ),
+            )
+            .await;
+            assert!(messages
+                .iter()
+                .flat_map(|message| &message.new_files)
+                .all(|file| file.schema_id == 0));
+            // The committer's historical read schema must not replace the
+            // latest schema used for publication or conflict validation.
+            commit(&historical, messages).await;
+            let values = if change == "drop_readd" {
+                [None; 4]
+            } else {
+                [Some(11), Some(20), Some(30), Some(40)]
+            };
+            let expected = vec![
+                vec![Some(1), values[0], Some(100), None],
+                vec![Some(2), values[1], Some(200), None],
+                vec![Some(3), values[2], Some(300), Some(999)],
+                vec![Some(4), values[3], Some(400), None],
+            ];
+            assert_eq!(rows(&latest).await, expected, "{operation} {change}");
+            let stale = append_mutation(
+                &historical,
+                operation,
+                batch(
+                    &["id", "value", "keep"],
+                    &[vec![1, 4], vec![12, 41], vec![101, 400]],
+                ),
+            )
+            .await;
+            let error = historical
+                .new_write_builder()
+                .new_commit()
+                .commit(stale)
+                .await
+                .unwrap_err();
+            assert!(
+                error.to_string().to_lowercase().contains("conflict"),
+                "{operation} {change}: {error}"
+            );
+            assert_eq!(rows(&latest).await, expected);
+        }
+    }
 }
 
 #[tokio::test]
@@ -396,7 +602,7 @@ async fn 
historical_partial_files_follow_field_ids_after_schema_changes() {
             .iter()
             .flat_map(|message| &message.new_files)
             .all(|file| file.schema_id == 0 && file.write_cols == 
Some(vec!["value".into()])));
-        commit(&latest, messages).await;
+        commit(&historical, messages).await;
         let expected = if change == "drop_readd" {
             [None, None, None]
         } else {
diff --git a/crates/paimon/tests/historical_write_commit_test.rs 
b/crates/paimon/tests/historical_write_commit_test.rs
new file mode 100644
index 00000000..babf2ef1
--- /dev/null
+++ b/crates/paimon/tests/historical_write_commit_test.rs
@@ -0,0 +1,319 @@
+// 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.
+
+mod common;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{Array, ArrayRef, Int32Array, RecordBatch};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, Datum, IntType, Schema, TableSchema};
+use paimon::table::{BranchManager, CommitMessage, Table};
+
+fn batch(extra: bool, values: &[[i32; 4]]) -> RecordBatch {
+    let names = if extra {
+        &["id", "pt", "value", "extra"][..]
+    } else {
+        &["id", "pt", "value"][..]
+    };
+    RecordBatch::try_from_iter(names.iter().enumerate().map(|(index, name)| {
+        (
+            *name,
+            Arc::new(Int32Array::from(
+                values.iter().map(|row| row[index]).collect::<Vec<_>>(),
+            )) as ArrayRef,
+        )
+    }))
+    .unwrap()
+}
+
+async fn write(table: &Table, data: RecordBatch, overwrite: bool) -> 
Vec<CommitMessage> {
+    let builder = table.new_write_builder();
+    let builder = if overwrite {
+        builder.with_overwrite()
+    } else {
+        builder
+    };
+    let mut writer = builder.new_write().unwrap();
+    writer.write_arrow_batch(&data).await.unwrap();
+    writer.prepare_commit().await.unwrap()
+}
+
+async fn publish(table: &Table, messages: Vec<CommitMessage>) {
+    table
+        .new_write_builder()
+        .try_new_commit()
+        .unwrap()
+        .commit(messages)
+        .await
+        .unwrap();
+}
+
+async fn rows(table: &Table) -> Vec<Vec<Option<i32>>> {
+    let builder = table.new_read_builder();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let batches: Vec<_> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut rows = Vec::new();
+    for batch in batches {
+        for row in 0..batch.num_rows() {
+            rows.push(
+                batch
+                    .columns()
+                    .iter()
+                    .map(|column| {
+                        let column = 
column.as_any().downcast_ref::<Int32Array>().unwrap();
+                        column.is_valid(row).then(|| column.value(row))
+                    })
+                    .collect::<Vec<_>>(),
+            );
+        }
+    }
+    rows.sort();
+    rows
+}
+
+async fn evolved(path: &str, mode: &str) -> (Table, Table, Vec<CommitMessage>) 
{
+    let schema = |extra| {
+        let mut builder = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("pt", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .partition_keys(["pt"]);
+        if mode == "pk" {
+            builder = builder.primary_key(["id", "pt"]).option("bucket", "1");
+        } else if mode == "de" {
+            builder = builder
+                .option("data-evolution.enabled", "true")
+                .option("row-tracking.enabled", "true");
+        }
+        if extra {
+            builder = builder.column("extra", DataType::Int(IntType::new()));
+        }
+        TableSchema::new(i64::from(extra), &builder.build().unwrap())
+    };
+    let (io, original) = memory_table(path, schema(false));
+    setup_dirs(&io, path).await;
+    persist_table_schema(&io, path, original.schema()).await;
+    publish(
+        &original,
+        write(&original, batch(false, &[[1, 1, 10, 0]]), false).await,
+    )
+    .await;
+    let latest_schema = schema(true);
+    persist_table_schema(&io, path, &latest_schema).await;
+    let latest = original
+        .copy_with_resolved_schema(latest_schema, "main")
+        .unwrap();
+    let mut values = vec![[2, 1, 20, 200], [4, 2, 40, 400]];
+    if mode == "pk" {
+        values.push([1, 1, 25, 250]);
+    }
+    let messages = write(&latest, batch(true, &values), false).await;
+    publish(&latest, messages.clone()).await;
+    let historical = latest
+        .copy_with_time_travel(HashMap::from([("scan.snapshot-id".into(), 
"1".into())]))
+        .await
+        .unwrap();
+    assert_eq!(historical.schema().id(), 0);
+    assert_eq!(
+        rows(&historical).await,
+        vec![vec![Some(1), Some(1), Some(10)]]
+    );
+    (latest, historical, messages)
+}
+
+#[tokio::test]
+async fn historical_writers_and_committers_use_latest_publication_state() {
+    for mode in ["append", "de", "pk"] {
+        for operation in ["append", "commit", "overwrite", "truncate", 
"partitions"] {
+            let (latest, historical, _) = evolved(
+                &format!("memory:/historical_write_{mode}_{operation}"),
+                mode,
+            )
+            .await;
+            let mut expected = rows(&latest).await;
+            if operation == "truncate" {
+                historical
+                    .new_write_builder()
+                    .try_new_commit()
+                    .unwrap()
+                    .truncate_table()
+                    .await
+                    .unwrap();
+                expected.clear();
+            } else if operation == "partitions" {
+                historical
+                    .new_write_builder()
+                    .try_new_commit()
+                    .unwrap()
+                    .truncate_partitions(vec![HashMap::from([("pt".into(), 
Some(Datum::Int(1)))])])
+                    .await
+                    .unwrap();
+                expected.retain(|row| row[1] != Some(1));
+            } else {
+                let current_input = operation == "commit";
+                let messages = write(
+                    if current_input { &latest } else { &historical },
+                    batch(current_input, &[[3, 1, 30, 300]]),
+                    operation == "overwrite",
+                )
+                .await;
+                assert!(messages
+                    .iter()
+                    .flat_map(|message| &message.new_files)
+                    .all(|file| file.schema_id == i64::from(current_input)));
+                if operation == "overwrite" {
+                    historical
+                        .new_write_builder()
+                        .try_new_commit()
+                        .unwrap()
+                        .overwrite(
+                            messages,
+                            Some(HashMap::from([("pt".into(), 
Some(Datum::Int(1)))])),
+                        )
+                        .await
+                        .unwrap();
+                    expected.retain(|row| row[1] != Some(1));
+                } else {
+                    publish(&historical, messages).await;
+                }
+                expected.push(vec![
+                    Some(3),
+                    Some(1),
+                    Some(30),
+                    current_input.then_some(300),
+                ]);
+                expected.sort();
+            }
+            assert_eq!(rows(&latest).await, expected, "{mode} {operation}");
+            let snapshot = latest
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap();
+            assert_eq!(snapshot.id(), 3);
+            assert_eq!(snapshot.schema_id(), 1);
+            // Publication does not change the writer handle's selected read 
view.
+            assert_eq!(
+                rows(&historical).await,
+                vec![vec![Some(1), Some(1), Some(10)]]
+            );
+        }
+    }
+}
+
+#[tokio::test]
+async fn historical_primary_key_writer_restores_latest_sequence_numbers() {
+    let (latest, historical, recent) = 
evolved("memory:/historical_pk_sequences", "pk").await;
+    let latest_sequence = recent
+        .iter()
+        .flat_map(|message| &message.new_files)
+        .filter(|file| file.max_sequence_number >= 0)
+        .map(|file| file.max_sequence_number)
+        .max()
+        .unwrap();
+    let messages = write(&historical, batch(false, &[[1, 1, 99, 0]]), 
false).await;
+    assert!(messages
+        .iter()
+        .flat_map(|message| &message.new_files)
+        .all(|file| file.min_sequence_number > latest_sequence));
+    publish(&historical, messages).await;
+    assert!(rows(&latest)
+        .await
+        .contains(&vec![Some(1), Some(1), Some(99), None]));
+}
+
+#[tokio::test]
+async fn captured_empty_writer_keeps_its_empty_read_view() {
+    for mode in ["append", "de", "pk"] {
+        let (latest, historical, _) =
+            evolved(&format!("memory:/captured_empty_write_{mode}"), 
mode).await;
+        let empty = historical.copy_with_pinned_snapshot(None);
+        assert!(rows(&empty).await.is_empty());
+        let before = rows(&latest).await.len();
+        publish(
+            &empty,
+            write(&empty, batch(false, &[[3, 1, 30, 0]]), false).await,
+        )
+        .await;
+        assert!(rows(&empty).await.is_empty());
+        assert_eq!(rows(&latest).await.len(), before + 1);
+        assert_eq!(
+            latest
+                .snapshot_manager()
+                .get_latest_snapshot()
+                .await
+                .unwrap()
+                .unwrap()
+                .schema_id(),
+            1
+        );
+    }
+}
+
+#[tokio::test]
+async fn historical_branch_publication_keeps_main_isolated() {
+    let (main, _, _) = evolved("memory:/historical_write_branch", "de").await;
+    let snapshot = main
+        .snapshot_manager()
+        .get_latest_snapshot()
+        .await
+        .unwrap()
+        .unwrap();
+    main.tag_manager().create("base", &snapshot).await.unwrap();
+    BranchManager::new(main.file_io().clone(), main.location().into())
+        .create_branch_from_tag("dev", "base")
+        .await
+        .unwrap();
+    let branch = main.copy_with_branch("dev").await.unwrap();
+    let historical = branch
+        .copy_with_time_travel(HashMap::from([("scan.snapshot-id".into(), 
"2".into())]))
+        .await
+        .unwrap();
+    let main_rows = rows(&main).await;
+    publish(
+        &branch,
+        write(&branch, batch(true, &[[5, 1, 50, 500]]), false).await,
+    )
+    .await;
+    publish(
+        &historical,
+        write(&historical, batch(true, &[[6, 1, 60, 600]]), false).await,
+    )
+    .await;
+    assert_eq!(rows(&main).await, main_rows);
+    assert_eq!(rows(&branch).await.len(), 5);
+    historical
+        .new_write_builder()
+        .try_new_commit()
+        .unwrap()
+        .truncate_table()
+        .await
+        .unwrap();
+    assert!(rows(&branch).await.is_empty());
+    assert_eq!(rows(&main).await, main_rows);
+}
diff --git a/crates/paimon/tests/partitioned_blob_read_test.rs 
b/crates/paimon/tests/partitioned_blob_read_test.rs
new file mode 100644
index 00000000..b4820d17
--- /dev/null
+++ b/crates/paimon/tests/partitioned_blob_read_test.rs
@@ -0,0 +1,280 @@
+// 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.
+
+mod common;
+
+use arrow_array::{ArrayRef, Int32Array, Int64Array, LargeBinaryArray, 
RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{
+    BlobType, DataType, Datum, IntType, Predicate, PredicateBuilder, Schema, 
TableSchema,
+    VarCharType,
+};
+use std::sync::Arc;
+
+#[tokio::test]
+async fn 
blob_preselection_restores_omitted_partition_columns_before_filtering() {
+    for nullable in [true, false] {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column(
+                "p",
+                DataType::VarChar(
+                    VarCharType::with_nullable(nullable, 
VarCharType::MAX_LENGTH).unwrap(),
+                ),
+            )
+            .column("payload", DataType::Blob(BlobType::new()))
+            .partition_keys(["p"])
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.enabled", "true")
+            .option("deletion-vectors.enabled", "true")
+            .option("read.batch-size", "2")
+            .build()
+            .unwrap();
+        let (io, table) = memory_table(
+            "memory:/partitioned_blob_read",
+            TableSchema::new(0, &schema),
+        );
+        setup_dirs(&io, table.location()).await;
+        persist_table_schema(&io, table.location(), table.schema()).await;
+        let batch = RecordBatch::try_from_iter([
+            (
+                "id",
+                Arc::new(Int32Array::from(vec![1, 2, 3, 4])) as ArrayRef,
+            ),
+            (
+                "p",
+                Arc::new(StringArray::from(vec!["a", "b", "a", "b"])) as 
ArrayRef,
+            ),
+            (
+                "payload",
+                Arc::new(LargeBinaryArray::from(vec![
+                    b"one".as_slice(),
+                    b"two",
+                    b"three",
+                    b"four",
+                ])) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .with_write_type(vec!["id".into(), "payload".into()])
+            .unwrap();
+        writer.write_arrow_batch(&batch).await.unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        assert!(messages
+            .iter()
+            .flat_map(|message| &message.new_files)
+            .all(|file| {
+                !file
+                    .write_cols
+                    .as_ref()
+                    .unwrap()
+                    .iter()
+                    .any(|column| column == "p")
+            }));
+        builder.new_commit().commit(messages).await.unwrap();
+        let splits = table.new_read_builder().new_scan().plan().await.unwrap();
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let predicate = Predicate::or(vec![
+            pb.equal("p", Datum::String("a".into())).unwrap(),
+            pb.equal("id", Datum::Int(999)).unwrap(),
+        ]);
+        for limit in [None, Some(1)] {
+            let mut read = table.new_read_builder();
+            read.with_projection(&["id", "payload"])
+                .unwrap()
+                .with_filter(predicate.clone());
+            if let Some(limit) = limit {
+                read.with_limit(limit);
+            }
+            let batches: Vec<RecordBatch> = read
+                .new_read()
+                .unwrap()
+                .to_arrow(splits.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            let mut ids = batches
+                .iter()
+                .flat_map(|batch| {
+                    batch
+                        .column(0)
+                        .as_any()
+                        .downcast_ref::<Int32Array>()
+                        .unwrap()
+                        .values()
+                        .to_vec()
+                })
+                .collect::<Vec<_>>();
+            ids.sort_unstable();
+            assert_eq!(
+                ids,
+                if limit.is_some() { vec![1] } else { vec![1, 3] },
+                "nullable={nullable}, limit={limit:?}"
+            );
+            let payloads = batches
+                .iter()
+                .flat_map(|batch| {
+                    batch
+                        .column(1)
+                        .as_any()
+                        .downcast_ref::<LargeBinaryArray>()
+                        .unwrap()
+                        .iter()
+                        .map(|value| value.unwrap().to_vec())
+                })
+                .collect::<Vec<_>>();
+            assert_eq!(
+                payloads,
+                if limit.is_some() {
+                    vec![b"one".to_vec()]
+                } else {
+                    vec![b"one".to_vec(), b"three".to_vec()]
+                }
+            );
+        }
+        // A projection containing only partition columns still has one row
+        // per visible row, without needing a physical provider for that 
column.
+        let mut read = table.new_read_builder();
+        read.with_projection(&["p"]).unwrap();
+        let batches: Vec<RecordBatch> = read
+            .new_read()
+            .unwrap()
+            .to_arrow(splits.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 
4);
+        let mut values = batches
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<StringArray>()
+                    .unwrap()
+                    .iter()
+                    .map(|value| value.unwrap().to_string())
+            })
+            .collect::<Vec<_>>();
+        values.sort();
+        assert_eq!(values, ["a", "a", "b", "b"]);
+
+        let mut metadata = table.new_read_builder();
+        metadata.with_projection(&["id", "_ROW_ID"]).unwrap();
+        let rows: Vec<RecordBatch> = metadata
+            .new_read()
+            .unwrap()
+            .to_arrow(splits.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let row_ids = rows
+            .iter()
+            .flat_map(|batch| {
+                let ids = batch
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap();
+                let row_ids = batch
+                    .column(1)
+                    .as_any()
+                    .downcast_ref::<Int64Array>()
+                    .unwrap();
+                ids.values()
+                    .iter()
+                    .copied()
+                    .zip(row_ids.values().iter().copied())
+            })
+            .collect::<std::collections::HashMap<_, _>>();
+        let mut update = builder.new_update().unwrap();
+        update.with_update_type(vec!["id".into()]).unwrap();
+        let delta = RecordBatch::try_from_iter([
+            ("id", Arc::new(Int32Array::from(vec![11])) as ArrayRef),
+            (
+                "_ROW_ID",
+                Arc::new(Int64Array::from(vec![row_ids[&1]])) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        let messages = update
+            .update_by_arrow_with_row_id(vec![delta])
+            .await
+            .unwrap();
+        assert!(messages
+            .iter()
+            .flat_map(|message| &message.new_files)
+            .all(|file| file.write_cols == Some(vec!["id".to_string()])));
+        builder.new_commit().commit(messages).await.unwrap();
+        let deletions = builder
+            .new_update()
+            .unwrap()
+            .delete_by_row_id(vec![row_ids[&3]])
+            .await
+            .unwrap();
+        builder.new_commit().commit(deletions).await.unwrap();
+
+        let latest = table.new_read_builder().new_scan().plan().await.unwrap();
+        for projection in [vec!["id", "payload"], vec![], vec!["p"]] {
+            let mut read = table.new_read_builder();
+            read.with_projection(&projection)
+                .unwrap()
+                .with_filter(predicate.clone())
+                .with_limit(1);
+            let batches: Vec<RecordBatch> = read
+                .new_read()
+                .unwrap()
+                .to_arrow(latest.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+            assert!(batches
+                .iter()
+                .all(|batch| batch.num_columns() == projection.len()));
+            if projection == ["id", "payload"] {
+                let batch = batches.iter().find(|batch| batch.num_rows() > 
0).unwrap();
+                assert_eq!(
+                    batch
+                        .column(0)
+                        .as_any()
+                        .downcast_ref::<Int32Array>()
+                        .unwrap()
+                        .value(0),
+                    11
+                );
+                assert_eq!(
+                    batch
+                        .column(1)
+                        .as_any()
+                        .downcast_ref::<LargeBinaryArray>()
+                        .unwrap()
+                        .value(0),
+                    b"one"
+                );
+            }
+        }
+    }
+}
diff --git a/crates/paimon/tests/postpone_native_write_test.rs 
b/crates/paimon/tests/postpone_native_write_test.rs
index 3e4d7b53..888d4dae 100644
--- a/crates/paimon/tests/postpone_native_write_test.rs
+++ b/crates/paimon/tests/postpone_native_write_test.rs
@@ -22,6 +22,49 @@ use common::incremental_helpers::{make_batch_with_kinds, 
memory_table, pk_schema
 use paimon::spec::{BinaryRow, POSTPONE_BUCKET};
 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
 
+#[tokio::test]
+async fn 
automatic_postpone_configuration_locks_on_buffered_not_filtered_input() {
+    let (io, table) = memory_table(
+        "memory:/postpone_configuration",
+        pk_schema(&[("bucket", "-2"), ("ignore-delete", "true")]),
+    );
+    setup_dirs(&io, table.location()).await;
+    let consumer: std::sync::Arc<dyn paimon::spec::BlobConsumer> =
+        std::sync::Arc::new(|_: &str, _: 
Option<&paimon::spec::BlobDescriptor>| {
+            panic!("PK consumer must not run")
+        });
+    let mut writer = table
+        .new_postpone_fixed_bucket_write_builder()
+        .unwrap()
+        .new_write()
+        .unwrap();
+    writer
+        .write_arrow_batch(&make_batch_with_kinds(vec![1], vec![10], vec![3]))
+        .await
+        .unwrap();
+    writer.with_blob_consumer(Some(consumer.clone())).unwrap();
+    writer.with_blob_consumer(None).unwrap();
+    writer.with_blob_uri_reader_factory(None).unwrap();
+    // Automatic planning retains this input until prepare_commit. Its inner
+    // writer has no physical output yet, but changing configuration is late.
+    writer
+        .write_arrow_batch(&make_batch_with_kinds(vec![2], vec![20], vec![0]))
+        .await
+        .unwrap();
+    assert!(writer.with_blob_consumer(None).is_err());
+    assert!(writer.with_blob_consumer(Some(consumer)).is_err());
+    assert!(writer.with_blob_uri_reader_factory(None).is_err());
+    let messages = writer.prepare_commit().await.unwrap();
+    assert_eq!(
+        messages
+            .iter()
+            .flat_map(|message| &message.new_files)
+            .map(|file| file.row_count)
+            .sum::<i64>(),
+        1
+    );
+}
+
 #[tokio::test]
 async fn postpone_rolls_by_batch_row_limit_and_preserves_unsorted_retracts() {
     let (io, table) = memory_table(
diff --git a/crates/paimon/tests/precomputed_bucket_write_test.rs 
b/crates/paimon/tests/precomputed_bucket_write_test.rs
index 5a2f58fe..73835de1 100644
--- a/crates/paimon/tests/precomputed_bucket_write_test.rs
+++ b/crates/paimon/tests/precomputed_bucket_write_test.rs
@@ -582,6 +582,56 @@ async fn 
prepared_checkpoints_keep_sequence_progress_before_publication() {
     assert_eq!(rows(&table).await, vec![(1, 30), (2, 20)]);
 }
 
+#[tokio::test]
+async fn newly_opened_buckets_restore_latest_sequences_after_a_checkpoint() {
+    let table = table(&[("bucket", "2")]).await;
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        .write_arrow(&[make_batch(vec![1], vec![10])], Some(0))
+        .await
+        .unwrap();
+    builder
+        .new_commit()
+        .commit_with_identifier(writer.prepare_commit().await.unwrap(), 1)
+        .await
+        .unwrap();
+
+    let mut other = table.new_write_builder().new_write().unwrap();
+    other
+        .write_arrow(&[make_batch(vec![2, 2, 2], vec![20, 21, 22])], Some(1))
+        .await
+        .unwrap();
+    commit(&table, other.prepare_commit().await.unwrap()).await;
+
+    writer
+        .write_arrow(&[make_batch(vec![2], vec![99])], Some(1))
+        .await
+        .unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    assert_eq!(messages.len(), 1);
+    assert_eq!(messages[0].new_files[0].min_sequence_number, 3);
+    builder
+        .new_commit()
+        .commit_with_identifier(messages, 2)
+        .await
+        .unwrap();
+    assert_eq!(rows(&table).await, vec![(1, 10), (2, 99)]);
+
+    writer
+        .write_arrow(&[make_batch(vec![1], vec![11])], Some(0))
+        .await
+        .unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    assert_eq!(messages[0].new_files[0].min_sequence_number, 1);
+    builder
+        .new_commit()
+        .commit_with_identifier(messages, 3)
+        .await
+        .unwrap();
+    assert_eq!(rows(&table).await, vec![(1, 11), (2, 99)]);
+}
+
 #[tokio::test]
 async fn configured_overwrite_can_use_ordinary_and_direct_assignment() {
     let table = table(&[("bucket", "-1")]).await;
diff --git a/crates/paimon/tests/preplanned_row_id_read_test.rs 
b/crates/paimon/tests/preplanned_row_id_read_test.rs
new file mode 100644
index 00000000..3507d090
--- /dev/null
+++ b/crates/paimon/tests/preplanned_row_id_read_test.rs
@@ -0,0 +1,250 @@
+// 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.
+
+mod common;
+
+use arrow_array::{Int32Array, RecordBatch};
+use common::incremental_helpers::{
+    make_batch, memory_table, persist_table_schema, setup_dirs, write_batch,
+};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, Datum, IntType, Predicate, PredicateBuilder, 
Schema, TableSchema};
+use paimon::table::{DataSplit, DataSplitBuilder, ReadBuilder, RowRange};
+
+async fn ids(builder: &ReadBuilder<'_>, splits: &[DataSplit]) -> Vec<i32> {
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(splits)
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut result = batches
+        .iter()
+        .flat_map(|batch| {
+            assert_eq!(batch.schema().fields().len(), 1);
+            assert_eq!(batch.schema().field(0).name(), "id");
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect::<Vec<_>>();
+    result.sort_unstable();
+    result
+}
+
+#[tokio::test]
+async fn a_reader_applies_its_row_id_filter_to_an_existing_plan() {
+    for evolution in [false, true] {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.enabled", evolution.to_string())
+            .option("read.batch-size", "2")
+            .build()
+            .unwrap();
+        let (io, table) = memory_table("memory:/preplanned_row_id", 
TableSchema::new(0, &schema));
+        setup_dirs(&io, table.location()).await;
+        persist_table_schema(&io, table.location(), table.schema()).await;
+        write_batch(&table, &make_batch(vec![10, 11, 12], vec![100, 110, 
120])).await;
+        write_batch(&table, &make_batch(vec![13, 14, 15], vec![130, 140, 
150])).await;
+        let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+        let fields = table.schema().fields_with_read_metadata();
+        let pb = PredicateBuilder::new(&fields);
+        let cases = [
+            (pb.equal("_ROW_ID", Datum::Long(1)).unwrap(), vec![11]),
+            (
+                pb.equal("_SEQUENCE_NUMBER", Datum::Long(2)).unwrap(),
+                vec![13, 14, 15],
+            ),
+            (
+                Predicate::and(vec![
+                    pb.greater_or_equal("_ROW_ID", Datum::Long(1)).unwrap(),
+                    pb.less_than("_ROW_ID", Datum::Long(5)).unwrap(),
+                    pb.greater_than("value", Datum::Int(120)).unwrap(),
+                ]),
+                vec![13, 14],
+            ),
+            (
+                Predicate::or(vec![
+                    pb.equal("_ROW_ID", Datum::Long(1)).unwrap(),
+                    pb.equal("value", Datum::Int(140)).unwrap(),
+                ]),
+                vec![11, 14],
+            ),
+            (
+                Predicate::or(vec![
+                    pb.equal("_SEQUENCE_NUMBER", Datum::Long(1)).unwrap(),
+                    pb.equal("_ROW_ID", Datum::Long(4)).unwrap(),
+                ]),
+                vec![10, 11, 12, 14],
+            ),
+            (
+                Predicate::and(vec![
+                    pb.equal("_ROW_ID", Datum::Long(1)).unwrap(),
+                    pb.equal("_ROW_ID", Datum::Long(2)).unwrap(),
+                ]),
+                vec![],
+            ),
+        ];
+        for (predicate, expected) in cases {
+            let mut builder = table.new_read_builder();
+            builder
+                .with_projection(&["id"])
+                .unwrap()
+                .with_filter(predicate.clone());
+            assert_eq!(
+                ids(&builder, plan.splits()).await,
+                expected,
+                "evolution={evolution}, {predicate:?}"
+            );
+            let mut count = builder.clone();
+            count.with_projection(&[]).unwrap();
+            let counted: Vec<_> = count
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            assert!(counted.iter().all(|batch| batch.num_columns() == 0));
+            assert_eq!(
+                counted.iter().map(RecordBatch::num_rows).sum::<usize>(),
+                expected.len()
+            );
+            if evolution {
+                builder.with_limit(1);
+                let limited = ids(&builder, plan.splits()).await;
+                assert_eq!(limited.len(), expected.len().min(1));
+                assert!(limited.iter().all(|id| expected.contains(id)));
+            }
+        }
+
+        let mut selected = table.new_read_builder();
+        selected.with_row_ranges(vec![RowRange::new(1, 4)]);
+        let selected = selected.new_scan().plan().await.unwrap();
+        let mut reader = table.new_read_builder();
+        reader
+            .with_projection(&["id"])
+            .unwrap()
+            .with_filter(Predicate::or(vec![
+                pb.equal("_ROW_ID", Datum::Long(0)).unwrap(),
+                pb.equal("_ROW_ID", Datum::Long(2)).unwrap(),
+                pb.equal("_ROW_ID", Datum::Long(4)).unwrap(),
+                pb.equal("_ROW_ID", Datum::Long(5)).unwrap(),
+            ]));
+        assert_eq!(ids(&reader, selected.splits()).await, vec![12, 14]);
+        // A new filter replaces its old derived range as well as the residual.
+        reader.with_filter(pb.equal("id", Datum::Int(13)).unwrap());
+        assert_eq!(ids(&reader, plan.splits()).await, vec![13]);
+    }
+}
+
+#[tokio::test]
+async fn physical_slices_use_the_readers_coordinate_system_before_filtering() {
+    for tracked in [false, true] {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("value", DataType::Int(IntType::new()))
+            .option("row-tracking.enabled", tracked.to_string())
+            .option("read.batch-size", "2")
+            .build()
+            .unwrap();
+        let (io, table) = memory_table("memory:/physical_slices", 
TableSchema::new(0, &schema));
+        setup_dirs(&io, table.location()).await;
+        persist_table_schema(&io, table.location(), table.schema()).await;
+        let mut seen = std::collections::HashSet::new();
+        let mut files = Vec::new();
+        let mut bucket_path = String::new();
+        for start in [10, 20, 30] {
+            let values = (start..start + 4).collect::<Vec<_>>();
+            write_batch(&table, &make_batch(values.clone(), values)).await;
+            let plan = 
table.new_read_builder().new_scan().plan().await.unwrap();
+            for split in plan.splits() {
+                bucket_path = split.bucket_path().to_string();
+                for file in split.data_files() {
+                    if seen.insert(file.file_name.clone()) {
+                        files.push(file.clone());
+                    }
+                }
+            }
+        }
+        assert_eq!(files.len(), 3);
+        // Drop the middle file and reverse the others. Stable row IDs retain
+        // their gaps; raw offsets restart at zero in this supplied split.
+        let selected = vec![files[2].clone(), files[0].clone()];
+        let mut offset = 0;
+        let ranges = selected
+            .iter()
+            .zip([(2, 4), (1, 3)])
+            .map(|(file, (start, end))| {
+                let base = if tracked {
+                    file.first_row_id.unwrap()
+                } else {
+                    offset
+                };
+                offset += file.row_count;
+                RowRange::new(base + start, base + end - 1)
+            })
+            .collect();
+        let split = DataSplitBuilder::new()
+            .with_snapshot(3)
+            .with_partition(paimon::spec::BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(bucket_path)
+            .with_data_files(selected)
+            .with_raw_convertible(true)
+            .with_row_ranges(ranges)
+            .build()
+            .unwrap();
+        let mut reader = table.new_read_builder();
+        reader.with_projection(&["id"]).unwrap();
+        assert_eq!(
+            ids(&reader, std::slice::from_ref(&split)).await,
+            vec![11, 12, 32, 33],
+            "tracked={tracked}"
+        );
+        reader.with_filter(
+            PredicateBuilder::new(table.schema().fields())
+                .greater_or_equal("id", Datum::Int(12))
+                .unwrap(),
+        );
+        assert_eq!(
+            ids(&reader, std::slice::from_ref(&split)).await,
+            vec![12, 32, 33],
+            "tracked={tracked}"
+        );
+        reader.with_projection(&[]).unwrap();
+        let counted = reader
+            .new_read()
+            .unwrap()
+            .to_arrow(&[split])
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert!(counted.iter().all(|batch| batch.num_columns() == 0));
+        assert_eq!(counted.iter().map(RecordBatch::num_rows).sum::<usize>(), 
3);
+    }
+}
diff --git a/crates/paimon/tests/rest_catalog/merge_sources.rs 
b/crates/paimon/tests/rest_catalog/merge_sources.rs
index 011a4ad2..3f2917c5 100644
--- a/crates/paimon/tests/rest_catalog/merge_sources.rs
+++ b/crates/paimon/tests/rest_catalog/merge_sources.rs
@@ -17,6 +17,272 @@
 
 use super::*;
 use paimon::table::{MergeAssignment, MergeSource, TableCommit, WhenMatched, 
WhenNotMatched};
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+#[tokio::test]
+async fn historical_append_writers_do_not_require_query_read_grants() {
+    for empty in [false, true] {
+        for policy in ["denied", "filter", "mask"] {
+            let g = written(
+                "historical_append_target",
+                |options| {
+                    people(
+                        &[
+                            options,
+                            &[
+                                ("data-evolution.enabled", "true"),
+                                ("row-tracking.enabled", "true"),
+                            ],
+                        ]
+                        .concat(),
+                    )
+                },
+                vec![people_rows(1..=2), people_rows(3..=3)],
+            )
+            .await;
+            let snapshot = 
g.table.snapshot_manager().get_snapshot(1).await.unwrap();
+            let target = g
+                .table
+                .copy_with_pinned_snapshot((!empty).then_some(&snapshot));
+            match policy {
+                "denied" => g.ctx.server.set_column_auth(
+                    "default",
+                    "historical_append_target",
+                    vec!["id".into()],
+                ),
+                "filter" => g.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 
1)], &[]),
+                "mask" => g.set_rules(&[], &[("name", upper(1, "name"))]),
+                _ => unreachable!(),
+            }
+            let builder = target.new_write_builder();
+            let mut writer = builder.new_write().unwrap();
+            writer
+                .write_arrow_batch(&people_batch(&[(4, "new")]))
+                .await
+                .unwrap();
+            let messages = writer.prepare_commit().await.unwrap();
+            assert!(!messages.is_empty());
+            assert!(messages
+                .iter()
+                .flat_map(|message| &message.new_files)
+                .all(|file| file.schema_id == target.schema().id() && 
file.first_row_id.is_none()));
+            builder.try_new_commit().unwrap();
+            assert!(g
+                .ctx
+                .server
+                .auth_selects("default", "historical_append_target")
+                .is_empty());
+
+            // The mock REST endpoint serves SELECT authorization, not commit
+            // publication. Publish through its backing filesystem fixture.
+            let mut options = Options::new();
+            options.set(
+                CatalogOptions::WAREHOUSE,
+                g._tmp.path().to_string_lossy().replace('\\', "/"),
+            );
+            let publisher = FileSystemCatalog::new(options)
+                .unwrap()
+                .get_table(&g.identifier)
+                .await
+                .unwrap();
+            TableCommit::new(publisher.clone(), "historical-append".into())
+                .commit(messages)
+                .await
+                .unwrap();
+            assert_eq!(
+                ids(&read_all(&publisher.new_read_builder()).await.unwrap()),
+                [1, 2, 3, 4]
+            );
+
+            // A supplied schema cannot obtain publication authority merely by
+            // selecting a historical view of the same physical table.
+            let assembled = Table::new(
+                target.file_io().clone(),
+                target.identifier().clone(),
+                target.location().into(),
+                target.schema().clone(),
+                None,
+            )
+            .copy_with_pinned_snapshot((!empty).then_some(&snapshot));
+            
assert_refused(assembled.new_write_builder().new_write().err().unwrap());
+            assert_refused(
+                assembled
+                    .new_write_builder()
+                    .try_new_commit()
+                    .err()
+                    .unwrap(),
+            );
+            assert_refused(
+                TableCommit::new(assembled, "assembled".into())
+                    .commit(Vec::new())
+                    .await
+                    .unwrap_err(),
+            );
+            assert!(g
+                .ctx
+                .server
+                .auth_selects("default", "historical_append_target")
+                .is_empty());
+        }
+    }
+}
+
+#[tokio::test]
+async fn 
historical_insert_mutations_require_whole_target_grants_before_staging() {
+    for operation in ["upsert", "merge"] {
+        for empty in [false, true] {
+            for policy in ["whole", "denied", "filter", "mask"] {
+                let g = written(
+                    "historical_insert_target",
+                    |options| {
+                        people(
+                            &[
+                                options,
+                                &[
+                                    ("data-evolution.enabled", "true"),
+                                    ("row-tracking.enabled", "true"),
+                                    ("deletion-vectors.enabled", "true"),
+                                ],
+                            ]
+                            .concat(),
+                        )
+                    },
+                    vec![people_rows(1..=2), people_rows(3..=3)],
+                )
+                .await;
+                let snapshot = 
g.table.snapshot_manager().get_snapshot(1).await.unwrap();
+                let target = g
+                    .table
+                    .copy_with_pinned_snapshot((!empty).then_some(&snapshot));
+                match policy {
+                    "denied" => g.ctx.server.set_column_auth(
+                        "default",
+                        "historical_insert_target",
+                        vec!["id".into()],
+                    ),
+                    "filter" => g.set_rules(&[int_leaf(0, "id", 
"GREATER_THAN", 1)], &[]),
+                    "mask" => g.set_rules(&[], &[("name", upper(1, "name"))]),
+                    _ => g.set_rules(&[], &[]),
+                }
+                let before = target
+                    .file_io()
+                    .list_status_recursive(target.location())
+                    .await
+                    .unwrap()
+                    .len();
+                let calls = Arc::new(AtomicUsize::new(0));
+                let called = calls.clone();
+                let assignment = 
MergeAssignment::Value(paimon::table::UpdateAssignment::Function(
+                    Arc::new(move |batches| {
+                        called.fetch_add(1, Ordering::SeqCst);
+                        Ok(batches
+                            .iter()
+                            .map(|batch| 
batch.column_by_name("s.name").unwrap().clone())
+                            .collect())
+                    }),
+                ));
+                let mut update = 
target.new_write_builder().new_update().unwrap();
+                update.with_update_type(vec!["name".into()]).unwrap();
+                let source = people_batch(&[(1, "changed"), (4, "new")]);
+                let result = if operation == "upsert" {
+                    update
+                        .upsert_by_arrow_with_key(vec![source], 
vec!["id".into()])
+                        .await
+                } else {
+                    update
+                        .merge_into(
+                            MergeSource::Batches(vec![source]),
+                            vec![("id".into(), "id".into())],
+                            vec![WhenMatched {
+                                condition: None,
+                                delete: false,
+                                assignments: vec![("name".into(), 
assignment.clone())],
+                            }],
+                            vec![WhenNotMatched {
+                                condition: None,
+                                assignments: vec![
+                                    ("id".into(), 
MergeAssignment::SourceColumn("id".into())),
+                                    ("name".into(), assignment),
+                                ],
+                            }],
+                        )
+                        .await
+                };
+                if policy == "whole" {
+                    let messages = result.unwrap();
+                    assert!(!messages.is_empty());
+                    if operation == "merge" {
+                        assert!(calls.load(Ordering::SeqCst) > 0);
+                    }
+                    assert!(messages
+                        .iter()
+                        .any(|message| message.check_from_snapshot.is_none()));
+                    // This fixture's REST server supplies grants, while its
+                    // filesystem catalog owns snapshot publication.
+                    let mut options = Options::new();
+                    options.set(
+                        CatalogOptions::WAREHOUSE,
+                        g._tmp.path().to_string_lossy().replace('\\', "/"),
+                    );
+                    let publisher = FileSystemCatalog::new(options)
+                        .unwrap()
+                        .get_table(&g.identifier)
+                        .await
+                        .unwrap();
+                    TableCommit::new(publisher, "historical-inserts".into())
+                        .commit(messages)
+                        .await
+                        .unwrap();
+                    let mut expected = vec![
+                        (1, Some("changed".into())),
+                        (2, Some("bob".into())),
+                        (3, Some("carol".into())),
+                        (4, Some("new".into())),
+                    ];
+                    if empty {
+                        expected.push((1, Some("alice".into())));
+                        expected.sort();
+                    }
+                    assert_eq!(
+                        
id_names(&read_all(&g.table.new_read_builder()).await.unwrap()),
+                        expected
+                    );
+                } else {
+                    let error = result.unwrap_err();
+                    if policy == "denied" {
+                        assert_forbidden(error);
+                    } else {
+                        assert!(error.to_string().contains("unrestricted"), 
"{error}");
+                    }
+                    assert_eq!(calls.load(Ordering::SeqCst), 0);
+                    assert_eq!(
+                        target
+                            .file_io()
+                            .list_status_recursive(target.location())
+                            .await
+                            .unwrap()
+                            .len(),
+                        before
+                    );
+                    assert_eq!(
+                        target
+                            .snapshot_manager()
+                            .get_latest_snapshot_id()
+                            .await
+                            .unwrap(),
+                        Some(2)
+                    );
+                }
+                let selects = g
+                    .ctx
+                    .server
+                    .auth_selects("default", "historical_insert_target");
+                assert!(!selects.is_empty());
+                assert!(selects.iter().all(Option::is_none));
+            }
+        }
+    }
+}
 
 fn target() -> Table {
     Table::new(
diff --git a/crates/paimon/tests/table_predicate_delete_test.rs 
b/crates/paimon/tests/table_predicate_delete_test.rs
index 1dc8e441..6ccc96e9 100644
--- a/crates/paimon/tests/table_predicate_delete_test.rs
+++ b/crates/paimon/tests/table_predicate_delete_test.rs
@@ -527,7 +527,6 @@ async fn 
historical_predicate_delete_keeps_auth_and_field_index_validation() {
     assert!(messages
         .iter()
         .all(|message| message.check_from_snapshot == Some(1)));
-    assert!(historical.new_write_builder().new_write().is_err());
     let auth = table.copy_with_options(HashMap::from([(
         "query-auth.enabled".into(),
         "true".into(),
@@ -541,8 +540,14 @@ async fn 
historical_predicate_delete_keeps_auth_and_field_index_validation() {
         .unwrap_err();
     assert!(error.to_string().contains("query-auth.enabled"));
     assert_eq!(ids(&table).await, vec![0, 1, 2, 3]);
-    write_batch(&table, &batch(vec![4], vec![Some("a")], vec![50], 
vec![1])).await;
-    commit(&table, messages).await;
+    // A historical handle can append a later row and publish the earlier
+    // predicate deletion; the read snapshot still limits deletion selection.
+    write_batch(
+        &historical,
+        &batch(vec![4], vec![Some("a")], vec![50], vec![1]),
+    )
+    .await;
+    commit(&historical, messages).await;
     assert_eq!(ids(&table).await, vec![2, 3, 4]);
 }
 
diff --git a/crates/paimon/tests/table_update_test.rs 
b/crates/paimon/tests/table_update_test.rs
index cd39b34d..3a78114f 100644
--- a/crates/paimon/tests/table_update_test.rs
+++ b/crates/paimon/tests/table_update_test.rs
@@ -1373,7 +1373,7 @@ async fn 
empty_update_type_means_all_columns_for_upsert_only() {
 }
 
 #[tokio::test]
-async fn 
update_builder_rejects_overwrite_but_allows_historical_partial_updates() {
+async fn 
update_builder_rejects_overwrite_but_historical_handle_can_publish_updates() {
     let table = evolution_table().await;
     assert!(table
         .new_write_builder()
@@ -1394,9 +1394,13 @@ async fn 
update_builder_rejects_overwrite_but_allows_historical_partial_updates(
     assert!(messages
         .iter()
         .all(|message| message.check_from_snapshot == Some(1)));
-    assert!(historical.new_write_builder().new_write().is_err());
-    assert!(historical.new_write_builder().try_new_commit().is_err());
-    commit(&table, messages).await;
+    historical
+        .new_write_builder()
+        .try_new_commit()
+        .unwrap()
+        .commit(messages)
+        .await
+        .unwrap();
     assert_eq!(
         read_rows(&table).await,
         vec![vec![1, 11, 100], vec![2, 20, 200], vec![3, 30, 300]]

Reply via email to