feat(scan): [3/N] read and apply V3 deletion vectors (#3035)

* feat(scan): carry deletion-vector coordinates on FileScanTaskDeleteFile

Add referenced_data_file, content_offset, and content_size_in_bytes to
FileScanTaskDeleteFile, populated from the delete file's manifest entry.
These locate a deletion-vector blob and scope it to its data file, which
the delete loader needs to read and apply V3 deletion vectors.

Refs #2792.

* fix after merging in main

* fix public-api.txt

* fix test name

* update

* apply V3 deletion vectors during scan

* identify deletion vectors by content type and file format

* Fix after merging upstream/main

* Update comment wording.
diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index 95df9e9..57edfd6 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -1313,6 +1313,7 @@
 pub iceberg::scan::FileScanTaskDeleteFile::content_offset: core::option::Option<i64>
 pub iceberg::scan::FileScanTaskDeleteFile::content_size_in_bytes: core::option::Option<i64>
 pub iceberg::scan::FileScanTaskDeleteFile::equality_ids: core::option::Option<alloc::vec::Vec<i32>>
+pub iceberg::scan::FileScanTaskDeleteFile::file_format: iceberg::spec::DataFileFormat
 pub iceberg::scan::FileScanTaskDeleteFile::file_path: alloc::string::String
 pub iceberg::scan::FileScanTaskDeleteFile::file_size_in_bytes: u64
 pub iceberg::scan::FileScanTaskDeleteFile::file_type: iceberg::spec::DataContentType
@@ -1328,7 +1329,7 @@
 pub fn iceberg::scan::FileScanTaskDeleteFile::fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result
 impl core::marker::StructuralPartialEq for iceberg::scan::FileScanTaskDeleteFile
 impl iceberg::scan::FileScanTaskDeleteFile
-pub fn iceberg::scan::FileScanTaskDeleteFile::builder() -> FileScanTaskDeleteFileBuilder<((), (), (), (), (), (), (), (), (), ())>
+pub fn iceberg::scan::FileScanTaskDeleteFile::builder() -> FileScanTaskDeleteFileBuilder<((), (), (), (), (), (), (), (), (), (), ())>
 impl serde_core::ser::Serialize for iceberg::scan::FileScanTaskDeleteFile
 pub fn iceberg::scan::FileScanTaskDeleteFile::serialize<__S>(&self, __serializer: __S) -> core::result::Result<<__S as serde_core::ser::Serializer>::Ok, <__S as serde_core::ser::Serializer>::Error> where __S: serde_core::ser::Serializer
 impl<'de> serde_core::de::Deserialize<'de> for iceberg::scan::FileScanTaskDeleteFile
diff --git a/crates/iceberg/src/arrow/caching_delete_file_loader.rs b/crates/iceberg/src/arrow/caching_delete_file_loader.rs
index 896248f..905fa86 100644
--- a/crates/iceberg/src/arrow/caching_delete_file_loader.rs
+++ b/crates/iceberg/src/arrow/caching_delete_file_loader.rs
@@ -19,6 +19,7 @@
 use std::sync::Arc;
 
 use arrow_array::{Array, ArrayRef, Int64Array, StringArray, StructArray};
+use bytes::Bytes;
 use futures::{StreamExt, TryStreamExt};
 use tokio::sync::oneshot::{Receiver, channel};
 
@@ -27,15 +28,16 @@
 use crate::arrow::scan_metrics::ScanMetrics;
 use crate::arrow::{arrow_primitive_to_literal, arrow_schema_to_schema};
 use crate::delete_vector::DeleteVector;
+use crate::encryption::{EncryptedInputFile, StandardKeyMetadata};
 use crate::expr::Predicate::AlwaysTrue;
 use crate::expr::{Predicate, Reference};
 use crate::io::FileIO;
 use crate::runtime::Runtime;
 use crate::scan::{ArrowRecordBatchStream, FileScanTaskDeleteFile};
 use crate::spec::{
-    DataContentType, Datum, ListType, MapType, NestedField, NestedFieldRef, PartnerAccessor,
-    PrimitiveType, Schema, SchemaRef, SchemaWithPartnerVisitor, StructType, Type, VariantType,
-    visit_schema_with_partner,
+    DataContentType, DataFileFormat, Datum, ListType, MapType, NestedField, NestedFieldRef,
+    PartnerAccessor, PrimitiveType, Schema, SchemaRef, SchemaWithPartnerVisitor, StructType, Type,
+    VariantType, visit_schema_with_partner,
 };
 use crate::{Error, ErrorKind, Result};
 
@@ -51,7 +53,6 @@
 
 // Intermediate context during processing of a delete file task.
 enum DeleteFileContext {
-    // TODO: Delete Vector loader from Puffin files
     ExistingEqDel,
     ExistingPosDel,
     PosDels {
@@ -63,6 +64,15 @@
         equality_ids: HashSet<i32>,
         sender: tokio::sync::oneshot::Sender<Predicate>,
     },
+    // A V3 deletion vector: the raw deletion-vector-v1 blob bytes, the data file whose rows it
+    // deletes, and the manifest's expected cardinality. The blob is decoded and validated in the
+    // parse phase.
+    DelVec {
+        data_file_path: String,
+        blob: Bytes,
+        record_count: u64,
+        dv_path: String,
+    },
 }
 
 // Final result of the processing of a delete file task before
@@ -72,6 +82,11 @@
         file_path: String,
         results: HashMap<String, DeleteVector>,
     },
+    // A single deletion vector decoded from a Puffin blob, keyed by the data file it applies to.
+    DelVec {
+        data_file_path: String,
+        delete_vector: DeleteVector,
+    },
     EqDel,
     ExistingPosDel,
 }
@@ -117,12 +132,13 @@
     ///    tasks from starting to load the same equality delete file. We spawn a task to load
     ///    the EQ delete's record batch stream, convert it to a predicate, update the delete filter,
     ///    and notify any task that was waiting for it.
-    ///  * When this gets updated to add support for delete vectors, the load phase will return
-    ///    a PuffinReader for them.
+    ///  * For a V3 deletion vector, the load phase reads the blob's byte range directly from its
+    ///    Puffin file (decrypting first if the entry carries key metadata), and the parse phase
+    ///    decodes it into a single `DeleteVector`.
     ///  * The parse phase parses each record batch stream according to its associated data type.
     ///    The result of this is a map of data file paths to delete vectors for the positional
-    ///    delete tasks (and in future for the delete vector tasks). For equality delete
-    ///    file tasks, this results in an unbound Predicate.
+    ///    delete tasks, or a single (data file path, delete vector) pair for a deletion vector
+    ///    task. For equality delete file tasks, this results in an unbound Predicate.
     ///  * The unbound Predicates resulting from equality deletes are sent to their associated oneshot
     ///    channel to store them in the right place in the delete file managers state.
     ///  * The results of all of these futures are awaited on in parallel with the specified
@@ -143,10 +159,10 @@
     ///                                                     |
     ///                                                     |
     ///                       +-----------------------------+--------------------------+
-    ///                     Pos Del           Del Vec (Not yet Implemented)         EQ Del
+    ///                     Pos Del                       Del Vec                    EQ Del
     ///                       |                             |                          |
     ///              [parse pos del stream]         [parse del vec puffin]       [parse eq del]
-    ///          HashMap<String, RoaringTreeMap> HashMap<String, RoaringTreeMap>   (Predicate, Sender)
+    ///          HashMap<String, RoaringTreeMap>        DeleteVector             (Predicate, Sender)
     ///                       |                             |                          |
     ///                       |                             |                 [persist to state]
     ///                       |                             |                          ()
