You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by st...@apache.org on 2026/06/03 18:29:48 UTC

(iceberg) branch main updated: Core: Refactor v4 struct builders to improve validation (#16408)

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

stevenzwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new c646404008 Core: Refactor v4 struct builders to improve validation (#16408)
c646404008 is described below

commit c6464040081ef6c578a59a08f9d668466486dd56
Author: Anoop Johnson <an...@apache.org>
AuthorDate: Wed Jun 3 11:29:41 2026 -0700

    Core: Refactor v4 struct builders to improve validation (#16408)
---
 .../org/apache/iceberg/DeletionVectorStruct.java   |  24 +-
 .../org/apache/iceberg/ManifestInfoStruct.java     | 107 +++--
 .../java/org/apache/iceberg/TrackingBuilder.java   | 179 ++++++++
 .../java/org/apache/iceberg/TrackingStruct.java    |  97 +----
 .../apache/iceberg/TestDeletionVectorStruct.java   |  47 ++-
 .../org/apache/iceberg/TestManifestInfoStruct.java | 257 ++++++++++--
 .../org/apache/iceberg/TestTrackedFileStruct.java  |  20 +-
 .../org/apache/iceberg/TestTrackingStruct.java     | 464 +++++++++++++++++----
 8 files changed, 934 insertions(+), 261 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java b/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java
index 0eb7c2fe1e..04d23fa33a 100644
--- a/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java
+++ b/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java
@@ -51,7 +51,7 @@ class DeletionVectorStruct extends SupportsIndexProjection implements DeletionVe
   }
 
   private DeletionVectorStruct(String location, long offset, long sizeInBytes, long cardinality) {
-    super(BASE_TYPE, BASE_TYPE);
+    super(BASE_TYPE.fields().size());
     this.location = location;
     this.offset = offset;
     this.sizeInBytes = sizeInBytes;
@@ -140,37 +140,41 @@ class DeletionVectorStruct extends SupportsIndexProjection implements DeletionVe
 
   static class Builder {
     private String location = null;
-    private long offset = -1L;
-    private long sizeInBytes = -1L;
-    private long cardinality = -1L;
+    private Long offset = null;
+    private Long sizeInBytes = null;
+    private Long cardinality = null;
 
     Builder location(String dvLocation) {
+      Preconditions.checkArgument(dvLocation != null, "Invalid location: null");
       this.location = dvLocation;
       return this;
     }
 
     Builder offset(long dvOffset) {
+      Preconditions.checkArgument(dvOffset >= 0, "Invalid offset: %s (must be >= 0)", dvOffset);
       this.offset = dvOffset;
       return this;
     }
 
     Builder sizeInBytes(long dvSizeInBytes) {
+      Preconditions.checkArgument(
+          dvSizeInBytes >= 0, "Invalid size in bytes: %s (must be >= 0)", dvSizeInBytes);
       this.sizeInBytes = dvSizeInBytes;
       return this;
     }
 
     Builder cardinality(long dvCardinality) {
+      Preconditions.checkArgument(
+          dvCardinality >= 0, "Invalid cardinality: %s (must be >= 0)", dvCardinality);
       this.cardinality = dvCardinality;
       return this;
     }
 
     DeletionVectorStruct build() {
-      Preconditions.checkArgument(location != null, "Invalid location: null");
-      Preconditions.checkArgument(offset >= 0, "Invalid offset: %s (must be >= 0)", offset);
-      Preconditions.checkArgument(
-          sizeInBytes >= 0, "Invalid size in bytes: %s (must be >= 0)", sizeInBytes);
-      Preconditions.checkArgument(
-          cardinality >= 0, "Invalid cardinality: %s (must be >= 0)", cardinality);
+      Preconditions.checkArgument(location != null, "Missing required value: location");
+      Preconditions.checkArgument(offset != null, "Missing required value: offset");
+      Preconditions.checkArgument(sizeInBytes != null, "Missing required value: size in bytes");
+      Preconditions.checkArgument(cardinality != null, "Missing required value: cardinality");
       return new DeletionVectorStruct(location, offset, sizeInBytes, cardinality);
     }
   }
diff --git a/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java b/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java
index 922047bffe..6a7ccea6b6 100644
--- a/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java
+++ b/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java
@@ -74,7 +74,7 @@ class ManifestInfoStruct extends SupportsIndexProjection implements ManifestInfo
     this.dvCardinality = toCopy.dvCardinality;
   }
 
-  private ManifestInfoStruct(
+  ManifestInfoStruct(
       int addedFilesCount,
       int existingFilesCount,
       int deletedFilesCount,
@@ -86,7 +86,7 @@ class ManifestInfoStruct extends SupportsIndexProjection implements ManifestInfo
       long minSequenceNumber,
       byte[] dv,
       Long dvCardinality) {
-    super(BASE_TYPE, BASE_TYPE);
+    super(BASE_TYPE.fields().size());
     this.addedFilesCount = addedFilesCount;
     this.existingFilesCount = existingFilesCount;
     this.deletedFilesCount = deletedFilesCount;
@@ -252,121 +252,140 @@ class ManifestInfoStruct extends SupportsIndexProjection implements ManifestInfo
         .add("replaced_rows_count", replacedRowsCount)
         .add("min_sequence_number", minSequenceNumber)
         .add("dv", dv == null ? "null" : "(binary)")
-        .add("dv_cardinality", dvCardinality == null ? "null" : dvCardinality)
+        .add("dv_cardinality", dvCardinality)
         .toString();
   }
 
   static class Builder {
-    private int addedFilesCount = -1;
-    private int existingFilesCount = -1;
-    private int deletedFilesCount = -1;
-    private int replacedFilesCount = -1;
-    private long addedRowsCount = -1L;
-    private long existingRowsCount = -1L;
-    private long deletedRowsCount = -1L;
-    private long replacedRowsCount = -1L;
-    private long minSequenceNumber = -1L;
+    private Integer addedFilesCount = null;
+    private Integer existingFilesCount = null;
+    private Integer deletedFilesCount = null;
+    private Integer replacedFilesCount = null;
+    private Long addedRowsCount = null;
+    private Long existingRowsCount = null;
+    private Long deletedRowsCount = null;
+    private Long replacedRowsCount = null;
+    private Long minSequenceNumber = null;
     private byte[] dv = null;
     private Long dvCardinality = null;
 
     Builder addedFilesCount(int count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid added files count: %s (must be >= 0)", count);
       this.addedFilesCount = count;
       return this;
     }
 
     Builder existingFilesCount(int count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid existing files count: %s (must be >= 0)", count);
       this.existingFilesCount = count;
       return this;
     }
 
     Builder deletedFilesCount(int count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid deleted files count: %s (must be >= 0)", count);
       this.deletedFilesCount = count;
       return this;
     }
 
     Builder replacedFilesCount(int count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid replaced files count: %s (must be >= 0)", count);
       this.replacedFilesCount = count;
       return this;
     }
 
     Builder addedRowsCount(long count) {
+      Preconditions.checkArgument(count >= 0, "Invalid added rows count: %s (must be >= 0)", count);
       this.addedRowsCount = count;
       return this;
     }
 
     Builder existingRowsCount(long count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid existing rows count: %s (must be >= 0)", count);
       this.existingRowsCount = count;
       return this;
     }
 
     Builder deletedRowsCount(long count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid deleted rows count: %s (must be >= 0)", count);
       this.deletedRowsCount = count;
       return this;
     }
 
     Builder replacedRowsCount(long count) {
+      Preconditions.checkArgument(
+          count >= 0, "Invalid replaced rows count: %s (must be >= 0)", count);
       this.replacedRowsCount = count;
       return this;
     }
 
     Builder minSequenceNumber(long sequenceNumber) {
+      Preconditions.checkArgument(
+          sequenceNumber >= 0, "Invalid min sequence number: %s (must be >= 0)", sequenceNumber);
       this.minSequenceNumber = sequenceNumber;
       return this;
     }
 
     Builder dv(ByteBuffer buffer) {
-      this.dv = buffer != null ? ByteBuffers.toByteArray(buffer) : null;
-      return this;
-    }
-
-    Builder dv(byte[] buffer) {
-      this.dv = buffer;
+      Preconditions.checkArgument(buffer != null, "Invalid DV: null");
+      this.dv = ByteBuffers.toByteArray(buffer);
       return this;
     }
 
-    Builder dvCardinality(Long cardinality) {
+    Builder dvCardinality(long cardinality) {
+      Preconditions.checkArgument(
+          cardinality >= 0, "Invalid DV cardinality: %s (must be >= 0)", cardinality);
       this.dvCardinality = cardinality;
       return this;
     }
 
     ManifestInfoStruct build() {
       Preconditions.checkArgument(
-          addedFilesCount >= 0, "Invalid added files count: %s (must be >= 0)", addedFilesCount);
+          addedFilesCount != null, "Missing required value: added files count");
       Preconditions.checkArgument(
-          existingFilesCount >= 0,
-          "Invalid existing files count: %s (must be >= 0)",
-          existingFilesCount);
+          existingFilesCount != null, "Missing required value: existing files count");
       Preconditions.checkArgument(
-          deletedFilesCount >= 0,
-          "Invalid deleted files count: %s (must be >= 0)",
-          deletedFilesCount);
+          deletedFilesCount != null, "Missing required value: deleted files count");
       Preconditions.checkArgument(
-          replacedFilesCount >= 0,
-          "Invalid replaced files count: %s (must be >= 0)",
-          replacedFilesCount);
+          replacedFilesCount != null, "Missing required value: replaced files count");
+      Preconditions.checkArgument(
+          addedRowsCount != null, "Missing required value: added rows count");
+      Preconditions.checkArgument(
+          existingRowsCount != null, "Missing required value: existing rows count");
+      Preconditions.checkArgument(
+          deletedRowsCount != null, "Missing required value: deleted rows count");
       Preconditions.checkArgument(
-          addedRowsCount >= 0, "Invalid added rows count: %s (must be >= 0)", addedRowsCount);
+          replacedRowsCount != null, "Missing required value: replaced rows count");
       Preconditions.checkArgument(
-          existingRowsCount >= 0,
-          "Invalid existing rows count: %s (must be >= 0)",
-          existingRowsCount);
+          minSequenceNumber != null, "Missing required value: min sequence number");
       Preconditions.checkArgument(
-          deletedRowsCount >= 0, "Invalid deleted rows count: %s (must be >= 0)", deletedRowsCount);
+          addedRowsCount == 0 || addedFilesCount > 0,
+          "Invalid added counts: %s rows in %s files",
+          addedRowsCount,
+          addedFilesCount);
+      Preconditions.checkArgument(
+          existingRowsCount == 0 || existingFilesCount > 0,
+          "Invalid existing counts: %s rows in %s files",
+          existingRowsCount,
+          existingFilesCount);
       Preconditions.checkArgument(
-          replacedRowsCount >= 0,
-          "Invalid replaced rows count: %s (must be >= 0)",
-          replacedRowsCount);
+          deletedRowsCount == 0 || deletedFilesCount > 0,
+          "Invalid deleted counts: %s rows in %s files",
+          deletedRowsCount,
+          deletedFilesCount);
       Preconditions.checkArgument(
-          minSequenceNumber >= 0,
-          "Invalid min sequence number: %s (must be >= 0)",
-          minSequenceNumber);
+          replacedRowsCount == 0 || replacedFilesCount > 0,
+          "Invalid replaced counts: %s rows in %s files",
+          replacedRowsCount,
+          replacedFilesCount);
       Preconditions.checkArgument(
           (dv == null) == (dvCardinality == null),
           "Invalid DV and cardinality: must both be null or non-null");
