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

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


The following commit(s) were added to refs/heads/main by this push:
     new 38bc5dd156 [#11196] feat(maintenance): Trigger Iceberg manifest 
rewrites from policies (#13533)
38bc5dd156 is described below

commit 38bc5dd1560e6dd592b787ae1e8d6f9cd663ffb9
Author: Akshay Thorat <[email protected]>
AuthorDate: Sun Oct 25 19:39:04 2026 -0700

    [#11196] feat(maintenance): Trigger Iceberg manifest rewrites from policies 
(#13533)
    
    ### What changes were proposed in this pull request?
    
    Add configurable thresholds, a built-in strategy handler, and a job
    adapter for `system_iceberg_rewrite_manifests`, using the collector
    merged in #13717. Trigger at 500 manifests, or at 100 with average size
    below 8 MiB.
    
    Resolve omitted `spec_id` from Iceberg's current
    `TableMetadata.defaultSpecId()`, projected as reserved, read-only
    `default-spec-id` table metadata. Explicit IDs override the default. Pin
    the resolved ID through job submission so later partition evolution
    cannot change the rewrite target.
    
    Require complete count/average statistics and compare their snapshot ID
    with metadata fetched after statistics. Validate integer and boolean
    policy fields without coercion. Invalid policy/statistics inputs or an
    unresolvable default skip the affected table while the batch continues.
    
    ### Why are the changes needed?
    
    Connect manifest statistics to policy-driven rewrites, support empty
    policy content, and preserve the evaluated partition spec through
    submission. Related to #11196 and design #12942.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Adds count/size thresholds, optional `spec_id`, and `use_caching` to the
    typed policy. Users do not configure `default-spec-id`; the catalog
    exposes it from Iceberg metadata. Custom metadata providers must expose
    it for policies without an explicit spec.
    
    Missing/incomplete statistics or unverifiable snapshot metadata produce
    no recommendation. Recollect after snapshot changes. Policy options
    override shared submitter settings; omitted caching preserves the
    Iceberg runtime default. Snapshot checks are not transactional with
    submission. Scheduling and cooldowns remain separate.
    
    ### How was this patch tested?
    
    - Full Iceberg catalog and optimizer suites, plus focused API, DTO, and
    rewrite-manifests job tests: 474 passed, 5 skipped in the latest run.
    - Real Spark regression reproduces the omitted-spec failure, evaluates
    spec 1, evolves the default to spec 2 before submission, verifies only
    spec 1 is rewritten, and selects spec 2 on the next evaluation.
    - Regressions cover explicit overrides, missing/malformed defaults,
    healthy tables continuing in the batch, metadata projection after
    evolution, and rejection of writes to the reserved property.
    - Policy REST validation, caching overrides, snapshot freshness, and
    invalid handler inputs were also tested in earlier revisions.
    - `spotlessApply`, affected-module Javadocs, `:docs:build`, `rat`, and
    `git diff --check` passed.
    
    Docker-tagged tests were not run locally because the macOS Docker
    connector is unavailable.
    
    ---------
    
    Co-authored-by: İbrahim ERBİLEN <[email protected]>
---
 .../policy/IcebergRewriteManifestsContent.java     | 158 +++++++--
 .../apache/gravitino/policy/PolicyContents.java    |  32 +-
 .../policy/TestIcebergRewriteManifestsContent.java |  66 ++++
 .../lakehouse/iceberg/IcebergConstants.java        |   4 +
 .../iceberg/IcebergTablePropertiesMetadata.java    |   6 +
 .../iceberg/ops/IcebergCatalogWrapperHelper.java   |   3 +
 .../iceberg/utils/IcebergTablePropertiesUtil.java  |   2 +
 .../TestIcebergTablePropertiesMetadata.java        |  15 +
 .../iceberg/ops/TestIcebergTableUpdate.java        |  11 +
 .../utils/TestIcebergTablePropertiesUtil.java      |  56 +++
 .../gravitino/dto/policy/PolicyContentDTO.java     | 133 ++++++-
 .../apache/gravitino/dto/util/DTOConverters.java   |  18 +-
 .../dto/policy/TestManifestRewritePolicyDTO.java   | 154 +++++++++
 docs/iceberg-rewrite-manifests-policy.md           | 105 ++++++
 docs/lakehouse-iceberg-catalog.md                  |   1 +
 docs/open-api/policies.yaml                        |  31 +-
 docs/policies.md                                   |  20 +-
 .../optimizer-cli-reference.md                     |   7 +-
 .../optimizer-configuration.md                     |   7 +
 maintenance/jobs/build.gradle.kts                  |   7 +
 .../TestIcebergRewriteManifestsJobWithSpark.java   | 152 ++++++++
 .../optimizer/recommender/Recommender.java         |  85 +++--
 .../handler/ManifestRewriteJobContext.java         |  77 +++++
 .../handler/ManifestRewriteStrategyHandler.java    | 212 ++++++++++++
 .../recommender/job/GravitinoJobSubmitter.java     |   3 +
 .../job/GravitinoManifestRewriteJobAdapter.java    |  46 +++
 .../TestManifestRewriteRecommendation.java         | 334 ++++++++++++++++++
 .../recommender/TestRecommenderExecutionMode.java  |  60 ++++
 .../TestManifestRewriteStrategyHandler.java        | 384 +++++++++++++++++++++
 .../recommender/job/TestGravitinoJobSubmitter.java |  34 ++
 .../server/web/rest/TestPolicyOperations.java      |  40 +++
 31 files changed, 2184 insertions(+), 79 deletions(-)

diff --git 
a/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
 
b/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
index 53d59202cb..5a00e4e490 100644
--- 
a/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
+++ 
b/api/src/main/java/org/apache/gravitino/policy/IcebergRewriteManifestsContent.java
@@ -18,20 +18,18 @@
  */
 package org.apache.gravitino.policy;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import javax.annotation.Nullable;
 import org.apache.gravitino.MetadataObject;
 
-/**
- * Placeholder built-in policy content for Iceberg manifest rewrite.
- *
- * <p>Configurable thresholds will be added when the rewrite-manifests policy 
lands; this class
- * registers the built-in type for TMS task mapping.
- */
+/** Typed thresholds and target options for Iceberg manifest rewriting. */
 public class IcebergRewriteManifestsContent implements PolicyContent {
   /** Property key for strategy type. */
   public static final String STRATEGY_TYPE_KEY = "strategy.type";
@@ -41,21 +39,109 @@ public class IcebergRewriteManifestsContent implements 
PolicyContent {
   public static final String TRIGGER_EXPR_KEY = "trigger-expr";
   /** Rule key for the score expression. */
   public static final String SCORE_EXPR_KEY = "score-expr";
-  /** Strategy type for manifest rewrite. */
+  /** Strategy type for manifest rewriting. */
   public static final String STRATEGY_TYPE_VALUE = "iceberg-rewrite-manifests";
-  /** Built-in job template name for rewrite manifests. */
+  /** Built-in rewrite job template. */
   public static final String JOB_TEMPLATE_NAME_VALUE = 
"builtin-iceberg-rewrite-manifests";
+  /** Rule key for critical manifest count. */
+  public static final String MANIFEST_COUNT_CRITICAL = 
"manifest_count_critical";
+  /** Default critical manifest count. */
+  public static final long DEFAULT_MANIFEST_COUNT_CRITICAL = 500L;
+  /** Rule key for minimum count for the size trigger. */
+  public static final String MANIFEST_COUNT_WARNING = "manifest_count_warning";
+  /** Default minimum count for the size trigger. */
+  public static final long DEFAULT_MANIFEST_COUNT_WARNING = 100L;
+  /** Rule key for exclusive average size threshold in bytes. */
+  public static final String AVG_MANIFEST_SIZE_THRESHOLD_BYTES =
+      "avg_manifest_size_threshold_bytes";
+  /** Default exclusive average size threshold in bytes. */
+  public static final long DEFAULT_AVG_MANIFEST_SIZE_THRESHOLD_BYTES = 
8388608L;
+  /** Rule key for the requested partition spec ID; omission selects Iceberg's 
current default. */
+  public static final String SPEC_ID = "spec_id";
+  /** Rule key for caching option, or null for the Iceberg default. */
+  public static final String USE_CACHING = "use_caching";
+
+  private final Long manifestCountCritical;
+  private final Long manifestCountWarning;
+  private final Long avgManifestSizeThresholdBytes;
+  private final Integer specId;
+  private final Boolean useCaching;
+
+  /** Creates default thresholds targeting Iceberg's current default partition 
spec. */
+  public IcebergRewriteManifestsContent() {
+    this(null, null, null, null, null);
+  }
 
-  private static final Set<MetadataObject.Type> SUPPORTED_OBJECT_TYPES =
-      ImmutableSet.of(
-          MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA, 
MetadataObject.Type.TABLE);
+  IcebergRewriteManifestsContent(
+      @Nullable Long manifestCountCritical,
+      @Nullable Long manifestCountWarning,
+      @Nullable Long avgManifestSizeThresholdBytes,
+      @Nullable Integer specId,
+      @Nullable Boolean useCaching) {
+    this.manifestCountCritical =
+        manifestCountCritical == null ? DEFAULT_MANIFEST_COUNT_CRITICAL : 
manifestCountCritical;
+    this.manifestCountWarning =
+        manifestCountWarning == null ? DEFAULT_MANIFEST_COUNT_WARNING : 
manifestCountWarning;
+    this.avgManifestSizeThresholdBytes =
+        avgManifestSizeThresholdBytes == null
+            ? DEFAULT_AVG_MANIFEST_SIZE_THRESHOLD_BYTES
+            : avgManifestSizeThresholdBytes;
+    this.specId = specId;
+    this.useCaching = useCaching;
+  }
 
-  /** Default constructor for Jackson deserialization only. */
-  public IcebergRewriteManifestsContent() {}
+  /**
+   * Returns the critical manifest count.
+   *
+   * @return critical manifest count
+   */
+  public Long manifestCountCritical() {
+    return manifestCountCritical;
+  }
+
+  /**
+   * Returns the minimum count for the size trigger.
+   *
+   * @return minimum count for the size trigger
+   */
+  public Long manifestCountWarning() {
+    return manifestCountWarning;
+  }
+
+  /**
+   * Returns the exclusive average size threshold in bytes.
+   *
+   * @return exclusive average size threshold in bytes
+   */
+  public Long avgManifestSizeThresholdBytes() {
+    return avgManifestSizeThresholdBytes;
+  }
+
+  /**
+   * Returns the requested partition spec ID. When absent, the recommender 
resolves Iceberg's
+   * current default from table metadata and retains it through job submission.
+   *
+   * @return requested partition spec ID, or null to use Iceberg's current 
default
+   */
+  @Nullable
+  public Integer specId() {
+    return specId;
+  }
+
+  /**
+   * Returns the caching option, or null for the Iceberg default.
+   *
+   * @return caching option, or null for the Iceberg default
+   */
+  @Nullable
+  public Boolean useCaching() {
+    return useCaching;
+  }
 
   @Override
   public Set<MetadataObject.Type> supportedObjectTypes() {
-    return SUPPORTED_OBJECT_TYPES;
+    return ImmutableSet.of(
+        MetadataObject.Type.CATALOG, MetadataObject.Type.SCHEMA, 
MetadataObject.Type.TABLE);
   }
 
   @Override
@@ -67,24 +153,50 @@ public class IcebergRewriteManifestsContent implements 
PolicyContent {
   @Override
   public Map<String, Object> rules() {
     Map<String, Object> rules = new LinkedHashMap<>();
-    // Placeholders must not auto-trigger until real thresholds land in a 
follow-up.
-    rules.put(TRIGGER_EXPR_KEY, "false");
-    rules.put(SCORE_EXPR_KEY, "1");
+    rules.put(MANIFEST_COUNT_CRITICAL, manifestCountCritical);
+    rules.put(MANIFEST_COUNT_WARNING, manifestCountWarning);
+    rules.put(AVG_MANIFEST_SIZE_THRESHOLD_BYTES, 
avgManifestSizeThresholdBytes);
+    if (specId != null) {
+      rules.put(SPEC_ID, specId);
+    }
+    if (useCaching != null) {
+      rules.put(USE_CACHING, useCaching);
+    }
     return Collections.unmodifiableMap(rules);
   }
 
   @Override
-  public boolean equals(Object other) {
-    return other instanceof IcebergRewriteManifestsContent;
+  public void validate() {
+    PolicyContent.super.validate();
+    Preconditions.checkArgument(manifestCountWarning > 0, 
"manifest_count_warning must be > 0");
+    Preconditions.checkArgument(
+        manifestCountCritical >= manifestCountWarning,
+        "manifest_count_critical must be >= manifest_count_warning");
+    Preconditions.checkArgument(
+        avgManifestSizeThresholdBytes > 0, "avg_manifest_size_threshold_bytes 
must be > 0");
+    Preconditions.checkArgument(specId == null || specId >= 0, "spec_id must 
be >= 0");
   }
 
   @Override
-  public int hashCode() {
-    return IcebergRewriteManifestsContent.class.hashCode();
+  public boolean equals(Object other) {
+    if (!(other instanceof IcebergRewriteManifestsContent)) {
+      return false;
+    }
+    IcebergRewriteManifestsContent that = (IcebergRewriteManifestsContent) 
other;
+    return Objects.equals(manifestCountCritical, that.manifestCountCritical)
+        && Objects.equals(manifestCountWarning, that.manifestCountWarning)
+        && Objects.equals(avgManifestSizeThresholdBytes, 
that.avgManifestSizeThresholdBytes)
+        && Objects.equals(specId, that.specId)
+        && Objects.equals(useCaching, that.useCaching);
   }
 
   @Override
-  public String toString() {
-    return "IcebergRewriteManifestsContent{}";
+  public int hashCode() {
+    return Objects.hash(
+        manifestCountCritical,
+        manifestCountWarning,
+        avgManifestSizeThresholdBytes,
+        specId,
+        useCaching);
   }
 }
diff --git a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java 
b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
index 9905811353..d1028a29fb 100644
--- a/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
+++ b/api/src/main/java/org/apache/gravitino/policy/PolicyContents.java
@@ -171,12 +171,38 @@ public class PolicyContents {
   }
 
   /**
-   * Creates placeholder Iceberg rewrite-manifests policy content.
+   * Creates a manifest rewrite policy with default thresholds targeting 
Iceberg's current default
+   * partition spec.
    *
-   * @return rewrite-manifests policy content
+   * @return manifest rewrite policy
    */
   public static PolicyContent icebergRewriteManifests() {
-    return new IcebergRewriteManifestsContent();
+    return icebergRewriteManifests(null, null, null, null, null);
+  }
+
+  /**
+   * Creates a manifest rewrite policy. Null thresholds use the built-in 
defaults.
+   *
+   * @param manifestCountCritical count that triggers regardless of size
+   * @param manifestCountWarning minimum count for the size trigger
+   * @param avgManifestSizeThresholdBytes exclusive average size threshold in 
bytes
+   * @param specId requested existing spec, or null to resolve Iceberg's 
current default at
+   *     evaluation
+   * @param useCaching caching option, or null for the Iceberg default
+   * @return manifest rewrite policy
+   */
+  public static IcebergRewriteManifestsContent icebergRewriteManifests(
+      @Nullable Long manifestCountCritical,
+      @Nullable Long manifestCountWarning,
+      @Nullable Long avgManifestSizeThresholdBytes,
+      @Nullable Integer specId,
+      @Nullable Boolean useCaching) {
+    return new IcebergRewriteManifestsContent(
+        manifestCountCritical,
+        manifestCountWarning,
+        avgManifestSizeThresholdBytes,
+        specId,
+        useCaching);
   }
 
   /**
diff --git 
a/api/src/test/java/org/apache/gravitino/policy/TestIcebergRewriteManifestsContent.java
 
b/api/src/test/java/org/apache/gravitino/policy/TestIcebergRewriteManifestsContent.java
new file mode 100644
index 0000000000..bbdd801abd
--- /dev/null
+++ 
b/api/src/test/java/org/apache/gravitino/policy/TestIcebergRewriteManifestsContent.java
@@ -0,0 +1,66 @@
+/*
+ * 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.gravitino.policy;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestIcebergRewriteManifestsContent {
+  @Test
+  void testDefaultsAndRegistration() {
+    IcebergRewriteManifestsContent content = new 
IcebergRewriteManifestsContent();
+    content.validate();
+    Assertions.assertEquals(500L, content.manifestCountCritical());
+    Assertions.assertEquals(100L, content.manifestCountWarning());
+    Assertions.assertEquals(8388608L, content.avgManifestSizeThresholdBytes());
+    Assertions.assertNull(content.specId());
+    Assertions.assertNull(content.useCaching());
+    Assertions.assertEquals(content, PolicyContents.icebergRewriteManifests());
+    Assertions.assertEquals(
+        content.hashCode(), 
PolicyContents.icebergRewriteManifests().hashCode());
+    Assertions.assertEquals(
+        IcebergRewriteManifestsContent.class,
+        
Policy.BuiltInType.fromPolicyType("system_iceberg_rewrite_manifests").contentClass());
+    Assertions.assertThrows(
+        UnsupportedOperationException.class, () -> 
content.rules().put("spec_id", 1));
+  }
+
+  @Test
+  void testValidationAndOptions() {
+    for (IcebergRewriteManifestsContent content :
+        new IcebergRewriteManifestsContent[] {
+          PolicyContents.icebergRewriteManifests(99L, 100L, null, null, null),
+          PolicyContents.icebergRewriteManifests(null, 0L, null, null, null),
+          PolicyContents.icebergRewriteManifests(null, null, 0L, null, null),
+          PolicyContents.icebergRewriteManifests(null, null, null, -1, null)
+        }) {
+      Assertions.assertThrows(IllegalArgumentException.class, 
content::validate);
+    }
+    IcebergRewriteManifestsContent content =
+        PolicyContents.icebergRewriteManifests(7L, 7L, 1L, 0, false);
+    content.validate();
+    Assertions.assertEquals(0, content.rules().get("spec_id"));
+    Assertions.assertEquals(false, content.rules().get("use_caching"));
+    Assertions.assertNotEquals(content, 
PolicyContents.icebergRewriteManifests());
+    IcebergRewriteManifestsContent largestSpec =
+        PolicyContents.icebergRewriteManifests(null, null, null, 
Integer.MAX_VALUE, null);
+    largestSpec.validate();
+    Assertions.assertEquals(Integer.MAX_VALUE, largestSpec.specId());
+  }
+}
diff --git 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
index 120cebb619..8c0e1bb97f 100644
--- 
a/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
+++ 
b/catalogs/catalog-common/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergConstants.java
@@ -98,6 +98,10 @@ public class IcebergConstants {
   public static final String OWNER = "owner";
   public static final String LOCATION = "location";
   public static final String CURRENT_SNAPSHOT_ID = "current-snapshot-id";
+
+  /** Read-only partition spec ID projected from Iceberg table metadata. */
+  public static final String DEFAULT_SPEC_ID = "default-spec-id";
+
   public static final String CHERRY_PICK_SNAPSHOT_ID = 
"cherry-pick-snapshot-id";
   public static final String SORT_ORDER = "sort-order";
   public static final String IDENTIFIER_FIELDS = "identifier-fields";
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergTablePropertiesMetadata.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergTablePropertiesMetadata.java
index b2eff94881..37a0466afe 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergTablePropertiesMetadata.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergTablePropertiesMetadata.java
@@ -38,6 +38,10 @@ public class IcebergTablePropertiesMetadata extends 
BasePropertiesMetadata {
   public static final String CREATOR = IcebergConstants.CREATOR;
   public static final String LOCATION = IcebergConstants.LOCATION;
   public static final String CURRENT_SNAPSHOT_ID = 
IcebergConstants.CURRENT_SNAPSHOT_ID;
+
+  /** Read-only partition spec ID projected from Iceberg table metadata. */
+  public static final String DEFAULT_SPEC_ID = 
IcebergConstants.DEFAULT_SPEC_ID;
+
   public static final String CHERRY_PICK_SNAPSHOT_ID = 
IcebergConstants.CHERRY_PICK_SNAPSHOT_ID;
   public static final String SORT_ORDER = IcebergConstants.SORT_ORDER;
   public static final String IDENTIFIER_FIELDS = 
IcebergConstants.IDENTIFIER_FIELDS;
@@ -77,6 +81,8 @@ public class IcebergTablePropertiesMetadata extends 
BasePropertiesMetadata {
                 CURRENT_SNAPSHOT_ID,
                 "The snapshot represents the current state of the table",
                 false),
+            stringReservedPropertyEntry(
+                DEFAULT_SPEC_ID, "The current default partition spec from 
Iceberg metadata", false),
             stringReservedPropertyEntry(
                 CHERRY_PICK_SNAPSHOT_ID,
                 "Selecting a specific snapshot in a merge operation",
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/IcebergCatalogWrapperHelper.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/IcebergCatalogWrapperHelper.java
index 808a830651..413cd4f4bf 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/IcebergCatalogWrapperHelper.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/IcebergCatalogWrapperHelper.java
@@ -19,6 +19,8 @@
 
 package org.apache.gravitino.catalog.lakehouse.iceberg.ops;
 
+import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants.DEFAULT_SPEC_ID;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
@@ -64,6 +66,7 @@ public class IcebergCatalogWrapperHelper {
           "location",
           "comment",
           "current-snapshot-id",
+          DEFAULT_SPEC_ID,
           "cherry-pick-snapshot-id",
           "sort-order",
           "identifier-fields");
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/IcebergTablePropertiesUtil.java
 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/IcebergTablePropertiesUtil.java
index 75b39ffe08..55ca37154c 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/IcebergTablePropertiesUtil.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/IcebergTablePropertiesUtil.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog.lakehouse.iceberg.utils;
 
 import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTable.DEFAULT_ICEBERG_PROVIDER;
 import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTablePropertiesMetadata.CURRENT_SNAPSHOT_ID;
+import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTablePropertiesMetadata.DEFAULT_SPEC_ID;
 import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTablePropertiesMetadata.FORMAT;
 import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTablePropertiesMetadata.FORMAT_VERSION;
 import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTablePropertiesMetadata.IDENTIFIER_FIELDS;
@@ -54,6 +55,7 @@ public class IcebergTablePropertiesUtil {
             ? String.valueOf(table.currentSnapshot().snapshotId())
             : "none";
     properties.put(CURRENT_SNAPSHOT_ID, currentSnapshotId);
+    properties.put(DEFAULT_SPEC_ID, Integer.toString(table.defaultSpecId()));
     properties.put(LOCATION, table.location());
 
     properties.put(FORMAT_VERSION, String.valueOf(table.formatVersion()));
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTablePropertiesMetadata.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTablePropertiesMetadata.java
index 08ae281b91..8debcf137a 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTablePropertiesMetadata.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergTablePropertiesMetadata.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.catalog.lakehouse.iceberg;
 
 import com.google.common.collect.ImmutableMap;
+import java.util.Map;
 import java.util.stream.Stream;
 import org.apache.gravitino.catalog.PropertiesMetadataHelpers;
 import org.junit.jupiter.api.Assertions;
@@ -68,6 +69,20 @@ public class TestIcebergTablePropertiesMetadata {
     Assertions.assertThrows(IllegalArgumentException.class, () -> 
validateFormatVersion(value));
   }
 
+  @Test
+  void testDefaultSpecIdCannotBeSetOrRemoved() {
+    Map<String, String> property = Map.of("default-spec-id", "1");
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> PropertiesMetadataHelpers.validatePropertyForCreate(metadata, 
property));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> PropertiesMetadataHelpers.validatePropertyForAlter(metadata, 
property, Map.of()));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> PropertiesMetadataHelpers.validatePropertyForAlter(metadata, 
Map.of(), property));
+  }
+
   private static Stream<String> invalidFormatVersions() {
     // Just outside the range (0, 5) and clearly out of range / non-numeric.
     return Stream.of(
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/TestIcebergTableUpdate.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/TestIcebergTableUpdate.java
index 2be6a402e2..34cd91a7f7 100644
--- 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/TestIcebergTableUpdate.java
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/ops/TestIcebergTableUpdate.java
@@ -163,6 +163,17 @@ public class TestIcebergTableUpdate {
             });
   }
 
+  /** Rejects attempts to modify the default spec through ordinary table 
properties. */
+  @Test
+  public void testDefaultSpecPropertyIsReadOnly() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> updateTable(identifier, 
TableChange.setProperty("default-spec-id", "1")));
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> updateTable(identifier, 
TableChange.removeProperty("default-spec-id")));
+  }
+
   @Test
   public void testRenameTable() {
     TableChange renameTable = TableChange.rename("new_table_name");
diff --git 
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/TestIcebergTablePropertiesUtil.java
 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/TestIcebergTablePropertiesUtil.java
new file mode 100644
index 0000000000..ebee424b21
--- /dev/null
+++ 
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/utils/TestIcebergTablePropertiesUtil.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog.lakehouse.iceberg.utils;
+
+import java.nio.file.Path;
+import java.util.Map;
+import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergTable;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestIcebergTablePropertiesUtil {
+  @TempDir Path temporaryDirectory;
+
+  @Test
+  void testDefaultSpecComesFromMetadataAndTracksEvolution() {
+    Schema schema = new Schema(Types.NestedField.required(1, "id", 
Types.LongType.get()));
+    TableMetadata metadata =
+        TableMetadata.newTableMetadata(
+            schema,
+            PartitionSpec.unpartitioned(),
+            temporaryDirectory.toUri().toString(),
+            Map.of("default-spec-id", "999"));
+    Assertions.assertEquals(0, metadata.defaultSpecId());
+    Assertions.assertEquals(
+        "0", IcebergTable.fromIcebergTable(metadata, 
"events").properties().get("default-spec-id"));
+
+    TableMetadata evolved =
+        TableMetadata.buildFrom(metadata)
+            
.setDefaultPartitionSpec(PartitionSpec.builderFor(schema).identity("id").build())
+            .build();
+    Assertions.assertEquals(1, evolved.defaultSpecId());
+    Assertions.assertEquals(
+        "1", IcebergTable.fromIcebergTable(evolved, 
"events").properties().get("default-spec-id"));
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java 
b/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
index f2417fc4d7..0692a92a24 100644
--- a/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
+++ b/common/src/main/java/org/apache/gravitino/dto/policy/PolicyContentDTO.java
@@ -19,6 +19,12 @@
 package org.apache.gravitino.dto.policy;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -26,6 +32,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import javax.annotation.Nullable;
+import lombok.AccessLevel;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.EqualsAndHashCode;
@@ -35,6 +42,7 @@ import org.apache.gravitino.authorization.Privilege;
 import org.apache.gravitino.policy.ColumnMaskContent;
 import org.apache.gravitino.policy.IcebergDataCompactionContent;
 import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
 import org.apache.gravitino.policy.PolicyContent;
 import org.apache.gravitino.policy.PolicyContents;
 import org.apache.gravitino.policy.RowFilterContent;
@@ -307,19 +315,96 @@ public interface PolicyContentDTO extends PolicyContent {
     }
   }
 
-  /** Placeholder DTO for Iceberg rewrite-manifests policy content. */
+  /** Typed manifest rewrite policy content. */
   @EqualsAndHashCode
   @ToString
+  @Builder(setterPrefix = "with")
+  @AllArgsConstructor(access = AccessLevel.PRIVATE)
   class IcebergRewriteManifestsContentDTO implements PolicyContentDTO {
+    @JsonProperty("manifest_count_critical")
+    @JsonDeserialize(using = ManifestThresholdDeserializer.class)
+    private Long manifestCountCritical;
+
+    @JsonProperty("manifest_count_warning")
+    @JsonDeserialize(using = ManifestThresholdDeserializer.class)
+    private Long manifestCountWarning;
+
+    @JsonProperty("avg_manifest_size_threshold_bytes")
+    @JsonDeserialize(using = ManifestThresholdDeserializer.class)
+    private Long avgManifestSizeThresholdBytes;
+
+    @JsonProperty("spec_id")
+    @JsonDeserialize(using = ManifestSpecIdDeserializer.class)
+    private Integer specId;
+
+    @JsonProperty("use_caching")
+    @JsonDeserialize(using = ManifestUseCachingDeserializer.class)
+    private Boolean useCaching;
+
     private IcebergRewriteManifestsContentDTO() {}
 
     /**
-     * @return empty rewrite-manifests content DTO
+     * Returns manifest rewrite content with default thresholds.
+     *
+     * @return default rewrite-manifests content DTO
      */
     public static IcebergRewriteManifestsContentDTO instance() {
       return new IcebergRewriteManifestsContentDTO();
     }
 
+    /**
+     * Returns manifest_count_critical.
+     *
+     * @return manifest_count_critical
+     */
+    public Long manifestCountCritical() {
+      return manifestCountCritical == null
+          ? IcebergRewriteManifestsContent.DEFAULT_MANIFEST_COUNT_CRITICAL
+          : manifestCountCritical;
+    }
+
+    /**
+     * Returns manifest_count_warning.
+     *
+     * @return manifest_count_warning
+     */
+    public Long manifestCountWarning() {
+      return manifestCountWarning == null
+          ? IcebergRewriteManifestsContent.DEFAULT_MANIFEST_COUNT_WARNING
+          : manifestCountWarning;
+    }
+
+    /**
+     * Returns avg_manifest_size_threshold_bytes.
+     *
+     * @return avg_manifest_size_threshold_bytes
+     */
+    public Long avgManifestSizeThresholdBytes() {
+      return avgManifestSizeThresholdBytes == null
+          ? 
IcebergRewriteManifestsContent.DEFAULT_AVG_MANIFEST_SIZE_THRESHOLD_BYTES
+          : avgManifestSizeThresholdBytes;
+    }
+
+    /**
+     * Returns spec_id.
+     *
+     * @return spec_id or null when omitted
+     */
+    @Nullable
+    public Integer specId() {
+      return specId;
+    }
+
+    /**
+     * Returns use_caching.
+     *
+     * @return use_caching or null when omitted
+     */
+    @Nullable
+    public Boolean useCaching() {
+      return useCaching;
+    }
+
     @Override
     public Set<MetadataObject.Type> supportedObjectTypes() {
       return toDomainContent().supportedObjectTypes();
@@ -341,8 +426,50 @@ public interface PolicyContentDTO extends PolicyContent {
       toDomainContent().validate();
     }
 
+    /** Reads a manifest threshold without silently coercing fractional values 
or strings. */
+    public static class ManifestThresholdDeserializer extends 
JsonDeserializer<Long> {
+      @Override
+      public Long deserialize(JsonParser parser, DeserializationContext 
context)
+          throws IOException {
+        if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
+          return context.reportInputMismatch(
+              Long.class, "Manifest thresholds must be 64-bit integers");
+        }
+        return parser.getLongValue();
+      }
+    }
+
+    /** Reads a partition spec ID without silently coercing fractional values 
or strings. */
+    public static class ManifestSpecIdDeserializer extends 
JsonDeserializer<Integer> {
+      @Override
+      public Integer deserialize(JsonParser parser, DeserializationContext 
context)
+          throws IOException {
+        if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
+          return context.reportInputMismatch(Integer.class, "spec_id must be a 
32-bit integer");
+        }
+        return parser.getIntValue();
+      }
+    }
+
+    /** Reads the caching option without silently coercing numbers or strings 
to booleans. */
+    public static class ManifestUseCachingDeserializer extends 
JsonDeserializer<Boolean> {
+      @Override
+      public Boolean deserialize(JsonParser parser, DeserializationContext 
context)
+          throws IOException {
+        if (!parser.hasToken(JsonToken.VALUE_TRUE) && 
!parser.hasToken(JsonToken.VALUE_FALSE)) {
+          return context.reportInputMismatch(Boolean.class, "use_caching must 
be a boolean");
+        }
+        return parser.getBooleanValue();
+      }
+    }
+
     private PolicyContent toDomainContent() {
-      return PolicyContents.icebergRewriteManifests();
+      return PolicyContents.icebergRewriteManifests(
+          manifestCountCritical(),
+          manifestCountWarning(),
+          avgManifestSizeThresholdBytes(),
+          specId(),
+          useCaching());
     }
   }
 
diff --git 
a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java 
b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
index 0544512fd9..69f7922280 100644
--- a/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
+++ b/common/src/main/java/org/apache/gravitino/dto/util/DTOConverters.java
@@ -690,7 +690,14 @@ public class DTOConverters {
           .build();
     }
     if (policyContent instanceof IcebergRewriteManifestsContent) {
-      return PolicyContentDTO.IcebergRewriteManifestsContentDTO.instance();
+      IcebergRewriteManifestsContent content = 
(IcebergRewriteManifestsContent) policyContent;
+      return PolicyContentDTO.IcebergRewriteManifestsContentDTO.builder()
+          .withManifestCountCritical(content.manifestCountCritical())
+          .withManifestCountWarning(content.manifestCountWarning())
+          
.withAvgManifestSizeThresholdBytes(content.avgManifestSizeThresholdBytes())
+          .withSpecId(content.specId())
+          .withUseCaching(content.useCaching())
+          .build();
     }
     if (policyContent instanceof IcebergSnapshotExpirationContent) {
       return PolicyContentDTO.IcebergSnapshotExpirationContentDTO.instance();
@@ -1583,7 +1590,14 @@ public class DTOConverters {
           content.olderThanDays(), content.location(), content.dryRun());
     }
     if (policyContentDTO instanceof 
PolicyContentDTO.IcebergRewriteManifestsContentDTO) {
-      return PolicyContents.icebergRewriteManifests();
+      PolicyContentDTO.IcebergRewriteManifestsContentDTO content =
+          (PolicyContentDTO.IcebergRewriteManifestsContentDTO) 
policyContentDTO;
+      return PolicyContents.icebergRewriteManifests(
+          content.manifestCountCritical(),
+          content.manifestCountWarning(),
+          content.avgManifestSizeThresholdBytes(),
+          content.specId(),
+          content.useCaching());
     }
     if (policyContentDTO instanceof 
PolicyContentDTO.IcebergSnapshotExpirationContentDTO) {
       return PolicyContents.icebergSnapshotExpiration();
diff --git 
a/common/src/test/java/org/apache/gravitino/dto/policy/TestManifestRewritePolicyDTO.java
 
b/common/src/test/java/org/apache/gravitino/dto/policy/TestManifestRewritePolicyDTO.java
new file mode 100644
index 0000000000..9518c392ad
--- /dev/null
+++ 
b/common/src/test/java/org/apache/gravitino/dto/policy/TestManifestRewritePolicyDTO.java
@@ -0,0 +1,154 @@
+/*
+ * 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.gravitino.dto.policy;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import org.apache.gravitino.dto.requests.PolicyCreateRequest;
+import org.apache.gravitino.dto.requests.PolicyUpdateRequest;
+import org.apache.gravitino.dto.util.DTOConverters;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+import org.apache.gravitino.policy.PolicyContents;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class TestManifestRewritePolicyDTO {
+  @Test
+  void testPolicyAndCreateRequestRoundTrip() throws Exception {
+    IcebergRewriteManifestsContent content =
+        PolicyContents.icebergRewriteManifests(900L, 200L, 4096L, 3, false);
+    String persisted = JsonUtils.anyFieldMapper().writeValueAsString(content);
+    Assertions.assertEquals(
+        content,
+        JsonUtils.anyFieldMapper().readValue(persisted, 
IcebergRewriteManifestsContent.class));
+    PolicyContentDTO dto = DTOConverters.toDTO(content);
+    Assertions.assertEquals(content, DTOConverters.fromDTO(dto));
+    String json =
+        
"{\"name\":\"rewrite\",\"policyType\":\"system_iceberg_rewrite_manifests\","
+            + "\"enabled\":true,\"content\":"
+            + JsonUtils.objectMapper().writeValueAsString(dto)
+            + "}";
+    PolicyDTO policy = JsonUtils.objectMapper().readValue(json, 
PolicyDTO.class);
+    Assertions.assertEquals(content, DTOConverters.fromDTO(policy.content()));
+    PolicyCreateRequest request =
+        JsonUtils.objectMapper().readValue(json, PolicyCreateRequest.class);
+    request.validate();
+    Assertions.assertEquals(content, 
DTOConverters.fromDTO(request.getPolicyContent()));
+  }
+
+  @ParameterizedTest
+  @CsvSource({
+    "use_caching,0",
+    "use_caching,1",
+    "use_caching,1.0",
+    "use_caching,\"true\"",
+    "use_caching,\"false\"",
+    "use_caching,[]",
+    "use_caching,{}",
+    "spec_id,1.5",
+    "spec_id,2147483648",
+    "spec_id,true",
+    "spec_id,\"1\"",
+    "manifest_count_critical,500.5",
+    "manifest_count_critical,9223372036854775808",
+    "manifest_count_warning,100.5",
+    "manifest_count_warning,false",
+    "avg_manifest_size_threshold_bytes,4096.5",
+    "avg_manifest_size_threshold_bytes,\"4096\""
+  })
+  void testRejectInvalidValuesInCreateAndUpdate(String field, String value) {
+    String content = "{\"" + field + "\":" + value + "}";
+    String create =
+        
"{\"name\":\"rewrite\",\"policyType\":\"system_iceberg_rewrite_manifests\","
+            + "\"enabled\":true,\"content\":"
+            + content
+            + "}";
+    String update =
+        
"{\"@type\":\"updateContent\",\"policyType\":\"system_iceberg_rewrite_manifests\","
+            + "\"newContent\":"
+            + content
+            + "}";
+    Assertions.assertThrows(
+        JsonProcessingException.class,
+        () -> JsonUtils.objectMapper().readValue(create, 
PolicyCreateRequest.class));
+    Assertions.assertThrows(
+        JsonProcessingException.class,
+        () ->
+            JsonUtils.objectMapper()
+                .readValue(update, 
PolicyUpdateRequest.UpdatePolicyContentRequest.class));
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void testBooleanCachingValues(boolean useCaching) throws Exception {
+    PolicyContentDTO.IcebergRewriteManifestsContentDTO dto =
+        JsonUtils.objectMapper()
+            .readValue(
+                "{\"use_caching\":" + useCaching + "}",
+                PolicyContentDTO.IcebergRewriteManifestsContentDTO.class);
+    dto.validate();
+    Assertions.assertEquals(
+        PolicyContents.icebergRewriteManifests(null, null, null, null, 
useCaching),
+        DTOConverters.fromDTO(dto));
+  }
+
+  @Test
+  void testNumericBoundsAndNullDefaults() throws Exception {
+    PolicyContentDTO.IcebergRewriteManifestsContentDTO bounds =
+        JsonUtils.objectMapper()
+            .readValue(
+                
"{\"spec_id\":2147483647,\"manifest_count_critical\":9223372036854775807}",
+                PolicyContentDTO.IcebergRewriteManifestsContentDTO.class);
+    bounds.validate();
+    Assertions.assertEquals(Integer.MAX_VALUE, bounds.specId());
+    Assertions.assertEquals(Long.MAX_VALUE, bounds.manifestCountCritical());
+    PolicyContentDTO.IcebergRewriteManifestsContentDTO defaults =
+        JsonUtils.objectMapper()
+            .readValue(
+                
"{\"spec_id\":null,\"manifest_count_critical\":null,\"manifest_count_warning\":null,"
+                    + 
"\"avg_manifest_size_threshold_bytes\":null,\"use_caching\":null}",
+                PolicyContentDTO.IcebergRewriteManifestsContentDTO.class);
+    defaults.validate();
+    Assertions.assertEquals(
+        PolicyContents.icebergRewriteManifests(), 
DTOConverters.fromDTO(defaults));
+  }
+
+  @Test
+  void testUpdateAndOmittedDefaults() throws Exception {
+    String json =
+        
"{\"@type\":\"updateContent\",\"policyType\":\"system_iceberg_rewrite_manifests\",\"newContent\":{}}";
+    PolicyUpdateRequest.UpdatePolicyContentRequest request =
+        JsonUtils.objectMapper()
+            .readValue(json, 
PolicyUpdateRequest.UpdatePolicyContentRequest.class);
+    request.validate();
+    PolicyContentDTO.IcebergRewriteManifestsContentDTO dto =
+        JsonUtils.objectMapper()
+            .readValue("{}", 
PolicyContentDTO.IcebergRewriteManifestsContentDTO.class);
+    Assertions.assertEquals(PolicyContents.icebergRewriteManifests(), 
DTOConverters.fromDTO(dto));
+    dto.validate();
+    PolicyContentDTO.IcebergRewriteManifestsContentDTO invalid =
+        JsonUtils.objectMapper()
+            .readValue(
+                "{\"spec_id\":-1}", 
PolicyContentDTO.IcebergRewriteManifestsContentDTO.class);
+    Assertions.assertThrows(IllegalArgumentException.class, invalid::validate);
+  }
+}
diff --git a/docs/iceberg-rewrite-manifests-policy.md 
b/docs/iceberg-rewrite-manifests-policy.md
new file mode 100644
index 0000000000..53142db2b9
--- /dev/null
+++ b/docs/iceberg-rewrite-manifests-policy.md
@@ -0,0 +1,105 @@
+---
+title: "Iceberg Manifest Rewrite Policy"
+slug: "/iceberg-rewrite-manifests-policy"
+keyword: "iceberg, manifests, policy, optimizer, Gravitino"
+license: "This software is licensed under the Apache License version 2."
+---
+
+## Behavior
+
+`system_iceberg_rewrite_manifests` evaluates manifest statistics for one 
resolved partition
+spec and submits `builtin-iceberg-rewrite-manifests`. Associate the policy 
with a tag and
+assign that tag to a catalog, schema, or table through the existing
+[policy API](./manage-policies-in-gravitino.md). Tables inherit tags from 
their ancestors.
+The strategy type is `iceberg-rewrite-manifests`; its handler and job adapter 
are built in.
+
+| Content field | Default | Meaning |
+| --- | --- | --- |
+| `manifest_count_critical` | `500` | Trigger when count is at least this 
value, regardless of size. |
+| `manifest_count_warning` | `100` | Minimum count for the small-average-size 
trigger. Must be positive. |
+| `avg_manifest_size_threshold_bytes` | `8388608` | Trigger when average size 
is strictly below this value and the warning count is reached. Must be 
positive. |
+| `spec_id` | Iceberg's current default | Existing partition spec to evaluate. 
Omission resolves the current default from Iceberg metadata. |
+| `use_caching` | Omitted | Optional boolean forwarded to Iceberg; omission 
preserves the runtime default. |
+
+The critical count must be at least the warning count. Spec IDs must be 
non-negative
+32-bit integers. Thresholds use integer counts and bytes. Recommendations are 
ranked by
+the selected spec's manifest count. The policy API accepts only JSON booleans 
for
+`use_caching`; numbers and strings are rejected.
+
+At the defaults, 99 manifests never trigger; 100 manifests averaging 8 MiB do 
not trigger;
+100 averaging less than 8 MiB trigger; and 500 trigger regardless of size.
+
+## Collect, evaluate, and submit
+
+Submit `builtin-iceberg-update-manifest-stats` to collect complete
+`custom-manifest-number-by-spec` and `custom-avg-manifest-size-by-spec` maps 
plus
+`custom-manifest-snapshot-id` from one table snapshot. The collector covers 
every defined
+spec in one traversal; it has no `spec_id` argument. Both maps are keyed by 
decimal spec ID.
+
+The handler compares the published snapshot ID with `current-snapshot-id` in 
the table
+metadata supplied by the optimizer's table metadata provider. Missing, 
malformed, or
+mismatched snapshot IDs produce no recommendation; recollect before evaluating 
again.
+For a table without a snapshot, the collector publishes `-1` and Iceberg table 
metadata
+reports `none`. The provider must expose this metadata property to verify 
freshness.
+The recommender reads metadata after statistics to detect snapshot changes 
during collection
+of its inputs. This freshness check is not transactional with job submission; 
the table may
+still change after evaluation.
+
+The handler reads the chosen spec's count and average from the same statistics 
response.
+A missing map or key produces no recommendation. An empty spec has count and 
average zero.
+Invalid policy rules or malformed measurements are logged and skipped for that 
table without
+aborting recommendations for other tables. Counts from other specs are ignored.
+
+The recommender and CLI use the table's current default partition spec when 
`spec_id` is
+omitted. The following example uses default thresholds and assumes statistics 
have been collected:
+
+```shell
+curl -X POST -H 'Content-Type: application/json' \
+  -d '{
+    "name": "rewrite_manifests",
+    "policyType": "system_iceberg_rewrite_manifests",
+    "enabled": true,
+    "content": {}
+  }' \
+  http://localhost:8090/api/metalakes/test/policies
+```
+
+Associate `rewrite_manifests` with a tag using the `ALL_VALUES` selector, then 
assign that tag
+to the table. Configure the optimizer's Gravitino providers and Spark 
submission settings as described in
+[Optimizer 
configuration](./table-maintenance-service/optimizer-configuration.md), then 
preview:
+
+```shell
+./bin/gravitino-optimizer.sh --type submit-strategy-jobs \
+  --identifiers rest_catalog.db.t1 --strategy-name rewrite_manifests --dry-run
+```
+
+Remove `--dry-run` to submit the recommendations. The adapter supplies 
`catalog_name`,
+`table_identifier`, the exact evaluated `spec_id`, and optional `use_caching`.
+Its catalog, table, spec, and caching values override shared job-submitter 
configuration.
+When the policy omits `use_caching`, the adapter supplies an empty option to 
clear any shared
+caching setting and leave the Iceberg runtime default in effect.
+Recollect statistics after a successful rewrite before evaluating again.
+
+## Selecting a partition spec
+
+When `spec_id` is omitted, the recommender resolves Iceberg's current 
`default-spec-id`
+from the table metadata supplied by the metadata provider. Empty policy 
content therefore
+evaluates the current default spec with the default thresholds. Set `spec_id` 
explicitly to
+target another existing spec, such as an older spec after partition evolution.
+
+The Iceberg catalog projects `TableMetadata.defaultSpecId()` as the reserved, 
read-only table
+property `default-spec-id`. This is metadata, not an operator setting; it 
cannot be set or
+removed through Gravitino table-property changes. Custom metadata providers 
must expose the
+same value from Iceberg metadata. If an omitted target cannot be resolved 
because metadata is
+missing or invalid, the recommender warns and skips that table without 
aborting the batch.
+
+The resolved ID is used for both statistics lookup and job submission. 
Partition evolution
+after evaluation cannot retarget an already-created job context. A later 
evaluation with an
+omitted `spec_id` resolves the new default; an explicit policy target remains 
unchanged.
+The handler never infers a spec from statistics map keys. After a snapshot 
change, recollect
+statistics before evaluating again.
+
+Rewriting reorganizes manifests within the selected spec and does not migrate 
data files
+between specs. This policy introduces no scheduling, cooldown, or last-success 
statistic.
+For runtime requirements and direct job submission, see the
+[rewrite-manifests job 
reference](./table-maintenance-service/optimizer-cli-reference.md).
diff --git a/docs/lakehouse-iceberg-catalog.md 
b/docs/lakehouse-iceberg-catalog.md
index 9b57d22a74..4a7c410b95 100644
--- a/docs/lakehouse-iceberg-catalog.md
+++ b/docs/lakehouse-iceberg-catalog.md
@@ -473,6 +473,7 @@ Pass [Iceberg table 
properties](https://iceberg.apache.org/docs/1.5.2/configurat
 | `comment`                 | The table comment; use the `comment` field in 
table meta instead.                                                             
                                                                                
                                                                      | (none)  
      | No       | Yes      | No        |
 | `creator`                 | The table creator.                               
                                                                                
                                                                                
                                                                   | (none)     
   | No       | Yes      | No        |
 | `current-snapshot-id`     | The snapshot represents the current state of the 
table.                                                                          
                                                                                
                                                                   | (none)     
   | No       | Yes      | No        |
+| `default-spec-id`         | The current default partition spec ID projected 
from Iceberg metadata. Read-only; cannot be set or removed.                     
                                                                                
                                                                    | (none)    
    | No       | Yes      | No        |
 | `cherry-pick-snapshot-id` | Selecting a specific snapshot in a merge 
operation.                                                                      
                                                                                
                                                                           | 
(none)        | No       | Yes      | No        |
 | `sort-order`              | Iceberg table sort order; use `SortOrder` in 
table meta instead.                                                             
                                                                                
                                                                       | (none) 
       | No       | Yes      | No        |
 | `identifier-fields`       | The identifier fields for defining the table.    
                                                                                
                                                                                
                                                                   | (none)     
   | No       | Yes      | No        |
diff --git a/docs/open-api/policies.yaml b/docs/open-api/policies.yaml
index 8f8a9f1a61..781080ff47 100644
--- a/docs/open-api/policies.yaml
+++ b/docs/open-api/policies.yaml
@@ -459,8 +459,37 @@ components:
 
     IcebergRewriteManifestsContent:
       type: object
-      description: Placeholder content for system_iceberg_rewrite_manifests. 
Configurable thresholds land in a follow-up.
+      description: Thresholds for one resolved Iceberg partition spec. 
Supports catalog, schema, and table policies.
       additionalProperties: false
+      properties:
+        manifest_count_critical:
+          type: integer
+          format: int64
+          minimum: 1
+          default: 500
+          description: Inclusive count trigger, regardless of size. Must be at 
least manifest_count_warning.
+        manifest_count_warning:
+          type: integer
+          format: int64
+          minimum: 1
+          default: 100
+          description: Inclusive minimum count for the average-size trigger.
+        avg_manifest_size_threshold_bytes:
+          type: integer
+          format: int64
+          minimum: 1
+          default: 8388608
+          description: Exclusive average manifest size threshold in bytes.
+        spec_id:
+          type: integer
+          format: int32
+          minimum: 0
+          nullable: true
+          description: Existing partition spec. Omission resolves Iceberg's 
current default spec from table metadata. If it cannot be resolved, the table 
is skipped. The evaluated ID is retained through job submission.
+        use_caching:
+          type: boolean
+          nullable: true
+          description: Optional caching setting. Omission preserves the 
Iceberg runtime default.
 
     IcebergRewriteManifestsPolicy:
       allOf:
diff --git a/docs/policies.md b/docs/policies.md
index 3e3889c075..c1ffba9dbc 100644
--- a/docs/policies.md
+++ b/docs/policies.md
@@ -35,14 +35,15 @@ policy. A table with only `data_domain=risk` does not.
 
 ### Policy Types and Content
 
-| Type                                 | Rules                                
| Consumer                  |
-|--------------------------------------|--------------------------------------|---------------------------|
-| `system_iceberg_compaction`          | Compaction thresholds and scheduling 
| Table maintenance service |
-| `system_iceberg_orphan_file_removal` | Orphan-file cleanup options          
| Table maintenance service |
-| `system_row_filter`                  | Row-filter expression                
| Not enforced yet          |
-| `system_column_mask`                 | Column-mask expression               
| Not enforced yet          |
-| `system_access_control`              | Privileges and applicable roles      
| Not enforced yet          |
-| `custom`                             | A free-form map that you define      
| A system that you provide |
+| Type                                 | Rules                                 
     | Consumer                  |
+| ------------------------------------ | 
------------------------------------------ | ------------------------- |
+| `system_iceberg_compaction`          | Compaction thresholds and scheduling  
     | Table maintenance service |
+| `system_iceberg_rewrite_manifests`   | Manifest count and average-size 
thresholds | Table maintenance service |
+| `system_iceberg_orphan_file_removal` | Orphan-file cleanup options           
     | Table maintenance service |
+| `system_row_filter`                  | Row-filter expression                 
     | Not enforced yet          |
+| `system_column_mask`                 | Column-mask expression                
     | Not enforced yet          |
+| `system_access_control`              | Privileges and applicable roles       
     | Not enforced yet          |
+| `custom`                             | A free-form map that you define       
     | A system that you provide |
 
 The row-filter, column-mask, and access-control policy types can be created 
and associated with
 tags, but Gravitino does not enforce them yet. Until enforcement is available, 
these policy types
@@ -170,7 +171,8 @@ do not re-parse stored policy expressions.
 
 A custom policy's rules live in `customRules`. Gravitino stores them and 
returns them to clients;
 it does not interpret their names or values. Built-in types have a defined 
content shape. See
-[Iceberg compaction policy](./iceberg-compaction-policy.md) for the compaction 
rules and
+[Iceberg compaction policy](./iceberg-compaction-policy.md) for the compaction 
rules,
+[Iceberg manifest rewrite policy](./iceberg-rewrite-manifests-policy.md) for 
manifest thresholds, and
 [Table maintenance service](./table-maintenance-service/optimizer.md) for a 
worked example.
 
 Policy content also has `properties` and `supportedObjectTypes`. Properties 
describe the policy
diff --git a/docs/table-maintenance-service/optimizer-cli-reference.md 
b/docs/table-maintenance-service/optimizer-cli-reference.md
index 90614f575b..f01ace00cc 100644
--- a/docs/table-maintenance-service/optimizer-cli-reference.md
+++ b/docs/table-maintenance-service/optimizer-cli-reference.md
@@ -509,7 +509,7 @@ Leave `use_caching` unset to use the installed Iceberg 
version's default (`false
 
 ### How to find `spec_id`
 
-Omit `spec_id` for routine maintenance of the current partition spec. Do not 
guess IDs such as `0` or `1`. Read `default-spec-id` and the `partition-specs` 
array from the table's current Iceberg metadata JSON. The array maps each 
`spec-id` to its partition fields and transforms; `default-spec-id` identifies 
the current spec. These are Iceberg table metadata fields, not Gravitino 
catalog properties.
+Omit `spec_id` for routine maintenance of the current partition spec. Do not 
guess IDs such as `0` or `1`. Read the reserved, read-only Gravitino table 
property `default-spec-id` for the current default. Use the `partition-specs` 
array in the table's Iceberg metadata JSON to see each `spec-id`'s partition 
fields and transforms.
 
 To discover which specs have manifests in the current snapshot, run:
 
@@ -518,7 +518,7 @@ SELECT DISTINCT partition_spec_id
 FROM rest_catalog.db.t1.manifests;
 ```
 
-This query lists represented specs, not which one is current. A defined spec 
with no manifests may be absent; use the metadata JSON to identify the default 
and interpret the transforms.
+This query lists represented specs, not which one is current. A defined spec 
with no manifests may be absent; use `default-spec-id` to identify the current 
default and the metadata JSON to interpret the transforms.
 
 For example, suppose metadata shows spec `0` uses `day(event_time)` and the 
current spec `1` uses `hour(event_time)`. Omitting `spec_id` rewrites eligible 
manifests for spec `1`. Passing `"spec_id": "0"` consolidates the old day-spec 
manifests. Neither run converts day-partitioned data files to hour partitioning 
or rewrites manifests belonging to the other spec.
 
@@ -572,6 +572,9 @@ Rewrite Manifests Results: Rewritten manifests: 24, Added 
manifests: 2
 
 Both counts at zero indicate a successful no-op, for example when no manifests 
for the selected spec need rewriting. Other specs remain unchanged.
 
+The [manifest rewrite policy](../iceberg-rewrite-manifests-policy.md) 
evaluates fresh per-spec
+measurements from the table-wide collector and submits this job with the 
evaluated `spec_id`.
+
 ## Related
 
 - [Table Maintenance Service](./optimizer.md) for the concepts and the 
walkthrough
diff --git a/docs/table-maintenance-service/optimizer-configuration.md 
b/docs/table-maintenance-service/optimizer-configuration.md
index fe10b1321b..ffa9334358 100644
--- a/docs/table-maintenance-service/optimizer-configuration.md
+++ b/docs/table-maintenance-service/optimizer-configuration.md
@@ -53,6 +53,13 @@ gravitino.optimizer.jobSubmitterConfig.warehouse_location =
 gravitino.optimizer.jobSubmitterConfig.spark_conf = 
{"spark.master":"local[2]","spark.hadoop.fs.defaultFS":"file:///"}
 ```
 
+For manifest rewrite policies, the mapping
+`gravitino.optimizer.strategyHandler.iceberg-rewrite-manifests.className` is 
optional. When
+omitted or blank, the recommender uses the built-in
+`org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteStrategyHandler`.
+An explicit class name overrides that default. An omitted policy `spec_id` 
uses Iceberg's
+current default spec from table metadata; see [Iceberg manifest rewrite 
policy](../iceberg-rewrite-manifests-policy.md).
+
 When the Gravitino server has authentication enabled, 
`builtin-iceberg-update-stats` needs
 credentials in `--updater-options` / `updater_options` for the Gravitino 
client (statistics
 updater and `submit-update-stats-job` `runJob` calls):
diff --git a/maintenance/jobs/build.gradle.kts 
b/maintenance/jobs/build.gradle.kts
index ae5055bda8..2d40e6a663 100644
--- a/maintenance/jobs/build.gradle.kts
+++ b/maintenance/jobs/build.gradle.kts
@@ -54,6 +54,7 @@ dependencies {
   }
 
   testImplementation(project(":api"))
+  testImplementation(project(":maintenance:optimizer"))
   testImplementation(project(":common"))
   // Used to check that the placeholders of the built-in templates resolve the 
way the server does.
   testImplementation(project(":core"))
@@ -83,6 +84,12 @@ dependencies {
   testRuntimeOnly(libs.junit.jupiter.engine)
 }
 
+// The optimizer packages its runtime dependencies beside its jar in 
build/libs.
+// Order the integration-test compile after that directory has been populated.
+tasks.compileTestJava {
+  dependsOn(":maintenance:optimizer:copyDepends")
+}
+
 tasks.test {
   useJUnitPlatform()
 }
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJobWithSpark.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJobWithSpark.java
index e518511c6e..e91877dcea 100644
--- 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJobWithSpark.java
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergRewriteManifestsJobWithSpark.java
@@ -26,12 +26,28 @@ import java.io.ByteArrayOutputStream;
 import java.io.PrintStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
+import java.time.Instant;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.dto.AuditDTO;
+import org.apache.gravitino.dto.policy.PolicyDTO;
+import org.apache.gravitino.dto.rel.TableDTO;
+import org.apache.gravitino.dto.util.DTOConverters;
 import org.apache.gravitino.maintenance.jobs.TemplateArguments;
+import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyHandlerContext;
+import 
org.apache.gravitino.maintenance.optimizer.common.IcebergManifestStatistics;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteStrategyHandler;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.job.GravitinoManifestRewriteJobAdapter;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.strategy.GravitinoStrategy;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.spark.Spark3Util;
 import org.apache.spark.sql.Row;
 import org.apache.spark.sql.SparkSession;
 import org.junit.jupiter.api.AfterEach;
@@ -140,6 +156,142 @@ public class TestIcebergRewriteManifestsJobWithSpark {
     assertEquals(6, records().size());
   }
 
+  /**
+   * Recollects stale measurements without changing the target selected before 
partition evolution.
+   *
+   * @throws Exception if the Iceberg table cannot be loaded
+   */
+  @Test
+  public void testCollectEvaluateAndRewriteAfterDefaultSpecChanges() throws 
Exception {
+    int resolvedSpecId = Spark3Util.loadIcebergTable(spark, 
TABLE).spec().specId();
+    PolicyDTO policy =
+        PolicyDTO.builder()
+            .withName("rewrite")
+            .withPolicyType("system_iceberg_rewrite_manifests")
+            .withContent(
+                DTOConverters.toDTO(
+                    PolicyContents.icebergRewriteManifests(3L, 3L, null, 
resolvedSpecId, false)))
+            .build();
+    List<StatisticEntry<?>> measurements =
+        IcebergUpdateManifestStatsJob.collectManifestStatistics(
+            spark, "manifest_catalog", "db.events");
+    IcebergManifestStatistics measurement =
+        IcebergManifestStatistics.fromStatistics(
+                measurements.stream()
+                    .collect(Collectors.toMap(StatisticEntry::name, 
StatisticEntry::value)),
+                resolvedSpecId)
+            .orElseThrow();
+    assertEquals(1, resolvedSpecId);
+    assertEquals(3, measurement.count());
+    // Change the default after collection, before evaluation and submission.
+    spark.sql(
+        "ALTER TABLE "
+            + TABLE
+            + " REPLACE PARTITION FIELD hours(event_time) WITH 
months(event_time)");
+    insertRows(6);
+    List<Row> records = records();
+    Set<String> oldManifests = manifests(0);
+    Set<String> newDefaultManifests = manifests(2);
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(manifestContext(policy, measurements));
+    assertFalse(handler.shouldTrigger(), "Measurements from an older snapshot 
must be recollected");
+    measurements =
+        IcebergUpdateManifestStatsJob.collectManifestStatistics(
+            spark, "manifest_catalog", "db.events");
+    handler.initialize(manifestContext(policy, measurements));
+    assertTrue(handler.shouldTrigger());
+    Map<String, String> config =
+        new GravitinoManifestRewriteJobAdapter()
+            .jobConfig(handler.evaluate().jobExecutionContext().orElseThrow());
+    assertEquals("1", config.get("spec_id"));
+    assertEquals("manifest_catalog", config.get("catalog_name"));
+    assertEquals("db.events", config.get("table_identifier"));
+    runJob(config.get("spec_id"), config.get("use_caching"));
+    assertEquals(1, manifests(1).size());
+    assertEquals(oldManifests, manifests(0));
+    assertEquals(newDefaultManifests, manifests(2));
+    assertEquals(records, records());
+    handler.initialize(
+        manifestContext(
+            policy,
+            IcebergUpdateManifestStatsJob.collectManifestStatistics(
+                spark, "manifest_catalog", "db.events")));
+    assertFalse(handler.shouldTrigger());
+  }
+
+  /**
+   * Resolves an omitted spec from Iceberg metadata and retains it through 
later partition
+   * evolution.
+   *
+   * @throws Exception if the Iceberg table cannot be loaded
+   */
+  @Test
+  public void testDefaultSpecIsPinnedAcrossPartitionEvolution() throws 
Exception {
+    PolicyDTO policy =
+        PolicyDTO.builder()
+            .withName("rewrite")
+            .withPolicyType("system_iceberg_rewrite_manifests")
+            .withContent(
+                DTOConverters.toDTO(
+                    PolicyContents.icebergRewriteManifests(3L, 3L, null, null, 
false)))
+            .build();
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        manifestContext(
+            policy,
+            IcebergUpdateManifestStatsJob.collectManifestStatistics(
+                spark, "manifest_catalog", "db.events")));
+    assertTrue(handler.shouldTrigger(), "An omitted spec must use the current 
Iceberg default");
+    Map<String, String> config =
+        new GravitinoManifestRewriteJobAdapter()
+            .jobConfig(handler.evaluate().jobExecutionContext().orElseThrow());
+    assertEquals("1", config.get("spec_id"));
+
+    spark.sql(
+        "ALTER TABLE "
+            + TABLE
+            + " REPLACE PARTITION FIELD hours(event_time) WITH 
months(event_time)");
+    insertRows(6);
+    Set<String> oldManifests = manifests(0);
+    Set<String> newDefaultManifests = manifests(2);
+    List<Row> records = records();
+    runJob(config.get("spec_id"), config.get("use_caching"));
+    assertEquals(1, manifests(1).size());
+    assertEquals(oldManifests, manifests(0));
+    assertEquals(newDefaultManifests, manifests(2));
+    assertEquals(records, records());
+
+    handler.initialize(
+        manifestContext(
+            policy,
+            IcebergUpdateManifestStatsJob.collectManifestStatistics(
+                spark, "manifest_catalog", "db.events")));
+    assertTrue(handler.shouldTrigger());
+    assertEquals(
+        "2", 
handler.evaluate().jobExecutionContext().orElseThrow().jobOptions().get("spec_id"));
+  }
+
+  private StrategyHandlerContext manifestContext(
+      PolicyDTO policy, List<StatisticEntry<?>> measurements) throws Exception 
{
+    TableMetadata metadata =
+        ((BaseTable) Spark3Util.loadIcebergTable(spark, 
TABLE)).operations().current();
+    return StrategyHandlerContext.builder(
+            NameIdentifier.of("manifest_catalog", "db", "events"), new 
GravitinoStrategy(policy))
+        .withTableStatistics(measurements)
+        .withTableMetadata(
+            TableDTO.builder()
+                .withName("events")
+                .withAudit(
+                    
AuditDTO.builder().withCreator("test").withCreateTime(Instant.EPOCH).build())
+                .withProperties(
+                    Map.of(
+                        "current-snapshot-id",
+                            
Long.toString(metadata.currentSnapshot().snapshotId()),
+                        "default-spec-id", 
Integer.toString(metadata.defaultSpecId())))
+                .build())
+        .build();
+  }
+
   private void runJob(String spec, String caching) {
     spark.stop();
     SparkSession.clearActiveSession();
diff --git 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
index 8da6a52cf3..d73ceb4aa0 100644
--- 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
+++ 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/Recommender.java
@@ -49,6 +49,8 @@ import 
org.apache.gravitino.maintenance.optimizer.common.CloseableGroup;
 import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
 import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
 import org.apache.gravitino.maintenance.optimizer.common.util.ProviderUtils;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteStrategyHandler;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
 import org.apache.gravitino.rel.Table;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -264,29 +266,40 @@ public class Recommender implements AutoCloseable {
 
     PriorityQueue<StrategyEvaluation> scoreQueue = new 
PriorityQueue<>(evaluationComparator());
     for (NameIdentifier identifier : identifiers) {
-      StrategyHandler strategyHandler = loadStrategyHandler(strategy, 
identifier);
-      if (!strategyHandler.shouldTrigger()) {
+      StrategyHandler strategyHandler = 
createStrategyHandler(strategy.strategyType());
+      StrategyHandlerContext context =
+          loadStrategyHandlerContext(strategy, identifier, 
strategyHandler.dataRequirements());
+      try {
+        strategyHandler.initialize(context);
+        if (!strategyHandler.shouldTrigger()) {
+          LOG.info(
+              "Skip strategy {} for identifier {} because strategy handler 
trigger condition is not met",
+              strategyName,
+              identifier);
+          continue;
+        }
+        StrategyEvaluation evaluation = strategyHandler.evaluate();
+        if (evaluation.score() < 0 || 
evaluation.jobExecutionContext().isEmpty()) {
+          LOG.info(
+              "Skip strategy {} for identifier {} because evaluation score is 
negative "
+                  + "or job execution context is missing",
+              strategyName,
+              identifier);
+          continue;
+        }
         LOG.info(
-            "Skip strategy {} for identifier {} because strategy handler 
trigger condition is not met",
+            "Recommend strategy {} for identifier {} score: {}",
             strategyName,
-            identifier);
-        continue;
-      }
-      StrategyEvaluation evaluation = strategyHandler.evaluate();
-      if (evaluation.score() < 0 || 
evaluation.jobExecutionContext().isEmpty()) {
-        LOG.info(
-            "Skip strategy {} for identifier {} because evaluation score is 
negative "
-                + "or job execution context is missing",
+            identifier,
+            evaluation.score());
+        scoreQueue.add(evaluation);
+      } catch (IllegalArgumentException e) {
+        LOG.warn(
+            "Skip strategy {} for identifier {} because policy rules or 
statistics are invalid",
             strategyName,
-            identifier);
-        continue;
+            identifier,
+            e);
       }
-      LOG.info(
-          "Recommend strategy {} for identifier {} score: {}",
-          strategyName,
-          identifier,
-          evaluation.score());
-      scoreQueue.add(evaluation);
     }
 
     List<StrategyEvaluation> results = new ArrayList<>();
@@ -307,10 +320,8 @@ public class Recommender implements AutoCloseable {
                     .orElse(""));
   }
 
-  private StrategyHandler loadStrategyHandler(Strategy strategy, 
NameIdentifier nameIdentifier) {
-    StrategyHandler strategyHandler = 
createStrategyHandler(strategy.strategyType());
-
-    Set<DataRequirement> declaredRequirements = 
strategyHandler.dataRequirements();
+  private StrategyHandlerContext loadStrategyHandlerContext(
+      Strategy strategy, NameIdentifier nameIdentifier, Set<DataRequirement> 
declaredRequirements) {
     EnumSet<DataRequirement> requirements =
         declaredRequirements.isEmpty()
             ? EnumSet.noneOf(DataRequirement.class)
@@ -319,10 +330,6 @@ public class Recommender implements AutoCloseable {
     List<StatisticEntry<?>> statistics = Collections.emptyList();
     Map<PartitionPath, List<StatisticEntry<?>>> partitionStatistics = 
Collections.emptyMap();
 
-    if (requirements.contains(DataRequirement.TABLE_METADATA)) {
-      tableMetadata = tableMetadataProvider.tableMetadata(nameIdentifier);
-    }
-
     if (requirements.contains(DataRequirement.TABLE_STATISTICS)
         || requirements.contains(DataRequirement.PARTITION_STATISTICS)) {
       SupportTableStatistics supportTableStatistics = 
requireTableStatisticsProvider();
@@ -334,15 +341,16 @@ public class Recommender implements AutoCloseable {
       }
     }
 
-    StrategyHandlerContext context =
-        StrategyHandlerContext.builder(nameIdentifier, strategy)
-            .withTableMetadata(tableMetadata)
-            .withTableStatistics(statistics)
-            .withPartitionStatistics(partitionStatistics)
-            .build();
-    strategyHandler.initialize(context);
+    // Read metadata after statistics so the freshness check sees intervening 
snapshot changes.
+    if (requirements.contains(DataRequirement.TABLE_METADATA)) {
+      tableMetadata = tableMetadataProvider.tableMetadata(nameIdentifier);
+    }
 
-    return strategyHandler;
+    return StrategyHandlerContext.builder(nameIdentifier, strategy)
+        .withTableMetadata(tableMetadata)
+        .withTableStatistics(statistics)
+        .withPartitionStatistics(partitionStatistics)
+        .build();
   }
 
   private StrategyHandler createStrategyHandler(String strategyType) {
@@ -371,7 +379,12 @@ public class Recommender implements AutoCloseable {
    * {@code COMPACTION}) to {@link StrategyHandler} implementations.
    */
   private String getStrategyHandlerClassName(String strategyType) {
-    return optimizerEnv.config().getStrategyHandlerClassName(strategyType);
+    String configured = 
optimizerEnv.config().getStrategyHandlerClassName(strategyType);
+    if (StringUtils.isBlank(configured)
+        && 
IcebergRewriteManifestsContent.STRATEGY_TYPE_VALUE.equals(strategyType)) {
+      return ManifestRewriteStrategyHandler.class.getName();
+    }
+    return configured;
   }
 
   private Map<String, List<NameIdentifier>> getIdentifiersByStrategyName(
diff --git 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/ManifestRewriteJobContext.java
 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/ManifestRewriteJobContext.java
new file mode 100644
index 0000000000..74f2dbb334
--- /dev/null
+++ 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/ManifestRewriteJobContext.java
@@ -0,0 +1,77 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.handler;
+
+import com.google.common.base.Preconditions;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.NameIdentifier;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+
+/** Immutable rewrite request retaining the spec whose statistics were 
evaluated. */
+public final class ManifestRewriteJobContext implements JobExecutionContext {
+  private final NameIdentifier name;
+  private final int specId;
+  @Nullable private final Boolean useCaching;
+
+  /**
+   * Creates a request for one resolved partition spec.
+   *
+   * @param name normalized catalog/schema/table identifier
+   * @param specId resolved existing partition spec
+   * @param useCaching optional Iceberg caching setting
+   */
+  public ManifestRewriteJobContext(NameIdentifier name, int specId, @Nullable 
Boolean useCaching) {
+    Preconditions.checkArgument(specId >= 0, "spec_id must be >= 0");
+    this.name = Objects.requireNonNull(name, "name");
+    this.specId = specId;
+    this.useCaching = useCaching;
+  }
+
+  /**
+   * Returns the resolved spec, never the table's subsequently changed default.
+   *
+   * @return resolved partition spec ID
+   */
+  public int specId() {
+    return specId;
+  }
+
+  @Override
+  public NameIdentifier nameIdentifier() {
+    return name;
+  }
+
+  @Override
+  public String jobTemplateName() {
+    return IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE;
+  }
+
+  @Override
+  public Map<String, String> jobOptions() {
+    // An empty option clears shared submitter settings and preserves 
Iceberg's runtime default.
+    return Map.of(
+        "spec_id",
+        Integer.toString(specId),
+        "use_caching",
+        useCaching == null ? "" : useCaching.toString());
+  }
+}
diff --git 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/ManifestRewriteStrategyHandler.java
 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/ManifestRewriteStrategyHandler.java
new file mode 100644
index 0000000000..06a2c03a85
--- /dev/null
+++ 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/ManifestRewriteStrategyHandler.java
@@ -0,0 +1,212 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.handler;
+
+import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants.CURRENT_SNAPSHOT_ID;
+import static 
org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants.DEFAULT_SPEC_ID;
+
+import com.google.common.base.Preconditions;
+import java.math.BigInteger;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyEvaluation;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyHandler;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyHandlerContext;
+import 
org.apache.gravitino.maintenance.optimizer.common.IcebergManifestStatistics;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Evaluates a complete manifest measurement for exactly one resolved 
partition spec. */
+public final class ManifestRewriteStrategyHandler implements StrategyHandler {
+  private static final Logger LOG = 
LoggerFactory.getLogger(ManifestRewriteStrategyHandler.class);
+
+  private StrategyEvaluation evaluation = StrategyEvaluation.NO_EXECUTION;
+
+  @Override
+  public Set<DataRequirement> dataRequirements() {
+    return Set.of(DataRequirement.TABLE_METADATA, 
DataRequirement.TABLE_STATISTICS);
+  }
+
+  @Override
+  public String strategyType() {
+    return IcebergRewriteManifestsContent.STRATEGY_TYPE_VALUE;
+  }
+
+  @Override
+  public void initialize(StrategyHandlerContext context) {
+    evaluation = StrategyEvaluation.NO_EXECUTION;
+    Objects.requireNonNull(context, "context");
+    Preconditions.checkArgument(
+        strategyType().equals(context.strategy().strategyType()),
+        "Unexpected strategy type: %s",
+        context.strategy().strategyType());
+    Preconditions.checkArgument(
+        IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE.equals(
+            context.strategy().jobTemplateName()),
+        "Unexpected manifest rewrite job template");
+    Map<String, Object> rules = context.strategy().rules();
+    Integer requestedSpec = requestedSpec(rules);
+    Integer resolvedSpec = requestedSpec == null ? defaultSpec(context) : 
requestedSpec;
+    if (resolvedSpec == null) {
+      LOG.warn(
+          "Skipping manifest rewrite policy {} for {}: cannot resolve Iceberg 
{} from table metadata",
+          context.strategy().name(),
+          context.nameIdentifier(),
+          DEFAULT_SPEC_ID);
+      return;
+    }
+    IcebergRewriteManifestsContent content =
+        PolicyContents.icebergRewriteManifests(
+            longRule(rules, 
IcebergRewriteManifestsContent.MANIFEST_COUNT_CRITICAL),
+            longRule(rules, 
IcebergRewriteManifestsContent.MANIFEST_COUNT_WARNING),
+            longRule(rules, 
IcebergRewriteManifestsContent.AVG_MANIFEST_SIZE_THRESHOLD_BYTES),
+            requestedSpec,
+            booleanRule(rules, IcebergRewriteManifestsContent.USE_CACHING));
+    content.validate();
+    Map<String, StatisticValue<?>> statistics = new LinkedHashMap<>();
+    for (StatisticEntry<?> entry : context.tableStatistics()) {
+      Preconditions.checkArgument(
+          statistics.putIfAbsent(entry.name(), entry.value()) == null,
+          "Duplicate statistic: %s",
+          entry.name());
+    }
+    if (!hasCurrentSnapshot(context, 
statistics.get(IcebergManifestStatistics.SNAPSHOT_ID))) {
+      LOG.info(
+          "Skipping manifest rewrite for {}: verify table snapshot metadata 
and recollect manifest statistics",
+          context.nameIdentifier());
+      return;
+    }
+    Optional<IcebergManifestStatistics> measurement =
+        IcebergManifestStatistics.fromStatistics(statistics, resolvedSpec);
+    if (measurement.isEmpty()) {
+      LOG.info(
+          "Collect manifest statistics for spec {} of {} before evaluating",
+          resolvedSpec,
+          context.nameIdentifier());
+      return;
+    }
+    IcebergManifestStatistics values = measurement.get();
+    if (values.count() >= content.manifestCountCritical()
+        || (values.count() >= content.manifestCountWarning()
+            && values.averageSize() < 
content.avgManifestSizeThresholdBytes())) {
+      evaluation =
+          new StrategyEvaluationImpl(
+              values.count(),
+              new ManifestRewriteJobContext(
+                  context.nameIdentifier(), resolvedSpec, 
content.useCaching()));
+    }
+  }
+
+  @Override
+  public boolean shouldTrigger() {
+    return evaluation.jobExecutionContext().isPresent();
+  }
+
+  @Override
+  public StrategyEvaluation evaluate() {
+    return evaluation;
+  }
+
+  private static boolean hasCurrentSnapshot(
+      StrategyHandlerContext context, @Nullable StatisticValue<?> snapshot) {
+    if (!(snapshot instanceof StatisticValues.LongValue)) {
+      return false;
+    }
+    String currentSnapshot =
+        context
+            .tableMetadata()
+            .map(table -> table.properties().get(CURRENT_SNAPSHOT_ID))
+            .orElse(null);
+    long collectedSnapshot = ((StatisticValues.LongValue) snapshot).value();
+    return collectedSnapshot == -1L
+        ? "none".equals(currentSnapshot)
+        : collectedSnapshot >= 0 && 
Long.toString(collectedSnapshot).equals(currentSnapshot);
+  }
+
+  @Nullable
+  private static Integer defaultSpec(StrategyHandlerContext context) {
+    String value =
+        context.tableMetadata().map(table -> 
table.properties().get(DEFAULT_SPEC_ID)).orElse(null);
+    if (value == null || !value.matches("[0-9]+")) {
+      return null;
+    }
+    try {
+      return Integer.valueOf(value);
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  @Nullable
+  private static Integer requestedSpec(Map<String, Object> rules) {
+    Long value = longRule(rules, IcebergRewriteManifestsContent.SPEC_ID);
+    Preconditions.checkArgument(
+        value == null || (value >= 0 && value <= Integer.MAX_VALUE),
+        "spec_id must be a non-negative 32-bit integer");
+    return value == null ? null : value.intValue();
+  }
+
+  @Nullable
+  private static Long longRule(Map<String, Object> rules, String key) {
+    Object value = rules.get(key);
+    if (value == null) {
+      return null;
+    }
+    if (value instanceof Byte
+        || value instanceof Short
+        || value instanceof Integer
+        || value instanceof Long) {
+      return ((Number) value).longValue();
+    }
+    try {
+      if (value instanceof BigInteger) {
+        return ((BigInteger) value).longValueExact();
+      }
+      Preconditions.checkArgument(
+          value instanceof String && ((String) value).matches("-?[0-9]+"),
+          "%s must be an integer",
+          key);
+      return Long.valueOf((String) value);
+    } catch (NumberFormatException | ArithmeticException e) {
+      throw new IllegalArgumentException(key + " must be an integer", e);
+    }
+  }
+
+  @Nullable
+  private static Boolean booleanRule(Map<String, Object> rules, String key) {
+    Object value = rules.get(key);
+    if (value == null) {
+      return null;
+    }
+    Preconditions.checkArgument(
+        "true".equalsIgnoreCase(value.toString()) || 
"false".equalsIgnoreCase(value.toString()),
+        "%s must be true or false",
+        key);
+    return Boolean.valueOf(value.toString());
+  }
+}
diff --git 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
index 7b1cceeaf5..69fb475611 100644
--- 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
+++ 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoJobSubmitter.java
@@ -32,6 +32,7 @@ import 
org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
 import 
org.apache.gravitino.maintenance.optimizer.common.util.GravitinoClientUtils;
 import org.apache.gravitino.policy.IcebergDataCompactionContent;
 import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
 
 /** Submits optimizer jobs to Gravitino using job template adapters. */
 public class GravitinoJobSubmitter implements JobSubmitter {
@@ -51,6 +52,8 @@ public class GravitinoJobSubmitter implements JobSubmitter {
       ImmutableMap.of(
           IcebergDataCompactionContent.JOB_TEMPLATE_NAME_VALUE,
           GravitinoCompactionJobAdapter.class,
+          IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE,
+          GravitinoManifestRewriteJobAdapter.class,
           IcebergOrphanFileRemovalContent.JOB_TEMPLATE_NAME_VALUE,
           GravitinoOrphanFileRemovalJobAdapter.class);
 
diff --git 
a/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoManifestRewriteJobAdapter.java
 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoManifestRewriteJobAdapter.java
new file mode 100644
index 0000000000..7186d5fe34
--- /dev/null
+++ 
b/maintenance/optimizer/src/main/java/org/apache/gravitino/maintenance/optimizer/recommender/job/GravitinoManifestRewriteJobAdapter.java
@@ -0,0 +1,46 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.job;
+
+import com.google.common.base.Preconditions;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
+import org.apache.gravitino.maintenance.optimizer.common.util.IdentifierUtils;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteJobContext;
+
+/** Maps a manifest rewrite decision to the built-in Spark job's arguments. */
+public final class GravitinoManifestRewriteJobAdapter implements 
GravitinoJobAdapter {
+  @Override
+  public Map<String, String> jobConfig(JobExecutionContext context) {
+    Preconditions.checkArgument(
+        context instanceof ManifestRewriteJobContext,
+        "jobExecutionContext must be ManifestRewriteJobContext");
+    ManifestRewriteJobContext rewrite = (ManifestRewriteJobContext) context;
+    IdentifierUtils.requireTableIdentifierNormalized(rewrite.nameIdentifier());
+    Map<String, String> config = new LinkedHashMap<>(rewrite.jobOptions());
+    config.put(
+        "catalog_name",
+        
IdentifierUtils.getCatalogNameFromTableIdentifier(rewrite.nameIdentifier()));
+    config.put(
+        "table_identifier",
+        
IdentifierUtils.removeCatalogFromIdentifier(rewrite.nameIdentifier()).toString());
+    return Map.copyOf(config);
+  }
+}
diff --git 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestManifestRewriteRecommendation.java
 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestManifestRewriteRecommendation.java
new file mode 100644
index 0000000000..1890daa451
--- /dev/null
+++ 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestManifestRewriteRecommendation.java
@@ -0,0 +1,334 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
+import org.apache.gravitino.maintenance.optimizer.api.recommender.JobSubmitter;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyProvider;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.SupportTableStatistics;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.TableMetadataProvider;
+import 
org.apache.gravitino.maintenance.optimizer.common.IcebergManifestStatistics;
+import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
+import org.apache.gravitino.maintenance.optimizer.common.StatisticEntryImpl;
+import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.job.GravitinoManifestRewriteJobAdapter;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.strategy.GravitinoStrategy;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
+import org.apache.gravitino.policy.Policy;
+import org.apache.gravitino.policy.PolicyContent;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+class TestManifestRewriteRecommendation {
+  @Test
+  void testBuiltInDiscoveryDryRunAndSubmission() throws Exception {
+    NameIdentifier identifier = NameIdentifier.of("catalog", "db", "table");
+    Policy policy = Mockito.mock(Policy.class);
+    Mockito.when(policy.name()).thenReturn("rewrite");
+    Mockito.when(policy.content())
+        .thenReturn(PolicyContents.icebergRewriteManifests(null, null, null, 
7, false));
+    GravitinoStrategy strategy = new GravitinoStrategy(policy);
+    StrategyProvider strategies = Mockito.mock(StrategyProvider.class);
+    
Mockito.when(strategies.strategies(identifier)).thenReturn(List.of(strategy));
+    Mockito.when(strategies.strategy("rewrite")).thenReturn(strategy);
+    SupportTableStatistics statistics = 
Mockito.mock(SupportTableStatistics.class);
+    Mockito.when(statistics.tableStatistics(identifier))
+        .thenReturn(
+            List.of(
+                new StatisticEntryImpl<>(
+                    IcebergManifestStatistics.MANIFEST_NUMBER,
+                    StatisticValues.objectValue(Map.of("7", 
StatisticValues.longValue(500)))),
+                new StatisticEntryImpl<>(
+                    IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+                    StatisticValues.objectValue(
+                        Map.of("7", StatisticValues.doubleValue(16 * 1024 * 
1024)))),
+                new StatisticEntryImpl<>(
+                    IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.longValue(1))));
+    TableMetadataProvider metadata = Mockito.mock(TableMetadataProvider.class);
+    Table table = Mockito.mock(Table.class);
+    Mockito.when(table.properties()).thenReturn(Map.of("current-snapshot-id", 
"1"));
+    Mockito.when(metadata.tableMetadata(identifier)).thenReturn(table);
+    JobSubmitter submitter = Mockito.mock(JobSubmitter.class);
+    Mockito.when(submitter.submitJob(Mockito.anyString(), 
Mockito.any())).thenReturn("job-1");
+    try (Recommender recommender =
+        new Recommender(
+            strategies,
+            statistics,
+            metadata,
+            submitter,
+            new OptimizerEnv(new OptimizerConfig(Map.of())))) {
+      Assertions.assertEquals(
+          1, recommender.recommendForStrategyName(List.of(identifier), 
"rewrite", 1).size());
+      Mockito.verifyNoInteractions(submitter);
+      PolicyContent validContent = policy.content();
+      for (Object invalid : new Object[] {"+500", new StringBuilder("500"), 
-1L}) {
+        Mockito.when(policy.content())
+            .thenReturn(
+                PolicyContents.custom(
+                    Map.of("spec_id", 7, "manifest_count_critical", invalid),
+                    validContent.supportedObjectTypes(),
+                    validContent.properties()));
+        Assertions.assertTrue(
+            recommender.recommendForStrategyName(List.of(identifier), 
"rewrite", 1).isEmpty());
+      }
+      Mockito.when(policy.content()).thenReturn(validContent);
+      List<Recommender.RecommendationResult> results =
+          recommender.submitForStrategyName(List.of(identifier), "rewrite");
+      Assertions.assertEquals("job-1", results.get(0).jobId());
+      ArgumentCaptor<JobExecutionContext> captured =
+          ArgumentCaptor.forClass(JobExecutionContext.class);
+      Mockito.verify(submitter)
+          .submitJob(
+              
Mockito.eq(IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE),
+              captured.capture());
+      Assertions.assertEquals(
+          Map.of(
+              "catalog_name",
+              "catalog",
+              "table_identifier",
+              "db.table",
+              "spec_id",
+              "7",
+              "use_caching",
+              "false"),
+          new 
GravitinoManifestRewriteJobAdapter().jobConfig(captured.getValue()));
+      Mockito.verify(metadata, 
Mockito.atLeastOnce()).tableMetadata(identifier);
+      
Mockito.when(table.properties()).thenReturn(Map.of("current-snapshot-id", "2"));
+      Assertions.assertTrue(
+          recommender.submitForStrategyName(List.of(identifier), 
"rewrite").isEmpty());
+      Mockito.verifyNoMoreInteractions(submitter);
+      Mockito.verify(statistics, 
Mockito.never()).partitionStatistics(Mockito.any());
+      
Mockito.when(statistics.tableStatistics(identifier)).thenReturn(List.of());
+      Assertions.assertTrue(
+          recommender.submitForStrategyName(List.of(identifier), 
"rewrite").isEmpty());
+      Mockito.verifyNoMoreInteractions(submitter);
+      
Mockito.when(policy.content()).thenReturn(PolicyContents.icebergRewriteManifests());
+      Assertions.assertTrue(
+          recommender.submitForStrategyName(List.of(identifier), 
"rewrite").isEmpty());
+      Mockito.verifyNoMoreInteractions(submitter);
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("invalidMeasurements")
+  void testInvalidStatisticsDoNotAbortOtherTables(List<StatisticEntry<?>> 
invalid)
+      throws Exception {
+    NameIdentifier bad = NameIdentifier.of("catalog", "db", "bad");
+    NameIdentifier healthy = NameIdentifier.of("catalog", "db", "healthy");
+    StrategyProvider strategies = manifestStrategies(bad, healthy);
+    SupportTableStatistics statistics = 
Mockito.mock(SupportTableStatistics.class);
+    Mockito.when(statistics.tableStatistics(bad)).thenReturn(invalid);
+    Mockito.when(statistics.tableStatistics(healthy))
+        .thenReturn(measurements(StatisticValues.longValue(500), 1));
+    TableMetadataProvider metadata = Mockito.mock(TableMetadataProvider.class);
+    Table table = tableAtSnapshot("1");
+    Mockito.when(metadata.tableMetadata(Mockito.any())).thenReturn(table);
+    JobSubmitter submitter = Mockito.mock(JobSubmitter.class);
+    Mockito.when(submitter.submitJob(Mockito.anyString(), 
Mockito.any())).thenReturn("healthy-job");
+    try (Recommender recommender =
+        new Recommender(
+            strategies,
+            statistics,
+            metadata,
+            submitter,
+            new OptimizerEnv(new OptimizerConfig(Map.of())))) {
+      List<Recommender.RecommendationResult> results =
+          recommender.submitForStrategyName(List.of(bad, healthy), "rewrite");
+      Assertions.assertEquals(1, results.size());
+      Assertions.assertEquals(healthy, results.get(0).identifier());
+      Assertions.assertEquals("healthy-job", results.get(0).jobId());
+      ArgumentCaptor<JobExecutionContext> captured =
+          ArgumentCaptor.forClass(JobExecutionContext.class);
+      Mockito.verify(submitter)
+          .submitJob(
+              
Mockito.eq(IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE),
+              captured.capture());
+      Assertions.assertEquals(healthy, captured.getValue().nameIdentifier());
+    }
+  }
+
+  @Test
+  void testSnapshotChangeDuringStatisticsReadSkipsRecommendation() throws 
Exception {
+    NameIdentifier identifier = NameIdentifier.of("catalog", "db", "table");
+    AtomicReference<Table> currentTable = new 
AtomicReference<>(tableAtSnapshot("1"));
+    SupportTableStatistics statistics = 
Mockito.mock(SupportTableStatistics.class);
+    Mockito.when(statistics.tableStatistics(identifier))
+        .thenAnswer(
+            invocation -> {
+              currentTable.set(tableAtSnapshot("2"));
+              return measurements(StatisticValues.longValue(500), 1);
+            });
+    TableMetadataProvider metadata = Mockito.mock(TableMetadataProvider.class);
+    Mockito.when(metadata.tableMetadata(identifier)).thenAnswer(invocation -> 
currentTable.get());
+    JobSubmitter submitter = Mockito.mock(JobSubmitter.class);
+    try (Recommender recommender =
+        new Recommender(
+            manifestStrategies(identifier),
+            statistics,
+            metadata,
+            submitter,
+            new OptimizerEnv(new OptimizerConfig(Map.of())))) {
+      Assertions.assertTrue(
+          recommender.submitForStrategyName(List.of(identifier), 
"rewrite").isEmpty());
+      Mockito.verifyNoInteractions(submitter);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"statistics", "metadata"})
+  void testProviderFailuresStillPropagate(String failingProvider) throws 
Exception {
+    NameIdentifier identifier = NameIdentifier.of("catalog", "db", "table");
+    IllegalArgumentException failure = new IllegalArgumentException("Provider 
read failed");
+    SupportTableStatistics statistics = 
Mockito.mock(SupportTableStatistics.class);
+    Mockito.when(statistics.tableStatistics(identifier))
+        .thenReturn(measurements(StatisticValues.longValue(500), 1));
+    TableMetadataProvider metadata = Mockito.mock(TableMetadataProvider.class);
+    if ("statistics".equals(failingProvider)) {
+      Mockito.when(statistics.tableStatistics(identifier)).thenThrow(failure);
+    } else {
+      Mockito.when(metadata.tableMetadata(identifier)).thenThrow(failure);
+    }
+    JobSubmitter submitter = Mockito.mock(JobSubmitter.class);
+    try (Recommender recommender =
+        new Recommender(
+            manifestStrategies(identifier),
+            statistics,
+            metadata,
+            submitter,
+            new OptimizerEnv(new OptimizerConfig(Map.of())))) {
+      Assertions.assertSame(
+          failure,
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> recommender.submitForStrategyName(List.of(identifier), 
"rewrite")));
+      Mockito.verifyNoInteractions(submitter);
+    }
+  }
+
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(strings = {"", "-1", "1.5", "+1", " 1", "2147483648", 
"invalid"})
+  void testUnresolvableDefaultSkipsOnlyAffectedTable(String defaultSpec) 
throws Exception {
+    NameIdentifier bad = NameIdentifier.of("catalog", "db", "bad");
+    NameIdentifier healthy = NameIdentifier.of("catalog", "db", "healthy");
+    SupportTableStatistics statistics = 
Mockito.mock(SupportTableStatistics.class);
+    Mockito.when(statistics.tableStatistics(Mockito.any()))
+        .thenReturn(measurements(StatisticValues.longValue(500), 1));
+    TableMetadataProvider metadata = Mockito.mock(TableMetadataProvider.class);
+    Table badTable = Mockito.mock(Table.class);
+    Map<String, String> properties = new HashMap<>();
+    properties.put("current-snapshot-id", "1");
+    if (defaultSpec != null) {
+      properties.put("default-spec-id", defaultSpec);
+    }
+    Mockito.when(badTable.properties()).thenReturn(properties);
+    Table healthyTable = tableAtSnapshot("1");
+    Mockito.when(metadata.tableMetadata(bad)).thenReturn(badTable);
+    Mockito.when(metadata.tableMetadata(healthy)).thenReturn(healthyTable);
+    JobSubmitter submitter = Mockito.mock(JobSubmitter.class);
+    try (Recommender recommender =
+        new Recommender(
+            manifestStrategies(PolicyContents.icebergRewriteManifests(), bad, 
healthy),
+            statistics,
+            metadata,
+            submitter,
+            new OptimizerEnv(new OptimizerConfig(Map.of())))) {
+      List<Recommender.RecommendationResult> results =
+          recommender.submitForStrategyName(List.of(bad, healthy), "rewrite");
+      Assertions.assertEquals(1, results.size());
+      Assertions.assertEquals(healthy, results.get(0).identifier());
+      Assertions.assertEquals("7", results.get(0).jobOptions().get("spec_id"));
+      ArgumentCaptor<JobExecutionContext> captured =
+          ArgumentCaptor.forClass(JobExecutionContext.class);
+      Mockito.verify(submitter)
+          .submitJob(
+              
Mockito.eq(IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE),
+              captured.capture());
+      Assertions.assertEquals("7", 
captured.getValue().jobOptions().get("spec_id"));
+    }
+  }
+
+  private static Stream<List<StatisticEntry<?>>> invalidMeasurements() {
+    return Stream.of(
+        measurements(StatisticValues.stringValue("500"), 1),
+        measurements(StatisticValues.longValue(-1), 1),
+        measurements(StatisticValues.longValue(500), Double.NaN),
+        measurements(StatisticValues.longValue(0), 1),
+        List.of(
+            new StatisticEntryImpl<>(
+                IcebergManifestStatistics.MANIFEST_NUMBER, 
StatisticValues.longValue(500)),
+            new StatisticEntryImpl<>(
+                IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.longValue(1))));
+  }
+
+  private static List<StatisticEntry<?>> measurements(StatisticValue<?> count, 
double average) {
+    return List.of(
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.MANIFEST_NUMBER,
+            StatisticValues.objectValue(Map.of("7", count))),
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+            StatisticValues.objectValue(Map.of("7", 
StatisticValues.doubleValue(average)))),
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.longValue(1)));
+  }
+
+  private static StrategyProvider manifestStrategies(NameIdentifier... 
identifiers) {
+    return manifestStrategies(
+        PolicyContents.icebergRewriteManifests(null, null, null, 7, null), 
identifiers);
+  }
+
+  private static StrategyProvider manifestStrategies(
+      PolicyContent content, NameIdentifier... identifiers) {
+    Policy policy = Mockito.mock(Policy.class);
+    Mockito.when(policy.name()).thenReturn("rewrite");
+    Mockito.when(policy.content()).thenReturn(content);
+    GravitinoStrategy strategy = new GravitinoStrategy(policy);
+    StrategyProvider strategies = Mockito.mock(StrategyProvider.class);
+    Mockito.when(strategies.strategy("rewrite")).thenReturn(strategy);
+    for (NameIdentifier identifier : identifiers) {
+      
Mockito.when(strategies.strategies(identifier)).thenReturn(List.of(strategy));
+    }
+    return strategies;
+  }
+
+  private static Table tableAtSnapshot(String snapshot) {
+    Table table = Mockito.mock(Table.class);
+    Mockito.when(table.properties())
+        .thenReturn(Map.of("current-snapshot-id", snapshot, "default-spec-id", 
"7"));
+    return table;
+  }
+}
diff --git 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestRecommenderExecutionMode.java
 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestRecommenderExecutionMode.java
index 1edf9492ba..9cb224c97d 100644
--- 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestRecommenderExecutionMode.java
+++ 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/TestRecommenderExecutionMode.java
@@ -36,6 +36,8 @@ import 
org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
 import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mockito;
 
 class TestRecommenderExecutionMode {
@@ -210,6 +212,54 @@ class TestRecommenderExecutionMode {
     Assertions.assertTrue(exception.getMessage().contains("limit must be > 
0"));
   }
 
+  @ParameterizedTest
+  @ValueSource(strings = {"initialize", "shouldTrigger", "evaluate"})
+  void testInvalidHandlerInputSkipsOnlyAffectedTable(String failureStage) 
throws Exception {
+    NameIdentifier bad = NameIdentifier.of("catalog", "db", failureStage);
+    NameIdentifier healthy = NameIdentifier.of("catalog", "db", "healthy");
+    Strategy strategy = new TestStrategy("s1", STRATEGY_TYPE, "tpl-1");
+    StrategyProvider strategies = Mockito.mock(StrategyProvider.class);
+    
Mockito.when(strategies.strategies(Mockito.any())).thenReturn(List.of(strategy));
+    Mockito.when(strategies.strategy("s1")).thenReturn(strategy);
+    JobSubmitter submitter = Mockito.mock(JobSubmitter.class);
+    try (Recommender recommender =
+        createRecommender(
+            strategies,
+            Mockito.mock(StatisticsProvider.class),
+            Mockito.mock(TableMetadataProvider.class),
+            submitter)) {
+      List<Recommender.RecommendationResult> results =
+          recommender.recommendForStrategyName(List.of(bad, healthy), "s1", 
Integer.MAX_VALUE);
+      Assertions.assertEquals(1, results.size());
+      Assertions.assertEquals(healthy, results.get(0).identifier());
+      Mockito.verifyNoInteractions(submitter);
+    }
+  }
+
+  @Test
+  void testMissingHandlerConfigurationStillFails() throws Exception {
+    NameIdentifier identifier = NameIdentifier.of("catalog", "db", "table");
+    Strategy strategy = new TestStrategy("s1", STRATEGY_TYPE, "tpl-1");
+    StrategyProvider strategies = Mockito.mock(StrategyProvider.class);
+    
Mockito.when(strategies.strategies(identifier)).thenReturn(List.of(strategy));
+    Mockito.when(strategies.strategy("s1")).thenReturn(strategy);
+    try (Recommender recommender =
+        new Recommender(
+            strategies,
+            Mockito.mock(StatisticsProvider.class),
+            Mockito.mock(TableMetadataProvider.class),
+            Mockito.mock(JobSubmitter.class),
+            new OptimizerEnv(new OptimizerConfig(Map.of())))) {
+      IllegalArgumentException failure =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  recommender.recommendForStrategyName(
+                      List.of(identifier), "s1", Integer.MAX_VALUE));
+      Assertions.assertTrue(failure.getMessage().contains("No StrategyHandler 
class configured"));
+    }
+  }
+
   private static Recommender createRecommender(
       StrategyProvider strategyProvider,
       StatisticsProvider statisticsProvider,
@@ -224,12 +274,14 @@ class TestRecommenderExecutionMode {
         strategyProvider, statisticsProvider, tableMetadataProvider, 
jobSubmitter, optimizerEnv);
   }
 
+  /** Handler that rejects the table named for a lifecycle stage at that 
stage. */
   public static class TestStrategyHandler implements StrategyHandler {
     private StrategyHandlerContext context;
 
     @Override
     public void initialize(StrategyHandlerContext context) {
       this.context = context;
+      rejectInvalidTable("initialize");
     }
 
     @Override
@@ -239,11 +291,13 @@ class TestRecommenderExecutionMode {
 
     @Override
     public boolean shouldTrigger() {
+      rejectInvalidTable("shouldTrigger");
       return true;
     }
 
     @Override
     public StrategyEvaluation evaluate() {
+      rejectInvalidTable("evaluate");
       Strategy strategy = context.strategy();
       JobExecutionContext jobExecutionContext =
           new JobExecutionContext() {
@@ -274,6 +328,12 @@ class TestRecommenderExecutionMode {
         }
       };
     }
+
+    private void rejectInvalidTable(String stage) {
+      if (stage.equals(context.nameIdentifier().name())) {
+        throw new IllegalArgumentException("Invalid table input at " + stage);
+      }
+    }
   }
 
   private static final class TestStrategy implements Strategy {
diff --git 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/TestManifestRewriteStrategyHandler.java
 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/TestManifestRewriteStrategyHandler.java
new file mode 100644
index 0000000000..a8fd356aab
--- /dev/null
+++ 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/handler/TestManifestRewriteStrategyHandler.java
@@ -0,0 +1,384 @@
+/*
+ * 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.gravitino.maintenance.optimizer.recommender.handler;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
+import 
org.apache.gravitino.maintenance.optimizer.api.recommender.StrategyHandlerContext;
+import 
org.apache.gravitino.maintenance.optimizer.common.IcebergManifestStatistics;
+import org.apache.gravitino.maintenance.optimizer.common.StatisticEntryImpl;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.job.GravitinoManifestRewriteJobAdapter;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.strategy.GravitinoStrategy;
+import org.apache.gravitino.policy.Policy;
+import org.apache.gravitino.policy.PolicyContent;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.stats.StatisticValues;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
+
+class TestManifestRewriteStrategyHandler {
+  @ParameterizedTest
+  @CsvSource({
+    "0,0,false",
+    "99,1,false",
+    "100,8388607,true",
+    "100,8388608,false",
+    "100,8388609,false",
+    "499,8388607,true",
+    "499,8388608,false",
+    "500,8388608,true",
+    "500,16777216,true",
+    "501,16777216,true"
+  })
+  void testBoundaries(long count, double average, boolean trigger) {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 1, null),
+            statistics(count, average)));
+    Assertions.assertEquals(trigger, handler.shouldTrigger());
+    Assertions.assertEquals(trigger, 
handler.evaluate().jobExecutionContext().isPresent());
+    if (trigger) {
+      Assertions.assertEquals(count, handler.evaluate().score());
+    }
+  }
+
+  @Test
+  void testMissingSpecAndPartialMeasurementsDoNotTrigger() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    for (List<StatisticEntry<?>> stats :
+        List.of(
+            List.<StatisticEntry<?>>of(),
+            List.of(statistics(500, 1).get(0), statistics(500, 1).get(2)),
+            statistics(500, 1).subList(1, 3))) {
+      handler.initialize(
+          context(PolicyContents.icebergRewriteManifests(null, null, null, 1, 
null), stats));
+      Assertions.assertFalse(handler.shouldTrigger());
+    }
+    handler.initialize(
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 2, null), 
statistics(500, 1)));
+    Assertions.assertFalse(handler.shouldTrigger());
+  }
+
+  @Test
+  void testMissingDefaultSkipsButExplicitSpecRetainsOptions() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    StrategyHandlerContext context =
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, null, 
false),
+            statistics(100, 1));
+    handler.initialize(context);
+    Assertions.assertFalse(handler.shouldTrigger());
+    handler.initialize(
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 1, false),
+            statistics(100, 1)));
+    Map<String, String> config =
+        new GravitinoManifestRewriteJobAdapter()
+            .jobConfig(handler.evaluate().jobExecutionContext().orElseThrow());
+    Assertions.assertEquals(
+        Map.of(
+            "catalog_name",
+            "iceberg",
+            "table_identifier",
+            "db.tbl",
+            "spec_id",
+            "1",
+            "use_caching",
+            "false"),
+        config);
+    handler.initialize(context);
+    Assertions.assertFalse(handler.shouldTrigger());
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"0", "1", "2147483647"})
+  void testDefaultSpecIsResolvedAndPinned(String specId) {
+    Table metadata = Mockito.mock(Table.class);
+    Mockito.when(metadata.properties())
+        .thenReturn(Map.of("current-snapshot-id", "1", "default-spec-id", 
specId));
+    StrategyHandlerContext context = 
context(PolicyContents.icebergRewriteManifests(), List.of());
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        StrategyHandlerContext.builder(context.nameIdentifier(), 
context.strategy())
+            .withTableMetadata(metadata)
+            .withTableStatistics(
+                List.of(
+                    new StatisticEntryImpl<>(
+                        IcebergManifestStatistics.MANIFEST_NUMBER,
+                        StatisticValues.objectValue(
+                            Map.of(specId, StatisticValues.longValue(500)))),
+                    new StatisticEntryImpl<>(
+                        IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+                        StatisticValues.objectValue(
+                            Map.of(specId, StatisticValues.doubleValue(1)))),
+                    new StatisticEntryImpl<>(
+                        IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.longValue(1))))
+            .build());
+    Assertions.assertTrue(handler.shouldTrigger());
+    Mockito.when(metadata.properties())
+        .thenReturn(Map.of("current-snapshot-id", "1", "default-spec-id", 
"2"));
+    Assertions.assertEquals(
+        specId,
+        new GravitinoManifestRewriteJobAdapter()
+            .jobConfig(handler.evaluate().jobExecutionContext().orElseThrow())
+            .get("spec_id"));
+  }
+
+  @Test
+  void testExplicitSpecOverridesCurrentDefault() {
+    StrategyHandlerContext context =
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 0, null), 
statistics(0, 0));
+    Table metadata = Mockito.mock(Table.class);
+    Mockito.when(metadata.properties())
+        .thenReturn(Map.of("current-snapshot-id", "1", "default-spec-id", 
"1"));
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        StrategyHandlerContext.builder(context.nameIdentifier(), 
context.strategy())
+            .withTableMetadata(metadata)
+            .withTableStatistics(context.tableStatistics())
+            .build());
+    Assertions.assertTrue(handler.shouldTrigger());
+    Assertions.assertEquals(
+        "0", 
handler.evaluate().jobExecutionContext().orElseThrow().jobOptions().get("spec_id"));
+  }
+
+  @Test
+  void testConfigurableThresholds() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        context(PolicyContents.icebergRewriteManifests(10L, 5L, 2L, 1, true), 
statistics(5, 1)));
+    Assertions.assertTrue(handler.shouldTrigger());
+  }
+
+  @Test
+  void testOnlySelectedSpecCanTrigger() {
+    List<StatisticEntry<?>> measurements = statistics(0, 0);
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        context(PolicyContents.icebergRewriteManifests(null, null, null, 1, 
null), measurements));
+    Assertions.assertFalse(handler.shouldTrigger(), "Spec 1 has zero 
manifests");
+    handler.initialize(
+        context(PolicyContents.icebergRewriteManifests(null, null, null, 0, 
null), measurements));
+    Assertions.assertTrue(handler.shouldTrigger(), "Spec 0 has 1000 
manifests");
+    Assertions.assertEquals(1000, handler.evaluate().score());
+    Assertions.assertEquals(
+        "0",
+        new GravitinoManifestRewriteJobAdapter()
+            .jobConfig(handler.evaluate().jobExecutionContext().orElseThrow())
+            .get("spec_id"));
+  }
+
+  @ParameterizedTest
+  @CsvSource({"true,true", "TRUE,true", "TrUe,true", "false,false", 
"FALSE,false", "FaLsE,false"})
+  void testMixedCaseCachingReachesJobConfiguration(String input, String 
expected) {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        context(customContent(Map.of("spec_id", 1, "use_caching", input)), 
statistics(500, 1)));
+    Assertions.assertEquals(
+        expected,
+        new GravitinoManifestRewriteJobAdapter()
+            .jobConfig(handler.evaluate().jobExecutionContext().orElseThrow())
+            .get("use_caching"));
+  }
+
+  @Test
+  void testIntegralRuleValues() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    for (Object warning :
+        new Object[] {(byte) 100, (short) 100, 100, 100L, 
BigInteger.valueOf(100), "100"}) {
+      handler.initialize(
+          context(
+              customContent(Map.of("spec_id", 1, "manifest_count_warning", 
warning)),
+              statistics(100, 1)));
+      Assertions.assertTrue(handler.shouldTrigger());
+    }
+    handler.initialize(
+        context(customContent(Map.of("spec_id", (long) Integer.MAX_VALUE)), 
statistics(500, 1)));
+    Assertions.assertFalse(handler.shouldTrigger());
+  }
+
+  @Test
+  void testNonIntegerRuleValuesAreRejected() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    for (Object warning :
+        new Object[] {
+          "+100",
+          "100.0",
+          "1e2",
+          " 100",
+          100.0d,
+          100.0f,
+          new BigDecimal("100"),
+          new StringBuilder("100"),
+          new BigInteger("9223372036854775808"),
+          "9223372036854775808"
+        }) {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              handler.initialize(
+                  context(
+                      customContent(Map.of("spec_id", 1, 
"manifest_count_warning", warning)),
+                      statistics(100, 1))),
+          warning.toString());
+    }
+  }
+
+  @Test
+  void testMalformedRulesAndMeasurements() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    for (Map<String, Object> rules :
+        List.<Map<String, Object>>of(
+            Map.of("spec_id", "2147483648"),
+            Map.of("spec_id", 2147483648L),
+            Map.of("spec_id", -1),
+            Map.of("spec_id", 1.5),
+            Map.of("spec_id", 1, "use_caching", "yes"),
+            Map.of("spec_id", 1, "manifest_count_critical", 99))) {
+      PolicyContent content = customContent(rules);
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () -> handler.initialize(context(content, statistics(500, 1))));
+    }
+    for (double average : new double[] {-1, Double.NaN, 
Double.POSITIVE_INFINITY}) {
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              handler.initialize(
+                  context(
+                      PolicyContents.icebergRewriteManifests(null, null, null, 
1, null),
+                      statistics(500, average))));
+    }
+  }
+
+  @Test
+  void testUnverifiedSnapshotsDoNotTriggerAndClearEarlierEvaluation() {
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    StrategyHandlerContext valid =
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 1, null), 
statistics(500, 1));
+    for (Map<String, String> properties :
+        List.of(
+            Map.<String, String>of(), Map.of("current-snapshot-id", "2"),
+            Map.of("current-snapshot-id", "none"), 
Map.of("current-snapshot-id", "invalid"))) {
+      handler.initialize(valid);
+      Assertions.assertTrue(handler.shouldTrigger());
+      Table metadata = Mockito.mock(Table.class);
+      Mockito.when(metadata.properties()).thenReturn(properties);
+      handler.initialize(
+          StrategyHandlerContext.builder(valid.nameIdentifier(), 
valid.strategy())
+              .withTableStatistics(valid.tableStatistics())
+              .withTableMetadata(metadata)
+              .build());
+      Assertions.assertFalse(handler.shouldTrigger());
+    }
+    handler.initialize(
+        StrategyHandlerContext.builder(valid.nameIdentifier(), 
valid.strategy())
+            .withTableStatistics(valid.tableStatistics())
+            .build());
+    Assertions.assertFalse(handler.shouldTrigger());
+    handler.initialize(
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 1, null),
+            statistics(500, 1).subList(0, 2)));
+    Assertions.assertFalse(handler.shouldTrigger());
+    handler.initialize(
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 1, null),
+            List.of(
+                statistics(500, 1).get(0),
+                statistics(500, 1).get(1),
+                new StatisticEntryImpl<>(
+                    IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.stringValue("1")))));
+    Assertions.assertFalse(handler.shouldTrigger());
+  }
+
+  @Test
+  void testEmptyTableSnapshotIsAccepted() {
+    StrategyHandlerContext valid =
+        context(
+            PolicyContents.icebergRewriteManifests(null, null, null, 1, null), 
statistics(0, 0));
+    Table metadata = Mockito.mock(Table.class);
+    
Mockito.when(metadata.properties()).thenReturn(Map.of("current-snapshot-id", 
"none"));
+    ManifestRewriteStrategyHandler handler = new 
ManifestRewriteStrategyHandler();
+    handler.initialize(
+        StrategyHandlerContext.builder(valid.nameIdentifier(), 
valid.strategy())
+            .withTableMetadata(metadata)
+            .withTableStatistics(
+                List.of(
+                    statistics(0, 0).get(0),
+                    statistics(0, 0).get(1),
+                    new StatisticEntryImpl<>(
+                        IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.longValue(-1))))
+            .build());
+    Assertions.assertFalse(handler.shouldTrigger());
+  }
+
+  private static PolicyContent customContent(Map<String, Object> rules) {
+    return PolicyContents.custom(
+        rules,
+        PolicyContents.icebergRewriteManifests().supportedObjectTypes(),
+        PolicyContents.icebergRewriteManifests().properties());
+  }
+
+  private static StrategyHandlerContext context(
+      PolicyContent content, List<StatisticEntry<?>> statistics) {
+    Policy policy = Mockito.mock(Policy.class);
+    Mockito.when(policy.content()).thenReturn(content);
+    Mockito.when(policy.name()).thenReturn("rewrite");
+    Table metadata = Mockito.mock(Table.class);
+    
Mockito.when(metadata.properties()).thenReturn(Map.of("current-snapshot-id", 
"1"));
+    return StrategyHandlerContext.builder(
+            NameIdentifier.of("iceberg", "db", "tbl"), new 
GravitinoStrategy(policy))
+        .withTableMetadata(metadata)
+        .withTableStatistics(statistics)
+        .build();
+  }
+
+  private static List<StatisticEntry<?>> statistics(long count, double 
average) {
+    return List.of(
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.MANIFEST_NUMBER,
+            StatisticValues.objectValue(
+                Map.of(
+                    "0", StatisticValues.longValue(1000), "1", 
StatisticValues.longValue(count)))),
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+            StatisticValues.objectValue(
+                Map.of(
+                    "0",
+                    StatisticValues.doubleValue(1),
+                    "1",
+                    StatisticValues.doubleValue(average)))),
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.SNAPSHOT_ID, 
StatisticValues.longValue(1)));
+  }
+}
diff --git 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoJobSubmitter.java
 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoJobSubmitter.java
index 2963fd003c..cebf7edbbf 100644
--- 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoJobSubmitter.java
+++ 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/recommender/job/TestGravitinoJobSubmitter.java
@@ -24,12 +24,46 @@ import org.apache.gravitino.NameIdentifier;
 import 
org.apache.gravitino.maintenance.optimizer.api.recommender.JobExecutionContext;
 import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
 import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.handler.ManifestRewriteJobContext;
 import org.apache.gravitino.policy.IcebergDataCompactionContent;
 import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
+import org.apache.gravitino.policy.IcebergRewriteManifestsContent;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
 public class TestGravitinoJobSubmitter {
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(booleans = {true, false})
+  void testManifestAdapterPreservesPolicyOptionsOverGlobalConfig(Boolean 
useCaching) {
+    GravitinoJobAdapter adapter =
+        new GravitinoJobSubmitter()
+            
.loadJobAdapter(IcebergRewriteManifestsContent.JOB_TEMPLATE_NAME_VALUE);
+    Assertions.assertInstanceOf(GravitinoManifestRewriteJobAdapter.class, 
adapter);
+    OptimizerConfig config =
+        new OptimizerConfig(
+            Map.of(
+                OptimizerConfig.JOB_SUBMITTER_CONFIG_PREFIX + "spec_id", "99",
+                OptimizerConfig.JOB_SUBMITTER_CONFIG_PREFIX + "use_caching", 
"false",
+                OptimizerConfig.JOB_SUBMITTER_CONFIG_PREFIX + "catalog_name", 
"other",
+                OptimizerConfig.JOB_SUBMITTER_CONFIG_PREFIX + "spark_master", 
"local[2]"));
+    Map<String, String> result =
+        GravitinoJobSubmitter.buildJobConfig(
+            config,
+            new ManifestRewriteJobContext(
+                NameIdentifier.of("catalog", "db", "table"), 1, useCaching),
+            adapter);
+    Assertions.assertEquals("1", result.get("spec_id"));
+    Assertions.assertEquals("catalog", result.get("catalog_name"));
+    Assertions.assertEquals("local[2]", result.get("spark_master"));
+    Assertions.assertEquals(
+        useCaching == null ? "" : useCaching.toString(), 
result.get("use_caching"));
+    Assertions.assertThrows(IllegalArgumentException.class, () -> 
adapter.jobConfig(null));
+  }
+
   @Test
   void loadJobAdapterReturnsCompactionAdapterForBuiltInTemplateName() {
     GravitinoJobSubmitter submitter = new GravitinoJobSubmitter();
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestPolicyOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestPolicyOperations.java
index 0063630f7b..fbff3efd7f 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestPolicyOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestPolicyOperations.java
@@ -81,6 +81,8 @@ import org.glassfish.jersey.test.TestProperties;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.mockito.Mockito;
 
 public class TestPolicyOperations extends BaseOperationsTest {
@@ -409,6 +411,44 @@ public class TestPolicyOperations extends 
BaseOperationsTest {
     Assertions.assertEquals(RuntimeException.class.getSimpleName(), 
errorResp1.getType());
   }
 
+  @ParameterizedTest
+  @CsvSource({
+    "spec_id,1.5",
+    "manifest_count_critical,500.5",
+    "manifest_count_warning,100.5",
+    "avg_manifest_size_threshold_bytes,4096.5",
+    "use_caching,0",
+    "use_caching,1"
+  })
+  public void testCreateManifestPolicyRejectsInvalidFieldTypes(String field, 
String value) {
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(1L)
+            .withName("rewrite")
+            .withPolicyType(Policy.BuiltInType.ICEBERG_REWRITE_MANIFESTS)
+            .withEnabled(true)
+            .withContent(PolicyContents.icebergRewriteManifests())
+            .withAuditInfo(testAuditInfo1)
+            .build();
+    when(policyManager.createPolicy(any(), any(), any(), any(), anyBoolean(), 
any()))
+        .thenReturn(policy);
+    String json =
+        
"{\"name\":\"rewrite\",\"policyType\":\"system_iceberg_rewrite_manifests\","
+            + "\"enabled\":true,\"content\":{\""
+            + field
+            + "\":"
+            + value
+            + "}}";
+    try (Response response =
+        target(policyPath(metalake))
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(Entity.entity(json, MediaType.APPLICATION_JSON_TYPE))) {
+      Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), 
response.getStatus());
+    }
+    Mockito.verifyNoInteractions(policyManager);
+  }
+
   @Test
   public void testCreatePolicyWithIcebergCompactionType() {
     PolicyContent content =

Reply via email to