@@ -211,13 +227,22 @@
                     .try_buffer_unordered(concurrency_limit_data_files);
 
                 while let Some(item) = results_stream.next().await {
-                    let item = item?;
-                    if let ParsedDeleteFileContext::DelVecs { file_path, results } = item {
-                        for (data_file_path, delete_vector) in results.into_iter() {
+                    match item? {
+                        ParsedDeleteFileContext::DelVecs { file_path, results } => {
+                            for (data_file_path, delete_vector) in results.into_iter() {
+                                del_filter.upsert_delete_vector(data_file_path, delete_vector);
+                            }
+                            // Mark the positional delete file as fully loaded so waiters can proceed
+                            del_filter.finish_pos_del_load(&file_path);
+                        }
+                        ParsedDeleteFileContext::DelVec {
+                            data_file_path,
+                            delete_vector,
+                        } => {
                             del_filter.upsert_delete_vector(data_file_path, delete_vector);
                         }
-                        // Mark the positional delete file as fully loaded so waiters can proceed
-                        del_filter.finish_pos_del_load(&file_path);
+                        ParsedDeleteFileContext::EqDel
+                        | ParsedDeleteFileContext::ExistingPosDel => {}
                     }
                 }
 
@@ -239,6 +264,12 @@
     ) -> Result<DeleteFileContext> {
         match task.file_type {
             DataContentType::PositionDeletes => {
+                // A V3 deletion vector arrives as a PositionDeletes entry whose deletes live in
+                // a Puffin blob, not in a positional-delete parquet file.
+                if task.file_format == DataFileFormat::Puffin {
+                    return Self::load_deletion_vector(task, basic_delete_file_loader).await;
+                }
+
                 match del_filter.try_start_pos_del_load(&task.file_path) {
                     PosDelLoadAction::AlreadyLoaded => Ok(DeleteFileContext::ExistingPosDel),
                     PosDelLoadAction::WaitFor(notified) => {
@@ -299,6 +330,137 @@
         }
     }
 
+    /// Validates a deletion-vector task and returns what the read needs as typed values:
+    /// `(start, len, referenced data file path, expected cardinality)`.
+    ///
+    /// The spec requires `referenced_data_file`, `content_offset` and `content_size_in_bytes` on
+    /// a deletion vector, and a deletion vector is always built from a manifest entry, so it
+    /// always carries `record_count`. A missing one is a manifest-entry inconsistency rather
+    /// than an I/O failure.
+    ///
+    /// Equality and ordinary position deletes have no equivalent validation in this loader: a
+    /// malformed equality/position delete file fails loudly when the Parquet reader can't open
+    /// it. A deletion vector's coordinates instead drive a raw byte-range read with no format
+    /// to fail against, so a bad coordinate would otherwise decode silently into the wrong (or
+    /// no) deletes, per the same corrupted-blob concern Iceberg-Java validates in
+    /// `BitmapPositionDeleteIndex.deserializeBitmap`.
+    fn validate_deletion_vector_task(
+        task: &FileScanTaskDeleteFile,
+    ) -> Result<(u64, u64, String, u64)> {
+        let content_offset = task.content_offset.ok_or_else(|| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "deletion vector {} is missing content_offset",
+                    task.file_path
+                ),
+            )
+        })?;
+        let content_size = task.content_size_in_bytes.ok_or_else(|| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "deletion vector {} is missing content_size_in_bytes",
+                    task.file_path
+                ),
+            )
+        })?;
+        let data_file_path = task.referenced_data_file.clone().ok_or_else(|| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "deletion vector {} is missing referenced_data_file",
+                    task.file_path
+                ),
+            )
+        })?;
+        let record_count = task.record_count.ok_or_else(|| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!("deletion vector {} is missing record_count", task.file_path),
+            )
+        })?;
+
+        let start = u64::try_from(content_offset).map_err(|_| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "deletion vector {} has negative content_offset {content_offset}",
+                    task.file_path
+                ),
+            )
+        })?;
+        let len = u64::try_from(content_size).map_err(|_| {
+            Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "deletion vector {} has negative content_size_in_bytes {content_size}",
+                    task.file_path
+                ),
+            )
+        })?;
+
+        Ok((start, len, data_file_path, record_count))
+    }
+
+    /// Validates a decoded deletion vector's cardinality against the manifest entry's
+    /// `record_count`, mirroring Iceberg-Java's `BitmapPositionDeleteIndex.deserializeBitmap`.
+    fn validate_deletion_vector_cardinality(
+        delete_vector: &DeleteVector,
+        expected: u64,
+        dv_path: &str,
+    ) -> Result<()> {
+        let actual = delete_vector.len();
+        if actual != expected {
+            return Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    "deletion vector {dv_path} decoded to {actual} positions, expected {expected} from record_count"
+                ),
+            ));
+        }
+        Ok(())
+    }
+
+    /// Reads a V3 deletion vector blob directly from its Puffin file.
+    ///
+    /// The spec requires a delete manifest entry's `content_offset` / `content_size_in_bytes` to
+    /// match the blob's offset and length in the Puffin footer, so the blob is read by range
+    /// without parsing the footer. It is decoded into a [`DeleteVector`] in the parse phase.
+    ///
+    /// Decrypts the range read when `task.key_metadata` is set, the same way `ManifestReader`
+    /// decrypts a manifest file (`spec/manifest/reader.rs`): the coordinate space of
+    /// `content_offset` / `content_size_in_bytes` is the plaintext file, which is what
+    /// `EncryptedInputFile` reads over.
+    async fn load_deletion_vector(
+        task: &FileScanTaskDeleteFile,
+        basic_delete_file_loader: BasicDeleteFileLoader,
+    ) -> Result<DeleteFileContext> {
+        let (start, len, data_file_path, record_count) = Self::validate_deletion_vector_task(task)?;
+
+        let input_file = basic_delete_file_loader
+            .file_io()
+            .new_input(&task.file_path)?;
+        let blob = match task.key_metadata.as_deref() {
+            Some(key_metadata) => {
+                let key_metadata = StandardKeyMetadata::decode(key_metadata)?;
+                EncryptedInputFile::new(input_file, key_metadata)
+                    .reader()
+                    .await?
+                    .read(start..start + len)
+                    .await?
+            }
+            None => input_file.reader().await?.read(start..start + len).await?,
+        };
+
+        Ok(DeleteFileContext::DelVec {
+            data_file_path,
+            blob,
+            record_count,
+            dv_path: task.file_path.clone(),
+        })
+    }
+
     async fn parse_file_content_for_task(
         ctx: DeleteFileContext,
     ) -> Result<ParsedDeleteFileContext> {
@@ -312,6 +474,20 @@
                     results: del_vecs,
                 })
             }
