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 a089a631 feat(datafusion): support the $audit_log system table (#787)
a089a631 is described below

commit a089a63198b99e9663feed5a78abe1119a9efb57
Author: jerry <[email protected]>
AuthorDate: Tue Sep 15 13:37:59 2026 +0800

    feat(datafusion): support the $audit_log system table (#787)
---
 crates/integrations/datafusion/src/catalog.rs      |   6 +
 .../datafusion/src/physical_plan/audit_log.rs      | 210 ++++++++++++++
 .../datafusion/src/physical_plan/mod.rs            |   2 +
 .../datafusion/src/physical_plan/scan.rs           | 166 ++++++-----
 .../datafusion/src/system_tables/audit_log.rs      | 132 +++++++++
 .../datafusion/src/system_tables/mod.rs            |  30 ++
 crates/integrations/datafusion/src/table/mod.rs    |   8 +-
 .../integrations/datafusion/tests/system_tables.rs | 322 ++++++++++++++++++++-
 crates/paimon/src/table/audit_log_table.rs         |  19 +-
 crates/paimon/src/table/audit_log_table/merge.rs   | 189 ++++++++++++
 crates/paimon/src/table/audit_log_table/read.rs    | 210 ++++++++++++++
 crates/paimon/src/table/audit_log_table/scan.rs    | 265 +++++++++++++++++
 crates/paimon/src/table/kv_file_reader.rs          | 123 ++++----
 crates/paimon/src/table/mod.rs                     |   4 +-
 crates/paimon/src/table/sort_merge.rs              |   2 +-
 crates/paimon/src/table/table_read.rs              |  33 ++-
 crates/paimon/src/table/table_scan.rs              |  18 +-
 crates/paimon/tests/audit_log_table_test.rs        | 241 ++++++++++++++-
 docs/src/sql.md                                    |  17 +-
 19 files changed, 1833 insertions(+), 164 deletions(-)

diff --git a/crates/integrations/datafusion/src/catalog.rs 
b/crates/integrations/datafusion/src/catalog.rs
index 340cf07c..06f34c3c 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -693,11 +693,17 @@ impl SchemaProvider for PaimonSchemaProvider {
 
         let object = system_tables::parse_object_name_for_datafusion(name)?;
         if let Some(system_name) = object.system_table().map(str::to_string) {
+            let dynamic_options = self
+                .dynamic_options
+                .read()
+                .unwrap_or_else(|e| e.into_inner())
+                .clone();
             return await_with_runtime(system_tables::load(
                 Arc::clone(&self.catalog),
                 self.database.clone(),
                 object,
                 system_name,
+                dynamic_options,
             ))
             .await;
         }
diff --git a/crates/integrations/datafusion/src/physical_plan/audit_log.rs 
b/crates/integrations/datafusion/src/physical_plan/audit_log.rs
new file mode 100644
index 00000000..5fdfeccb
--- /dev/null
+++ b/crates/integrations/datafusion/src/physical_plan/audit_log.rs
@@ -0,0 +1,210 @@
+// 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.
+
+//! Audit execution policy layered over the shared scan mechanics.
+
+use std::sync::Arc;
+
+use datafusion::common::{stats::Precision, Statistics};
+use datafusion::error::Result as DFResult;
+use datafusion::execution::{SendableRecordBatchStream, TaskContext};
+use datafusion::physical_plan::{DisplayAs, ExecutionPlan, PlanProperties};
+use paimon::table::AuditLogRead;
+
+use super::PaimonTableScan;
+
+/// Retains retract rows; physical filters remain above this scan.
+#[derive(Debug, Clone)]
+pub(crate) struct PaimonAuditLogScan {
+    inner: PaimonTableScan,
+}
+
+impl PaimonAuditLogScan {
+    pub(crate) fn new(inner: PaimonTableScan) -> Self {
+        Self { inner }
+    }
+}
+
+impl ExecutionPlan for PaimonAuditLogScan {
+    fn name(&self) -> &str {
+        "PaimonAuditLogScan"
+    }
+
+    fn properties(&self) -> &Arc<PlanProperties> {
+        self.inner.properties()
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan + 'static>> {
+        vec![]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        _children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> DFResult<Arc<dyn ExecutionPlan>> {
+        Ok(self)
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        _context: Arc<TaskContext>,
+    ) -> DFResult<SendableRecordBatchStream> {
+        self.inner.execute_with(partition, |read, splits| {
+            AuditLogRead::new(read)?.to_arrow(splits)
+        })
+    }
+
+    fn partition_statistics(&self, partition: Option<usize>) -> 
DFResult<Arc<Statistics>> {
+        let mut statistics = self.inner.partition_statistics(partition)?;
+        Arc::make_mut(&mut statistics).num_rows = Precision::Absent;
+        Ok(statistics)
+    }
+}
+
+impl DisplayAs for PaimonAuditLogScan {
+    fn fmt_as(
+        &self,
+        t: datafusion::physical_plan::DisplayFormatType,
+        f: &mut std::fmt::Formatter,
+    ) -> std::fmt::Result {
+        write!(f, "{}: ", self.name())?;
+        self.inner.fmt_as(t, f)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::table::{datafusion_arrow_schema, PaimonScanBuilder};
+    use datafusion::config::ConfigOptions;
+    use datafusion::logical_expr::Operator;
+    use datafusion::physical_expr::expressions::{lit, BinaryExpr, Column};
+    use datafusion::physical_expr::PhysicalExpr;
+    use datafusion::physical_plan::filter_pushdown::{
+        ChildFilterPushdownResult, ChildPushdownResult, FilterPushdownPhase, 
PushedDown,
+    };
+    use paimon::catalog::Identifier;
+    use paimon::table::Table;
+    use paimon::DataSplitBuilder;
+
+    fn first_row_audit_scan() -> PaimonAuditLogScan {
+        let file_io = 
paimon::io::FileIOBuilder::new("memory").build().unwrap();
+        let schema = paimon::spec::Schema::builder()
+            .column(
+                "id",
+                paimon::spec::DataType::Int(paimon::spec::IntType::new()),
+            )
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .option("merge-engine", "first-row")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "first_row_audit"),
+            "memory:/first-row-audit".to_string(),
+            paimon::spec::TableSchema::new(0, &schema),
+            None,
+        );
+        let split = |snapshot| {
+            DataSplitBuilder::new()
+                .with_snapshot(snapshot)
+                .with_partition(paimon::spec::BinaryRow::new(0))
+                .with_bucket(0)
+                
.with_bucket_path("memory:/first-row-audit/bucket-0".to_string())
+                .with_total_buckets(1)
+                .with_data_files(vec![])
+                .build()
+                .unwrap()
+        };
+        let read_fields = paimon::table::AuditLogTable::new(table.clone())
+            .fields()
+            .unwrap();
+        let arrow_schema = datafusion_arrow_schema(&read_fields, 
true).unwrap();
+        let plan = PaimonScanBuilder {
+            table: &table,
+            schema: &arrow_schema,
+            plan: paimon::table::Plan::new(vec![split(1), split(2)]),
+            scan_trace: None,
+            projection: None,
+            pushed_predicate: None,
+            limit: None,
+            target_partitions: 8,
+            filter_exact: false,
+            case_sensitive: true,
+        }
+        .build_scan(read_fields)
+        .unwrap();
+        PaimonAuditLogScan::new(plan)
+    }
+
+    #[test]
+    fn test_first_row_audit_distributes_independent_splits() {
+        let scan = first_row_audit_scan();
+
+        assert_eq!(scan.inner.planned_partitions().len(), 2);
+        assert!(scan
+            .inner
+            .planned_partitions()
+            .iter()
+            .all(|splits| splits.len() == 1));
+    }
+
+    #[test]
+    fn test_audit_physical_filters_remain_above_scan() {
+        let scan = first_row_audit_scan();
+        let filters: Vec<Arc<dyn PhysicalExpr>> = vec![
+            Arc::new(BinaryExpr::new(
+                Arc::new(Column::new("id", 1)),
+                Operator::Gt,
+                lit(1_i32),
+            )),
+            Arc::new(BinaryExpr::new(
+                Arc::new(Column::new("rowkind", 0)),
+                Operator::Eq,
+                lit("-D"),
+            )),
+        ];
+        let result = scan
+            .handle_child_pushdown_result(
+                FilterPushdownPhase::Post,
+                ChildPushdownResult {
+                    parent_filters: filters
+                        .into_iter()
+                        .map(|filter| ChildFilterPushdownResult {
+                            filter,
+                            child_results: Vec::new(),
+                        })
+                        .collect(),
+                    self_filters: Vec::new(),
+                },
+                &ConfigOptions::default(),
+            )
+            .unwrap();
+
+        assert!(matches!(
+            result.filters.as_slice(),
+            [PushedDown::No, PushedDown::No]
+        ));
+        assert!(result.updated_node.is_none());
+        assert_eq!(
+            scan.partition_statistics(None).unwrap().num_rows,
+            Precision::Absent
+        );
+    }
+}
diff --git a/crates/integrations/datafusion/src/physical_plan/mod.rs 
b/crates/integrations/datafusion/src/physical_plan/mod.rs
index 2d190503..e0e3c887 100644
--- a/crates/integrations/datafusion/src/physical_plan/mod.rs
+++ b/crates/integrations/datafusion/src/physical_plan/mod.rs
@@ -15,10 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod audit_log;
 pub(crate) mod scan;
 mod search_score;
 pub(crate) mod sink;
 
+pub(crate) use audit_log::PaimonAuditLogScan;
 pub use scan::PaimonTableScan;
 pub(crate) use search_score::{SearchScoreExec, SearchScoreOutputColumn};
 pub use sink::PaimonDataSink;
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index d2fc56c3..432e3a2a 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -53,7 +53,7 @@ use paimon::arrow::ParquetReadBudget;
 use paimon::spec::{
     CoreOptions, DataField, Datum, MergeEngine, Predicate, PredicateBuilder, 
PredicateOperator,
 };
-use paimon::table::{ScanTrace, Table};
+use paimon::table::{ArrowRecordBatchStream, ScanTrace, Table, TableRead};
 use paimon::DataSplit;
 
 use crate::error::to_datafusion_error;
@@ -996,6 +996,92 @@ impl PaimonTableScan {
             .map(|(accumulator, field)| accumulator.finish(field.data_type(), 
exact_null_counts))
             .collect()
     }
+
+    pub(crate) fn execute_with(
+        &self,
+        partition: usize,
+        read_splits: impl FnOnce(TableRead<'_>, &[DataSplit]) -> 
paimon::Result<ArrowRecordBatchStream>
+            + Send
+            + 'static,
+    ) -> DFResult<SendableRecordBatchStream> {
+        let splits = 
Arc::clone(self.planned_partitions.get(partition).ok_or_else(|| {
+            datafusion::error::DataFusionError::Internal(format!(
+                "PaimonTableScan: partition index {partition} out of range 
(total {})",
+                self.planned_partitions.len()
+            ))
+        })?);
+
+        let table = self.table.clone();
+        let schema = self.schema();
+        let read_type = self.read_type.clone();
+        let pushed_predicate = self.pushed_predicate.clone();
+        let case_sensitive = self.case_sensitive;
+        let runtime_filters = self.runtime_filters.clone();
+        let decoder_filters = self.decoder_filters.clone();
+        let parquet_read_budget = Arc::clone(&self.parquet_read_budget);
+
+        let fut = async move {
+            let mut read_builder = table.new_read_builder();
+            let runtime_filter_plan = partition_runtime_decoder_filters(
+                &decoder_filters,
+                table.schema().fields(),
+                case_sensitive,
+            );
+            let mut paimon_predicates = 
pushed_predicate.into_iter().collect::<Vec<_>>();
+            paimon_predicates.extend(runtime_filter_plan.paimon_predicates);
+
+            read_builder.with_case_sensitive(case_sensitive);
+            read_builder.with_read_type(read_type);
+            if !paimon_predicates.is_empty() {
+                read_builder.with_filter(Predicate::and(paimon_predicates));
+            }
+            read_builder.with_parquet_read_budget(parquet_read_budget);
+
+            let mut read = 
read_builder.new_read().map_err(to_datafusion_error)?;
+            if !runtime_filter_plan.datafusion_filters.is_empty() {
+                let predicate = 
conjunction(runtime_filter_plan.datafusion_filters);
+                read = 
read.with_row_filter_factory(Arc::new(DataFusionRowFilterFactory::new(
+                    predicate,
+                    Arc::clone(&schema),
+                )));
+            }
+            let stream = read_splits(read, 
&splits).map_err(to_datafusion_error)?;
+            let batch_schema = Arc::clone(&schema);
+            let stream = stream.map(move |result| {
+                let mut batch = result
+                    .map_err(to_datafusion_error)
+                    .and_then(|batch| to_datafusion_batch(batch, 
&batch_schema))?;
+                // The decoder hook is an optimization and may be unavailable
+                // for a file/path. Retain every original live expression as
+                // the exact fallback; evaluating it on decoder survivors is
+                // idempotent.
+                for filter in &runtime_filters {
+                    let predicate = 
filter.evaluate(&batch)?.into_array(batch.num_rows())?;
+                    let predicate = predicate
+                        .as_any()
+                        .downcast_ref::<BooleanArray>()
+                        .ok_or_else(|| {
+                            
datafusion::error::DataFusionError::Execution(format!(
+                                "Paimon runtime filter must return Boolean, 
got {}",
+                                predicate.data_type()
+                            ))
+                        })?;
+                    batch = filter_record_batch(&batch, predicate)?;
+                }
+                Ok(batch)
+            });
+
+            Ok::<_, 
datafusion::error::DataFusionError>(RecordBatchStreamAdapter::new(
+                schema,
+                Box::pin(stream),
+            ))
+        };
+
+        Ok(Box::pin(RecordBatchStreamAdapter::new(
+            self.schema(),
+            futures::stream::once(fut).try_flatten(),
+        )))
+    }
 }
 
 impl ExecutionPlan for PaimonTableScan {
@@ -1088,83 +1174,7 @@ impl ExecutionPlan for PaimonTableScan {
         partition: usize,
         _context: Arc<TaskContext>,
     ) -> DFResult<SendableRecordBatchStream> {
-        let splits = 
Arc::clone(self.planned_partitions.get(partition).ok_or_else(|| {
-            datafusion::error::DataFusionError::Internal(format!(
-                "PaimonTableScan: partition index {partition} out of range 
(total {})",
-                self.planned_partitions.len()
-            ))
-        })?);
-
-        let table = self.table.clone();
-        let schema = self.schema();
-        let read_type = self.read_type.clone();
-        let pushed_predicate = self.pushed_predicate.clone();
-        let case_sensitive = self.case_sensitive;
-        let runtime_filters = self.runtime_filters.clone();
-        let decoder_filters = self.decoder_filters.clone();
-        let parquet_read_budget = Arc::clone(&self.parquet_read_budget);
-
-        let fut = async move {
-            let mut read_builder = table.new_read_builder();
-            let runtime_filter_plan = partition_runtime_decoder_filters(
-                &decoder_filters,
-                table.schema().fields(),
-                case_sensitive,
-            );
-            let mut paimon_predicates = 
pushed_predicate.into_iter().collect::<Vec<_>>();
-            paimon_predicates.extend(runtime_filter_plan.paimon_predicates);
-
-            read_builder.with_case_sensitive(case_sensitive);
-            read_builder.with_read_type(read_type);
-            if !paimon_predicates.is_empty() {
-                read_builder.with_filter(Predicate::and(paimon_predicates));
-            }
-            read_builder.with_parquet_read_budget(parquet_read_budget);
-
-            let mut read = 
read_builder.new_read().map_err(to_datafusion_error)?;
-            if !runtime_filter_plan.datafusion_filters.is_empty() {
-                let predicate = 
conjunction(runtime_filter_plan.datafusion_filters);
-                read = 
read.with_row_filter_factory(Arc::new(DataFusionRowFilterFactory::new(
-                    predicate,
-                    Arc::clone(&schema),
-                )));
-            }
-            let stream = read.to_arrow(&splits).map_err(to_datafusion_error)?;
-            let batch_schema = Arc::clone(&schema);
-            let stream = stream.map(move |result| {
-                let mut batch = result
-                    .map_err(to_datafusion_error)
-                    .and_then(|batch| to_datafusion_batch(batch, 
&batch_schema))?;
-                // The decoder hook is an optimization and may be unavailable
-                // for a file/path. Retain every original live expression as
-                // the exact fallback; evaluating it on decoder survivors is
-                // idempotent.
-                for filter in &runtime_filters {
-                    let predicate = 
filter.evaluate(&batch)?.into_array(batch.num_rows())?;
-                    let predicate = predicate
-                        .as_any()
-                        .downcast_ref::<BooleanArray>()
-                        .ok_or_else(|| {
-                            
datafusion::error::DataFusionError::Execution(format!(
-                                "Paimon runtime filter must return Boolean, 
got {}",
-                                predicate.data_type()
-                            ))
-                        })?;
-                    batch = filter_record_batch(&batch, predicate)?;
-                }
-                Ok(batch)
-            });
-
-            Ok::<_, 
datafusion::error::DataFusionError>(RecordBatchStreamAdapter::new(
-                schema,
-                Box::pin(stream),
-            ))
-        };
-
-        Ok(Box::pin(RecordBatchStreamAdapter::new(
-            self.schema(),
-            futures::stream::once(fut).try_flatten(),
-        )))
+        self.execute_with(partition, |read, splits| read.to_arrow(splits))
     }
 
     fn partition_statistics(&self, partition: Option<usize>) -> 
DFResult<Arc<Statistics>> {
diff --git a/crates/integrations/datafusion/src/system_tables/audit_log.rs 
b/crates/integrations/datafusion/src/system_tables/audit_log.rs
new file mode 100644
index 00000000..b464afd4
--- /dev/null
+++ b/crates/integrations/datafusion/src/system_tables/audit_log.rs
@@ -0,0 +1,132 @@
+// 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.
+
+//! Mirrors Java 
[AuditLogTable](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/table/system/AuditLogTable.java).
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use datafusion::arrow::datatypes::SchemaRef;
+use datafusion::catalog::Session;
+use datafusion::datasource::{TableProvider, TableType};
+use datafusion::error::Result as DFResult;
+use datafusion::logical_expr::{Expr, TableProviderFilterPushDown};
+use datafusion::physical_plan::ExecutionPlan;
+use paimon::spec::DataField;
+use paimon::table::{AuditLogTable as PaimonAuditLogTable, Table};
+
+use crate::error::to_datafusion_error;
+use crate::filter_pushdown::{analyze_filters, classify_filter_pushdown};
+use crate::physical_plan::PaimonAuditLogScan;
+use crate::runtime::await_with_runtime;
+use crate::table::{datafusion_arrow_schema, PaimonScanBuilder};
+
+pub(super) fn build(table: Table) -> DFResult<Arc<dyn TableProvider>> {
+    let fields = PaimonAuditLogTable::new(table.clone())
+        .fields()
+        .map_err(to_datafusion_error)?;
+    let schema = datafusion_arrow_schema(&fields, true)?;
+    Ok(Arc::new(AuditLogTable {
+        table,
+        fields,
+        schema,
+    }))
+}
+
+#[derive(Debug)]
+struct AuditLogTable {
+    table: Table,
+    fields: Vec<DataField>,
+    schema: SchemaRef,
+}
+
+#[async_trait]
+impl TableProvider for AuditLogTable {
+    fn schema(&self) -> SchemaRef {
+        self.schema.clone()
+    }
+
+    fn table_type(&self) -> TableType {
+        TableType::View
+    }
+
+    async fn scan(
+        &self,
+        state: &dyn Session,
+        projection: Option<&Vec<usize>>,
+        filters: &[Expr],
+        limit: Option<usize>,
+    ) -> DFResult<Arc<dyn ExecutionPlan>> {
+        let filter_analysis = analyze_filters(filters, 
self.table.schema().fields(), true);
+        let pushed_limit = limit.filter(|_| 
!filter_analysis.requires_residual);
+        let mut read_builder = self.table.new_read_builder();
+        if let Some(indices) = projection {
+            read_builder.with_read_type(
+                indices
+                    .iter()
+                    .map(|&index| self.fields[index].clone())
+                    .filter(|field| {
+                        !matches!(
+                            field.id(),
+                            paimon::spec::ROW_KIND_FIELD_ID
+                                | paimon::spec::SEQUENCE_NUMBER_FIELD_ID
+                        )
+                    })
+                    .collect(),
+            );
+        }
+        if let Some(predicate) = filter_analysis.pushed_predicate.clone() {
+            read_builder.with_filter(predicate);
+        }
+        if let Some(limit) = pushed_limit {
+            read_builder.with_limit(limit);
+        }
+        let (plan, trace) = 
await_with_runtime(read_builder.new_audit_scan().plan_with_trace())
+            .await
+            .map_err(to_datafusion_error)?;
+
+        let scan = PaimonScanBuilder {
+            table: &self.table,
+            schema: &self.schema,
+            plan,
+            scan_trace: Some(trace),
+            projection,
+            pushed_predicate: filter_analysis.pushed_predicate,
+            limit: pushed_limit,
+            target_partitions: 
state.config_options().execution.target_partitions,
+            filter_exact: false,
+            case_sensitive: true,
+        }
+        .build_scan(self.fields.clone())?;
+        Ok(Arc::new(PaimonAuditLogScan::new(scan)))
+    }
+
+    fn supports_filters_pushdown(
+        &self,
+        filters: &[&Expr],
+    ) -> DFResult<Vec<TableProviderFilterPushDown>> {
+        let read_builder = self.table.new_read_builder();
+        Ok(filters
+            .iter()
+            .map(|filter| {
+                classify_filter_pushdown(filter, self.table.schema().fields(), 
true, |predicate| {
+                    read_builder.is_exact_filter_pushdown(predicate)
+                })
+            })
+            .collect())
+    }
+}
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs 
b/crates/integrations/datafusion/src/system_tables/mod.rs
index c81b8403..22fbc508 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -20,6 +20,7 @@
 //! Mirrors Java 
[SystemTableLoader](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/system/SystemTableLoader.java):
 //! `TABLES` maps each system-table name to its builder function.
 
+use std::collections::HashMap;
 use std::sync::Arc;
 
 use datafusion::datasource::TableProvider;
@@ -29,6 +30,7 @@ use paimon::table::Table;
 
 use crate::error::to_datafusion_error;
 
+mod audit_log;
 mod branches;
 mod consumers;
 mod files;
@@ -49,6 +51,7 @@ type Builder = fn(Table) -> DFResult<Arc<dyn TableProvider>>;
 // in `load` because it needs the catalog handle (for metastore-tracked audit
 // metadata via `Catalog::list_partitions`).
 const TABLES: &[(&str, Builder)] = &[
+    ("audit_log", audit_log::build),
     ("branches", branches::build),
     ("consumers", consumers::build),
     ("files", files::build),
@@ -63,6 +66,7 @@ const TABLES: &[(&str, Builder)] = &[
 ];
 
 const SYSTEM_TABLE_NAMES: &[&str] = &[
+    "audit_log",
     "branches",
     "consumers",
     "files",
@@ -133,6 +137,7 @@ pub(crate) fn provider_for_table(
     if !is_registered(system_name) {
         return Ok(None);
     }
+    crate::table_loader::ensure_paimon_served(&table, &identifier)?;
     // Fail closed: system tables expose file metadata the client can't 
authorize.
     paimon::spec::CoreOptions::new(table.schema().options())
         .ensure_read_authorized()
@@ -156,13 +161,29 @@ pub(crate) async fn load(
     database: String,
     object: ParsedObjectName,
     system_name: String,
+    dynamic_options: HashMap<String, String>,
 ) -> DFResult<Option<Arc<dyn TableProvider>>> {
     if !is_registered(&system_name) {
         return Ok(None);
     }
+    if system_name.eq_ignore_ascii_case("audit_log")
+        && 
paimon::spec::CoreOptions::new(&dynamic_options).table_read_sequence_number_enabled()
+    {
+        return Err(DataFusionError::Plan(
+            "table-read.sequence-number.enabled is not supported by dynamic 
options for $audit_log"
+                .to_string(),
+        ));
+    }
+    paimon::spec::CoreOptions::new(&dynamic_options)
+        .ensure_read_authorized()
+        .map_err(to_datafusion_error)?;
     let identifier = Identifier::new(database, object.table().to_string());
     match catalog.get_table(&identifier).await {
         Ok(mut table) => {
+            crate::table_loader::ensure_paimon_served(&table, &identifier)?;
+            paimon::spec::CoreOptions::new(table.schema().options())
+                .ensure_read_authorized()
+                .map_err(to_datafusion_error)?;
             if let Some(branch) = object.branch() {
                 if !system_name.eq_ignore_ascii_case("branches") {
                     table = table
@@ -171,6 +192,12 @@ pub(crate) async fn load(
                         .map_err(to_datafusion_error)?;
                 }
             }
+            if system_name.eq_ignore_ascii_case("audit_log") && 
!dynamic_options.is_empty() {
+                table = table
+                    .copy_with_time_travel(dynamic_options)
+                    .await
+                    .map_err(to_datafusion_error)?;
+            }
             provider_for_table(catalog, identifier, table, &system_name)
         }
         Err(paimon::Error::TableNotExist { .. }) => 
Err(DataFusionError::Plan(format!(
@@ -209,6 +236,9 @@ mod tests {
 
     #[test]
     fn is_registered_is_case_insensitive() {
+        assert!(is_registered("audit_log"));
+        assert!(is_registered("Audit_Log"));
+        assert!(is_registered("AUDIT_LOG"));
         assert!(is_registered("options"));
         assert!(is_registered("Options"));
         assert!(is_registered("OPTIONS"));
diff --git a/crates/integrations/datafusion/src/table/mod.rs 
b/crates/integrations/datafusion/src/table/mod.rs
index 9683bf58..f7db6c3e 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -356,6 +356,10 @@ impl PaimonScanBuilder<'_> {
         self,
         read_fields: Vec<DataField>,
     ) -> DFResult<Arc<dyn ExecutionPlan>> {
+        Ok(Arc::new(self.build_scan(read_fields)?))
+    }
+
+    pub(crate) fn build_scan(self, read_fields: Vec<DataField>) -> 
DFResult<PaimonTableScan> {
         let (projected_schema, read_type) = if let Some(indices) = 
self.projection {
             let fields: Vec<Field> = indices
                 .iter()
@@ -381,7 +385,7 @@ impl PaimonScanBuilder<'_> {
                 .collect()
         };
 
-        Ok(Arc::new(PaimonTableScan::try_new(
+        PaimonTableScan::try_new(
             projected_schema,
             self.table.clone(),
             read_type,
@@ -392,7 +396,7 @@ impl PaimonScanBuilder<'_> {
             self.scan_trace,
             None,
             self.case_sensitive,
-        )?))
+        )
     }
 }
 
diff --git a/crates/integrations/datafusion/tests/system_tables.rs 
b/crates/integrations/datafusion/tests/system_tables.rs
index 1a64ccb7..dae555f7 100644
--- a/crates/integrations/datafusion/tests/system_tables.rs
+++ b/crates/integrations/datafusion/tests/system_tables.rs
@@ -22,7 +22,8 @@ mod common;
 use std::sync::Arc;
 
 use datafusion::arrow::array::{
-    Array, BooleanArray, Int32Array, Int64Array, ListArray, StringArray, 
TimestampMillisecondArray,
+    Array, BooleanArray, Int32Array, Int64Array, Int8Array, ListArray, 
StringArray,
+    TimestampMillisecondArray,
 };
 use datafusion::arrow::datatypes::{DataType, Field, TimeUnit};
 use datafusion::arrow::record_batch::RecordBatch;
@@ -30,6 +31,8 @@ use paimon::catalog::Identifier;
 use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
 use paimon_datafusion::SQLContext;
 
+use common::string_value;
+
 const FIXTURE_TABLE: &str = "test_tantivy_fulltext";
 
 fn extract_test_warehouse() -> (tempfile::TempDir, String) {
@@ -85,17 +88,26 @@ async fn query_error(ctx: &SQLContext, sql: &str) -> String 
{
 }
 
 #[tokio::test]
-async fn test_query_auth_table_fails_closed() {
+async fn test_query_auth_system_tables_fail_closed() {
     let (ctx, _catalog, _tmp) = create_context().await;
     run_sql(
         &ctx,
-        "CREATE TABLE paimon.default.qa (id INT) WITH ('query-auth.enabled' = 
'true')",
+        "CREATE TABLE paimon.default.qa (id INT) WITH (
+            'query-auth.enabled' = 'true',
+            's3.secret-key' = 'persisted-secret'
+        )",
     )
     .await;
 
-    // Data reads and data-derived system tables must all fail closed.
+    // Rust cannot yet apply query-auth filters or masks to table and 
system-table
+    // data. Metadata paths, including persisted options, must fail closed.
     for sql in [
         "SELECT * FROM paimon.default.qa",
+        "SELECT * FROM paimon.default.qa$audit_log",
+        "SELECT * FROM paimon.default.qa$files",
+        "SELECT value FROM paimon.default.qa$options WHERE key = 
's3.secret-key'",
+        "SELECT * FROM paimon.default.qa$schemas",
+        "SELECT * FROM paimon.default.qa$partitions",
         "SELECT * FROM paimon.default.qa$manifests",
         "SELECT * FROM paimon.default.qa$table_indexes",
     ] {
@@ -105,6 +117,308 @@ async fn test_query_auth_table_fails_closed() {
             "`{sql}` should fail closed, got: {err}"
         );
     }
+
+    run_sql(&ctx, "CREATE TABLE paimon.default.qa_dynamic (id INT)").await;
+    run_sql(&ctx, "SET 'paimon.query-auth.enabled' = 'true'").await;
+    for sql in [
+        "SELECT * FROM paimon.default.qa_dynamic",
+        "SELECT * FROM paimon.default.qa_dynamic$audit_log",
+        "SELECT * FROM paimon.default.qa_dynamic$files",
+        "SELECT * FROM paimon.default.qa_dynamic$options",
+        "SELECT * FROM paimon.default.qa_dynamic$schemas",
+        "SELECT * FROM paimon.default.qa_dynamic$partitions",
+        "SELECT * FROM paimon.default.qa_dynamic$manifests",
+        "SELECT * FROM paimon.default.qa_dynamic$table_indexes",
+    ] {
+        let err = query_error(&ctx, sql).await;
+        assert!(
+            err.contains("query-auth.enabled"),
+            "dynamic auth should make `{sql}` fail closed, got: {err}"
+        );
+    }
+    run_sql(&ctx, "RESET 'paimon.query-auth.enabled'").await;
+
+    run_sql(&ctx, "SET 'paimon.s3.secret-key' = 'session-secret'").await;
+    let batches = run_sql(
+        &ctx,
+        "SELECT value FROM paimon.default.qa_dynamic$options \
+         WHERE key = 's3.secret-key'",
+    )
+    .await;
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
+    run_sql(&ctx, "RESET 'paimon.s3.secret-key'").await;
+}
+
+#[tokio::test]
+async fn test_audit_log_rejects_dynamic_sequence_number_option() {
+    let (ctx, _catalog, _tmp) = create_context().await;
+    run_sql(
+        &ctx,
+        "CREATE TABLE paimon.default.audit_dynamic_sequence (
+            id INT NOT NULL,
+            PRIMARY KEY (id)
+        ) WITH ('bucket' = '1')",
+    )
+    .await;
+
+    run_sql(
+        &ctx,
+        "SET 'paimon.table-read.sequence-number.enabled' = 'true'",
+    )
+    .await;
+    let err = query_error(
+        &ctx,
+        "SELECT * FROM paimon.default.audit_dynamic_sequence$audit_log",
+    )
+    .await;
+    assert!(
+        err.contains("table-read.sequence-number.enabled")
+            && err.contains("not supported by dynamic options"),
+        "unexpected error: {err}"
+    );
+    run_sql(&ctx, "RESET 'paimon.table-read.sequence-number.enabled'").await;
+}
+
+#[tokio::test]
+async fn test_audit_log_respects_dynamic_time_travel() {
+    let (ctx, _catalog, _tmp) = create_context().await;
+    run_sql(&ctx, "CREATE TABLE paimon.default.audit_tt (id INT)").await;
+    run_sql(&ctx, "INSERT INTO paimon.default.audit_tt VALUES (1)").await;
+    run_sql(&ctx, "INSERT INTO paimon.default.audit_tt VALUES (2)").await;
+
+    run_sql(&ctx, "SET 'paimon.scan.version' = '1'").await;
+    let batches = run_sql(
+        &ctx,
+        "SELECT COUNT(*) FROM paimon.default.audit_tt$audit_log",
+    )
+    .await;
+    assert_eq!(
+        batches[0]
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap()
+            .value(0),
+        1
+    );
+    run_sql(&ctx, "RESET 'paimon.scan.version'").await;
+}
+
+#[tokio::test]
+async fn test_audit_log_system_table_keeps_row_kinds_and_sequence_numbers() {
+    let (ctx, catalog, _tmp) = create_context().await;
+    run_sql(
+        &ctx,
+        "CREATE TABLE paimon.default.audit_rows (
+            id INT NOT NULL,
+            value INT,
+            PRIMARY KEY (id)
+        ) WITH (
+            'bucket' = '1',
+            'merge-engine' = 'deduplicate',
+            'changelog-producer' = 'input',
+            'table-read.sequence-number.enabled' = 'true'
+        )",
+    )
+    .await;
+
+    let table = catalog
+        .get_table(&Identifier::new("default", "audit_rows"))
+        .await
+        .unwrap();
+    let batch = RecordBatch::try_new(
+        Arc::new(datafusion::arrow::datatypes::Schema::new(vec![
+            Field::new("id", DataType::Int32, false),
+            Field::new("value", DataType::Int32, true),
+            Field::new("_VALUE_KIND", DataType::Int8, false),
+        ])),
+        vec![
+            Arc::new(Int32Array::from(vec![1, 2, 1, 2])),
+            Arc::new(Int32Array::from(vec![10, 20, 10, 25])),
+            Arc::new(Int8Array::from(vec![0, 0, 3, 2])),
+        ],
+    )
+    .unwrap();
+    let builder = table.new_write_builder();
+    let mut write = builder.new_write().unwrap();
+    write.write_arrow_batch(&batch).await.unwrap();
+    let messages = write.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages).await.unwrap();
+
+    let batches = run_sql(
+        &ctx,
+        "SELECT \"_SEQUENCE_NUMBER\", rowkind, id, value
+         FROM paimon.default.audit_rows$audit_log
+         WHERE rowkind = '-D' OR id = 2
+         ORDER BY id",
+    )
+    .await;
+    let mut rows = Vec::new();
+    for batch in &batches {
+        let sequence = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap();
+        let rowkind = batch.column(1);
+        let id = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let value = batch
+            .column(3)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        for row in 0..batch.num_rows() {
+            rows.push((
+                sequence.value(row),
+                string_value(rowkind.as_ref(), row).to_string(),
+                id.value(row),
+                value.value(row),
+            ));
+        }
+    }
+    assert_eq!(
+        rows,
+        vec![(2, "-D".to_string(), 1, 10), (3, "+U".to_string(), 2, 25)]
+    );
+
+    let batches = run_sql(
+        &ctx,
+        "SELECT id FROM paimon.default.audit_rows$audit_log WHERE value = 20",
+    )
+    .await;
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
+
+    let batches = run_sql(
+        &ctx,
+        "SELECT COUNT(*) FROM paimon.default.audit_rows$audit_log",
+    )
+    .await;
+    assert_eq!(
+        batches[0]
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int64Array>()
+            .unwrap()
+            .value(0),
+        2
+    );
+
+    run_sql(&ctx, "CREATE TABLE paimon.default.append_rows (id INT)").await;
+    run_sql(
+        &ctx,
+        "INSERT INTO paimon.default.append_rows VALUES (1), (2)",
+    )
+    .await;
+    let batches = run_sql(
+        &ctx,
+        "SELECT rowkind FROM paimon.default.append_rows$audit_log",
+    )
+    .await;
+    assert!(batches.iter().all(|batch| {
+        (0..batch.num_rows()).all(|row| string_value(batch.column(0).as_ref(), 
row) == "+I")
+    }));
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
+
+    let explain = run_sql(
+        &ctx,
+        "EXPLAIN SELECT id FROM paimon.default.audit_rows$audit_log WHERE id = 
2",
+    )
+    .await;
+    assert!(explain.iter().any(|batch| {
+        (0..batch.num_rows()).any(|row| {
+            let plan = string_value(batch.column(1).as_ref(), row);
+            plan.contains("PaimonAuditLogScan") && plan.contains("predicate=")
+        })
+    }));
+}
+
+#[tokio::test]
+async fn test_first_row_audit_log_merges_level_zero_before_filtering() {
+    let (ctx, _catalog, _tmp) = create_context().await;
+    run_sql(
+        &ctx,
+        "CREATE TABLE paimon.default.first_row_audit (
+            id INT NOT NULL,
+            value INT,
+            PRIMARY KEY (id)
+        ) WITH (
+            'bucket' = '1',
+            'merge-engine' = 'first-row'
+        )",
+    )
+    .await;
+    run_sql(
+        &ctx,
+        "INSERT INTO paimon.default.first_row_audit VALUES (1, 10)",
+    )
+    .await;
+    run_sql(
+        &ctx,
+        "INSERT INTO paimon.default.first_row_audit VALUES (1, 20)",
+    )
+    .await;
+
+    let batches = run_sql(
+        &ctx,
+        "SELECT value FROM paimon.default.first_row_audit$audit_log",
+    )
+    .await;
+    assert_eq!(
+        batches[0]
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap()
+            .values(),
+        &[10]
+    );
+
+    let batches = run_sql(
+        &ctx,
+        "SELECT value FROM paimon.default.first_row_audit$audit_log WHERE 
value = 20",
+    )
+    .await;
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
+}
+
+#[tokio::test]
+async fn test_audit_log_system_table_matches_deletion_vector_visibility() {
+    let (ctx, _catalog, _tmp) = create_context().await;
+    run_sql(
+        &ctx,
+        "CREATE TABLE paimon.default.dv_audit (id INT NOT NULL) WITH (
+            'row-tracking.enabled' = 'true',
+            'data-evolution.enabled' = 'true',
+            'deletion-vectors.enabled' = 'true'
+        )",
+    )
+    .await;
+    run_sql(
+        &ctx,
+        "INSERT INTO paimon.default.dv_audit (id) VALUES (1), (2)",
+    )
+    .await;
+    run_sql(&ctx, "DELETE FROM paimon.default.dv_audit WHERE id = 1").await;
+
+    let batches = run_sql(
+        &ctx,
+        "SELECT rowkind, id FROM paimon.default.dv_audit$audit_log",
+    )
+    .await;
+    assert_eq!(string_value(batches[0].column(0).as_ref(), 0), "+I");
+    assert_eq!(
+        batches[0]
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap()
+            .values(),
+        &[2]
+    );
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/table/audit_log_table.rs 
b/crates/paimon/src/table/audit_log_table.rs
index a6b6e5fe..f3d604df 100644
--- a/crates/paimon/src/table/audit_log_table.rs
+++ b/crates/paimon/src/table/audit_log_table.rs
@@ -16,12 +16,14 @@
 // under the License.
 
 use super::incremental_scan::{IncrementalPlan, IncrementalScan, 
IncrementalScanMode};
-use super::{ArrowRecordBatchStream, Table};
+use super::{ArrowRecordBatchStream, AuditLogRead, AuditLogScan, Table};
 use crate::spec::{
     BigIntType, DataField, DataType, VarCharType, ROW_KIND_FIELD_ID, 
ROW_KIND_FIELD_NAME,
     SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME,
 };
 
+pub(super) mod merge;
+
 /// Wrapper that exposes table rows with a leading `rowkind` audit column.
 ///
 /// Incremental reads produce:
@@ -80,6 +82,21 @@ impl AuditLogTable {
         IncrementalScan::for_table(&self.wrapped, mode, start_exclusive, 
end_inclusive)
     }
 
+    /// Plan a current-state audit read for [`Self::new_read`].
+    pub fn new_scan(&self) -> AuditLogScan<'_> {
+        self.wrapped.new_read_builder().new_audit_scan()
+    }
+
+    /// Creates an audit reader using the table's configured fields and 
options.
+    pub fn new_read(&self) -> crate::Result<AuditLogRead<'_>> {
+        AuditLogRead::new(
+            self.wrapped
+                .new_read_builder()
+                .with_read_type(self.fields()?)
+                .new_read()?,
+        )
+    }
+
     pub fn to_arrow(&self, plan: &IncrementalPlan) -> 
crate::Result<ArrowRecordBatchStream> {
         plan.validate()?;
         let read = self.wrapped.new_read_builder().new_read()?;
diff --git a/crates/paimon/src/table/audit_log_table/merge.rs 
b/crates/paimon/src/table/audit_log_table/merge.rs
new file mode 100644
index 00000000..801fa5cc
--- /dev/null
+++ b/crates/paimon/src/table/audit_log_table/merge.rs
@@ -0,0 +1,189 @@
+// 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.
+
+//! Merge policies for current-state audit reads.
+
+use super::super::sort_merge::{
+    compare_sequence_order, AggregateMergeFunction, BufferedBatch, 
MergeFunction, MergeResult,
+    MergeRow, PartialUpdateMergeFunction,
+};
+use crate::arrow::build_target_arrow_schema;
+use crate::spec::{
+    CoreOptions, DataField, MergeEngine, RowKind, SEQUENCE_NUMBER_FIELD_ID, 
VALUE_KIND_FIELD_ID,
+};
+use crate::table::kv_file_reader::KeyValueReadConfig;
+use crate::Error;
+use arrow_array::{Int64Array, Int8Array, RecordBatch};
+use arrow_schema::SchemaRef;
+use std::sync::Arc;
+
+pub(in crate::table) fn new_merge_function(
+    config: &KeyValueReadConfig,
+    fields: &[DataField],
+) -> crate::Result<Box<dyn MergeFunction>> {
+    let options = &config.table_options;
+    let engine = config.merge_engine;
+    if matches!(engine, MergeEngine::Deduplicate | MergeEngine::FirstRow) {
+        return Ok(Box::new(AuditKeyMergeFunction {
+            first_row: engine == MergeEngine::FirstRow,
+            ignore_delete: CoreOptions::new(options).ignore_delete(),
+        }));
+    }
+    let value_projection: Vec<_> = fields
+        .iter()
+        .enumerate()
+        .filter(|(_, field)| !matches!(field.id(), SEQUENCE_NUMBER_FIELD_ID | 
VALUE_KIND_FIELD_ID))
+        .map(|(index, _)| index)
+        .collect();
+    let value_fields: Vec<_> = value_projection
+        .iter()
+        .map(|&index| fields[index].clone())
+        .collect();
+    let inner: Box<dyn MergeFunction> = match engine {
+        MergeEngine::PartialUpdate => 
Box::new(PartialUpdateMergeFunction::new_with_schema(
+            options,
+            &config.table_name,
+            &config.table_fields,
+            &value_fields,
+            &config.primary_keys,
+        )?),
+        MergeEngine::Aggregation => Box::new(AggregateMergeFunction::new(
+            options,
+            &config.table_name,
+            &value_fields,
+            &config.primary_keys,
+            &config.sequence_fields,
+        )?),
+        _ => unreachable!(),
+    };
+    if value_projection.len() == fields.len() {
+        return Ok(inner);
+    }
+    Ok(Box::new(AuditValueMergeFunction {
+        inner,
+        value_projection,
+        value_schema: build_target_arrow_schema(&value_fields)?,
+        fields: fields.to_vec(),
+    }))
+}
+
+// Reuse the ordinary value merge, then expose its logical INSERT and latest 
add sequence.
+struct AuditValueMergeFunction {
+    inner: Box<dyn MergeFunction>,
+    value_projection: Vec<usize>,
+    value_schema: SchemaRef,
+    fields: Vec<DataField>,
+}
+
+impl MergeFunction for AuditValueMergeFunction {
+    fn merge(
+        &self,
+        rows: &[MergeRow],
+        batch_buffer: &[BufferedBatch],
+        source_output_col_indices: &[usize],
+        output_schema: &SchemaRef,
+    ) -> crate::Result<MergeResult> {
+        let indices: Vec<_> = self
+            .value_projection
+            .iter()
+            .map(|&index| source_output_col_indices[index])
+            .collect();
+        match self
+            .inner
+            .merge(rows, batch_buffer, &indices, &self.value_schema)?
+        {
+            MergeResult::MaterializedRow(batch) => {
+                let mut columns = batch.columns().to_vec();
+                for (index, field) in self.fields.iter().enumerate() {
+                    match field.id() {
+                        SEQUENCE_NUMBER_FIELD_ID => {
+                            let winner = rows
+                                .iter()
+                                .filter(|row| matches!(row.value_kind, 0 | 2))
+                                .max_by(|left, right| 
compare_sequence_order(left, right))
+                                .expect("materialized merge must contain an 
add row");
+                            columns.insert(
+                                index,
+                                
Arc::new(Int64Array::from(vec![winner.sequence_number])),
+                            );
+                        }
+                        VALUE_KIND_FIELD_ID => {
+                            columns.insert(index, 
Arc::new(Int8Array::from(vec![0])))
+                        }
+                        _ => {}
+                    }
+                }
+                Ok(MergeResult::MaterializedRow(
+                    RecordBatch::try_new(output_schema.clone(), 
columns).map_err(|error| {
+                        Error::UnexpectedError {
+                            message: format!("Failed to build audit merge row: 
{error}"),
+                            source: Some(Box::new(error)),
+                        }
+                    })?,
+                ))
+            }
+            result => Ok(result),
+        }
+    }
+}
+
+/// Keep the winning physical row, including retracts for deduplicate tables.
+struct AuditKeyMergeFunction {
+    first_row: bool,
+    ignore_delete: bool,
+}
+
+impl MergeFunction for AuditKeyMergeFunction {
+    fn merge(
+        &self,
+        rows: &[MergeRow],
+        _batch_buffer: &[BufferedBatch],
+        _source_output_col_indices: &[usize],
+        _output_schema: &SchemaRef,
+    ) -> crate::Result<MergeResult> {
+        let mut winner = None;
+        for row in rows {
+            if (self.first_row || self.ignore_delete)
+                && !RowKind::from_value(row.value_kind)?.is_add()
+            {
+                if self.ignore_delete {
+                    continue;
+                }
+                return Err(Error::Unsupported {
+                    message: "merge-engine=first-row does not support DELETE 
or UPDATE_BEFORE rows; set ignore-delete=true to ignore them".to_string(),
+                });
+            }
+            if winner.is_none_or(|best| {
+                let order = compare_sequence_order(row, best);
+                if self.first_row {
+                    order.is_lt()
+                } else {
+                    order.is_ge()
+                }
+            }) {
+                winner = Some(row);
+            }
+        }
+        Ok(match winner {
+            Some(row) => MergeResult::SourceRow {
+                batch_idx: row.batch_idx,
+                row_idx: row.row_idx,
+            },
+            None => MergeResult::Omit,
+        })
+    }
+}
diff --git a/crates/paimon/src/table/audit_log_table/read.rs 
b/crates/paimon/src/table/audit_log_table/read.rs
new file mode 100644
index 00000000..625d3170
--- /dev/null
+++ b/crates/paimon/src/table/audit_log_table/read.rs
@@ -0,0 +1,210 @@
+// 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.
+
+//! Current-state audit reads, including winning retract rows.
+
+use super::{
+    audit_sequence_number_enabled, rowkind_array_from_column, PaimonTableRead, 
TableRead,
+    TableReadKind, MAX_MERGE_INPUT_STREAMS,
+};
+use crate::arrow::build_target_arrow_schema;
+use crate::spec::{
+    DataField, DataType, MergeEngine, TinyIntType, ROW_KIND_FIELD_ID, 
SEQUENCE_NUMBER_FIELD_ID,
+    VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
+};
+use crate::table::data_file_reader::DataFileReader;
+use crate::table::kv_file_reader::{KeyValueFileReader, KeyValueReadConfig};
+use crate::table::ArrowRecordBatchStream;
+use crate::DataSplit;
+use arrow_array::{ArrayRef, RecordBatch, RecordBatchOptions, StringArray};
+use futures::{stream, StreamExt};
+use std::sync::Arc;
+
+/// Reads current-state audit rows using the supplied read's exact projection,
+/// predicates and Parquet budget. Include `rowkind` in the projection to 
expose it.
+#[derive(Debug, Clone)]
+pub struct AuditLogRead<'a> {
+    read: PaimonTableRead<'a>,
+}
+
+impl<'a> AuditLogRead<'a> {
+    pub fn new(read: TableRead<'a>) -> crate::Result<Self> {
+        read.ensure_query_auth_allowed()?;
+        match read.0 {
+            TableReadKind::Paimon(read) => Ok(Self { read }),
+            TableReadKind::Format(_) => Err(crate::Error::Unsupported {
+                message: "Format tables do not support audit log batch 
read".to_string(),
+            }),
+        }
+    }
+
+    /// Reads splits planned by an audit scan, retaining winning retract rows.
+    pub fn to_arrow(&self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
+        let output_read_type = self.read.read_type.clone();
+        if output_read_type
+            .iter()
+            .any(|field| field.id() == SEQUENCE_NUMBER_FIELD_ID)
+            && !audit_sequence_number_enabled(self.read.table)
+        {
+            return Err(crate::Error::DataInvalid {
+                message: "Audit read requested _SEQUENCE_NUMBER but 
table-read.sequence-number.enabled is false".to_string(),
+                source: None,
+            });
+        }
+        let audit_schema = build_target_arrow_schema(&output_read_type)?;
+        let has_primary_keys = 
!self.read.table.schema().primary_keys().is_empty();
+        let mut read_type: Vec<_> = output_read_type
+            .iter()
+            .filter(|field| field.id() != ROW_KIND_FIELD_ID)
+            .cloned()
+            .collect();
+        if has_primary_keys
+            && output_read_type
+                .iter()
+                .any(|field| field.id() == ROW_KIND_FIELD_ID)
+        {
+            read_type.push(DataField::new(
+                VALUE_KIND_FIELD_ID,
+                VALUE_KIND_FIELD_NAME.to_string(),
+                DataType::TinyInt(TinyIntType::new()),
+            ));
+        }
+
+        let physical_stream = if has_primary_keys {
+            let core_options = self.read.table.schema().core_options();
+            let merge_engine = core_options.merge_engine()?;
+            let (raw_splits, merge_splits): (Vec<_>, Vec<_>) = data_splits
+                .iter()
+                .cloned()
+                .partition(|split| audit_raw_convertible(split, merge_engine));
+            let parquet_read_budget = self.read.parquet_read_budget()?;
+            let raw_stream = DataFileReader::new(
+                self.read.table.file_io.clone(),
+                self.read.table.schema_manager().clone(),
+                self.read.table.schema().id(),
+                self.read.table.schema.fields().to_vec(),
+                read_type.clone(),
+                self.read.data_predicates.clone(),
+            )
+            
.with_file_index_read_enabled(core_options.file_index_read_enabled())
+            .with_batch_size(Some(core_options.read_batch_size()?))
+            .with_parquet_read_budget(Some(Arc::clone(&parquet_read_budget)))
+            .read(&raw_splits)?;
+            let merge_stream = KeyValueFileReader::new(
+                self.read.table.file_io.clone(),
+                KeyValueReadConfig {
+                    table_name: self.read.table.identifier().full_name(),
+                    table_options: self.read.table.schema().options().clone(),
+                    schema_manager: self.read.table.schema_manager().clone(),
+                    table_schema_id: self.read.table.schema().id(),
+                    table_fields: self.read.table.schema.fields().to_vec(),
+                    read_type,
+                    predicates: self.read.data_predicates.clone(),
+                    primary_keys: 
self.read.table.schema.trimmed_primary_keys(),
+                    merge_engine,
+                    sequence_fields: core_options
+                        .sequence_fields()
+                        .iter()
+                        .map(|field| field.to_string())
+                        .collect(),
+                    read_batch_size: core_options.read_batch_size()?,
+                    merge_splits: false,
+                    max_merge_input_streams: Some(MAX_MERGE_INPUT_STREAMS),
+                    parquet_read_budget: Some(parquet_read_budget),
+                },
+            )
+            .read_with_merge_function(
+                &merge_splits,
+                crate::table::audit_log_table::merge::new_merge_function,
+            )?;
+            Box::pin(stream::select_all([raw_stream, merge_stream]))
+        } else {
+            let mut read = self.read.clone();
+            read.read_type = read_type;
+            read.to_arrow(data_splits)?
+        };
+
+        Ok(Box::pin(async_stream::try_stream! {
+            futures::pin_mut!(physical_stream);
+            let mut projection = None;
+            while let Some(batch) = physical_stream.next().await {
+                let batch = batch?;
+                if projection.is_none() {
+                    projection = Some(output_read_type.iter().map(|field| {
+                        let name = if field.id() == ROW_KIND_FIELD_ID {
+                            if !has_primary_keys {
+                                return Ok(None);
+                            }
+                            VALUE_KIND_FIELD_NAME
+                        } else {
+                            field.name()
+                        };
+                        
batch.schema().index_of(name).map(Some).map_err(|error| 
crate::Error::DataInvalid {
+                            message: format!("Audit read missing column 
'{name}': {error}"),
+                            source: None,
+                        })
+                    }).collect::<crate::Result<Vec<_>>>()?);
+                }
+                let columns = 
output_read_type.iter().zip(projection.as_ref().unwrap())
+                    .map(|(field, index)| {
+                        let column: ArrayRef = match index {
+                            None => Arc::new(StringArray::from(vec!["+I"; 
batch.num_rows()])),
+                            Some(index) if field.id() == ROW_KIND_FIELD_ID =>
+                                
Arc::new(rowkind_array_from_column(batch.column(*index).as_ref())?),
+                            Some(index) => batch.column(*index).clone(),
+                        };
+                        Ok(column)
+                    }).collect::<crate::Result<Vec<_>>>()?;
+                let options = 
RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
+                yield RecordBatch::try_new_with_options(audit_schema.clone(), 
columns, &options)
+                    .map_err(|error| crate::Error::UnexpectedError {
+                        message: format!("Failed to build audit log batch: 
{error}"),
+                        source: Some(Box::new(error)),
+                    })?;
+            }
+        }))
+    }
+}
+
+// Legacy unknown delete counts and first-row level-0 files stay on the merge 
path.
+fn audit_raw_convertible(split: &DataSplit, merge_engine: MergeEngine) -> bool 
{
+    split.raw_convertible()
+        && split.data_files().iter().all(|file| {
+            file.delete_row_count == Some(0)
+                && (merge_engine != MergeEngine::FirstRow || file.level != 0)
+        })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::table::table_read::tests::{file, split};
+
+    #[test]
+    fn test_audit_split_routing() {
+        let raw = split(vec![file("a", 5, Some(0))], true);
+        let merge = split(vec![file("a", 5, Some(0))], false);
+        let legacy = split(vec![file("a", 5, None)], true);
+        assert!(audit_raw_convertible(&raw, MergeEngine::Deduplicate));
+        assert!(audit_raw_convertible(&raw, MergeEngine::FirstRow));
+        assert!(!audit_raw_convertible(&merge, MergeEngine::Deduplicate));
+        assert!(!audit_raw_convertible(&legacy, MergeEngine::Deduplicate));
+        let level_zero = split(vec![file("a", 0, Some(0))], true);
+        assert!(audit_raw_convertible(&level_zero, MergeEngine::Deduplicate));
+        assert!(!audit_raw_convertible(&level_zero, MergeEngine::FirstRow));
+    }
+}
diff --git a/crates/paimon/src/table/audit_log_table/scan.rs 
b/crates/paimon/src/table/audit_log_table/scan.rs
new file mode 100644
index 00000000..fa14c0f0
--- /dev/null
+++ b/crates/paimon/src/table/audit_log_table/scan.rs
@@ -0,0 +1,265 @@
+// 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.
+
+//! Audit scan wrapper: retain row versions and only forward safe pruning.
+
+use super::{TableScan, TableScanKind};
+use crate::spec::MergeEngine;
+use crate::table::kv_file_reader::retain_primary_key_conjuncts;
+use crate::table::merge_tree_split_generator::{merge_tree_split_for_batch, 
KeyComparator};
+use crate::table::{DataSplit, Plan, ReadBuilder, ScanTrace};
+use std::collections::HashMap;
+
+/// Plans current-state audit reads over the wrapped table's files.
+#[derive(Debug, Clone)]
+pub struct AuditLogScan<'a> {
+    scan: TableScan<'a>,
+}
+
+impl<'a> AuditLogScan<'a> {
+    fn new(mut scan: TableScan<'a>) -> Self {
+        if let TableScanKind::Paimon(inner) = &mut scan.0 {
+            // Preserve the read projection, including data-evolution column 
pruning.
+            inner.scan_all_files = true;
+            inner.limit = None;
+            inner.row_range_optimization_disabled = true;
+            inner.row_ranges = None;
+            if !inner.table.schema().primary_keys().is_empty() {
+                inner.data_predicates = retain_primary_key_conjuncts(
+                    &inner.data_predicates,
+                    inner.table.schema().fields(),
+                    &inner.table.schema().trimmed_primary_keys(),
+                );
+            }
+        }
+        Self { scan }
+    }
+
+    pub async fn plan(&self) -> crate::Result<Plan> {
+        self.plan_with_trace().await.map(|(plan, _)| plan)
+    }
+
+    pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
+        let TableScanKind::Paimon(inner) = &self.scan.0 else {
+            return Err(crate::Error::Unsupported {
+                message: "Format tables do not support audit log batch 
scan".to_string(),
+            });
+        };
+        let (plan, mut trace) = self.scan.plan_with_trace().await?;
+        let options = inner.table.schema().core_options();
+        let engine = options.merge_engine()?;
+        // Ordinary first-row and DV scans pack files without a key merge. 
Audit
+        // reads need the existing merge-tree planner to retain overlapping 
versions.
+        let plan = if engine == MergeEngine::FirstRow
+            || (options.deletion_vectors_enabled() && 
!options.deletion_vectors_merge_on_read())
+        {
+            if let Some(comparator) = 
KeyComparator::from_table_schema(inner.table.schema()) {
+                let mut buckets: HashMap<(Vec<u8>, i32), Vec<DataSplit>> = 
HashMap::new();
+                for split in plan.into_splits() {
+                    buckets
+                        .entry((split.partition().to_serialized_bytes(), 
split.bucket()))
+                        .or_default()
+                        .push(split);
+                }
+                let mut splits = Vec::new();
+                for bucket in buckets.into_values() {
+                    let first = &bucket[0];
+                    let files = bucket
+                        .iter()
+                        .flat_map(|split| split.data_files().iter().cloned())
+                        .collect();
+                    let deletion_files: HashMap<_, _> = bucket
+                        .iter()
+                        .flat_map(|split| {
+                            split.data_files().iter().filter_map(move |file| {
+                                split
+                                    .deletion_file_for_data_file(file)
+                                    .map(|deletion| (file.file_name.clone(), 
deletion.clone()))
+                            })
+                        })
+                        .collect();
+                    for group in merge_tree_split_for_batch(
+                        files,
+                        &comparator,
+                        options.source_split_target_size(),
+                        options.source_split_open_file_cost(),
+                        matches!(engine, MergeEngine::Deduplicate | 
MergeEngine::FirstRow),
+                    ) {
+                        let mut builder = DataSplit::builder()
+                            .with_snapshot(first.snapshot_id())
+                            .with_partition(first.partition().clone())
+                            .with_bucket(first.bucket())
+                            .with_bucket_path(first.bucket_path().to_string())
+                            .with_total_buckets(first.total_buckets())
+                            .with_raw_convertible(group.raw_convertible);
+                        if !deletion_files.is_empty() {
+                            builder = builder.with_data_deletion_files(
+                                group
+                                    .files
+                                    .iter()
+                                    .map(|file| 
deletion_files.get(&file.file_name).cloned())
+                                    .collect(),
+                            );
+                        }
+                        
splits.push(builder.with_data_files(group.files).build()?);
+                    }
+                }
+                Plan::new(splits)
+            } else {
+                plan
+            }
+        } else {
+            plan
+        };
+        trace.record_final_plan(
+            plan.splits().len(),
+            plan.splits().len(),
+            plan.splits()
+                .iter()
+                .map(|split| split.data_files().len())
+                .sum(),
+        );
+        trace.planned_data_file_bytes = plan.planned_data_file_bytes();
+        Ok((plan, trace))
+    }
+}
+
+impl<'a> ReadBuilder<'a> {
+    /// Create an audit scan that retains every visible row version.
+    pub fn new_audit_scan(&self) -> AuditLogScan<'a> {
+        AuditLogScan::new(self.new_scan())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{
+        BinaryRowBuilder, CoreOptions, DataField, DataType, Datum, IntType, 
PredicateBuilder,
+    };
+    use crate::table::table_scan::{
+        tests::{
+            data_evolution_test_table, pk_stats_file, pk_stats_gate_table, 
setup_scan_trace_dirs,
+            two_column_schema,
+        },
+        PaimonTableScan,
+    };
+    use crate::table::{CommitMessage, TableCommit};
+    use std::collections::HashSet;
+    #[test]
+    fn test_audit_scan_all_files_preserves_data_evolution_projection() {
+        let table = data_evolution_test_table(
+            "memory:/de_audit_scan_projection",
+            two_column_schema(0, "id", "name"),
+        )
+        .copy_with_options(HashMap::from([(
+            "global-index.enabled".to_string(),
+            "true".to_string(),
+        )]));
+        let projected = HashSet::from([1]);
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .equal("id", Datum::Int(1))
+            .unwrap();
+        let scan = PaimonTableScan::new(&table, None, vec![predicate], None, 
None, None)
+            .with_projected_read_field_ids(Some(projected.clone()));
+        let wrapped = 
AuditLogScan::new(TableScan(TableScanKind::Paimon(scan)));
+        let TableScanKind::Paimon(scan) = wrapped.scan.0 else {
+            panic!("expected Paimon scan")
+        };
+
+        assert!(scan.scan_all_files);
+        assert_eq!(scan.projected_read_field_ids, Some(projected));
+        assert!(
+            
scan.global_index_scan_settings(&CoreOptions::new(table.schema().options()), 
true,)
+                .unwrap()
+                .is_none(),
+            "audit scans must not prune physical row versions via global 
indexes"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_audit_stats_pruning_keeps_overlapping_versions() {
+        for option in ["deletion-vectors.enabled", "merge-engine"] {
+            let table_path = format!("memory:/audit_stats_{option}");
+            let value = if option == "merge-engine" {
+                "first-row"
+            } else {
+                "true"
+            };
+            let table = 
pk_stats_gate_table(&table_path).copy_with_options(HashMap::from([
+                (option.to_string(), value.to_string()),
+                ("source.split.target-size".to_string(), "1b".to_string()),
+            ]));
+            setup_scan_trace_dirs(&table).await;
+
+            let mut old = pk_stats_file("old-version.parquet", (1, 5), (100, 
200));
+            old.level = 1;
+            let mut new = pk_stats_file("new-version.parquet", (1, 5), (10, 
60));
+            // Compacted files on the same level have disjoint key ranges.
+            new.level = 2;
+            TableCommit::new(table.clone(), "dv-audit-gate-test".to_string())
+                .commit(vec![CommitMessage::new(
+                    BinaryRowBuilder::new(0).build_serialized(),
+                    0,
+                    vec![old, new],
+                )])
+                .await
+                .unwrap();
+
+            let fields = vec![
+                DataField::new(0, "id".to_string(), 
DataType::Int(IntType::new())),
+                DataField::new(1, "value".to_string(), 
DataType::Int(IntType::new())),
+            ];
+            let value_filter = PredicateBuilder::new(&fields)
+                .greater_than("value", Datum::Int(90))
+                .unwrap();
+            let mut reader = table.new_read_builder();
+            reader.with_filter(value_filter);
+
+            let (ordinary_plan, ordinary_trace) =
+                reader.new_scan().plan_with_trace().await.unwrap();
+            assert!(ordinary_trace.manifest_entries_pruned_by_data_stats >= 1);
+            assert_eq!(
+                ordinary_plan
+                    .splits()
+                    .iter()
+                    .map(|split| split.data_files().len())
+                    .sum::<usize>(),
+                1
+            );
+
+            let (audit_plan, audit_trace) =
+                reader.new_audit_scan().plan_with_trace().await.unwrap();
+            assert_eq!(audit_trace.manifest_entries_pruned_by_data_stats, 0);
+            assert_eq!(
+                audit_plan
+                    .splits()
+                    .iter()
+                    .map(|split| split.data_files().len())
+                    .sum::<usize>(),
+                2,
+                "both key versions must reach the audit merge path"
+            );
+            assert_eq!(
+                audit_plan.splits().len(),
+                1,
+                "overlapping versions must be planned together"
+            );
+            assert!(!audit_plan.splits()[0].raw_convertible());
+        }
+    }
+}
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index 88b1bf80..3b0a31ce 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -27,7 +27,7 @@
 
 use super::data_file_reader::DataFileReader;
 use super::sort_merge::{
-    AggregateMergeFunction, DeduplicateMergeFunction, 
PartialUpdateMergeFunction,
+    AggregateMergeFunction, DeduplicateMergeFunction, MergeFunction, 
PartialUpdateMergeFunction,
     SortMergeReaderBuilder,
 };
 use crate::arrow::{build_target_arrow_schema, ParquetReadBudget};
@@ -283,39 +283,44 @@ impl KeyValueFileReader {
     }
 
     fn new_merge_function(
-        merge_engine: MergeEngine,
-        table_options: &HashMap<String, String>,
-        table_name: &str,
-        table_fields: &[DataField],
+        config: &KeyValueReadConfig,
         merge_output_fields: &[DataField],
-        primary_keys: &[String],
-        sequence_fields: &[String],
-    ) -> crate::Result<Box<dyn super::sort_merge::MergeFunction>> {
-        match merge_engine {
+    ) -> crate::Result<Box<dyn MergeFunction>> {
+        match config.merge_engine {
             MergeEngine::Deduplicate => Ok(Box::new(DeduplicateMergeFunction)),
-            MergeEngine::PartialUpdate => Ok(Box::new(
-                PartialUpdateMergeFunction::new_with_schema(
-                    table_options,
-                    table_name,
-                    table_fields,
+            MergeEngine::PartialUpdate => {
+                Ok(Box::new(PartialUpdateMergeFunction::new_with_schema(
+                    &config.table_options,
+                    &config.table_name,
+                    &config.table_fields,
                     merge_output_fields,
-                    primary_keys,
-                )?,
-            )),
+                    &config.primary_keys,
+                )?))
+            }
             MergeEngine::FirstRow => Err(Error::Unsupported {
                 message: "KeyValueFileReader does not support 
merge-engine=first-row; first-row reads should use the non-KV path".to_string(),
             }),
             MergeEngine::Aggregation => 
Ok(Box::new(AggregateMergeFunction::new(
-                table_options,
-                table_name,
+                &config.table_options,
+                &config.table_name,
                 merge_output_fields,
-                primary_keys,
-                sequence_fields,
+                &config.primary_keys,
+                &config.sequence_fields,
             )?)),
         }
     }
 
     pub fn read(self, data_splits: &[DataSplit]) -> 
crate::Result<ArrowRecordBatchStream> {
+        self.read_with_merge_function(data_splits, Self::new_merge_function)
+    }
+
+    pub(super) fn read_with_merge_function(
+        self,
+        data_splits: &[DataSplit],
+        merge_function: impl Fn(&KeyValueReadConfig, &[DataField]) -> 
crate::Result<Box<dyn MergeFunction>>
+            + Send
+            + 'static,
+    ) -> crate::Result<ArrowRecordBatchStream> {
         // A projected `_ROW_ID` is synthesized as all-nulls here, so the 
residual
         // would silently drop every row rather than hit its missing-column 
guard.
         super::row_id_predicate::reject_row_id_filter(
@@ -371,10 +376,17 @@ impl KeyValueFileReader {
             ))
         };
         // User columns = read_type fields + any key fields not already in 
read_type
-        //              + any sequence fields not already included.
+        //              + any sequence fields not already included. Physical 
system
+        // fields are already the first two columns of every KV file.
         let read_type_names: std::collections::HashSet<&str> =
             self.config.read_type.iter().map(|f| f.name()).collect();
-        let mut user_fields: Vec<DataField> = self.config.read_type.clone();
+        let mut user_fields: Vec<DataField> = self
+            .config
+            .read_type
+            .iter()
+            .filter(|field| !matches!(field.id(), SEQUENCE_NUMBER_FIELD_ID | 
VALUE_KIND_FIELD_ID))
+            .cloned()
+            .collect();
         for kf in &key_fields {
             if !read_type_names.contains(kf.name()) {
                 user_fields.push(kf.clone());
@@ -447,17 +459,20 @@ impl KeyValueFileReader {
                     .unwrap()
             })
             .collect();
-        let value_fields: Vec<DataField> = user_fields
-            .iter()
-            .filter(|f| !key_names.contains(f.name()))
-            .cloned()
-            .collect();
-        let value_indices: Vec<usize> = user_fields
+        let (value_indices, value_fields): (Vec<_>, Vec<_>) = 
internal_read_type
             .iter()
             .enumerate()
-            .filter(|(_, f)| !key_names.contains(f.name()))
-            .map(|(i, _)| i + 2)
-            .collect();
+            .filter(|(index, field)| {
+                !key_names.contains(field.name())
+                    && (*index >= 2
+                        || self
+                            .config
+                            .read_type
+                            .iter()
+                            .any(|requested| requested.id() == field.id()))
+            })
+            .map(|(index, field)| (index, field.clone()))
+            .unzip();
 
         // If sequence.field is configured, find each field's index in the 
internal schema.
         let user_sequence_indices: Vec<usize> = self
@@ -506,23 +521,13 @@ impl KeyValueFileReader {
             data_splits.into_iter().map(|split| vec![split]).collect()
         };
         let file_io = self.file_io;
-        let merge_engine = self.config.merge_engine;
-        let schema_manager = self.config.schema_manager;
-        let table_schema_id = self.config.table_schema_id;
-        let table_fields = self.config.table_fields;
-        let table_name = self.config.table_name;
-        let table_options = self.config.table_options;
+        let config = self.config;
+        let table_schema_id = config.table_schema_id;
         let pushdown_predicates = self.pushdown_predicates;
-        let residual_predicates = self.config.predicates;
-        let primary_keys = self.config.primary_keys;
-        let sequence_fields = self.config.sequence_fields;
-        let read_batch_size = self.config.read_batch_size;
-        let max_merge_input_streams = self.config.max_merge_input_streams;
-        let parquet_read_budget = self.config.parquet_read_budget;
         #[cfg(test)]
         let input_batch_sizes = self.input_batch_sizes;
 
-        // Build the merge output schema (keys + values, no system columns).
+        // Build the merge output schema (keys + projected values).
         let mut merge_output_fields: Vec<DataField> = Vec::new();
         merge_output_fields.extend(key_fields);
         merge_output_fields.extend(value_fields);
@@ -561,13 +566,13 @@ impl KeyValueFileReader {
                     merge_splits,
                 ) {
                     let input_stream_count = merge_group.len();
-                    ensure_merge_input_limit(input_stream_count, 
max_merge_input_streams)?;
+                    ensure_merge_input_limit(input_stream_count, 
config.max_merge_input_streams)?;
                     // Sort-merge must first obtain one batch from every input
                     // stream. Keep concurrent row-group reads disabled 
whenever
                     // multiple runs advance in lockstep; one run may still use
                     // the shared budget because its files are opened serially.
                     let group_parquet_read_budget = if input_stream_count == 1 
{
-                        parquet_read_budget.clone()
+                        config.parquet_read_budget.clone()
                     } else {
                         None
                     };
@@ -576,15 +581,15 @@ impl KeyValueFileReader {
                     for MergeRun { files } in merge_group {
                         let reader = DataFileReader::new(
                             file_io.clone(),
-                            schema_manager.clone(),
+                            config.schema_manager.clone(),
                             table_schema_id,
-                            table_fields.clone(),
+                            config.table_fields.clone(),
                             internal_read_type.clone(),
                             pushdown_predicates.clone(),
                         )
-                        .with_batch_size(Some(read_batch_size))
+                        .with_batch_size(Some(config.read_batch_size))
                         
.with_parquet_read_budget(group_parquet_read_budget.clone());
-                        let run_schema_manager = schema_manager.clone();
+                        let run_schema_manager = config.schema_manager.clone();
                         let run_file_io = file_io.clone();
                         let deletion_files_by_split = 
deletion_files_by_split.clone();
                         let run_stream: ArrowRecordBatchStream = 
Box::pin(try_stream! {
@@ -646,15 +651,7 @@ impl KeyValueFileReader {
                         user_sequence_indices.clone(),
                         value_indices.clone(),
                         merge_output_schema.clone(),
-                        Self::new_merge_function(
-                            merge_engine,
-                            &table_options,
-                            &table_name,
-                            &table_fields,
-                            &merge_output_fields,
-                            &primary_keys,
-                            &sequence_fields,
-                        )?,
+                        merge_function(&config, &merge_output_fields)?,
                     )
                     .build()?;
 
@@ -668,13 +665,13 @@ impl KeyValueFileReader {
                         // the merge-output batch (keys + values, including 
widened
                         // predicate columns); the reorder below projects the 
output
                         // back to read_type.
-                        let batch = if residual_predicates.is_empty() {
+                        let batch = if config.predicates.is_empty() {
                             batch
                         } else {
                             match 
crate::arrow::residual::evaluate_predicates_mask(
                                 &batch,
-                                &residual_predicates,
-                                &table_fields,
+                                &config.predicates,
+                                &config.table_fields,
                                 &merge_output_fields,
                             )? {
                                 Some(mask) => 
arrow_select::filter::filter_record_batch(
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 88d257e6..7d682ced 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -172,8 +172,8 @@ pub use source::{
     merge_row_ranges, DataSplit, DataSplitBuilder, DeletionFile, 
PartitionBucket, Plan, RowRange,
 };
 pub use table_commit::TableCommit;
-pub use table_read::TableRead;
-pub use table_scan::TableScan;
+pub use table_read::{AuditLogRead, TableRead};
+pub use table_scan::{AuditLogScan, TableScan};
 pub use table_update::TableUpdate;
 pub use table_write::TableWrite;
 pub use tag_manager::TagManager;
diff --git a/crates/paimon/src/table/sort_merge.rs 
b/crates/paimon/src/table/sort_merge.rs
index a2619700..d1f2bdee 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -141,7 +141,7 @@ pub(crate) trait MergeFunction: Send + Sync {
 /// Filters out DELETE and UPDATE_BEFORE rows.
 pub(crate) struct DeduplicateMergeFunction;
 
-fn compare_sequence_order(lhs: &MergeRow, rhs: &MergeRow) -> Ordering {
+pub(super) fn compare_sequence_order(lhs: &MergeRow, rhs: &MergeRow) -> 
Ordering {
     match (lhs.user_sequences.is_empty(), rhs.user_sequences.is_empty()) {
         (false, false) => lhs
             .user_sequences
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index acaebeeb..b1926cb5 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -41,6 +41,10 @@ use futures::{stream, StreamExt};
 use std::cmp::Ordering;
 use std::sync::Arc;
 
+#[path = "audit_log_table/read.rs"]
+mod audit;
+pub use audit::AuditLogRead;
+
 const MAX_MERGE_INPUT_STREAMS: usize = 256;
 
 /// Table read: reads data from splits (e.g. produced by [TableScan::plan]).
@@ -1497,7 +1501,7 @@ mod tests {
     use crate::table::source::DataSplitBuilder;
     use futures::TryStreamExt;
 
-    fn file(name: &str, level: i32, delete_row_count: Option<i64>) -> 
DataFileMeta {
+    pub(super) fn file(name: &str, level: i32, delete_row_count: Option<i64>) 
-> DataFileMeta {
         DataFileMeta {
             file_name: name.to_string(),
             file_size: 128,
@@ -1523,7 +1527,7 @@ mod tests {
         }
     }
 
-    fn split(files: Vec<DataFileMeta>, raw_convertible: bool) -> DataSplit {
+    pub(super) fn split(files: Vec<DataFileMeta>, raw_convertible: bool) -> 
DataSplit {
         DataSplitBuilder::new()
             .with_snapshot(1)
             .with_partition(BinaryRow::new(0))
@@ -1577,11 +1581,14 @@ mod tests {
             .to_vec()
     }
 
-    fn file_index_table(path: &str, enabled: Option<bool>) -> Table {
+    fn file_index_table(path: &str, enabled: Option<bool>, primary_key: bool) 
-> Table {
         let mut builder = Schema::builder().column("id", 
DataType::Int(IntType::new()));
         if let Some(enabled) = enabled {
             builder = builder.option("file-index.read.enabled", 
enabled.to_string());
         }
+        if primary_key {
+            builder = builder.primary_key(["id"]).option("bucket", "1");
+        }
         Table::new(
             FileIOBuilder::new("memory").build().unwrap(),
             Identifier::new("default", "file_index_t"),
@@ -1597,7 +1604,7 @@ mod tests {
         indexed_file.row_count = 1;
         indexed_file.embedded_index = Some(embedded_bitmap_index().await);
         let split = split(vec![indexed_file], true);
-        let table = file_index_table("memory:/table_read_file_index", None);
+        let table = file_index_table("memory:/table_read_file_index", None, 
false);
         let fields = table.schema().fields().to_vec();
         let predicate = PredicateBuilder::new(&fields)
             .equal("id", Datum::Int(99))
@@ -1631,8 +1638,24 @@ mod tests {
             .unwrap();
         assert!(audit.is_empty());
 
+        let pk_table = file_index_table("memory:/table_read_audit_file_index", 
None, true);
+        let pk_fields = pk_table.schema().fields().to_vec();
+        let pk_predicate = PredicateBuilder::new(&pk_fields)
+            .equal("id", Datum::Int(99))
+            .unwrap();
+        let pk_read = TableRead::new(&pk_table, pk_fields, vec![pk_predicate]);
+        let splits = vec![split.clone()];
+        let current_audit = AuditLogRead::new(pk_read)
+            .unwrap()
+            .to_arrow(&splits)
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert!(current_audit.is_empty());
+
         let disabled_table =
-            file_index_table("memory:/table_read_file_index_disabled", 
Some(false));
+            file_index_table("memory:/table_read_file_index_disabled", 
Some(false), false);
         let disabled_fields = disabled_table.schema().fields().to_vec();
         let disabled_predicate = PredicateBuilder::new(&disabled_fields)
             .equal("id", Datum::Int(99))
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 221c5920..502202d2 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -57,6 +57,10 @@ use indexmap::IndexMap;
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
+#[path = "audit_log_table/scan.rs"]
+mod audit;
+pub use audit::AuditLogScan;
+
 /// Path segment for manifest directory under table.
 const MANIFEST_DIR: &str = "manifest";
 /// Path segment for index directory under table.
@@ -2585,7 +2589,7 @@ mod tests {
         );
     }
 
-    fn data_evolution_test_table(table_path: &str, schema: TableSchema) -> 
Table {
+    pub(super) fn data_evolution_test_table(table_path: &str, schema: 
TableSchema) -> Table {
         let file_io = FileIOBuilder::new("memory").build().unwrap();
         let schema = schema.copy_with_options(HashMap::from([(
             "data-evolution.enabled".to_string(),
@@ -2600,7 +2604,7 @@ mod tests {
         )
     }
 
-    fn two_column_schema(id: i64, left: &str, right: &str) -> TableSchema {
+    pub(super) fn two_column_schema(id: i64, left: &str, right: &str) -> 
TableSchema {
         TableSchema::new(
             id,
             &PaimonSchema::builder()
@@ -2801,7 +2805,7 @@ mod tests {
         ]))
     }
 
-    async fn setup_scan_trace_dirs(table: &Table) {
+    pub(super) async fn setup_scan_trace_dirs(table: &Table) {
         table
             .file_io()
             .mkdirs(&format!("{}/snapshot/", table.location()))
@@ -3879,7 +3883,7 @@ mod tests {
         );
     }
 
-    fn pk_stats_gate_table(table_path: &str) -> Table {
+    pub(super) fn pk_stats_gate_table(table_path: &str) -> Table {
         let file_io = FileIOBuilder::new("memory").build().unwrap();
         let schema = PaimonSchema::builder()
             .column("id", DataType::Int(IntType::new()))
@@ -3948,7 +3952,11 @@ mod tests {
         builder.build_serialized()
     }
 
-    fn pk_stats_file(name: &str, id_range: (i32, i32), value_range: (i32, 
i32)) -> DataFileMeta {
+    pub(super) fn pk_stats_file(
+        name: &str,
+        id_range: (i32, i32),
+        value_range: (i32, i32),
+    ) -> DataFileMeta {
         let mut file = test_data_file_meta(
             two_int_stats_row(Some(id_range.0), Some(value_range.0)),
             two_int_stats_row(Some(id_range.1), Some(value_range.1)),
diff --git a/crates/paimon/tests/audit_log_table_test.rs 
b/crates/paimon/tests/audit_log_table_test.rs
index 662ccaa4..94a31d43 100644
--- a/crates/paimon/tests/audit_log_table_test.rs
+++ b/crates/paimon/tests/audit_log_table_test.rs
@@ -20,8 +20,8 @@ mod common;
 use arrow_array::{Array, Int32Array, Int64Array, RecordBatch, StringArray};
 use futures::TryStreamExt;
 use paimon::spec::{
-    DataType, IntType, Schema, TableSchema, VarCharType, ROW_KIND_FIELD_ID, 
ROW_KIND_FIELD_NAME,
-    SEQUENCE_NUMBER_FIELD_NAME,
+    BigIntType, DataField, DataType, IntType, Schema, TableSchema, 
VarCharType, ROW_KIND_FIELD_ID,
+    ROW_KIND_FIELD_NAME, SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME,
 };
 use paimon::table::{AuditLogTable, IncrementalPlan, IncrementalScanMode, 
IncrementalSplit};
 
@@ -320,6 +320,101 @@ async fn audit_log_exposes_sequence_number_when_enabled() 
{
     assert!(rows.iter().all(|(_, seq, _, _)| *seq >= 0));
 }
 
+#[tokio::test]
+async fn ordinary_read_projection_keeps_sequence_number() {
+    let table_path = "memory:/audit_log/ordinary_sequence_projection";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+            ("table-read.sequence-number.enabled", "true"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+
+    let mut builder = table.new_read_builder();
+    builder.with_read_type(vec![
+        DataField::new(
+            SEQUENCE_NUMBER_FIELD_ID,
+            SEQUENCE_NUMBER_FIELD_NAME.to_string(),
+            DataType::BigInt(BigIntType::new()),
+        ),
+        table.schema().fields()[0].clone(),
+    ]);
+    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();
+
+    assert_eq!(
+        batches[0]
+            .schema()
+            .fields()
+            .iter()
+            .map(|field| field.name().as_str())
+            .collect::<Vec<_>>(),
+        vec![SEQUENCE_NUMBER_FIELD_NAME, "id"]
+    );
+    assert_eq!(
+        batches[0]
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap()
+            .value(0),
+        1
+    );
+}
+
+#[tokio::test]
+async fn audit_log_current_scan_keeps_delete_and_sequence_number() {
+    let table_path = "memory:/audit_log/current_state";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("changelog-producer", "input"),
+            ("merge-engine", "deduplicate"),
+            ("bucket", "1"),
+            ("table-read.sequence-number.enabled", "true"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1, 2], vec![10, 20])).await;
+    let builder = table.new_write_builder();
+    let mut write = builder.new_write().unwrap();
+    write
+        .write_arrow_batch(&make_batch_with_kinds(vec![1, 2], vec![10, 25], 
vec![3, 2]))
+        .await
+        .unwrap();
+    let messages = write.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages).await.unwrap();
+
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+    let batches: Vec<RecordBatch> = AuditLogTable::new(table)
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+
+    assert_eq!(
+        collect_audit_rows_with_sequence(&batches),
+        vec![("+U".to_string(), 3, 2, 25), ("-D".to_string(), 2, 1, 10),]
+    );
+}
+
 async fn audit_diff_rows(
     table: &paimon::table::Table,
     start: i64,
@@ -356,6 +451,148 @@ fn assert_rows_exclude(rows: &[(String, i32, i32)], 
excluded: &[(&str, i32, i32)
     }
 }
 
+#[tokio::test]
+async fn audit_log_current_scan_uses_merged_rowkind() {
+    for merge_engine in ["partial-update", "aggregation"] {
+        let table_path = format!("memory:/audit_log/current_{merge_engine}");
+        let (file_io, table) = memory_table(
+            &table_path,
+            pk_schema(&[
+                ("merge-engine", merge_engine),
+                ("bucket", "1"),
+                ("table-read.sequence-number.enabled", "true"),
+            ]),
+        );
+        setup_dirs(&file_io, &table_path).await;
+        persist_table_schema(&file_io, &table_path, table.schema()).await;
+
+        write_batch(&table, &make_batch(vec![1], vec![10])).await;
+        let builder = table.new_write_builder();
+        let mut write = builder.new_write().unwrap();
+        write
+            .write_arrow_batch(&make_batch_with_kinds(vec![1], vec![20], 
vec![2]))
+            .await
+            .unwrap();
+        let messages = write.prepare_commit().await.unwrap();
+        builder.new_commit().commit(messages).await.unwrap();
+
+        let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+        let batches: Vec<RecordBatch> = AuditLogTable::new(table)
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            collect_audit_rows_with_sequence(&batches),
+            vec![("+I".to_string(), 1, 1, 20)],
+            "merge-engine={merge_engine}"
+        );
+    }
+}
+
+#[tokio::test]
+async fn audit_log_current_scan_respects_ignore_delete() {
+    let table_path = "memory:/audit_log/current_ignore_delete";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("merge-engine", "deduplicate"),
+            ("ignore-delete", "true"),
+            ("bucket", "1"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    let builder = table.new_write_builder();
+    let mut write = builder.new_write().unwrap();
+    write
+        .write_arrow_batch(&make_batch_with_kinds(vec![1], vec![10], vec![3]))
+        .await
+        .unwrap();
+    let messages = write.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages).await.unwrap();
+
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+    let batches: Vec<RecordBatch> = AuditLogTable::new(table)
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+
+    assert_eq!(
+        collect_audit_rows(&batches),
+        vec![("+I".to_string(), 1, 10)]
+    );
+}
+
+#[tokio::test]
+async fn audit_log_current_scan_supports_first_row() {
+    let table_path = "memory:/audit_log/current_first_row";
+    let (file_io, table) = memory_table(
+        table_path,
+        pk_schema(&[
+            ("merge-engine", "first-row"),
+            ("bucket", "1"),
+            ("source.split.target-size", "1b"),
+            ("source.split.open-file-cost", "1b"),
+        ]),
+    );
+    setup_dirs(&file_io, table_path).await;
+    persist_table_schema(&file_io, table_path, table.schema()).await;
+
+    write_batch(&table, &make_batch(vec![1], vec![10])).await;
+    write_batch(&table, &make_batch(vec![3], vec![30])).await;
+    write_batch(&table, &make_batch(vec![1], vec![20])).await;
+
+    let mut limited_reader = table.new_read_builder();
+    limited_reader.with_limit(1);
+    let limited_plan = limited_reader.new_audit_scan().plan().await.unwrap();
+    assert_eq!(
+        limited_plan.splits().len(),
+        2,
+        "audit LIMIT must not discard files needed to resolve row versions"
+    );
+    assert_eq!(
+        limited_plan
+            .splits()
+            .iter()
+            .map(|split| split.data_files().len())
+            .sum::<usize>(),
+        3,
+        "audit LIMIT must retain every physical row version"
+    );
+
+    let audit = AuditLogTable::new(table);
+    let plan = audit.new_scan().plan().await.unwrap();
+    assert_eq!(plan.splits().len(), 2);
+    let mut rows = Vec::new();
+    for split in plan.splits() {
+        let batches: Vec<RecordBatch> = audit
+            .new_read()
+            .unwrap()
+            .to_arrow(std::slice::from_ref(split))
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        rows.extend(collect_audit_rows(&batches));
+    }
+    rows.sort_unstable();
+
+    assert_eq!(
+        rows,
+        vec![("+I".to_string(), 1, 10), ("+I".to_string(), 3, 30)]
+    );
+}
+
 #[tokio::test]
 async fn audit_log_diff_scan_emits_row_level_delete_insert_and_updates() {
     let table_path = "memory:/audit_log/diff_range";
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 8dc81360..276e6c23 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1986,7 +1986,22 @@ let df = ctx.sql("SELECT * FROM paimon.my_db.table_a 
JOIN paimon.my_db.table_b O
 
 ## System Tables
 
-Access table metadata via the `$` syntax.
+Access table metadata and audit rows via the `$` syntax.
+
+### $audit_log
+
+Read the current table state with each row's Paimon row kind (`+I`, `-U`, 
`+U`, or `-D`):
+
+```sql
+SELECT * FROM paimon.default.my_table$audit_log;
+```
+
+`rowkind` is the first column, followed by the table columns. Append-only rows 
are
+reported as `+I`; deduplicate primary-key reads retain the latest physical 
retract row
+instead of dropping it. Other primary-key merge engines retain or reject 
retracts
+according to their merge-engine options. As in Paimon Java, rows masked by 
deletion
+vectors are not reconstructed as retract records. When
+`table-read.sequence-number.enabled=true`, `_SEQUENCE_NUMBER` appears after 
`rowkind`.
 
 ### $options
 

Reply via email to