-      Preconditions.checkArgument(
-          dvCardinality == null || dvCardinality > 0,
-          "Invalid DV cardinality: %s (must be positive)",
-          dvCardinality);
       return new ManifestInfoStruct(
           addedFilesCount,
           existingFilesCount,
diff --git a/core/src/main/java/org/apache/iceberg/TrackingBuilder.java b/core/src/main/java/org/apache/iceberg/TrackingBuilder.java
new file mode 100644
index 0000000000..bf3cb3a066
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/TrackingBuilder.java
@@ -0,0 +1,179 @@
+/*
+ * 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.
+ */
+package org.apache.iceberg;
+
+import java.nio.ByteBuffer;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.ByteBuffers;
+
+class TrackingBuilder {
+  private final EntryStatus status;
+  private final Long snapshotId;
+  private final Long dataSequenceNumber;
+  private final Long fileSequenceNumber;
+  private final Long firstRowId;
+  // ID of the snapshot in which the new Tracking instance will be committed.
+  private final long newSnapshotId;
+  private Long dvSnapshotId;
+  private byte[] deletedPositions;
+  private byte[] replacedPositions;
+
+  /**
+   * Creates a builder for a newly added file.
+   *
+   * @param newSnapshotId the snapshot ID in which the new tracking instance will be committed
+   */
+  static TrackingBuilder added(long newSnapshotId) {
+    return new TrackingBuilder(newSnapshotId);
+  }
+
+  /**
+   * Creates a builder for a tracking row derived from {@code source}.
+   *
+   * @param source source tracking from a manifest entry
+   * @param newSnapshotId the snapshot ID in which the new tracking instance will be committed
+   */
+  static TrackingBuilder from(Tracking source, long newSnapshotId) {
+    return new TrackingBuilder(source, newSnapshotId);
+  }
+
+  /**
+   * Returns a DELETED tracking row derived from {@code source}.
+   *
+   * @param source source tracking from a manifest entry
+   * @param newSnapshotId the snapshot ID in which the new tracking instance will be committed
+   */
+  static Tracking deleted(Tracking source, long newSnapshotId) {
+    return terminal(EntryStatus.DELETED, source, newSnapshotId);
+  }
+
+  /**
+   * Returns a REPLACED tracking row derived from {@code source}.
+   *
+   * @param source source tracking from a manifest entry
+   * @param newSnapshotId the snapshot ID in which the new tracking instance will be committed
+   */
+  static Tracking replaced(Tracking source, long newSnapshotId) {
+    return terminal(EntryStatus.REPLACED, source, newSnapshotId);
+  }
+
+  private TrackingBuilder(long newSnapshotId) {
+    this.status = EntryStatus.ADDED;
+    this.snapshotId = newSnapshotId;
+    this.newSnapshotId = newSnapshotId;
+    this.dataSequenceNumber = null;
+    this.fileSequenceNumber = null;
+    this.firstRowId = null;
+    this.dvSnapshotId = null;
+    this.deletedPositions = null;
+    this.replacedPositions = null;
+  }
+
+  private TrackingBuilder(Tracking source, long newSnapshotId) {
+    validateSource(source);
+    validateStatusTransition(source.status(), EntryStatus.EXISTING);
+    this.status = EntryStatus.EXISTING;
+    this.snapshotId = source.snapshotId();
+    this.newSnapshotId = newSnapshotId;
+    this.dataSequenceNumber = source.dataSequenceNumber();
+    this.fileSequenceNumber = source.fileSequenceNumber();
+    this.firstRowId = source.firstRowId();
+    this.dvSnapshotId = source.dvSnapshotId();
+    this.deletedPositions = null;
+    this.replacedPositions = null;
+  }
+
+  /** Indicates that the DV has been updated for the new Tracking. */
+  TrackingBuilder dvUpdated() {
+    // DV applies to data files; deleted/replaced positions apply to manifest files
+    Preconditions.checkState(
+        deletedPositions == null && replacedPositions == null,
+        "Cannot mark DV updated on a manifest entry (deleted/replaced positions are set)");
+    this.dvSnapshotId = newSnapshotId;
+    return this;
+  }
+
+  TrackingBuilder deletedPositions(ByteBuffer positions) {
+    Preconditions.checkState(
+        status == EntryStatus.EXISTING, "Cannot set deleted positions on %s entry", status);
+    // DV applies to data files; deleted positions apply to manifest files
+    Preconditions.checkState(
+        dvSnapshotId == null,
+        "Cannot set deleted positions on a data file entry (DV snapshot ID is set)");
+    this.deletedPositions = ByteBuffers.toByteArray(positions);
+    return this;
+  }
+
+  TrackingBuilder replacedPositions(ByteBuffer positions) {
+    Preconditions.checkState(
+        status == EntryStatus.EXISTING, "Cannot set replaced positions on %s entry", status);
+    // DV applies to data files; replaced positions apply to manifest files
+    Preconditions.checkState(
+        dvSnapshotId == null,
+        "Cannot set replaced positions on a data file entry (DV snapshot ID is set)");
+    this.replacedPositions = ByteBuffers.toByteArray(positions);
+    return this;
+  }
+
+  Tracking build() {
+    return new TrackingStruct(
+        status,
+        snapshotId,
+        dataSequenceNumber,
+        fileSequenceNumber,
+        dvSnapshotId,
+        firstRowId,
+        deletedPositions,
+        replacedPositions);
+  }
+
+  private static Tracking terminal(EntryStatus to, Tracking source, long newSnapshotId) {
+    validateSource(source);
+    validateStatusTransition(source.status(), to);
+    return new TrackingStruct(
+        to,
+        newSnapshotId,
+        source.dataSequenceNumber(),
+        source.fileSequenceNumber(),
+        source.dvSnapshotId(),
+        source.firstRowId(),
+        null,
+        null);
+  }
+
+  private static void validateSource(Tracking source) {
+    Preconditions.checkArgument(source != null, "Invalid source tracking: null");
+    Preconditions.checkArgument(
+        source.dataSequenceNumber() != null,
+        "Invalid tracking source: data sequence number is null");
+    Preconditions.checkArgument(
+        source.fileSequenceNumber() != null,
+        "Invalid tracking source: file sequence number is null");
+  }
+
+  private static void validateStatusTransition(EntryStatus from, EntryStatus to) {
+    Preconditions.checkState(from != null, "Invalid tracking source: status is null");
+    Preconditions.checkState(
+        from != EntryStatus.DELETED && from != EntryStatus.REPLACED,
+        "Cannot revive non-live entry with status %s",
+        from);
+    Preconditions.checkState(
+        to != EntryStatus.ADDED, "Cannot transition to ADDED: ADDED is the starting status");
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/TrackingStruct.java b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
index 65513c8d4a..8ae4b7e4ce 100644
--- a/core/src/main/java/org/apache/iceberg/TrackingStruct.java
+++ b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
@@ -59,6 +59,11 @@ class TrackingStruct extends SupportsIndexProjection implements Tracking, Serial
     super(BASE_TYPE, type);
   }
 
+  /** Constructor for Java serialization. */
+  TrackingStruct() {
+    super(BASE_TYPE.fields().size());
+  }
+
   private TrackingStruct(TrackingStruct toCopy) {
     super(toCopy);
     this.status = toCopy.status;
@@ -79,7 +84,7 @@ class TrackingStruct extends SupportsIndexProjection implements Tracking, Serial
     this.manifestPos = toCopy.manifestPos;
   }
 
-  private TrackingStruct(
+  TrackingStruct(
       EntryStatus status,
       Long snapshotId,
       Long dataSequenceNumber,
@@ -88,7 +93,7 @@ class TrackingStruct extends SupportsIndexProjection implements Tracking, Serial
       Long firstRowId,
       byte[] deletedPositions,
       byte[] replacedPositions) {
-    super(BASE_TYPE, BASE_TYPE);
+    super(BASE_TYPE.fields().size());
     this.status = status;
     this.snapshotId = snapshotId;
     this.dataSequenceNumber = dataSequenceNumber;
@@ -249,95 +254,17 @@ class TrackingStruct extends SupportsIndexProjection implements Tracking, Serial
     }
   }
 
-  static Builder builder() {
-    return new Builder();
-  }
-
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
         .add("status", status)
-        .add("snapshot_id", snapshotId == null ? "null" : snapshotId)
-        .add("data_sequence_number", dataSequenceNumber == null ? "null" : dataSequenceNumber)
-        .add("file_sequence_number", fileSequenceNumber == null ? "null" : fileSequenceNumber)
-        .add("dv_snapshot_id", dvSnapshotId == null ? "null" : dvSnapshotId)
-        .add("first_row_id", firstRowId == null ? "null" : firstRowId)
+        .add("snapshot_id", snapshotId)
+        .add("data_sequence_number", dataSequenceNumber)
+        .add("file_sequence_number", fileSequenceNumber)
+        .add("dv_snapshot_id", dvSnapshotId)
+        .add("first_row_id", firstRowId)
         .add("deleted_positions", deletedPositions == null ? "null" : "(binary)")
         .add("replaced_positions", replacedPositions == null ? "null" : "(binary)")
         .toString();
   }
-
-  static class Builder {
-    private EntryStatus status = null;
-    private Long snapshotId = null;
-    private Long dataSequenceNumber = null;
-    private Long fileSequenceNumber = null;
-    private Long dvSnapshotId = null;
-    private Long firstRowId = null;
-    private byte[] deletedPositions = null;
-    private byte[] replacedPositions = null;
-
-    Builder status(EntryStatus entryStatus) {
-      this.status = entryStatus;
-      return this;
-    }
-
-    Builder snapshotId(Long id) {
-      this.snapshotId = id;
-      return this;
-    }
-
-    Builder dataSequenceNumber(Long sequenceNumber) {
-      this.dataSequenceNumber = sequenceNumber;
-      return this;
-    }
-
-    Builder fileSequenceNumber(Long sequenceNumber) {
-      this.fileSequenceNumber = sequenceNumber;
-      return this;
-    }
-
-    Builder dvSnapshotId(Long id) {
-      this.dvSnapshotId = id;
-      return this;
-    }
-
-    Builder firstRowId(Long rowId) {
-      this.firstRowId = rowId;
-      return this;
-    }
-
-    Builder deletedPositions(ByteBuffer positions) {
-      this.deletedPositions = positions != null ? ByteBuffers.toByteArray(positions) : null;
-      return this;
-    }
-
-    Builder deletedPositions(byte[] positions) {
-      this.deletedPositions = positions;
-      return this;
-    }
-
-    Builder replacedPositions(ByteBuffer positions) {
-      this.replacedPositions = positions != null ? ByteBuffers.toByteArray(positions) : null;
-      return this;
-    }
-
-    Builder replacedPositions(byte[] positions) {
-      this.replacedPositions = positions;
-      return this;
-    }
-
-    TrackingStruct build() {
-      Preconditions.checkArgument(status != null, "Invalid status: null");
-      return new TrackingStruct(
-          status,
-          snapshotId,
-          dataSequenceNumber,
-          fileSequenceNumber,
-          dvSnapshotId,
-          firstRowId,
-          deletedPositions,
-          replacedPositions);
-    }
-  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java b/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java
index 325f9afd9c..8242be38e9 100644
--- a/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java
@@ -87,6 +87,26 @@ class TestDeletionVectorStruct {
     assertThat(dv.get(1, Long.class)).isEqualTo(42L);
   }
 
+  @Test
+  void testInternalSetIgnoresUnknownOrdinal() {
+    DeletionVectorStruct dv =
+        DeletionVectorStruct.builder()
+            .location("s3://bucket/data/dv.puffin")
+            .offset(100L)
+            .sizeInBytes(512L)
+            .cardinality(42L)
+            .build();
+
+    // unknown ordinals from a newer format version are silently ignored
+    dv.internalSet(99, "value from a newer format");
+
+    // every field is unchanged
+    assertThat(dv.location()).isEqualTo("s3://bucket/data/dv.puffin");
+    assertThat(dv.offset()).isEqualTo(100L);
+    assertThat(dv.sizeInBytes()).isEqualTo(512L);
+    assertThat(dv.cardinality()).isEqualTo(42L);
+  }
+
   @Test
   void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException {
     DeletionVectorStruct dv =
@@ -106,11 +126,11 @@ class TestDeletionVectorStruct {
   }
 
   @Test
-  void testBuilderValidation() {
+  void testBuilderMissingRequiredFields() {
     assertThatThrownBy(
             () -> DeletionVectorStruct.builder().offset(0).sizeInBytes(1).cardinality(1).build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid location: null");
+        .hasMessage("Missing required value: location");
 
     assertThatThrownBy(
             () ->
@@ -120,7 +140,7 @@ class TestDeletionVectorStruct {
                     .cardinality(1)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid offset: -1 (must be >= 0)");
+        .hasMessage("Missing required value: offset");
 
     assertThatThrownBy(
             () ->
@@ -130,7 +150,7 @@ class TestDeletionVectorStruct {
                     .cardinality(1)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid size in bytes: -1 (must be >= 0)");
+        .hasMessage("Missing required value: size in bytes");
 
     assertThatThrownBy(
             () ->
@@ -140,6 +160,25 @@ class TestDeletionVectorStruct {
                     .sizeInBytes(1)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Missing required value: cardinality");
+  }
+
+  @Test
+  void testBuilderRejectsInvalidValuesAtSetter() {
+    assertThatThrownBy(() -> DeletionVectorStruct.builder().location(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid location: null");
+
+    assertThatThrownBy(() -> DeletionVectorStruct.builder().offset(-1))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid offset: -1 (must be >= 0)");
+
+    assertThatThrownBy(() -> DeletionVectorStruct.builder().sizeInBytes(-1))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid size in bytes: -1 (must be >= 0)");
+
+    assertThatThrownBy(() -> DeletionVectorStruct.builder().cardinality(-1))
+        .isInstanceOf(IllegalArgumentException.class)
         .hasMessage("Invalid cardinality: -1 (must be >= 0)");
   }
 
diff --git a/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java b/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java
index 3a694f1a38..9d78dca65f 100644
--- a/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java
@@ -30,19 +30,8 @@ class TestManifestInfoStruct {
 
   @Test
   void testFieldAccess() {
-    ManifestInfoStruct info = new ManifestInfoStruct(ManifestInfo.schema());
-
-    info.set(0, 10);
-    info.set(1, 20);
-    info.set(2, 3);
-    info.set(3, 2);
-    info.set(4, 1000L);
-    info.set(5, 2000L);
-    info.set(6, 300L);
-    info.set(7, 200L);
-    info.set(8, 5L);
-    info.set(9, ByteBuffer.wrap(new byte[] {0xF}));
-    info.set(10, 1L);
+    ManifestInfoStruct info =
+        new ManifestInfoStruct(10, 20, 3, 2, 1000L, 2000L, 300L, 200L, 5L, new byte[] {0xF}, 1L);
 
     assertThat(info.addedFilesCount()).isEqualTo(10);
     assertThat(info.existingFilesCount()).isEqualTo(20);
@@ -70,7 +59,7 @@ class TestManifestInfoStruct {
             .deletedRowsCount(300L)
             .replacedRowsCount(200L)
             .minSequenceNumber(5L)
-            .dv(new byte[] {0xF})
+            .dv(ByteBuffer.wrap(new byte[] {0xF}))
             .dvCardinality(1L)
             .build();
 
@@ -130,6 +119,40 @@ class TestManifestInfoStruct {
     assertThat(info.get(1, Long.class)).isEqualTo(5L);
   }
 
+  @Test
+  void testInternalSetIgnoresUnknownOrdinal() {
+    ManifestInfoStruct info =
+        ManifestInfoStruct.builder()
+            .addedFilesCount(10)
+            .existingFilesCount(20)
+            .deletedFilesCount(3)
+            .replacedFilesCount(2)
+            .addedRowsCount(1000L)
+            .existingRowsCount(2000L)
+            .deletedRowsCount(300L)
+            .replacedRowsCount(200L)
+            .minSequenceNumber(5L)
+            .dv(ByteBuffer.wrap(new byte[] {0xF}))
+            .dvCardinality(1L)
+            .build();
+
+    // unknown ordinals from a newer format version are silently ignored
+    info.internalSet(99, "value from a newer format");
+
+    // every field is unchanged
+    assertThat(info.addedFilesCount()).isEqualTo(10);
+    assertThat(info.existingFilesCount()).isEqualTo(20);
+    assertThat(info.deletedFilesCount()).isEqualTo(3);
+    assertThat(info.replacedFilesCount()).isEqualTo(2);
+    assertThat(info.addedRowsCount()).isEqualTo(1000L);
+    assertThat(info.existingRowsCount()).isEqualTo(2000L);
+    assertThat(info.deletedRowsCount()).isEqualTo(300L);
+    assertThat(info.replacedRowsCount()).isEqualTo(200L);
+    assertThat(info.minSequenceNumber()).isEqualTo(5L);
+    assertThat(info.dv()).isEqualTo(ByteBuffer.wrap(new byte[] {0xF}));
+    assertThat(info.dvCardinality()).isEqualTo(1L);
+  }
+
   @Test
   void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException {
     ManifestInfoStruct info =
@@ -143,7 +166,7 @@ class TestManifestInfoStruct {
             .deletedRowsCount(300L)
             .replacedRowsCount(200L)
             .minSequenceNumber(5L)
-            .dv(new byte[] {0xF})
+            .dv(ByteBuffer.wrap(new byte[] {0xF}))
             .dvCardinality(1L)
             .build();
 
@@ -163,7 +186,7 @@ class TestManifestInfoStruct {
   }
 
   @Test
-  void testBuilderValidation() {
+  void testBuilderMissingAddedFilesCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -177,8 +200,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid added files count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: added files count");
+  }
 
+  @Test
+  void testBuilderMissingExistingFilesCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -192,8 +218,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid existing files count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: existing files count");
+  }
 
+  @Test
+  void testBuilderMissingDeletedFilesCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -207,8 +236,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid deleted files count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: deleted files count");
+  }
 
+  @Test
+  void testBuilderMissingReplacedFilesCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -222,8 +254,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid replaced files count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: replaced files count");
+  }
 
+  @Test
+  void testBuilderMissingAddedRowsCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -237,8 +272,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid added rows count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: added rows count");
+  }
 
+  @Test
+  void testBuilderMissingExistingRowsCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -252,8 +290,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid existing rows count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: existing rows count");
+  }
 
+  @Test
+  void testBuilderMissingDeletedRowsCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -267,8 +308,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid deleted rows count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: deleted rows count");
+  }
 
+  @Test
+  void testBuilderMissingReplacedRowsCount() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -282,8 +326,11 @@ class TestManifestInfoStruct {
                     .minSequenceNumber(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid replaced rows count: -1 (must be >= 0)");
+        .hasMessage("Missing required value: replaced rows count");
+  }
 
+  @Test
+  void testBuilderMissingMinSequenceNumber() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -297,11 +344,81 @@ class TestManifestInfoStruct {
                     .replacedRowsCount(0L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Missing required value: min sequence number");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeAddedFilesCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().addedFilesCount(-1))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid added files count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeExistingFilesCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().existingFilesCount(-1))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid existing files count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeDeletedFilesCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().deletedFilesCount(-1))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid deleted files count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeReplacedFilesCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().replacedFilesCount(-1))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid replaced files count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeAddedRowsCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().addedRowsCount(-1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid added rows count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeExistingRowsCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().existingRowsCount(-1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid existing rows count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeDeletedRowsCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().deletedRowsCount(-1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid deleted rows count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeReplacedRowsCount() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().replacedRowsCount(-1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid replaced rows count: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsNegativeMinSequenceNumber() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().minSequenceNumber(-1L))
+        .isInstanceOf(IllegalArgumentException.class)
         .hasMessage("Invalid min sequence number: -1 (must be >= 0)");
   }
 
   @Test
-  void testBuilderDvPairingValidation() {
+  void testBuilderRejectsNegativeDvCardinality() {
+    assertThatThrownBy(() -> ManifestInfoStruct.builder().dvCardinality(-1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid DV cardinality: -1 (must be >= 0)");
+  }
+
+  @Test
+  void testBuilderRejectsRowsWithoutFiles() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -309,16 +426,91 @@ class TestManifestInfoStruct {
                     .existingFilesCount(0)
                     .deletedFilesCount(0)
                     .replacedFilesCount(0)
-                    .addedRowsCount(0L)
+                    .addedRowsCount(10L)
                     .existingRowsCount(0L)
                     .deletedRowsCount(0L)
                     .replacedRowsCount(0L)
                     .minSequenceNumber(0L)
-                    .dv(new byte[] {0xF})
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid DV and cardinality: must both be null or non-null");
+        .hasMessage("Invalid added counts: 10 rows in 0 files");
+
+    assertThatThrownBy(
+            () ->
+                ManifestInfoStruct.builder()
+                    .addedFilesCount(0)
+                    .existingFilesCount(0)
+                    .deletedFilesCount(0)
+                    .replacedFilesCount(0)
+                    .addedRowsCount(0L)
+                    .existingRowsCount(5L)
+                    .deletedRowsCount(0L)
+                    .replacedRowsCount(0L)
+                    .minSequenceNumber(0L)
+                    .build())
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid existing counts: 5 rows in 0 files");
+
+    assertThatThrownBy(
+            () ->
+                ManifestInfoStruct.builder()
+                    .addedFilesCount(0)
+                    .existingFilesCount(0)
+                    .deletedFilesCount(0)
+                    .replacedFilesCount(0)
+                    .addedRowsCount(0L)
+                    .existingRowsCount(0L)
+                    .deletedRowsCount(3L)
+                    .replacedRowsCount(0L)
+                    .minSequenceNumber(0L)
+                    .build())
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid deleted counts: 3 rows in 0 files");
 
+    assertThatThrownBy(
+            () ->
+                ManifestInfoStruct.builder()
+                    .addedFilesCount(0)
+                    .existingFilesCount(0)
+                    .deletedFilesCount(0)
+                    .replacedFilesCount(0)
+                    .addedRowsCount(0L)
+                    .existingRowsCount(0L)
+                    .deletedRowsCount(0L)
+                    .replacedRowsCount(7L)
+                    .minSequenceNumber(0L)
+                    .build())
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid replaced counts: 7 rows in 0 files");
+  }
+
+  @Test
+  void testBuilderAllowsFilesWithoutRows() {
+    ManifestInfoStruct info =
+        ManifestInfoStruct.builder()
+            .addedFilesCount(5)
+            .existingFilesCount(5)
+            .deletedFilesCount(5)
+            .replacedFilesCount(5)
+            .addedRowsCount(0L)
+            .existingRowsCount(0L)
+            .deletedRowsCount(0L)
+            .replacedRowsCount(0L)
+            .minSequenceNumber(0L)
+            .build();
+
+    assertThat(info.addedFilesCount()).isEqualTo(5);
+    assertThat(info.existingFilesCount()).isEqualTo(5);
+    assertThat(info.deletedFilesCount()).isEqualTo(5);
+    assertThat(info.replacedFilesCount()).isEqualTo(5);
+    assertThat(info.addedRowsCount()).isEqualTo(0L);
+    assertThat(info.existingRowsCount()).isEqualTo(0L);
+    assertThat(info.deletedRowsCount()).isEqualTo(0L);
+    assertThat(info.replacedRowsCount()).isEqualTo(0L);
+  }
+
+  @Test
+  void testBuilderDvPairingValidation() {
     assertThatThrownBy(
             () ->
                 ManifestInfoStruct.builder()
@@ -331,7 +523,7 @@ class TestManifestInfoStruct {
                     .deletedRowsCount(0L)
                     .replacedRowsCount(0L)
                     .minSequenceNumber(0L)
-                    .dvCardinality(1L)
+                    .dv(ByteBuffer.wrap(new byte[] {0xF}))
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
         .hasMessage("Invalid DV and cardinality: must both be null or non-null");
@@ -348,11 +540,10 @@ class TestManifestInfoStruct {
                     .deletedRowsCount(0L)
                     .replacedRowsCount(0L)
                     .minSequenceNumber(0L)
-                    .dv(new byte[] {0xF})
-                    .dvCardinality(0L)
+                    .dvCardinality(1L)
                     .build())
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid DV cardinality: 0 (must be positive)");
+        .hasMessage("Invalid DV and cardinality: must both be null or non-null");
   }
 
   @Test
@@ -368,7 +559,7 @@ class TestManifestInfoStruct {
             .deletedRowsCount(300L)
             .replacedRowsCount(200L)
             .minSequenceNumber(5L)
-            .dv(new byte[] {0xF})
+            .dv(ByteBuffer.wrap(new byte[] {0xF}))
             .dvCardinality(1L)
             .build();
 
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
index 3abb36aa51..8891db408b 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
@@ -35,11 +35,14 @@ class TestTrackedFileStruct {
           Types.NestedField.optional(1000, "id_bucket", Types.IntegerType.get()),
           Types.NestedField.optional(1001, "category", Types.StringType.get()));
 
+  // Ordinal of MetadataColumns.ROW_POSITION within TrackingStruct's BASE_TYPE,
+  // which appends ROW_POSITION after the Tracking schema fields.
+  private static final int MANIFEST_POS_ORDINAL = Tracking.schema().fields().size();
+
   @Test
   void testFieldAccess() {
     TrackedFileStruct file = new TrackedFileStruct();
-    TrackingStruct tracking =
-        TrackingStruct.builder().status(EntryStatus.ADDED).snapshotId(42L).build();
+    Tracking tracking = TrackingBuilder.added(42L).build();
     DeletionVectorStruct dv =
         DeletionVectorStruct.builder()
             .location("s3://bucket/dv.puffin")
@@ -98,9 +101,9 @@ class TestTrackedFileStruct {
   void testReaderSideFields() {
     TrackedFileStruct file = new TrackedFileStruct();
 
-    TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build();
+    TrackingStruct tracking = new TrackingStruct();
     tracking.setManifestLocation("s3://bucket/metadata/manifest.avro");
-    tracking.set(8, 7L);
+    tracking.set(MANIFEST_POS_ORDINAL, 7L);
 
     file.set(0, tracking);
     file.set(1, FileContent.DATA.id());
@@ -328,14 +331,9 @@ class TestTrackedFileStruct {
   }
 
   static TrackedFileStruct createFullTrackedFile() {
-    TrackingStruct tracking =
-        TrackingStruct.builder()
-            .status(EntryStatus.ADDED)
-            .snapshotId(42L)
-            .dataSequenceNumber(10L)
-            .build();
+    TrackingStruct tracking = (TrackingStruct) TrackingBuilder.added(42L).build();
     tracking.setManifestLocation("s3://bucket/manifest.avro");
-    tracking.set(8, 3L);
+    tracking.set(MANIFEST_POS_ORDINAL, 3L);
 
     DeletionVectorStruct dv =
         DeletionVectorStruct.builder()
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
index 98a7eff2af..0bbd8b3480 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
@@ -23,23 +23,44 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.io.IOException;
 import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
 import org.apache.iceberg.types.Types;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
 
 class TestTrackingStruct {
 
+  // Ordinals looked up from Tracking.schema() so tests don't hard-code positions.
+  private static final List<Types.NestedField> TRACKING_FIELDS = Tracking.schema().fields();
+  private static final int STATUS_ORDINAL = TRACKING_FIELDS.indexOf(Tracking.STATUS);
+  private static final int SNAPSHOT_ID_ORDINAL = TRACKING_FIELDS.indexOf(Tracking.SNAPSHOT_ID);
+  private static final int DATA_SEQUENCE_NUMBER_ORDINAL =
+      TRACKING_FIELDS.indexOf(Tracking.SEQUENCE_NUMBER);
+  private static final int FILE_SEQUENCE_NUMBER_ORDINAL =
+      TRACKING_FIELDS.indexOf(Tracking.FILE_SEQUENCE_NUMBER);
+  private static final int DV_SNAPSHOT_ID_ORDINAL =
+      TRACKING_FIELDS.indexOf(Tracking.DV_SNAPSHOT_ID);
+  private static final int FIRST_ROW_ID_ORDINAL = TRACKING_FIELDS.indexOf(Tracking.FIRST_ROW_ID);
+  private static final int DELETED_POSITIONS_ORDINAL =
+      TRACKING_FIELDS.indexOf(Tracking.DELETED_POSITIONS);
+  private static final int REPLACED_POSITIONS_ORDINAL =
+      TRACKING_FIELDS.indexOf(Tracking.REPLACED_POSITIONS);
+
   @Test
   void testFieldAccess() {
     TrackingStruct tracking = new TrackingStruct(Tracking.schema());
 
-    tracking.set(0, EntryStatus.ADDED.id());
-    tracking.set(1, 42L);
-    tracking.set(2, 10L);
-    tracking.set(3, 11L);
-    tracking.set(4, 43L);
-    tracking.set(5, 1000L);
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    tracking.set(SNAPSHOT_ID_ORDINAL, 42L);
+    tracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, 10L);
+    tracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, 11L);
+    tracking.set(DV_SNAPSHOT_ID_ORDINAL, 43L);
+    tracking.set(FIRST_ROW_ID_ORDINAL, 1000L);
 
     assertThat(tracking.status()).isEqualTo(EntryStatus.ADDED);
     assertThat(tracking.snapshotId()).isEqualTo(42L);
@@ -53,30 +74,33 @@ class TestTrackingStruct {
 
   @Test
   void testCopy() {
-    TrackingStruct tracking =
-        TrackingStruct.builder()
-            .status(EntryStatus.ADDED)
-            .snapshotId(42L)
-            .dataSequenceNumber(10L)
-            .deletedPositions(new byte[] {1, 2})
+    Tracking tracking =
+        TrackingBuilder.from(manifestSourceTracking(), 1L)
+            .deletedPositions(ByteBuffer.wrap(new byte[] {1, 2}))
+            .replacedPositions(ByteBuffer.wrap(new byte[] {3, 4}))
             .build();
 
-    TrackingStruct copy = tracking.copy();
+    Tracking copy = tracking.copy();
 
-    assertThat(copy.status()).isEqualTo(EntryStatus.ADDED);
-    assertThat(copy.snapshotId()).isEqualTo(42L);
-    assertThat(copy.dataSequenceNumber()).isEqualTo(10L);
-    assertThat(copy.deletedPositions()).isNotNull();
+    assertThat(copy.status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(copy.snapshotId()).isEqualTo(tracking.snapshotId());
+    assertThat(copy.dataSequenceNumber()).isEqualTo(tracking.dataSequenceNumber());
+    assertThat(copy.fileSequenceNumber()).isEqualTo(tracking.fileSequenceNumber());
+    assertThat(copy.dvSnapshotId()).isNull();
+    assertThat(copy.firstRowId()).isEqualTo(tracking.firstRowId());
+    assertThat(copy.deletedPositions()).isEqualTo(tracking.deletedPositions());
+    assertThat(copy.replacedPositions()).isEqualTo(tracking.replacedPositions());
 
-    // verify deep copy of ByteBuffer
-    assertThat(copy.deletedPositions()).isNotSameAs(tracking.deletedPositions());
+    // verify deep copy of ByteBuffer backing arrays
+    assertThat(copy.deletedPositions().array()).isNotSameAs(tracking.deletedPositions().array());
+    assertThat(copy.replacedPositions().array()).isNotSameAs(tracking.replacedPositions().array());
   }
 
   @ParameterizedTest
   @EnumSource(EntryStatus.class)
   void testAllStatuses(EntryStatus status) {
     TrackingStruct tracking = new TrackingStruct(Tracking.schema());
-    tracking.set(0, status.id());
+    tracking.set(STATUS_ORDINAL, status.id());
     assertThat(tracking.status()).isEqualTo(status);
   }
 
@@ -84,22 +108,24 @@ class TestTrackingStruct {
   void testIsLive() {
     TrackingStruct tracking = new TrackingStruct(Tracking.schema());
 
-    tracking.set(0, EntryStatus.ADDED.id());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
     assertThat(tracking.isLive()).isTrue();
 
-    tracking.set(0, EntryStatus.EXISTING.id());
+    tracking.set(STATUS_ORDINAL, EntryStatus.EXISTING.id());
     assertThat(tracking.isLive()).isTrue();
 
-    tracking.set(0, EntryStatus.DELETED.id());
+    tracking.set(STATUS_ORDINAL, EntryStatus.DELETED.id());
     assertThat(tracking.isLive()).isFalse();
 
-    tracking.set(0, EntryStatus.REPLACED.id());
+    tracking.set(STATUS_ORDINAL, EntryStatus.REPLACED.id());
     assertThat(tracking.isLive()).isFalse();
   }
 
   @Test
   void testInheritSnapshotId() {
-    TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build();
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+
     tracking.inheritFrom(createManifestTracking(100L, 60L));
 
     // snapshotId is null, should inherit from manifest
@@ -108,7 +134,9 @@ class TestTrackingStruct {
 
   @Test
   void testInheritSequenceNumberForAddedEntries() {
-    TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build();
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+
     tracking.inheritFrom(createManifestTracking(100L, 60L));
 
     // sequence numbers are null and status is ADDED, should inherit
@@ -118,12 +146,11 @@ class TestTrackingStruct {
 
   @Test
   void testDoNotInheritSequenceNumberForExistingEntries() {
-    TrackingStruct tracking =
-        TrackingStruct.builder()
-            .status(EntryStatus.EXISTING)
-            .dataSequenceNumber(5L)
-            .fileSequenceNumber(6L)
-            .build();
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.EXISTING.id());
+    tracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, 5L);
+    tracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, 6L);
+
     tracking.inheritFrom(createManifestTracking(100L, 60L));
 
     // sequence numbers are not inherited for EXISTING entries
@@ -133,13 +160,12 @@ class TestTrackingStruct {
 
   @Test
   void testExplicitValuesOverrideInheritance() {
-    TrackingStruct tracking =
-        TrackingStruct.builder()
-            .status(EntryStatus.ADDED)
-            .snapshotId(200L)
-            .dataSequenceNumber(75L)
-            .fileSequenceNumber(76L)
-            .build();
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    tracking.set(SNAPSHOT_ID_ORDINAL, 200L);
+    tracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, 75L);
+    tracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, 76L);
+
     tracking.inheritFrom(createManifestTracking(100L, 60L));
 
     // explicit values should take precedence
@@ -151,13 +177,13 @@ class TestTrackingStruct {
   @Test
   void testInheritFromRejectsUnequalSequenceNumbers() {
     TrackingStruct tracking = new TrackingStruct(Tracking.schema());
-    tracking.set(0, EntryStatus.ADDED.id());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
 
     TrackingStruct manifestTracking = new TrackingStruct(Tracking.schema());
-    manifestTracking.set(0, EntryStatus.ADDED.id());
-    manifestTracking.set(1, 100L);
-    manifestTracking.set(2, 50L);
-    manifestTracking.set(3, 60L);
+    manifestTracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    manifestTracking.set(SNAPSHOT_ID_ORDINAL, 100L);
+    manifestTracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, 50L);
+    manifestTracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, 60L);
 
     assertThatThrownBy(() -> tracking.inheritFrom(manifestTracking))
         .isInstanceOf(IllegalArgumentException.class)
@@ -166,7 +192,8 @@ class TestTrackingStruct {
 
   @Test
   void testNoDefaultingWithoutInheritance() {
-    TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build();
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
 
     // no inheritance, nulls stay null
     assertThat(tracking.snapshotId()).isNull();
@@ -174,20 +201,262 @@ class TestTrackingStruct {
     assertThat(tracking.fileSequenceNumber()).isNull();
   }
 
+  @Test
+  void testInheritFromNullIsNoOp() {
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+
+    tracking.inheritFrom(null);
+
+    // null source is a no-op; all unset fields stay null
+    assertThat(tracking.snapshotId()).isNull();
+    assertThat(tracking.dataSequenceNumber()).isNull();
+    assertThat(tracking.fileSequenceNumber()).isNull();
+  }
+
   private static Tracking createManifestTracking(long snapshotId, long sequenceNumber) {
-    return TrackingStruct.builder()
-        .status(EntryStatus.ADDED)
-        .snapshotId(snapshotId)
-        .dataSequenceNumber(sequenceNumber)
-        .fileSequenceNumber(sequenceNumber)
-        .build();
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    tracking.set(SNAPSHOT_ID_ORDINAL, snapshotId);
+    tracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, sequenceNumber);
+    tracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, sequenceNumber);
+    return tracking;
   }
 
   @Test
-  void testBuilderValidation() {
-    assertThatThrownBy(() -> TrackingStruct.builder().build())
+  void testAddedBuilder() {
+    Tracking tracking = TrackingBuilder.added(42L).dvUpdated().build();
+
+    assertThat(tracking.status()).isEqualTo(EntryStatus.ADDED);
+    assertThat(tracking.snapshotId()).isEqualTo(42L);
+    assertThat(tracking.dvSnapshotId()).isEqualTo(42L);
+    assertThat(tracking.deletedPositions()).isNull();
+    assertThat(tracking.replacedPositions()).isNull();
+    // sequence numbers and firstRowId remain null; populated by inheritance
+    assertThat(tracking.dataSequenceNumber()).isNull();
+    assertThat(tracking.fileSequenceNumber()).isNull();
+    assertThat(tracking.firstRowId()).isNull();
+  }
+
+  @Test
+  void testExistingBuilderPreservesSourceFields() {
+    Tracking source = sourceTracking();
+
+    Tracking existing = TrackingBuilder.from(source, 1L).build();
+
+    assertThat(existing.status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(existing.snapshotId()).isEqualTo(source.snapshotId());
+    assertThat(existing.dataSequenceNumber()).isEqualTo(source.dataSequenceNumber());
+    assertThat(existing.fileSequenceNumber()).isEqualTo(source.fileSequenceNumber());
+    assertThat(existing.dvSnapshotId()).isEqualTo(source.dvSnapshotId());
+    assertThat(existing.firstRowId()).isEqualTo(source.firstRowId());
+  }
+
+  @Test
+  void testDeleteUpdatesSnapshotIdAndPreservesRest() {
+    Tracking source = sourceTracking();
+
+    Tracking deleted = TrackingBuilder.deleted(source, 999L);
+
+    assertThat(deleted.status()).isEqualTo(EntryStatus.DELETED);
+    assertThat(deleted.snapshotId()).isEqualTo(999L);
+    assertThat(deleted.dataSequenceNumber()).isEqualTo(source.dataSequenceNumber());
+    assertThat(deleted.fileSequenceNumber()).isEqualTo(source.fileSequenceNumber());
+    assertThat(deleted.dvSnapshotId()).isEqualTo(source.dvSnapshotId());
+    assertThat(deleted.firstRowId()).isEqualTo(source.firstRowId());
+  }
+
+  @Test
+  void testReplaceUpdatesSnapshotIdAndPreservesRest() {
+    Tracking source = sourceTracking();
+
+    Tracking replaced = TrackingBuilder.replaced(source, 999L);
+
+    assertThat(replaced.status()).isEqualTo(EntryStatus.REPLACED);
+    assertThat(replaced.snapshotId()).isEqualTo(999L);
+    assertThat(replaced.dataSequenceNumber()).isEqualTo(source.dataSequenceNumber());
+    assertThat(replaced.fileSequenceNumber()).isEqualTo(source.fileSequenceNumber());
+    assertThat(replaced.dvSnapshotId()).isEqualTo(source.dvSnapshotId());
+    assertThat(replaced.firstRowId()).isEqualTo(source.firstRowId());
+  }
+
+  @Test
+  void testSourceDvPositionsAreNotCarriedForward() {
+    TrackingStruct source = sourceTracking();
+    source.set(DELETED_POSITIONS_ORDINAL, ByteBuffer.wrap(new byte[] {1, 2}));
+    source.set(REPLACED_POSITIONS_ORDINAL, ByteBuffer.wrap(new byte[] {3, 4}));
+
+    Tracking existing = TrackingBuilder.from(source, 1L).build();
+    assertThat(existing.deletedPositions()).isNull();
+    assertThat(existing.replacedPositions()).isNull();
+
+    Tracking deleted = TrackingBuilder.deleted(source, 999L);
+    assertThat(deleted.deletedPositions()).isNull();
+    assertThat(deleted.replacedPositions()).isNull();
+
+    Tracking replaced = TrackingBuilder.replaced(source, 999L);
+    assertThat(replaced.deletedPositions()).isNull();
+    assertThat(replaced.replacedPositions()).isNull();
+  }
+
+  @Test
+  void testExistingBuilderAllowsDvMutation() {
+    Tracking existing = TrackingBuilder.from(sourceTracking(), 999L).dvUpdated().build();
+    assertThat(existing.dvSnapshotId()).isEqualTo(999L);
+  }
+
+  @Test
+  void testManifestDVMutatorsRejectedOnAdded() {
+    assertThatThrownBy(
+            () -> TrackingBuilder.added(42L).deletedPositions(ByteBuffer.wrap(new byte[] {1})))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot set deleted positions on ADDED entry");
+
+    assertThatThrownBy(
+            () -> TrackingBuilder.added(42L).replacedPositions(ByteBuffer.wrap(new byte[] {1})))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot set replaced positions on ADDED entry");
+  }
+
+  @Test
+  void testDvSnapshotIdAndManifestDVPositionsAreMutuallyExclusive() {
+    // sourceTracking has dvSnapshotId=43, inherited by existing(source)
+    assertThatThrownBy(
+            () ->
+                TrackingBuilder.from(sourceTracking(), 1L)
+                    .deletedPositions(ByteBuffer.wrap(new byte[] {1})))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot set deleted positions on a data file entry (DV snapshot ID is set)");
+
+    assertThatThrownBy(
+            () ->
+                TrackingBuilder.from(sourceTracking(), 1L)
+                    .replacedPositions(ByteBuffer.wrap(new byte[] {1})))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot set replaced positions on a data file entry (DV snapshot ID is set)");
+
+    // Setting MDV positions first then dvUpdated is also rejected
+    assertThatThrownBy(
+            () ->
+                TrackingBuilder.from(manifestSourceTracking(), 1L)
+                    .deletedPositions(ByteBuffer.wrap(new byte[] {1}))
+                    .dvUpdated())
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage(
+            "Cannot mark DV updated on a manifest entry (deleted/replaced positions are set)");
+  }
+
+  @Test
+  void testBuilderRejectsNullSource() {
+    assertThatThrownBy(() -> TrackingBuilder.from(null, 1L))
         .isInstanceOf(IllegalArgumentException.class)
-        .hasMessage("Invalid status: null");
+        .hasMessage("Invalid source tracking: null");
+  }
+
+  @Test
+  void testSourceBuildersRejectSourceWithoutSequenceNumbers() {
+    Tracking missingBoth = TrackingBuilder.added(42L).build();
+
+    assertThatThrownBy(() -> TrackingBuilder.from(missingBoth, 1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid tracking source: data sequence number is null");
+
+    assertThatThrownBy(() -> TrackingBuilder.deleted(missingBoth, 1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid tracking source: data sequence number is null");
+
+    assertThatThrownBy(() -> TrackingBuilder.replaced(missingBoth, 1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid tracking source: data sequence number is null");
+
+    TrackingStruct missingFileSeq = new TrackingStruct(Tracking.schema());
+    missingFileSeq.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    missingFileSeq.set(SNAPSHOT_ID_ORDINAL, 42L);
+    missingFileSeq.set(DATA_SEQUENCE_NUMBER_ORDINAL, 10L);
+
+    assertThatThrownBy(() -> TrackingBuilder.from(missingFileSeq, 1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid tracking source: file sequence number is null");
+
+    assertThatThrownBy(() -> TrackingBuilder.deleted(missingFileSeq, 1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid tracking source: file sequence number is null");
+
+    assertThatThrownBy(() -> TrackingBuilder.replaced(missingFileSeq, 1L))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid tracking source: file sequence number is null");
+  }
+
+  private static Stream<Arguments> terminalTransitionCases() {
+    Consumer<Tracking> builderCall = source -> TrackingBuilder.from(source, 1L);
+    Consumer<Tracking> deletedCall = source -> TrackingBuilder.deleted(source, 1L);
+    Consumer<Tracking> replacedCall = source -> TrackingBuilder.replaced(source, 1L);
+    return Stream.of(
+        Arguments.of(EntryStatus.DELETED, builderCall),
+        Arguments.of(EntryStatus.DELETED, deletedCall),
+        Arguments.of(EntryStatus.DELETED, replacedCall),
+        Arguments.of(EntryStatus.REPLACED, builderCall),
+        Arguments.of(EntryStatus.REPLACED, deletedCall),
+        Arguments.of(EntryStatus.REPLACED, replacedCall));
+  }
+
+  @ParameterizedTest
+  @MethodSource("terminalTransitionCases")
+  void testRejectsTransitionsFromTerminalStatus(
+      EntryStatus sourceStatus, Consumer<Tracking> factoryCall) {
+    Tracking source = sourceTrackingWithStatus(sourceStatus);
+    assertThatThrownBy(() -> factoryCall.accept(source))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot revive non-live entry with status " + sourceStatus);
+  }
+
+  @Test
+  void testExistingToExistingIsAllowed() {
+    Tracking existingSource = sourceTrackingWithStatus(EntryStatus.EXISTING);
+
+    Tracking existing = TrackingBuilder.from(existingSource, 1L).build();
+
+    assertThat(existing.status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(existing.snapshotId()).isEqualTo(existingSource.snapshotId());
+  }
+
+  @Test
+  void testExistingToTerminalTransitions() {
+    Tracking existingSource = sourceTrackingWithStatus(EntryStatus.EXISTING);
+
+    Tracking deleted = TrackingBuilder.deleted(existingSource, 999L);
+    assertThat(deleted.status()).isEqualTo(EntryStatus.DELETED);
+    assertThat(deleted.snapshotId()).isEqualTo(999L);
+
+    Tracking replaced = TrackingBuilder.replaced(existingSource, 999L);
+    assertThat(replaced.status()).isEqualTo(EntryStatus.REPLACED);
+    assertThat(replaced.snapshotId()).isEqualTo(999L);
+  }
+
+  @Test
+  void testInternalSetIgnoresUnknownOrdinal() {
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    tracking.set(SNAPSHOT_ID_ORDINAL, 42L);
+    tracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, 10L);
+    tracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, 11L);
+    tracking.set(DV_SNAPSHOT_ID_ORDINAL, 43L);
+    tracking.set(FIRST_ROW_ID_ORDINAL, 1000L);
+    tracking.set(DELETED_POSITIONS_ORDINAL, ByteBuffer.wrap(new byte[] {1, 2}));
+    tracking.set(REPLACED_POSITIONS_ORDINAL, ByteBuffer.wrap(new byte[] {3, 4}));
+
+    // unknown ordinals from a newer format version are silently ignored
+    tracking.internalSet(99, "value from a newer format");
+
+    // every field is unchanged
+    assertThat(tracking.status()).isEqualTo(EntryStatus.ADDED);
+    assertThat(tracking.snapshotId()).isEqualTo(42L);
+    assertThat(tracking.dataSequenceNumber()).isEqualTo(10L);
+    assertThat(tracking.fileSequenceNumber()).isEqualTo(11L);
+    assertThat(tracking.dvSnapshotId()).isEqualTo(43L);
+    assertThat(tracking.firstRowId()).isEqualTo(1000L);
+    assertThat(tracking.deletedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2}));
+    assertThat(tracking.replacedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {3, 4}));
   }
 
   @Test
@@ -210,38 +479,85 @@ class TestTrackingStruct {
   }
 
   @Test
-  void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException {
-    TrackingStruct tracking =
-        TrackingStruct.builder()
-            .status(EntryStatus.ADDED)
-            .snapshotId(42L)
-            .dataSequenceNumber(10L)
-            .deletedPositions(new byte[] {1, 2})
-            .build();
+  void testAddedWithDvSnapshotIdJavaSerializationRoundTrip()
+      throws IOException, ClassNotFoundException {
+    Tracking tracking = TrackingBuilder.added(42L).dvUpdated().build();
 
-    TrackingStruct deserialized = TestHelpers.roundTripSerialize(tracking);
+    Tracking deserialized = TestHelpers.roundTripSerialize(tracking);
 
     assertThat(deserialized.status()).isEqualTo(EntryStatus.ADDED);
     assertThat(deserialized.snapshotId()).isEqualTo(42L);
-    assertThat(deserialized.dataSequenceNumber()).isEqualTo(10L);
-    assertThat(deserialized.deletedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2}));
+    assertThat(deserialized.dvSnapshotId()).isEqualTo(42L);
+    assertThat(deserialized.deletedPositions()).isNull();
+    assertThat(deserialized.replacedPositions()).isNull();
   }
 
   @Test
-  void testKryoSerializationRoundTrip() throws IOException {
-    TrackingStruct tracking =
-        TrackingStruct.builder()
-            .status(EntryStatus.ADDED)
-            .snapshotId(42L)
-            .dataSequenceNumber(10L)
-            .deletedPositions(new byte[] {1, 2})
+  void testExistingWithManifestDVPositionsJavaSerializationRoundTrip()
+      throws IOException, ClassNotFoundException {
+    Tracking tracking =
+        TrackingBuilder.from(manifestSourceTracking(), 1L)
+            .deletedPositions(ByteBuffer.wrap(new byte[] {1, 2}))
+            .replacedPositions(ByteBuffer.wrap(new byte[] {3, 4}))
             .build();
 
-    TrackingStruct deserialized = TestHelpers.KryoHelpers.roundTripSerialize(tracking);
+    Tracking deserialized = TestHelpers.roundTripSerialize(tracking);
+
+    assertThat(deserialized.status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(deserialized.dvSnapshotId()).isNull();
+    assertThat(deserialized.deletedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2}));
+    assertThat(deserialized.replacedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {3, 4}));
+  }
+
+  @Test
+  void testAddedWithDvSnapshotIdKryoSerializationRoundTrip() throws IOException {
+    Tracking tracking = TrackingBuilder.added(42L).dvUpdated().build();
+
+    Tracking deserialized = TestHelpers.KryoHelpers.roundTripSerialize(tracking);
 
     assertThat(deserialized.status()).isEqualTo(EntryStatus.ADDED);
     assertThat(deserialized.snapshotId()).isEqualTo(42L);
-    assertThat(deserialized.dataSequenceNumber()).isEqualTo(10L);
+    assertThat(deserialized.dvSnapshotId()).isEqualTo(42L);
+    assertThat(deserialized.deletedPositions()).isNull();
+    assertThat(deserialized.replacedPositions()).isNull();
+  }
+
+  @Test
+  void testExistingWithManifestDVPositionsKryoSerializationRoundTrip() throws IOException {
+    Tracking tracking =
+        TrackingBuilder.from(manifestSourceTracking(), 1L)
+            .deletedPositions(ByteBuffer.wrap(new byte[] {1, 2}))
+            .replacedPositions(ByteBuffer.wrap(new byte[] {3, 4}))
+            .build();
+
+    Tracking deserialized = TestHelpers.KryoHelpers.roundTripSerialize(tracking);
+
+    assertThat(deserialized.status()).isEqualTo(EntryStatus.EXISTING);
+    assertThat(deserialized.dvSnapshotId()).isNull();
     assertThat(deserialized.deletedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2}));
+    assertThat(deserialized.replacedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {3, 4}));
+  }
+
+  private static TrackingStruct sourceTracking() {
+    TrackingStruct tracking = new TrackingStruct(Tracking.schema());
+    tracking.set(STATUS_ORDINAL, EntryStatus.ADDED.id());
+    tracking.set(SNAPSHOT_ID_ORDINAL, 42L);
+    tracking.set(DATA_SEQUENCE_NUMBER_ORDINAL, 10L);
+    tracking.set(FILE_SEQUENCE_NUMBER_ORDINAL, 10L);
+    tracking.set(DV_SNAPSHOT_ID_ORDINAL, 43L);
+    tracking.set(FIRST_ROW_ID_ORDINAL, 1000L);
+    return tracking;
+  }
+
+  private static TrackingStruct sourceTrackingWithStatus(EntryStatus status) {
+    TrackingStruct tracking = sourceTracking();
+    tracking.set(STATUS_ORDINAL, status.id());
+    return tracking;
+  }
+
+  private static TrackingStruct manifestSourceTracking() {
+    TrackingStruct tracking = sourceTracking();
+    tracking.set(DV_SNAPSHOT_ID_ORDINAL, null);
+    return tracking;
   }
 }