+            DeleteFileContext::DelVec {
+                data_file_path,
+                blob,
+                record_count,
+                dv_path,
+            } => {
+                let delete_vector = DeleteVector::deserialize(&blob)?;
+                Self::validate_deletion_vector_cardinality(&delete_vector, record_count, &dv_path)?;
+
+                Ok(ParsedDeleteFileContext::DelVec {
+                    data_file_path,
+                    delete_vector,
+                })
+            }
             DeleteFileContext::FreshEqDel {
                 sender,
                 batch_stream,
@@ -700,6 +876,7 @@
     use crate::arrow::delete_filter::tests::setup;
     use crate::scan::FileScanTaskDeleteFile;
     use crate::spec::{DataContentType, Schema};
+    use crate::test_utils::encode_dv_blob;
 
     #[tokio::test]
     async fn test_delete_file_loader_parse_equality_deletes() {
@@ -1247,6 +1424,7 @@
             .with_file_path(pos_del_path.clone())
             .with_file_size_in_bytes(std::fs::metadata(&pos_del_path).unwrap().len())
             .with_file_type(DataContentType::PositionDeletes)
+            .with_file_format(DataFileFormat::Parquet)
             .with_partition_spec_id(0)
             .build();
 
@@ -1254,6 +1432,7 @@
             .with_file_path(eq_delete_path.clone())
             .with_file_size_in_bytes(std::fs::metadata(&eq_delete_path).unwrap().len())
             .with_file_type(DataContentType::EqualityDeletes)
+            .with_file_format(DataFileFormat::Parquet)
             .with_partition_spec_id(0)
             .with_equality_ids(Some(vec![2, 3])) // Only use field IDs that exist in both schemas
             .build();
@@ -1384,4 +1563,276 @@
         // confirming that the second load reused the result from the first.
         assert!(Arc::ptr_eq(&dv1, &dv2));
     }
+
+    fn dv_task(
+        dv_path: String,
+        file_size: u64,
+        data_file_path: String,
+        content_offset: i64,
+        content_size: i64,
+        record_count: u64,
+        key_metadata: Option<Box<[u8]>>,
+    ) -> FileScanTaskDeleteFile {
+        FileScanTaskDeleteFile::builder()
+            .with_file_path(dv_path)
+            .with_file_size_in_bytes(file_size)
+            .with_file_type(DataContentType::PositionDeletes)
+            .with_file_format(DataFileFormat::Puffin)
+            .with_partition_spec_id(0)
+            .with_referenced_data_file(Some(data_file_path))
+            .with_content_offset(Some(content_offset))
+            .with_content_size_in_bytes(Some(content_size))
+            .with_record_count(Some(record_count))
+            .with_key_metadata(key_metadata)
+            .build()
+    }
+
+    #[tokio::test]
+    async fn test_load_deletes_applies_deletion_vector() {
+        let tmp_dir = TempDir::new().unwrap();
+        let table_location = tmp_dir.path().to_str().unwrap().to_string();
+        let file_io = FileIO::new_with_fs();
+
+        let blob = encode_dv_blob([0u64, 1, 5]);
+
+        // Embed the blob in a Puffin-like file behind leading bytes so content_offset is
+        // non-zero, then let the loader read it back by range.
+        let content_offset = 12i64;
+        let content_size = blob.len() as i64;
+        let mut file_bytes = vec![0u8; content_offset as usize];
+        file_bytes.extend_from_slice(&blob);
+        file_bytes.extend_from_slice(&[0u8; 8]);
+        let dv_path = format!("{table_location}/deletes.puffin");
+        std::fs::write(&dv_path, &file_bytes).unwrap();
+
+        let data_file_path = format!("{table_location}/data-1.parquet");
+        let dv = dv_task(
+            dv_path.clone(),
+            std::fs::metadata(&dv_path).unwrap().len(),
+            data_file_path.clone(),
+            content_offset,
+            content_size,
+            3,
+            None,
+        );
+
+        let schema = Arc::new(
+            Schema::builder()
+                .with_fields(vec![
+                    NestedField::optional(1, "x", Type::Primitive(PrimitiveType::Long)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+
+        let loader = CachingDeleteFileLoader::new(file_io, 10, Runtime::current());
+        let delete_filter = loader.load_deletes(&[dv], schema).await.unwrap().unwrap();
+
+        let delete_vector = delete_filter
+            .get_delete_vector_for_path(&data_file_path)
+            .expect("a delete vector should be indexed for the referenced data file");
+        let mut positions: Vec<u64> = delete_vector.lock().unwrap().iter().collect();
+        positions.sort_unstable();
+        assert_eq!(positions, vec![0, 1, 5]);
+    }
+
+    #[tokio::test]
+    async fn test_load_deletes_decrypts_deletion_vector() {
+        use crate::encryption::{EncryptedOutputFile, StandardKeyMetadata};
+
+        let tmp_dir = TempDir::new().unwrap();
+        let table_location = tmp_dir.path().to_str().unwrap().to_string();
+        let file_io = FileIO::new_with_fs();
+
+        let key_metadata = StandardKeyMetadata::try_new(b"0123456789abcdef")
+            .unwrap()
+            .with_aad_prefix(b"test-aad-prefix!");
+        let encoded_key_metadata = key_metadata.encode().unwrap();
+
+        let blob = encode_dv_blob([2u64, 4]);
+        let plaintext_size = blob.len() as i64;
+        let dv_path = format!("{table_location}/deletes.puffin");
+        let output = EncryptedOutputFile::new(file_io.new_output(&dv_path).unwrap(), key_metadata);
+        output.write(Bytes::from(blob)).await.unwrap();
+
+        // content_offset / content_size_in_bytes are in the plaintext coordinate space, distinct
+        // from the ciphertext's on-disk size (header, nonce, and tag overhead).
+        let file_size = std::fs::metadata(&dv_path).unwrap().len();
+        let data_file_path = format!("{table_location}/data-1.parquet");
+        let dv = dv_task(
+            dv_path.clone(),
+            file_size,
+            data_file_path.clone(),
+            0,
+            plaintext_size,
+            2,
+            Some(encoded_key_metadata),
+        );
+
+        let schema = Arc::new(
+            Schema::builder()
+                .with_fields(vec![
+                    NestedField::optional(1, "x", Type::Primitive(PrimitiveType::Long)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+
+        let loader = CachingDeleteFileLoader::new(file_io, 10, Runtime::current());
+        let delete_filter = loader.load_deletes(&[dv], schema).await.unwrap().unwrap();
+
+        let delete_vector = delete_filter
+            .get_delete_vector_for_path(&data_file_path)
+            .expect("a delete vector should be indexed for the referenced data file");
+        let mut positions: Vec<u64> = delete_vector.lock().unwrap().iter().collect();
+        positions.sort_unstable();
+        assert_eq!(positions, vec![2, 4]);
+    }
+
+    #[tokio::test]
+    async fn test_load_deletes_rejects_deletion_vector_cardinality_mismatch() {
+        let tmp_dir = TempDir::new().unwrap();
+        let table_location = tmp_dir.path().to_str().unwrap().to_string();
+        let file_io = FileIO::new_with_fs();
+
+        let blob = encode_dv_blob([0u64, 1, 5]);
+        let dv_path = format!("{table_location}/deletes.puffin");
+        std::fs::write(&dv_path, &blob).unwrap();
+
+        let data_file_path = format!("{table_location}/data-1.parquet");
+        // record_count says 2 positions, but the blob decodes to 3.
+        let dv = dv_task(
+            dv_path.clone(),
+            std::fs::metadata(&dv_path).unwrap().len(),
+            data_file_path,
+            0,
+            blob.len() as i64,
+            2,
+            None,
+        );
+
+        let schema = Arc::new(
+            Schema::builder()
+                .with_fields(vec![
+                    NestedField::optional(1, "x", Type::Primitive(PrimitiveType::Long)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+
+        let loader = CachingDeleteFileLoader::new(file_io, 10, Runtime::current());
+        let err = loader
+            .load_deletes(&[dv], schema)
+            .await
+            .unwrap()
+            .unwrap_err();
+
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("expected 2 from record_count"));
+    }
+
+    // A well-formed deletion-vector task, for tests that then clear or corrupt one field.
+    fn valid_dv_task() -> FileScanTaskDeleteFile {
+        dv_task(
+            "deletes.puffin".to_string(),
+            100,
+            "data.parquet".to_string(),
+            4,
+            40,
+            2,
+            None,
+        )
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_rejects_missing_content_offset() {
+        let mut task = valid_dv_task();
+        task.content_offset = None;
+
+        let err = CachingDeleteFileLoader::validate_deletion_vector_task(&task).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("missing content_offset"));
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_rejects_missing_content_size() {
+        let mut task = valid_dv_task();
+        task.content_size_in_bytes = None;
+
+        let err = CachingDeleteFileLoader::validate_deletion_vector_task(&task).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("missing content_size_in_bytes"));
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_rejects_missing_referenced_data_file() {
+        let mut task = valid_dv_task();
+        task.referenced_data_file = None;
+
+        let err = CachingDeleteFileLoader::validate_deletion_vector_task(&task).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("missing referenced_data_file"));
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_rejects_missing_record_count() {
+        let mut task = valid_dv_task();
+        task.record_count = None;
+
+        let err = CachingDeleteFileLoader::validate_deletion_vector_task(&task).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("missing record_count"));
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_rejects_negative_content_offset() {
+        let mut task = valid_dv_task();
+        task.content_offset = Some(-1);
+
+        let err = CachingDeleteFileLoader::validate_deletion_vector_task(&task).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("negative content_offset"));
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_rejects_negative_content_size() {
+        let mut task = valid_dv_task();
+        task.content_size_in_bytes = Some(-1);
+
+        let err = CachingDeleteFileLoader::validate_deletion_vector_task(&task).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("negative content_size_in_bytes"));
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_task_accepts_valid_coordinates() {
+        let (start, len, data_file_path, record_count) =
+            CachingDeleteFileLoader::validate_deletion_vector_task(&valid_dv_task()).unwrap();
+        assert_eq!(start, 4);
+        assert_eq!(len, 40);
+        assert_eq!(data_file_path, "data.parquet");
+        assert_eq!(record_count, 2);
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_cardinality_accepts_matching_count() {
+        let mut dv = DeleteVector::default();
+        dv.insert(1);
+        dv.insert(2);
+
+        CachingDeleteFileLoader::validate_deletion_vector_cardinality(&dv, 2, "deletes.puffin")
+            .unwrap();
+    }
+
+    #[test]
+    fn test_validate_deletion_vector_cardinality_rejects_mismatched_count() {
+        let mut dv = DeleteVector::default();
+        dv.insert(1);
+
+        let err =
+            CachingDeleteFileLoader::validate_deletion_vector_cardinality(&dv, 2, "deletes.puffin")
+                .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("expected 2 from record_count"));
+    }
 }
diff --git a/crates/iceberg/src/arrow/delete_file_loader.rs b/crates/iceberg/src/arrow/delete_file_loader.rs
index 13fa974..5852cf1 100644
--- a/crates/iceberg/src/arrow/delete_file_loader.rs
+++ b/crates/iceberg/src/arrow/delete_file_loader.rs
@@ -182,7 +182,7 @@
         use crate::arrow::delete_filter::tests::create_pos_del_schema;
         use crate::encryption::StandardKeyMetadata;
         use crate::scan::FileScanTaskDeleteFile;
-        use crate::spec::DataContentType;
+        use crate::spec::{DataContentType, DataFileFormat};
 
         let encryption_key = b"0123456789abcdef";
         let aad_prefix = b"aad_prefix";
@@ -234,6 +234,7 @@
             file_path: del_path.clone(),
             file_size_in_bytes: std::fs::metadata(&del_path).unwrap().len(),
             file_type: DataContentType::PositionDeletes,
+            file_format: DataFileFormat::Parquet,
             partition_spec_id: 0,
             equality_ids: None,
             key_metadata: Some(Box::from(key_metadata.as_ref())),
@@ -266,7 +267,7 @@
 
         use crate::encryption::StandardKeyMetadata;
         use crate::scan::FileScanTaskDeleteFile;
-        use crate::spec::DataContentType;
+        use crate::spec::{DataContentType, DataFileFormat};
 
         let encryption_key = b"0123456789abcdef";
         let aad_prefix = b"my-table-uuid!!";
@@ -312,6 +313,7 @@
             file_path: del_path.clone(),
             file_size_in_bytes: std::fs::metadata(&del_path).unwrap().len(),
             file_type: DataContentType::EqualityDeletes,
+            file_format: DataFileFormat::Parquet,
             partition_spec_id: 0,
             equality_ids: Some(vec![1]),
             key_metadata: Some(Box::from(key_metadata.as_ref())),
diff --git a/crates/iceberg/src/arrow/delete_filter.rs b/crates/iceberg/src/arrow/delete_filter.rs
index fb67d02..cef81af 100644
--- a/crates/iceberg/src/arrow/delete_filter.rs
+++ b/crates/iceberg/src/arrow/delete_filter.rs
@@ -441,6 +441,7 @@
                 .len(),
             )
             .with_file_type(DataContentType::PositionDeletes)
+            .with_file_format(DataFileFormat::Parquet)
             .with_partition_spec_id(0)
             .build();
 
@@ -458,6 +459,7 @@
                 .len(),
             )
             .with_file_type(DataContentType::PositionDeletes)
+            .with_file_format(DataFileFormat::Parquet)
             .with_partition_spec_id(0)
             .build();
 
@@ -475,6 +477,7 @@
                 .len(),
             )
             .with_file_type(DataContentType::PositionDeletes)
+            .with_file_format(DataFileFormat::Parquet)
             .with_partition_spec_id(0)
             .build();
 
@@ -551,6 +554,7 @@
                     .with_file_path("eq-del.parquet".to_string())
                     .with_file_size_in_bytes(1) // never read; this test fails before opening the file
                     .with_file_type(DataContentType::EqualityDeletes)
+                    .with_file_format(DataFileFormat::Parquet)
                     .with_partition_spec_id(0)
                     .build(),
             ])
diff --git a/crates/iceberg/src/arrow/reader/positional_deletes.rs b/crates/iceberg/src/arrow/reader/positional_deletes.rs
index a66d9da..f0c7bf7 100644
--- a/crates/iceberg/src/arrow/reader/positional_deletes.rs
+++ b/crates/iceberg/src/arrow/reader/positional_deletes.rs
@@ -172,6 +172,7 @@
     use crate::io::FileIO;
     use crate::scan::{FileScanTask, FileScanTaskDeleteFile, FileScanTaskStream};
     use crate::spec::{DataContentType, DataFileFormat, NestedField, PrimitiveType, Schema, Type};
+    use crate::test_utils::encode_dv_blob;
 
     fn build_test_row_group_meta(
         schema_descr: SchemaDescPtr,
@@ -449,6 +450,7 @@
                     .with_file_size_in_bytes(std::fs::metadata(&delete_file_path).unwrap().len())
                     .with_file_path(delete_file_path)
                     .with_file_type(DataContentType::PositionDeletes)
+                    .with_file_format(DataFileFormat::Parquet)
                     .with_partition_spec_id(0)
                     .build(),
             ])
@@ -668,6 +670,7 @@
                     .with_file_size_in_bytes(std::fs::metadata(&delete_file_path).unwrap().len())
                     .with_file_path(delete_file_path)
                     .with_file_type(DataContentType::PositionDeletes)
+                    .with_file_format(DataFileFormat::Parquet)
                     .with_partition_spec_id(0)
                     .build(),
             ])
@@ -881,6 +884,7 @@
                     .with_file_size_in_bytes(std::fs::metadata(&delete_file_path).unwrap().len())
                     .with_file_path(delete_file_path)
                     .with_file_type(DataContentType::PositionDeletes)
+                    .with_file_format(DataFileFormat::Parquet)
                     .with_partition_spec_id(0)
                     .build(),
             ])
@@ -926,4 +930,189 @@
             "Should have ids 101-200 (all of row group 1)"
         );
     }
+
+    /// End-to-end read of a data file with a V3 deletion vector applied. Exercises the whole
+    /// deletion-vector read path together: the DV coordinates on the scan task, the loader
+    /// reading the blob by range from its Puffin file, decoding it with DeleteVector::deserialize,
+    /// validating cardinality against record_count, and ArrowReader filtering the deleted rows.
+    #[tokio::test]
+    async fn test_deletion_vector_applied_end_to_end() {
+        use arrow_array::Int32Array;
+
+        let tmp_dir = TempDir::new().unwrap();
+        let table_location = tmp_dir.path().to_str().unwrap().to_string();
+
+        let table_schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+
+        // Data file: ids 1..=5 at positions 0..=4.
+        let data_file_path = format!("{table_location}/data.parquet");
+        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![Arc::new(
+            Int32Array::from_iter_values(1..=5),
+        )])
+        .unwrap();
+        let props = WriterProperties::builder()
+            .set_compression(Compression::SNAPPY)
+            .build();
+        let file = File::create(&data_file_path).unwrap();
+        let mut writer = ArrowWriter::try_new(file, arrow_schema.clone(), Some(props)).unwrap();
+        writer.write(&batch).unwrap();
+        writer.close().unwrap();
+
+        // Deletion vector deleting positions 1 and 3 (ids 2 and 4), serialized as a
+        // deletion-vector-v1 blob and embedded in a Puffin-like file at a non-zero offset.
+        let blob = encode_dv_blob([1u64, 3]);
+        let content_offset = 12i64;
+        let content_size = blob.len() as i64;
+        let mut dv_file_bytes = vec![0u8; content_offset as usize];
+        dv_file_bytes.extend_from_slice(&blob);
+        let dv_path = format!("{table_location}/deletes.puffin");
+        std::fs::write(&dv_path, &dv_file_bytes).unwrap();
+
+        let file_io = FileIO::new_with_fs();
+        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
+
+        let task = FileScanTask::builder()
+            .with_file_size_in_bytes(std::fs::metadata(&data_file_path).unwrap().len())
+            .with_start(0)
+            .with_length(0)
+            .with_record_count(Some(5))
+            .with_data_file_path(data_file_path.clone())
+            .with_data_file_format(DataFileFormat::Parquet)
+            .with_schema(table_schema.clone())
+            .with_project_field_ids(vec![1])
+            .with_deletes(vec![
+                FileScanTaskDeleteFile::builder()
+                    .with_file_path(dv_path.clone())
+                    .with_file_size_in_bytes(std::fs::metadata(&dv_path).unwrap().len())
+                    .with_file_type(DataContentType::PositionDeletes)
+                    .with_file_format(DataFileFormat::Puffin)
+                    .with_partition_spec_id(0)
+                    .with_referenced_data_file(Some(data_file_path.clone()))
+                    .with_content_offset(Some(content_offset))
+                    .with_content_size_in_bytes(Some(content_size))
+                    .with_record_count(Some(2))
+                    .build(),
+            ])
+            .with_case_sensitive(false)
+            .build();
+
+        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
+        let result = reader
+            .read(tasks)
+            .unwrap()
+            .stream()
+            .try_collect::<Vec<RecordBatch>>()
+            .await
+            .unwrap();
+
+        let ids: Vec<i32> = result
+            .iter()
+            .flat_map(|batch| {
+                batch
+                    .column(0)
+                    .as_primitive::<arrow_array::types::Int32Type>()
+                    .values()
+                    .iter()
+                    .copied()
+            })
+            .collect();
+
+        // Positions 1 and 3 (ids 2 and 4) are deleted; ids 1, 3, 5 remain.
+        assert_eq!(ids, vec![1, 3, 5]);
+    }
+
+    /// A deletion vector whose decoded cardinality disagrees with the manifest entry's
+    /// `record_count` must fail the read rather than silently applying the wrong deletes, the
+    /// same invariant Iceberg-Java enforces in `BitmapPositionDeleteIndex.deserializeBitmap`.
+    #[tokio::test]
+    async fn test_deletion_vector_cardinality_mismatch_fails_read() {
+        use arrow_array::Int32Array;
+
+        let tmp_dir = TempDir::new().unwrap();
+        let table_location = tmp_dir.path().to_str().unwrap().to_string();
+
+        let table_schema = Arc::new(
+            Schema::builder()
+                .with_schema_id(1)
+                .with_fields(vec![
+                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
+                ])
+                .build()
+                .unwrap(),
+        );
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, false).with_metadata(HashMap::from([(
+                PARQUET_FIELD_ID_META_KEY.to_string(),
+                "1".to_string(),
+            )])),
+        ]));
+
+        let data_file_path = format!("{table_location}/data.parquet");
+        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![Arc::new(
+            Int32Array::from_iter_values(1..=5),
+        )])
+        .unwrap();
+        let props = WriterProperties::builder()
+            .set_compression(Compression::SNAPPY)
+            .build();
+        let file = File::create(&data_file_path).unwrap();
+        let mut writer = ArrowWriter::try_new(file, arrow_schema.clone(), Some(props)).unwrap();
+        writer.write(&batch).unwrap();
+        writer.close().unwrap();
+
+        // The blob decodes to 2 positions, but the manifest entry claims 5.
+        let blob = encode_dv_blob([1u64, 3]);
+        let dv_path = format!("{table_location}/deletes.puffin");
+        std::fs::write(&dv_path, &blob).unwrap();
+
+        let file_io = FileIO::new_with_fs();
+        let reader = ArrowReaderBuilder::new(file_io, Runtime::current()).build();
+
+        let task = FileScanTask::builder()
+            .with_file_size_in_bytes(std::fs::metadata(&data_file_path).unwrap().len())
+            .with_start(0)
+            .with_length(0)
+            .with_record_count(Some(5))
+            .with_data_file_path(data_file_path.clone())
+            .with_data_file_format(DataFileFormat::Parquet)
+            .with_schema(table_schema.clone())
+            .with_project_field_ids(vec![1])
+            .with_deletes(vec![
+                FileScanTaskDeleteFile::builder()
+                    .with_file_path(dv_path.clone())
+                    .with_file_size_in_bytes(std::fs::metadata(&dv_path).unwrap().len())
+                    .with_file_type(DataContentType::PositionDeletes)
+                    .with_file_format(DataFileFormat::Puffin)
+                    .with_partition_spec_id(0)
+                    .with_referenced_data_file(Some(data_file_path.clone()))
+                    .with_content_offset(Some(0))
+                    .with_content_size_in_bytes(Some(blob.len() as i64))
+                    .with_record_count(Some(5))
+                    .build(),
+            ])
+            .with_case_sensitive(false)
+            .build();
+
+        let tasks = Box::pin(futures::stream::iter(vec![Ok(task)])) as FileScanTaskStream;
+        let result: Result<Vec<RecordBatch>, _> =
+            reader.read(tasks).unwrap().stream().try_collect().await;
+
+        let err = result.unwrap_err();
+        assert_eq!(err.kind(), crate::ErrorKind::DataInvalid);
+        assert!(err.message().contains("expected 5 from record_count"));
+    }
 }
diff --git a/crates/iceberg/src/arrow/reader/row_filter.rs b/crates/iceberg/src/arrow/reader/row_filter.rs
index 2980392..389231c 100644
--- a/crates/iceberg/src/arrow/reader/row_filter.rs
+++ b/crates/iceberg/src/arrow/reader/row_filter.rs
@@ -1237,6 +1237,7 @@
             .with_deletes(vec![FileScanTaskDeleteFile {
                 file_path: pos_del_path.clone(),
                 file_type: DataContentType::PositionDeletes,
+                file_format: DataFileFormat::Parquet,
                 partition_spec_id: 0,
                 equality_ids: None,
                 file_size_in_bytes: std::fs::metadata(&pos_del_path).unwrap().len(),
diff --git a/crates/iceberg/src/delete_file_index.rs b/crates/iceberg/src/delete_file_index.rs
index bab5893..2e749ca 100644
--- a/crates/iceberg/src/delete_file_index.rs
+++ b/crates/iceberg/src/delete_file_index.rs
@@ -26,7 +26,8 @@
 use crate::metadata_columns::RESERVED_FIELD_ID_DELETE_FILE_PATH;
 use crate::runtime::Runtime;
 use crate::scan::{DeleteFileContext, FileScanTaskDeleteFile};
-use crate::spec::{DataContentType, DataFile, PrimitiveLiteral, Struct};
+use crate::spec::{DataContentType, DataFile, DataFileFormat, PrimitiveLiteral, Struct};
+use crate::{Error, ErrorKind, Result};
 
 /// Index of delete files
 #[derive(Debug, Clone)]
@@ -36,8 +37,18 @@
 
 #[derive(Debug)]
 enum DeleteFileIndexState {
+    // Arc, not Box: a waiter clones this out and awaits `notified_owned()` after dropping the
+    // read lock (a borrowed `notified()` future can't outlive the guard it was created under).
+    // If multiple callers arrive while still Populating, each clones its own handle to the same
+    // Notify, so one `notify_waiters()` call wakes all of them.
     Populating(Arc<Notify>),
-    Populated(PopulatedDeleteFileIndex),
+    // Boxed because PopulatedDeleteFileIndex is large relative to the other variants; there is
+    // exactly one owner (this enum, behind the RwLock), so this needs heap indirection, not
+    // shared ownership.
+    Populated(Box<PopulatedDeleteFileIndex>),
+    // Boxed for the same reason: Error is large enough to trip the same size check, and is never
+    // cloned out of the lock, only read as `&Error` via deref.
+    Failed(Box<Error>),
 }
 
 #[derive(Debug)]
@@ -46,7 +57,10 @@
     eq_deletes_by_partition: HashMap<Struct, Vec<Arc<DeleteFileContext>>>,
     pos_deletes_by_partition: HashMap<Struct, Vec<Arc<DeleteFileContext>>>,
     pos_deletes_by_path: HashMap<String, Vec<Arc<DeleteFileContext>>>,
-    // TODO: Deletion Vector support
+    // V3 deletion vectors, keyed by the data file they apply to (referenced_data_file). At most
+    // one exists per data file per snapshot, and when one applies it supersedes any position
+    // delete files for that data file, partition-scoped or path-scoped alike.
+    dvs_by_referenced_data_file: HashMap<String, Arc<DeleteFileContext>>,
 }
 
 /// Determines the single data file referenced by a position delete file, if any.
@@ -79,6 +93,13 @@
     }
 }
 
+/// Rebuilds an owned `Error` from the boxed `Error` cached in `DeleteFileIndexState::Failed`.
+/// `Error` isn't `Clone`, so each waiter gets its own copy of the kind and message rather than
+/// sharing the original's backtrace and source chain.
+fn clone_failed_index_error(err: &Error) -> Error {
+    Error::new(err.kind(), err.message().to_string())
+}
+
 impl DeleteFileIndex {
     /// create a new `DeleteFileIndex` along with the sender that populates it with delete files
     pub(crate) fn new(runtime: Runtime) -> (DeleteFileIndex, Sender<DeleteFileContext>) {
@@ -96,11 +117,14 @@
                 let delete_files: Vec<DeleteFileContext> =
                     delete_file_stream.collect::<Vec<_>>().await;
 
-                let populated_delete_file_index = PopulatedDeleteFileIndex::new(delete_files);
+                let new_state = match PopulatedDeleteFileIndex::new(delete_files) {
+                    Ok(index) => DeleteFileIndexState::Populated(Box::new(index)),
+                    Err(err) => DeleteFileIndexState::Failed(Box::new(err)),
+                };
 
                 {
                     let mut guard = state.write().unwrap();
-                    *guard = DeleteFileIndexState::Populated(populated_delete_file_index);
+                    *guard = new_state;
                 }
                 notify.notify_waiters();
             }
@@ -110,11 +134,15 @@
     }
 
     /// Gets all the delete files that apply to the specified data file.
+    ///
+    /// Fails if building the index found a spec violation, such as multiple deletion vectors
+    /// referencing the same data file, or if a matched deletion vector's sequence number
+    /// violates the spec relative to `seq_num`.
     pub(crate) async fn get_deletes_for_data_file(
         &self,
         data_file: &DataFile,
         seq_num: Option<i64>,
-    ) -> Vec<FileScanTaskDeleteFile> {
+    ) -> Result<Vec<FileScanTaskDeleteFile>> {
         // Create the `Notified` while holding the read lock. The read lock ensures that
         // when we go inside it, either the state is already at Populated or it is still
         // at Populating AND `notify_waiters()` has not been called yet. Any `Notified`
@@ -127,6 +155,9 @@
                 DeleteFileIndexState::Populated(index) => {
                     return index.get_deletes_for_data_file(data_file, seq_num);
                 }
+                DeleteFileIndexState::Failed(err) => {
+                    return Err(clone_failed_index_error(err));
+                }
             }
         };
 
@@ -137,7 +168,10 @@
             DeleteFileIndexState::Populated(index) => {
                 index.get_deletes_for_data_file(data_file, seq_num)
             }
-            _ => unreachable!("Cannot be any other state than loaded"),
+            DeleteFileIndexState::Failed(err) => Err(clone_failed_index_error(err)),
+            DeleteFileIndexState::Populating(_) => {
+                unreachable!("Cannot still be Populating after being notified")
+            }
         }
     }
 }
@@ -146,31 +180,83 @@
     /// Creates a new populated delete file index from a list of delete file contexts, which
     /// allows for fast lookup when determining which delete files apply to a given data file.
     ///
-    /// 1. Position deletes that reference a single data file, either through the
+    /// 1. A V3 deletion vector (a `PositionDeletes` entry stored as `Puffin`) is indexed by the
+    ///    `referenced_data_file` field, which the spec requires for deletion vectors.
+    ///    Fails if two deletion vectors reference the same data file: the spec allows at most
+    ///    one deletion vector per data file per snapshot.
+    /// 2. Other position deletes that reference a single data file, either through the
     ///    `referenced_data_file` field or through equal `file_path` column bounds,
     ///    are indexed by that data file's path.
-    /// 2. All other position deletes are indexed by the partition extracted from
+    /// 3. All other position deletes are indexed by the partition extracted from
     ///    their manifest entry.
-    /// 3. Equality deletes stored with an unpartitioned spec are applied as global
+    /// 4. Equality deletes stored with an unpartitioned spec are applied as global
     ///    deletes, per the spec. All other equality deletes are indexed by partition.
-    fn new(files: Vec<DeleteFileContext>) -> PopulatedDeleteFileIndex {
+    fn new(files: Vec<DeleteFileContext>) -> Result<PopulatedDeleteFileIndex> {
         let mut eq_deletes_by_partition: HashMap<Struct, Vec<Arc<DeleteFileContext>>> =
             HashMap::default();
         let mut pos_deletes_by_partition: HashMap<Struct, Vec<Arc<DeleteFileContext>>> =
             HashMap::default();
         let mut pos_deletes_by_path: HashMap<String, Vec<Arc<DeleteFileContext>>> =
             HashMap::default();
+        let mut dvs_by_referenced_data_file: HashMap<String, Arc<DeleteFileContext>> =
+            HashMap::default();
 
         let mut global_equality_deletes: Vec<Arc<DeleteFileContext>> = vec![];
 
-        files.into_iter().for_each(|ctx| {
+        for ctx in files {
             let arc_ctx = Arc::new(ctx);
 
-            let partition = arc_ctx.manifest_entry.data_file().partition();
+            let data_file = arc_ctx.manifest_entry.data_file();
+            let partition = data_file.partition();
 
             match arc_ctx.manifest_entry.content_type() {
                 DataContentType::PositionDeletes => {
-                    if let Some(path) = referenced_data_file(arc_ctx.manifest_entry.data_file()) {
+                    // A deletion vector is a position delete stored as a Puffin blob. The file
+                    // format is what distinguishes it from a position delete parquet file.
+                    if data_file.file_format() == DataFileFormat::Puffin {
+                        // The spec requires referenced_data_file, content_offset and
+                        // content_size_in_bytes on a deletion vector, so a missing one is a
+                        // malformed manifest entry, not an ordinary position delete to fall back
+                        // on.
+                        let Some(path) = data_file.referenced_data_file() else {
+                            return Err(Error::new(
+                                ErrorKind::DataInvalid,
+                                format!(
+                                    "deletion vector {} is missing referenced_data_file",
+                                    arc_ctx.manifest_entry.file_path()
+                                ),
+                            ));
+                        };
+
+                        if data_file.content_offset().is_none()
+                            || data_file.content_size_in_bytes().is_none()
+                        {
+                            return Err(Error::new(
+                                ErrorKind::DataInvalid,
+                                format!(
+                                    "deletion vector {} is missing content_offset or content_size_in_bytes",
+                                    arc_ctx.manifest_entry.file_path()
+                                ),
+                            ));
+                        }
+
+                        if let Some(existing) =
+                            dvs_by_referenced_data_file.insert(path.clone(), arc_ctx)
+                        {
+                            let inserted = &dvs_by_referenced_data_file[&path];
+                            return Err(Error::new(
+                                ErrorKind::DataInvalid,
+                                format!(
+                                    "found multiple deletion vectors for data file {path}: {} and {}",
+                                    existing.manifest_entry.file_path(),
+                                    inserted.manifest_entry.file_path()
+                                ),
+                            ));
+                        }
+                        continue;
+                    }
+
+                    if let Some(path) = referenced_data_file(data_file) {
                         pos_deletes_by_path.entry(path).or_default().push(arc_ctx);
                     } else {
                         pos_deletes_by_partition
@@ -192,22 +278,29 @@
                 }
                 _ => unreachable!(),
             }
-        });
+        }
 
-        PopulatedDeleteFileIndex {
+        Ok(PopulatedDeleteFileIndex {
             global_equality_deletes,
             eq_deletes_by_partition,
             pos_deletes_by_partition,
             pos_deletes_by_path,
-        }
+            dvs_by_referenced_data_file,
+        })
     }
 
     /// Determine all the delete files that apply to the provided `DataFile`.
+    ///
+    /// Fails if a matched deletion vector's partition or data sequence number is inconsistent
+    /// with the data file's: a data file's path is permanently tied to one partition, and the
+    /// spec guarantees a DV is only ever written at or after the sequence number of the data
+    /// file it applies to, so either violation means the delete manifest is inconsistent, not
+    /// that the DV simply doesn't apply.
     fn get_deletes_for_data_file(
         &self,
         data_file: &DataFile,
         seq_num: Option<i64>,
-    ) -> Vec<FileScanTaskDeleteFile> {
+    ) -> Result<Vec<FileScanTaskDeleteFile>> {
         let mut results = vec![];
 
         self.global_equality_deletes
@@ -233,6 +326,49 @@
                 .for_each(|delete| results.push(delete.as_ref().into()));
         }
 
+        // A deletion vector supersedes all position delete files for its data file, per the spec:
+        // "readers ignore any position delete files that would otherwise match it, because the DV
+        // subsumes them". An exact path match on referenced_data_file is sufficient proof of
+        // applicability, the same as for pos_deletes_by_path below, so this is checked before
+        // (and instead of) pos_deletes_by_partition and pos_deletes_by_path.
+        if let Some(dv) = self.dvs_by_referenced_data_file.get(data_file.file_path()) {
+            let dv_data_file = dv.manifest_entry.data_file();
+            // A file path belongs to exactly one partition for its lifetime, so an exact path
+            // match already implies partition equality; this checks that the manifest agrees,
+            // per the spec's explicit partition-equality condition for deletion vectors.
+            if data_file.partition() != dv_data_file.partition()
+                || data_file.partition_spec_id != dv.partition_spec_id
+            {
+                return Err(Error::new(
+                    ErrorKind::DataInvalid,
+                    format!(
+                        "deletion vector {} references data file {} but its partition (spec {}, {:?}) does not match the data file's partition (spec {}, {:?})",
+                        dv.manifest_entry.file_path(),
+                        data_file.file_path(),
+                        dv.partition_spec_id,
+                        dv_data_file.partition(),
+                        data_file.partition_spec_id,
+                        data_file.partition()
+                    ),
+                ));
+            }
+
+            if let Some(seq_num) = seq_num {
+                let dv_seq = dv.manifest_entry.sequence_number();
+                if dv_seq < Some(seq_num) {
+                    return Err(Error::new(
+                        ErrorKind::DataInvalid,
+                        format!(
+                            "deletion vector {} has data sequence number {dv_seq:?}, which must be >= the data file's sequence number {seq_num}",
+                            dv.manifest_entry.file_path()
+                        ),
+                    ));
+                }
+            }
+            results.push(dv.as_ref().into());
+            return Ok(results);
+        }
+
         if let Some(deletes) = self.pos_deletes_by_partition.get(data_file.partition()) {
             deletes
                 .iter()
@@ -261,7 +397,7 @@
                 .for_each(|delete| results.push(delete.as_ref().into()));
         }
 
-        results
+        Ok(results)
     }
 }
 
@@ -297,23 +433,26 @@
             })
             .collect();
 
-        let delete_file_index = PopulatedDeleteFileIndex::new(delete_contexts);
+        let delete_file_index = PopulatedDeleteFileIndex::new(delete_contexts).unwrap();
 
         let data_file = build_unpartitioned_data_file();
 
         // All deletes apply to sequence 0
-        let delete_files_to_apply_for_seq_0 =
-            delete_file_index.get_deletes_for_data_file(&data_file, Some(0));
+        let delete_files_to_apply_for_seq_0 = delete_file_index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap();
         assert_eq!(delete_files_to_apply_for_seq_0.len(), 4);
 
         // All deletes apply to sequence 3
-        let delete_files_to_apply_for_seq_3 =
-            delete_file_index.get_deletes_for_data_file(&data_file, Some(3));
+        let delete_files_to_apply_for_seq_3 = delete_file_index
+            .get_deletes_for_data_file(&data_file, Some(3))
+            .unwrap();
         assert_eq!(delete_files_to_apply_for_seq_3.len(), 4);
 
         // Last 3 deletes apply to sequence 4
-        let delete_files_to_apply_for_seq_4 =
-            delete_file_index.get_deletes_for_data_file(&data_file, Some(4));
+        let delete_files_to_apply_for_seq_4 = delete_file_index
+            .get_deletes_for_data_file(&data_file, Some(4))
+            .unwrap();
         let actual_paths_to_apply_for_seq_4: Vec<String> = delete_files_to_apply_for_seq_4
             .into_iter()
             .map(|file| file.file_path)
@@ -325,8 +464,9 @@
         );
 
         // Last 3 deletes apply to sequence 5
-        let delete_files_to_apply_for_seq_5 =
-            delete_file_index.get_deletes_for_data_file(&data_file, Some(5));
+        let delete_files_to_apply_for_seq_5 = delete_file_index
+            .get_deletes_for_data_file(&data_file, Some(5))
+            .unwrap();
         let actual_paths_to_apply_for_seq_5: Vec<String> = delete_files_to_apply_for_seq_5
             .into_iter()
             .map(|file| file.file_path)
@@ -337,8 +477,9 @@
         );
 
         // Only the last position delete applies to sequence 6
-        let delete_files_to_apply_for_seq_6 =
-            delete_file_index.get_deletes_for_data_file(&data_file, Some(6));
+        let delete_files_to_apply_for_seq_6 = delete_file_index
+            .get_deletes_for_data_file(&data_file, Some(6))
+            .unwrap();
         let actual_paths_to_apply_for_seq_6: Vec<String> = delete_files_to_apply_for_seq_6
             .into_iter()
             .map(|file| file.file_path)
@@ -352,8 +493,9 @@
         let partitioned_file =
             build_partitioned_data_file(&Struct::from_iter([Some(Literal::long(100))]), 1);
 
-        let delete_files_to_apply_for_partitioned_file =
-            delete_file_index.get_deletes_for_data_file(&partitioned_file, Some(0));
+        let delete_files_to_apply_for_partitioned_file = delete_file_index
+            .get_deletes_for_data_file(&partitioned_file, Some(0))
+            .unwrap();
         let actual_paths_to_apply_for_partitioned_file: Vec<String> =
             delete_files_to_apply_for_partitioned_file
                 .into_iter()
@@ -389,24 +531,27 @@
             })
             .collect();
 
-        let delete_file_index = PopulatedDeleteFileIndex::new(delete_contexts);
+        let delete_file_index = PopulatedDeleteFileIndex::new(delete_contexts).unwrap();
 
         let partitioned_file =
             build_partitioned_data_file(&Struct::from_iter([Some(Literal::long(100))]), spec_id);
 
         // All deletes apply to sequence 0
-        let delete_files_to_apply_for_seq_0 =
-            delete_file_index.get_deletes_for_data_file(&partitioned_file, Some(0));
+        let delete_files_to_apply_for_seq_0 = delete_file_index
+            .get_deletes_for_data_file(&partitioned_file, Some(0))
+            .unwrap();
         assert_eq!(delete_files_to_apply_for_seq_0.len(), 4);
 
         // All deletes apply to sequence 3
-        let delete_files_to_apply_for_seq_3 =
-            delete_file_index.get_deletes_for_data_file(&partitioned_file, Some(3));
+        let delete_files_to_apply_for_seq_3 = delete_file_index
+            .get_deletes_for_data_file(&partitioned_file, Some(3))
+            .unwrap();
         assert_eq!(delete_files_to_apply_for_seq_3.len(), 4);
 
         // Last 3 deletes apply to sequence 4
-        let delete_files_to_apply_for_seq_4 =
-            delete_file_index.get_deletes_for_data_file(&partitioned_file, Some(4));
+        let delete_files_to_apply_for_seq_4 = delete_file_index
+            .get_deletes_for_data_file(&partitioned_file, Some(4))
+            .unwrap();
         let actual_paths_to_apply_for_seq_4: Vec<String> = delete_files_to_apply_for_seq_4
             .into_iter()
             .map(|file| file.file_path)
@@ -418,8 +563,9 @@
         );
 
         // Last 3 deletes apply to sequence 5
-        let delete_files_to_apply_for_seq_5 =
-            delete_file_index.get_deletes_for_data_file(&partitioned_file, Some(5));
+        let delete_files_to_apply_for_seq_5 = delete_file_index
+            .get_deletes_for_data_file(&partitioned_file, Some(5))
+            .unwrap();
         let actual_paths_to_apply_for_seq_5: Vec<String> = delete_files_to_apply_for_seq_5
             .into_iter()
             .map(|file| file.file_path)
@@ -430,8 +576,9 @@
         );
 
         // Only the last position delete applies to sequence 6
-        let delete_files_to_apply_for_seq_6 =
-            delete_file_index.get_deletes_for_data_file(&partitioned_file, Some(6));
+        let delete_files_to_apply_for_seq_6 = delete_file_index
+            .get_deletes_for_data_file(&partitioned_file, Some(6))
+            .unwrap();
         let actual_paths_to_apply_for_seq_6: Vec<String> = delete_files_to_apply_for_seq_6
             .into_iter()
             .map(|file| file.file_path)
@@ -444,8 +591,9 @@
         // Data file with different partition tuples does not match any delete files
         let partitioned_second_file =
             build_partitioned_data_file(&Struct::from_iter([Some(Literal::long(200))]), 1);
-        let delete_files_to_apply_for_different_partition =
-            delete_file_index.get_deletes_for_data_file(&partitioned_second_file, Some(0));
+        let delete_files_to_apply_for_different_partition = delete_file_index
+            .get_deletes_for_data_file(&partitioned_second_file, Some(0))
+            .unwrap();
         let actual_paths_to_apply_for_different_partition: Vec<String> =
             delete_files_to_apply_for_different_partition
                 .into_iter()
@@ -455,8 +603,9 @@
 
         // Data file with same tuple but different spec ID does not match any delete files
         let partitioned_different_spec = build_partitioned_data_file(&partition_one, 2);
-        let delete_files_to_apply_for_different_spec =
-            delete_file_index.get_deletes_for_data_file(&partitioned_different_spec, Some(0));
+        let delete_files_to_apply_for_different_spec = delete_file_index
+            .get_deletes_for_data_file(&partitioned_different_spec, Some(0))
+            .unwrap();
         let actual_paths_to_apply_for_different_spec: Vec<String> =
             delete_files_to_apply_for_different_spec
                 .into_iter()
@@ -474,15 +623,20 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 0,
-        }]);
+        }])
+        .unwrap();
 
-        let deletes_for_a = index.get_deletes_for_data_file(&data_file_a, Some(0));
+        let deletes_for_a = index
+            .get_deletes_for_data_file(&data_file_a, Some(0))
+            .unwrap();
         assert_eq!(deletes_for_a.len(), 1);
         assert_eq!(deletes_for_a[0].file_path, pos_delete.file_path());
 
         // The delete references data file A, so it must not apply to data file B
         // even though B shares A's partition.
-        let deletes_for_b = index.get_deletes_for_data_file(&data_file_b, Some(0));
+        let deletes_for_b = index
+            .get_deletes_for_data_file(&data_file_b, Some(0))
+            .unwrap();
         assert!(deletes_for_b.is_empty());
     }
 
@@ -498,15 +652,20 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 0,
-        }]);
+        }])
+        .unwrap();
 
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file_a, Some(0)).len(),
+            index
+                .get_deletes_for_data_file(&data_file_a, Some(0))
+                .unwrap()
+                .len(),
             1
         );
         assert!(
             index
                 .get_deletes_for_data_file(&data_file_b, Some(0))
+                .unwrap()
                 .is_empty()
         );
     }
@@ -523,14 +682,21 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 0,
-        }]);
+        }])
+        .unwrap();
 
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file_a, Some(0)).len(),
+            index
+                .get_deletes_for_data_file(&data_file_a, Some(0))
+                .unwrap()
+                .len(),
             1
         );
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file_b, Some(0)).len(),
+            index
+                .get_deletes_for_data_file(&data_file_b, Some(0))
+                .unwrap()
+                .len(),
             1
         );
     }
@@ -546,10 +712,14 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 0,
-        }]);
+        }])
+        .unwrap();
 
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file, Some(0)).len(),
+            index
+                .get_deletes_for_data_file(&data_file, Some(0))
+                .unwrap()
+                .len(),
             1
         );
     }
@@ -562,25 +732,39 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 0,
-        }]);
+        }])
+        .unwrap();
 
         // Position deletes apply when the delete's sequence number is greater
         // than or equal to the data file's.
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file, Some(4)).len(),
+            index
+                .get_deletes_for_data_file(&data_file, Some(4))
+                .unwrap()
+                .len(),
             1
         );
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file, Some(5)).len(),
+            index
+                .get_deletes_for_data_file(&data_file, Some(5))
+                .unwrap()
+                .len(),
             1
         );
         assert!(
             index
                 .get_deletes_for_data_file(&data_file, Some(6))
+                .unwrap()
                 .is_empty()
         );
         // Without a sequence number, the delete applies unconditionally.
-        assert_eq!(index.get_deletes_for_data_file(&data_file, None).len(), 1);
+        assert_eq!(
+            index
+                .get_deletes_for_data_file(&data_file, None)
+                .unwrap()
+                .len(),
+            1
+        );
     }
 
     #[test]
@@ -607,10 +791,12 @@
                 manifest_entry: build_added_manifest_entry(5, &eq_delete).into(),
                 partition_spec_id: 0,
             },
-        ]);
+        ])
+        .unwrap();
 
         let mut actual_paths: Vec<String> = index
             .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap()
             .into_iter()
             .map(|delete| delete.file_path)
             .collect();
@@ -642,10 +828,12 @@
                 manifest_entry: build_added_manifest_entry(6, &second_delete).into(),
                 partition_spec_id: 0,
             },
-        ]);
+        ])
+        .unwrap();
 
         let mut actual_paths: Vec<String> = index
             .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap()
             .into_iter()
             .map(|delete| delete.file_path)
             .collect();
@@ -679,15 +867,19 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 1,
-        }]);
+        }])
+        .unwrap();
 
-        let deletes_for_referenced = index.get_deletes_for_data_file(&data_file, Some(0));
+        let deletes_for_referenced = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap();
         assert_eq!(deletes_for_referenced.len(), 1);
         assert_eq!(deletes_for_referenced[0].file_path, pos_delete.file_path());
 
         assert!(
             index
                 .get_deletes_for_data_file(&same_partition_neighbor, Some(0))
+                .unwrap()
                 .is_empty()
         );
     }
@@ -706,15 +898,20 @@
         let index = PopulatedDeleteFileIndex::new(vec![DeleteFileContext {
             manifest_entry: build_added_manifest_entry(5, &pos_delete).into(),
             partition_spec_id: 0,
-        }]);
+        }])
+        .unwrap();
 
         assert_eq!(
-            index.get_deletes_for_data_file(&data_file, Some(0)).len(),
+            index
+                .get_deletes_for_data_file(&data_file, Some(0))
+                .unwrap()
+                .len(),
             1
         );
         assert_eq!(
             index
                 .get_deletes_for_data_file(&other_data_file, Some(0))
+                .unwrap()
                 .len(),
             1
         );
@@ -791,6 +988,330 @@
         );
     }
 
+    #[test]
+    fn test_deletion_vector_supersedes_position_deletes() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        let dv = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+        let dv_path = dv.file_path().to_string();
+        let pos_del = build_partitioned_pos_delete(&partition, spec_id);
+
+        let contexts = vec![
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &dv).into(),
+                partition_spec_id: spec_id,
+            },
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &pos_del).into(),
+                partition_spec_id: spec_id,
+            },
+        ];
+
+        let index = PopulatedDeleteFileIndex::new(contexts).unwrap();
+        let applied: Vec<String> = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap()
+            .into_iter()
+            .map(|f| f.file_path)
+            .collect();
+
+        // Only the deletion vector applies; the partition-scoped position delete file, which
+        // would otherwise also match, is superseded.
+        assert_eq!(applied, vec![dv_path]);
+    }
+
+    #[test]
+    fn test_deletion_vector_with_stale_sequence_number_is_rejected() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        let dv = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+
+        let contexts = vec![DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(3, &dv).into(),
+            partition_spec_id: spec_id,
+        }];
+
+        let index = PopulatedDeleteFileIndex::new(contexts).unwrap();
+        // The DV's own sequence number (3) is less than the data file's (5): the spec guarantees
+        // a DV is never written before the data file it applies to, so this is an inconsistent
+        // manifest rather than a case where the DV simply doesn't apply.
+        let err = index
+            .get_deletes_for_data_file(&data_file, Some(5))
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(
+            err.message()
+                .contains("must be >= the data file's sequence number")
+        );
+    }
+
+    #[test]
+    fn test_deletion_vector_with_mismatched_partition_is_rejected() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let other_partition = Struct::from_iter([Some(Literal::long(200))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        // Malformed: the DV's referenced_data_file matches data_file's path exactly, but the
+        // DV's own partition disagrees, a state that cannot arise from a valid writer since a
+        // file path is permanently tied to one partition.
+        let dv = build_deletion_vector(data_file.file_path(), &other_partition, spec_id);
+
+        let contexts = vec![DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(5, &dv).into(),
+            partition_spec_id: spec_id,
+        }];
+
+        let index = PopulatedDeleteFileIndex::new(contexts).unwrap();
+        let err = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(
+            err.message()
+                .contains("does not match the data file's partition")
+        );
+    }
+
+    #[test]
+    fn test_deletion_vector_with_mismatched_partition_spec_is_rejected() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let data_file = build_partitioned_data_file(&partition, 1);
+
+        // Same partition value, but the DV's context was populated under a different partition
+        // spec id than the data file's: also a manifest inconsistency, since a file path is
+        // permanently tied to one partition spec.
+        let dv = build_deletion_vector(data_file.file_path(), &partition, 1);
+
+        let contexts = vec![DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(5, &dv).into(),
+            partition_spec_id: 2,
+        }];
+
+        let index = PopulatedDeleteFileIndex::new(contexts).unwrap();
+        let err = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(
+            err.message()
+                .contains("does not match the data file's partition")
+        );
+    }
+
+    #[test]
+    fn test_deletion_vector_supersedes_path_scoped_position_delete() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        let dv = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+        let dv_path = dv.file_path().to_string();
+        let pos_del = build_pos_delete_referencing(data_file.file_path());
+
+        let contexts = vec![
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &dv).into(),
+                partition_spec_id: spec_id,
+            },
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &pos_del).into(),
+                partition_spec_id: 0,
+            },
+        ];
+
+        let index = PopulatedDeleteFileIndex::new(contexts).unwrap();
+        let applied: Vec<String> = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap()
+            .into_iter()
+            .map(|f| f.file_path)
+            .collect();
+
+        // Only the deletion vector applies; the path-scoped position delete file, which would
+        // otherwise also match by exact referenced_data_file path, is superseded.
+        assert_eq!(applied, vec![dv_path]);
+    }
+
+    #[test]
+    fn test_deletion_vector_coexists_with_equality_delete() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        let dv = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+        let dv_path = dv.file_path().to_string();
+        let eq_del = build_unpartitioned_eq_delete();
+        let eq_del_path = eq_del.file_path().to_string();
+
+        let contexts = vec![
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &dv).into(),
+                partition_spec_id: spec_id,
+            },
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &eq_del).into(),
+                partition_spec_id: 0,
+            },
+        ];
+
+        let index = PopulatedDeleteFileIndex::new(contexts).unwrap();
+        let mut applied: Vec<String> = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .unwrap()
+            .into_iter()
+            .map(|f| f.file_path)
+            .collect();
+        applied.sort();
+
+        // A deletion vector only supersedes position deletes, not equality deletes: both apply.
+        let mut expected = vec![dv_path, eq_del_path];
+        expected.sort();
+        assert_eq!(applied, expected);
+    }
+
+    #[test]
+    fn test_deletion_vector_missing_referenced_data_file_is_rejected() {
+        let malformed_dv = DataFileBuilder::default()
+            .file_path("deletes.puffin".to_string())
+            .file_format(DataFileFormat::Puffin)
+            .content(DataContentType::PositionDeletes)
+            .record_count(1)
+            .content_offset(Some(4))
+            .content_size_in_bytes(Some(40))
+            .partition(Struct::empty())
+            .partition_spec_id(0)
+            .file_size_in_bytes(60)
+            .build()
+            .unwrap();
+
+        let contexts = vec![DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(5, &malformed_dv).into(),
+            partition_spec_id: 0,
+        }];
+
+        let err = PopulatedDeleteFileIndex::new(contexts).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("missing referenced_data_file"));
+    }
+
+    #[test]
+    fn test_deletion_vector_missing_coordinates_is_rejected() {
+        let malformed_dv = DataFileBuilder::default()
+            .file_path("deletes.puffin".to_string())
+            .file_format(DataFileFormat::Puffin)
+            .content(DataContentType::PositionDeletes)
+            .record_count(1)
+            .referenced_data_file(Some("data.parquet".to_string()))
+            .content_size_in_bytes(Some(40))
+            .partition(Struct::empty())
+            .partition_spec_id(0)
+            .file_size_in_bytes(60)
+            .build()
+            .unwrap();
+
+        let contexts = vec![DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(5, &malformed_dv).into(),
+            partition_spec_id: 0,
+        }];
+
+        let err = PopulatedDeleteFileIndex::new(contexts).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(
+            err.message()
+                .contains("missing content_offset or content_size_in_bytes")
+        );
+    }
+
+    #[test]
+    fn test_multiple_deletion_vectors_for_same_data_file_is_rejected() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        let dv_1 = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+        let dv_2 = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+
+        let contexts = vec![
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(5, &dv_1).into(),
+                partition_spec_id: spec_id,
+            },
+            DeleteFileContext {
+                manifest_entry: build_added_manifest_entry(6, &dv_2).into(),
+                partition_spec_id: spec_id,
+            },
+        ];
+
+        let err = PopulatedDeleteFileIndex::new(contexts).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("multiple deletion vectors"));
+    }
+
+    #[tokio::test]
+    async fn test_delete_file_index_propagates_multiple_dv_error_to_waiters() {
+        let partition = Struct::from_iter([Some(Literal::long(100))]);
+        let spec_id = 1;
+        let data_file = build_partitioned_data_file(&partition, spec_id);
+
+        let dv_1 = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+        let dv_2 = build_deletion_vector(data_file.file_path(), &partition, spec_id);
+
+        let (index, mut tx) = DeleteFileIndex::new(Runtime::current());
+        tx.try_send(DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(5, &dv_1).into(),
+            partition_spec_id: spec_id,
+        })
+        .unwrap();
+        tx.try_send(DeleteFileContext {
+            manifest_entry: build_added_manifest_entry(6, &dv_2).into(),
+            partition_spec_id: spec_id,
+        })
+        .unwrap();
+        drop(tx);
+
+        let err = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .await
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+        assert!(err.message().contains("multiple deletion vectors"));
+
+        // A second caller, arriving after the index has already settled into the Failed state,
+        // must see the same error rather than panicking on an unexpected state.
+        let err = index
+            .get_deletes_for_data_file(&data_file, Some(0))
+            .await
+            .unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::DataInvalid);
+    }
+
+    // A V3 deletion vector: a PositionDeletes entry stored as a Puffin blob, scoped to a single
+    // data file via referenced_data_file.
+    fn build_deletion_vector(
+        referenced_data_file: &str,
+        partition: &Struct,
+        spec_id: i32,
+    ) -> DataFile {
+        DataFileBuilder::default()
+            .file_path(format!("{}-deletes.puffin", Uuid::new_v4()))
+            .file_format(DataFileFormat::Puffin)
+            .content(DataContentType::PositionDeletes)
+            .record_count(1)
+            .referenced_data_file(Some(referenced_data_file.to_string()))
+            .content_offset(Some(4))
+            .content_size_in_bytes(Some(40))
+            .partition(partition.clone())
+            .partition_spec_id(spec_id)
+            .file_size_in_bytes(60)
+            .build()
+            .unwrap()
+    }
+
     fn build_unpartitioned_eq_delete() -> DataFile {
         build_partitioned_eq_delete(&Struct::empty(), 0)
     }
diff --git a/crates/iceberg/src/delete_vector.rs b/crates/iceberg/src/delete_vector.rs
index e938ce5..4ab13d5 100644
--- a/crates/iceberg/src/delete_vector.rs
+++ b/crates/iceberg/src/delete_vector.rs
@@ -72,7 +72,6 @@
         Ok(positions.len())
     }
 
-    #[allow(unused)]
     pub fn len(&self) -> u64 {
         self.inner.len()
     }
@@ -91,17 +90,12 @@
     /// format: a directory of 32-bit key / 32-bit roaring bitmap pairs, ordered by unsigned
     /// comparison of the keys, one bitmap per key.
     ///
-    /// Cardinality is not checked here. The caller validates the decoded length against the
-    /// delete file's `record_count`, where the manifest metadata is available.
-    ///
     /// # Errors
     ///
     /// Returns [`ErrorKind::DataInvalid`] if the blob is shorter than the minimum, the length
     /// prefix or CRC does not match, the magic is wrong, the roaring bitmap count exceeds the
     /// portable format's maximum, the roaring directory's keys are not ordered by unsigned
     /// comparison, or the roaring payload fails to decode.
-    // Consumed by the scan delete loader once the deletion-vector read path is wired up.
-    #[allow(dead_code)]
     pub fn deserialize(blob: &[u8]) -> Result<Self> {
         if blob.len() < DV_MIN_BLOB_BYTES {
             return Err(Error::new(
@@ -315,6 +309,22 @@
     }
 }
 
+// Reproduces Iceberg-Java's `deletion-vector-v1` framing so tests can round-trip through
+// `deserialize` without a Java writer, and so other test modules can build blob fixtures.
+// Cross-implementation golden fixtures produced by Iceberg-Java are tracked separately; this
+// only checks that our decode matches our encode.
+#[cfg(test)]
+pub(crate) fn frame_dv_blob(vector: &[u8]) -> Vec<u8> {
+    let body_len = DV_MAGIC_BYTES + vector.len();
+    let mut blob = Vec::with_capacity(DV_LENGTH_PREFIX_BYTES + body_len + DV_CRC_BYTES);
+    blob.extend_from_slice(&(body_len as u32).to_be_bytes());
+    blob.extend_from_slice(&DV_MAGIC);
+    blob.extend_from_slice(vector);
+    let crc = crc32fast::hash(&blob[DV_LENGTH_PREFIX_BYTES..]);
+    blob.extend_from_slice(&crc.to_be_bytes());
+    blob
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -372,20 +382,6 @@
         assert!(res.is_err());
     }
 
-    // Reproduces Iceberg-Java's `deletion-vector-v1` framing so tests can round-trip through
-    // `deserialize` without a Java writer. Cross-implementation golden fixtures produced by
-    // Iceberg-Java are tracked separately; this only checks that our decode matches our encode.
-    fn frame_dv_blob(vector: &[u8]) -> Vec<u8> {
-        let body_len = DV_MAGIC_BYTES + vector.len();
-        let mut blob = Vec::with_capacity(DV_LENGTH_PREFIX_BYTES + body_len + DV_CRC_BYTES);
-        blob.extend_from_slice(&(body_len as u32).to_be_bytes());
-        blob.extend_from_slice(&DV_MAGIC);
-        blob.extend_from_slice(vector);
-        let crc = crc32fast::hash(&blob[DV_LENGTH_PREFIX_BYTES..]);
-        blob.extend_from_slice(&crc.to_be_bytes());
-        blob
-    }
-
     fn encode_dv_blob(dv: &DeleteVector) -> Vec<u8> {
         let mut vector = Vec::with_capacity(dv.inner.serialized_size());
         dv.inner.serialize_into(&mut vector).unwrap();
diff --git a/crates/iceberg/src/scan/context.rs b/crates/iceberg/src/scan/context.rs
index 6768fee..a598975 100644
--- a/crates/iceberg/src/scan/context.rs
+++ b/crates/iceberg/src/scan/context.rs
@@ -126,7 +126,7 @@
                 self.manifest_entry.data_file(),
                 self.manifest_entry.sequence_number(),
             )
-            .await;
+            .await?;
 
         FileScanTask::builder()
             .with_file_size_in_bytes(self.manifest_entry.file_size_in_bytes())
diff --git a/crates/iceberg/src/scan/task.rs b/crates/iceberg/src/scan/task.rs
index 647fb39..3aff0f4 100644
--- a/crates/iceberg/src/scan/task.rs
+++ b/crates/iceberg/src/scan/task.rs
@@ -318,6 +318,7 @@
             .with_file_path(ctx.manifest_entry.file_path().to_string())
             .with_file_size_in_bytes(ctx.manifest_entry.file_size_in_bytes())
             .with_file_type(ctx.manifest_entry.content_type())
+            .with_file_format(ctx.manifest_entry.data_file().file_format())
             .with_partition_spec_id(ctx.partition_spec_id)
             .with_equality_ids(ctx.manifest_entry.data_file.equality_ids.clone())
             .with_referenced_data_file(ctx.manifest_entry.data_file.referenced_data_file.clone())
@@ -348,6 +349,10 @@
     /// delete file type
     pub file_type: DataContentType,
 
+    /// The delete file's format, from the manifest entry. A `PositionDeletes` entry written as
+    /// `Puffin` is a V3 deletion vector; one written as `Parquet` is a position delete file.
+    pub file_format: DataFileFormat,
+
     /// partition id
     pub partition_spec_id: i32,
 
@@ -383,8 +388,11 @@
     #[builder(default)]
     pub record_count: Option<u64>,
 
-    /// Key metadata for encrypted delete files (Parquet Modular Encryption).
-    /// When present, the reader uses this to build `FileDecryptionProperties`.
+    /// Key metadata for an encrypted delete file. When present, the reader uses this to
+    /// decrypt the file: for a Parquet equality or position delete file, this builds
+    /// `FileDecryptionProperties` (Parquet Modular Encryption); for a deletion vector, whose
+    /// Puffin file has no native encryption, this wraps the range read in an
+    /// `EncryptedInputFile` (AGS1 stream encryption).
     ///
     /// Same plaintext-DEK trust boundary as [`FileScanTask::key_metadata`]:
     /// this is serialized into the scan plan and crosses the planner -> worker
diff --git a/crates/iceberg/src/test_utils.rs b/crates/iceberg/src/test_utils.rs
index 63f4011..e80a51d 100644
--- a/crates/iceberg/src/test_utils.rs
+++ b/crates/iceberg/src/test_utils.rs
@@ -24,6 +24,8 @@
 use arrow_array::RecordBatch;
 use expect_test::Expect;
 use itertools::Itertools;
+#[cfg(test)]
+use roaring::RoaringTreemap;
 
 use crate::TableIdent;
 #[cfg(test)]
@@ -120,6 +122,19 @@
     )
 }
 
+/// Encodes a `deletion-vector-v1` Puffin blob for the given positions, matching the framing in
+/// [`DeleteVector::deserialize`](crate::delete_vector::DeleteVector::deserialize).
+#[cfg(test)]
+pub(crate) fn encode_dv_blob(positions: impl IntoIterator<Item = u64>) -> Vec<u8> {
+    let mut bitmap = RoaringTreemap::new();
+    for pos in positions {
+        bitmap.insert(pos);
+    }
+    let mut vector = Vec::new();
+    bitmap.serialize_into(&mut vector).unwrap();
+    crate::delete_vector::frame_dv_blob(&vector)
+}
+
 /// Build a table backed by the V3 encryption fixture and an in-memory KMS,
 /// so it has an [`EncryptionManager`](crate::encryption::EncryptionManager).
 ///