diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseSoftDeleteSearchIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseSoftDeleteSearchIT.java
new file mode 100644
index 000000000000..2674f2c2bc8c
--- /dev/null
+++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseSoftDeleteSearchIT.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2026 Collate
+ * Licensed 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.openmetadata.it.tests;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.jupiter.api.parallel.Isolated;
+import org.openmetadata.it.bootstrap.SharedEntities;
+import org.openmetadata.it.util.SdkClients;
+import org.openmetadata.schema.api.data.CreateDatabase;
+import org.openmetadata.schema.api.data.CreateDatabaseSchema;
+import org.openmetadata.schema.api.data.CreateTable;
+import org.openmetadata.schema.api.tests.CreateTestCase;
+import org.openmetadata.schema.api.tests.CreateTestCaseResolutionStatus;
+import org.openmetadata.schema.entity.data.Database;
+import org.openmetadata.schema.entity.data.DatabaseSchema;
+import org.openmetadata.schema.entity.data.Table;
+import org.openmetadata.schema.tests.TestCase;
+import org.openmetadata.schema.tests.type.Severity;
+import org.openmetadata.schema.tests.type.TestCaseResolutionStatus;
+import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes;
+import org.openmetadata.schema.type.Column;
+import org.openmetadata.schema.type.ColumnDataType;
+import org.openmetadata.sdk.client.OpenMetadataClient;
+import org.openmetadata.sdk.models.ListParams;
+import org.openmetadata.sdk.models.ListResponse;
+
+/**
+ * Regression for the live-indexing soft-delete propagation bug. {@code SOFT_DELETE_RESTORE_SCRIPT}
+ * was stamping a top-level {@code deleted} field onto child docs of every alias listed in the
+ * parent's {@code indexMapping}. For {@code testCase}, two of those children
+ * ({@code testCaseResolutionStatus}, {@code testCaseResult}) are time-series indexes whose
+ * Java schemas declare no {@code deleted} field. The poisoned doc broke Jackson on read and
+ * the Incident Manager UI surfaced an "Unrecognized field 'deleted'" toast.
+ *
+ *
This test exercises the end-to-end path: create a TC + result + incident, soft-delete the
+ * TC, and confirm that (a) the resolution-status listing API still parses cleanly and (b) the
+ * underlying ES doc carries no top-level {@code deleted} field.
+ */
+@Execution(ExecutionMode.SAME_THREAD)
+@Isolated
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class TestCaseSoftDeleteSearchIT {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @Test
+ void softDeletingTestCaseDoesNotPollutePropagatedTimeSeriesDocs() throws Exception {
+ OpenMetadataClient client = SdkClients.adminClient();
+
+ long ts = System.currentTimeMillis();
+ Database database = null;
+ DatabaseSchema schema = null;
+ Table table = null;
+ TestCase testCase = null;
+ try {
+ database =
+ client
+ .databases()
+ .create(
+ new CreateDatabase()
+ .withName("soft_delete_db_" + ts)
+ .withService(SharedEntities.get().MYSQL_SERVICE.getFullyQualifiedName()));
+ schema =
+ client
+ .databaseSchemas()
+ .create(
+ new CreateDatabaseSchema()
+ .withName("soft_delete_schema_" + ts)
+ .withDatabase(database.getFullyQualifiedName()));
+ table =
+ client
+ .tables()
+ .create(
+ new CreateTable()
+ .withName("soft_delete_table_" + ts)
+ .withDatabaseSchema(schema.getFullyQualifiedName())
+ .withColumns(
+ List.of(
+ new Column().withName("id").withDataType(ColumnDataType.BIGINT))));
+
+ String testDefFqn =
+ client
+ .testDefinitions()
+ .list(new ListParams().withLimit(1))
+ .getData()
+ .get(0)
+ .getFullyQualifiedName();
+
+ testCase =
+ client
+ .testCases()
+ .create(
+ new CreateTestCase()
+ .withName("soft_delete_tc_" + ts)
+ .withEntityLink(
+ "<#E::table::" + table.getFullyQualifiedName() + "::columns::id>")
+ .withTestDefinition(testDefFqn));
+
+ client
+ .testCaseResolutionStatuses()
+ .create(
+ new CreateTestCaseResolutionStatus()
+ .withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.New)
+ .withTestCaseReference(testCase.getFullyQualifiedName())
+ .withSeverity(Severity.Severity2));
+
+ awaitIncidentIndexed(client, testCase.getFullyQualifiedName());
+
+ client
+ .testCases()
+ .delete(testCase.getId().toString(), Map.of("hardDelete", "false", "recursive", "true"));
+
+ assertListingApiReturnsCleanlyAfterSoftDelete(client, testCase.getFullyQualifiedName());
+ assertNoTopLevelDeletedFieldOnIncidentDoc(client, testCase.getFullyQualifiedName());
+ } finally {
+ // Hard-delete the entire database tree so the test leaves no artefacts behind. The
+ // testCase + resolution statuses are recursively cascaded with the parent table.
+ // Best-effort cleanup — assertion failures take precedence over cleanup exceptions.
+ if (database != null) {
+ try {
+ client
+ .databases()
+ .delete(
+ database.getId().toString(), Map.of("hardDelete", "true", "recursive", "true"));
+ } catch (Exception ignored) {
+ // intentionally swallowed
+ }
+ }
+ }
+ }
+
+ private void awaitIncidentIndexed(OpenMetadataClient client, String testCaseFqn) {
+ await("Wait for resolution status to be searchable")
+ .atMost(Duration.ofMinutes(2))
+ .pollInterval(Duration.ofSeconds(2))
+ .ignoreExceptions()
+ .untilAsserted(
+ () -> {
+ ListResponse resp =
+ client
+ .testCaseResolutionStatuses()
+ .searchList(
+ new ListParams()
+ .withLimit(1)
+ .withLatest(true)
+ .addFilter("testCaseFQN", testCaseFqn));
+ assertNotNull(resp);
+ assertEquals(
+ 1,
+ resp.getData().size(),
+ "Incident for the test case should be indexed before we soft-delete");
+ });
+ }
+
+ private void assertListingApiReturnsCleanlyAfterSoftDelete(
+ OpenMetadataClient client, String testCaseFqn) {
+ await("API returns parseable body after soft-delete propagation")
+ .atMost(Duration.ofMinutes(1))
+ .pollInterval(Duration.ofSeconds(2))
+ .ignoreExceptions()
+ .untilAsserted(
+ () -> {
+ ListResponse resp =
+ client
+ .testCaseResolutionStatuses()
+ .searchList(
+ new ListParams()
+ .withLimit(10)
+ .withLatest(true)
+ .addFilter("testCaseFQN", testCaseFqn));
+ assertNotNull(
+ resp, "list endpoint must return a body; null implies a deserialization failure");
+ });
+ }
+
+ /**
+ * The fix in {@link org.openmetadata.service.search.SearchRepository#softDeleteOrRestoredChildren}
+ * filters out time-series child aliases before invoking the soft-delete script. Confirm by
+ * querying ES directly for any TCRS doc that has a top-level {@code deleted} field — there
+ * must be none for our test case.
+ */
+ private void assertNoTopLevelDeletedFieldOnIncidentDoc(
+ OpenMetadataClient client, String testCaseFqn) throws Exception {
+ String rawJson =
+ client
+ .search()
+ .query(
+ "testCaseReference.fullyQualifiedName.keyword:\""
+ + testCaseFqn
+ + "\" AND _exists_:deleted")
+ .index("test_case_resolution_status_search_index")
+ .size(5)
+ .execute();
+ JsonNode root = MAPPER.readTree(rawJson);
+ JsonNode hits = root.path("hits").path("hits");
+ assertTrue(
+ hits.isArray(), () -> "ES response missing hits.hits array; raw response was: " + rawJson);
+ assertFalse(
+ hits.elements().hasNext(),
+ () ->
+ "No `deleted` field should exist on testCaseResolutionStatus docs after a parent"
+ + " soft-delete; found "
+ + hits.size()
+ + " polluted docs. Raw response: "
+ + rawJson);
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java
index fbdc582784a7..fbfdaae9202e 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java
@@ -79,6 +79,8 @@
import org.openmetadata.service.jobs.JobDAO;
import org.openmetadata.service.resources.feeds.MessageParser.EntityLink;
import org.openmetadata.service.search.SearchRepository;
+import org.openmetadata.service.search.capability.EntityIndexCapability;
+import org.openmetadata.service.search.capability.EntityIndexCapabilityRegistry;
import org.openmetadata.service.search.indexes.SearchIndex;
import org.openmetadata.service.util.EntityUtil.Fields;
import org.openmetadata.service.util.FullyQualifiedName;
@@ -406,10 +408,19 @@ public static void initializeRepositories(OpenMetadataApplicationConfig config,
}
}
registerDomainSyncHandler();
+ validateIndexMappingsAgainstCapabilities();
initializedRepositories = true;
}
}
+ private static void validateIndexMappingsAgainstCapabilities() {
+ if (searchRepository == null || searchRepository.getEntityIndexMap() == null) {
+ return;
+ }
+ org.openmetadata.service.search.validation.IndexMappingValidator.validate(
+ searchRepository.getEntityIndexMap());
+ }
+
private static void registerDomainSyncHandler() {
try {
DomainSyncHandler domainSyncHandler = new DomainSyncHandler();
@@ -427,6 +438,7 @@ public static void cleanup() {
searchRepository = null;
entityRelationshipRepository = null;
ENTITY_REPOSITORY_MAP.clear();
+ EntityIndexCapabilityRegistry.clear();
}
public static void registerEntity(
@@ -435,6 +447,7 @@ public static void registerEntity(
EntityInterface.CANONICAL_ENTITY_NAME_MAP.put(entity.toLowerCase(Locale.ROOT), entity);
EntityInterface.ENTITY_TYPE_TO_CLASS_MAP.put(entity.toLowerCase(Locale.ROOT), clazz);
ENTITY_LIST.add(entity);
+ EntityIndexCapabilityRegistry.register(EntityIndexCapability.forEntity(entity));
LOG.debug("Registering entity {} {}", clazz, entity);
}
@@ -446,6 +459,7 @@ public static void registerEntity(
entity.toLowerCase(Locale.ROOT), entity);
EntityTimeSeriesInterface.ENTITY_TYPE_TO_CLASS_MAP.put(entity.toLowerCase(Locale.ROOT), clazz);
ENTITY_LIST.add(entity);
+ EntityIndexCapabilityRegistry.register(EntityIndexCapability.forTimeSeries(entity));
LOG.debug("Registering entity time series {} {}", clazz, entity);
}
@@ -720,6 +734,21 @@ public static boolean hasEntityRepository(@NonNull String entityType) {
|| ENTITY_TS_REPOSITORY_MAP.containsKey(entityType);
}
+ /**
+ * Returns true when {@code entityTypeOrAlias} maps to an {@link EntityTimeSeriesInterface}
+ * (append-only, no top-level {@code deleted} field). Backed by
+ * {@link EntityIndexCapabilityRegistry}; the legacy {@code ENTITY_TS_REPOSITORY_MAP} fallback
+ * keeps the helper usable in tests that register repositories directly without going through
+ * the standard capability registration path.
+ */
+ public static boolean isTimeSeriesEntity(@NonNull String entityTypeOrAlias) {
+ EntityIndexCapability capability = EntityIndexCapabilityRegistry.get(entityTypeOrAlias);
+ if (capability != null) {
+ return capability.isTimeSeries();
+ }
+ return ENTITY_TS_REPOSITORY_MAP.containsKey(entityTypeOrAlias);
+ }
+
public static EntityTimeSeriesRepository extends EntityTimeSeriesInterface>
getEntityTimeSeriesRepository(@NonNull String entityType) {
EntityTimeSeriesRepository extends EntityTimeSeriesInterface> entityTimeSeriesRepository =
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedIndexingStrategy.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedIndexingStrategy.java
index 83c3d4b8ee04..e1cb349abbed 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedIndexingStrategy.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedIndexingStrategy.java
@@ -22,6 +22,7 @@
import org.openmetadata.service.apps.bundles.searchIndex.distributed.DistributedSearchIndexExecutor;
import org.openmetadata.service.apps.bundles.searchIndex.distributed.IndexJobStatus;
import org.openmetadata.service.apps.bundles.searchIndex.distributed.SearchIndexJob;
+import org.openmetadata.service.apps.bundles.searchIndex.promotion.RatioPromotionPolicy;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jdbi3.EntityTimeSeriesRepository;
import org.openmetadata.service.jdbi3.ListFilter;
@@ -302,7 +303,10 @@ private boolean finalizeAllEntityReindex(
return finalSuccess;
}
- return new DistributedReindexFinalizer(indexPromotionHandler, stagedIndexContext)
+ double minRatio =
+ config != null ? config.minSuccessRatio() : RatioPromotionPolicy.DEFAULT_MIN_SUCCESS_RATIO;
+ return new DistributedReindexFinalizer(
+ indexPromotionHandler, stagedIndexContext, new RatioPromotionPolicy(minRatio))
.finalizeRemainingEntities(getPromotedEntities(), getFinalEntityStats(), finalSuccess);
}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedReindexFinalizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedReindexFinalizer.java
index d4aaadd06527..a8c8e19a929e 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedReindexFinalizer.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/DistributedReindexFinalizer.java
@@ -19,6 +19,8 @@
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.service.Entity;
import org.openmetadata.service.apps.bundles.searchIndex.distributed.SearchIndexJob;
+import org.openmetadata.service.apps.bundles.searchIndex.promotion.EntityPromotionContext;
+import org.openmetadata.service.apps.bundles.searchIndex.promotion.PromotionPolicy;
import org.openmetadata.service.search.RecreateIndexHandler;
import org.openmetadata.service.search.ReindexContext;
@@ -26,11 +28,15 @@
class DistributedReindexFinalizer {
private final RecreateIndexHandler indexPromotionHandler;
private final ReindexContext stagedIndexContext;
+ private final PromotionPolicy promotionPolicy;
DistributedReindexFinalizer(
- RecreateIndexHandler indexPromotionHandler, ReindexContext stagedIndexContext) {
+ RecreateIndexHandler indexPromotionHandler,
+ ReindexContext stagedIndexContext,
+ PromotionPolicy promotionPolicy) {
this.indexPromotionHandler = indexPromotionHandler;
this.stagedIndexContext = stagedIndexContext;
+ this.promotionPolicy = promotionPolicy;
}
boolean finalizeRemainingEntities(
@@ -132,8 +138,23 @@ private boolean computeEntitySuccess(
if (stats == null) {
return false;
}
- return stats.getFailedRecords() == 0
- && stats.getSuccessRecords() + stats.getFailedRecords() >= stats.getTotalRecords();
+ EntityPromotionContext promotionContext =
+ new EntityPromotionContext(
+ entityType,
+ stats.getTotalRecords(),
+ stats.getSuccessRecords(),
+ stats.getFailedRecords(),
+ stats.getProcessedRecords());
+ PromotionPolicy.Decision decision = promotionPolicy.evaluate(promotionContext);
+ LOG.debug(
+ "Promotion decision for entity '{}': fullySuccessful={} reason={} (stats: total={}, success={}, failed={})",
+ entityType,
+ decision.fullySuccessful(),
+ decision.reason(),
+ stats.getTotalRecords(),
+ stats.getSuccessRecords(),
+ stats.getFailedRecords());
+ return decision.fullySuccessful();
}
private void finalizeEntityReindex(String entityType, boolean success) {
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ReindexingConfiguration.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ReindexingConfiguration.java
index 7d7a68357156..d3596c7a878f 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ReindexingConfiguration.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ReindexingConfiguration.java
@@ -5,6 +5,7 @@
import java.util.Set;
import org.openmetadata.schema.system.EventPublisherJob;
import org.openmetadata.schema.type.IndexMappingLanguage;
+import org.openmetadata.service.apps.bundles.searchIndex.promotion.RatioPromotionPolicy;
import org.openmetadata.service.search.SearchClusterMetrics;
import org.openmetadata.service.search.SearchRepository;
import org.slf4j.Logger;
@@ -35,7 +36,8 @@ public record ReindexingConfiguration(
String slackBotToken,
String slackChannel,
int timeSeriesMaxDays,
- Map timeSeriesEntityDays) {
+ Map timeSeriesEntityDays,
+ double minSuccessRatio) {
private static final Logger LOG = LoggerFactory.getLogger(ReindexingConfiguration.class);
@@ -53,6 +55,8 @@ public record ReindexingConfiguration(
private static final int DEFAULT_INITIAL_BACKOFF = 1000;
private static final int DEFAULT_MAX_BACKOFF = 10000;
private static final int DEFAULT_TIME_SERIES_MAX_DAYS = 0;
+ private static final double DEFAULT_MIN_SUCCESS_RATIO =
+ RatioPromotionPolicy.DEFAULT_MIN_SUCCESS_RATIO;
public static ReindexingConfiguration applyAutoTuning(
ReindexingConfiguration config, SearchRepository searchRepository, long totalEntities) {
@@ -86,6 +90,7 @@ public static ReindexingConfiguration applyAutoTuning(
.slackChannel(config.slackChannel())
.timeSeriesMaxDays(config.timeSeriesMaxDays())
.timeSeriesEntityDays(config.timeSeriesEntityDays())
+ .minSuccessRatio(config.minSuccessRatio())
.build();
}
@@ -138,7 +143,10 @@ public static ReindexingConfiguration from(EventPublisherJob jobData) {
: DEFAULT_TIME_SERIES_MAX_DAYS,
jobData.getTimeSeriesEntityDays() != null
? jobData.getTimeSeriesEntityDays()
- : Collections.emptyMap());
+ : Collections.emptyMap(),
+ jobData.getMinSuccessRatio() != null
+ ? jobData.getMinSuccessRatio()
+ : DEFAULT_MIN_SUCCESS_RATIO);
}
/**
@@ -213,6 +221,7 @@ public static class Builder {
private String slackChannel;
private int timeSeriesMaxDays = DEFAULT_TIME_SERIES_MAX_DAYS;
private Map timeSeriesEntityDays = Collections.emptyMap();
+ private double minSuccessRatio = DEFAULT_MIN_SUCCESS_RATIO;
public Builder entities(Set entities) {
this.entities = entities;
@@ -319,6 +328,11 @@ public Builder timeSeriesEntityDays(Map timeSeriesEntityDays) {
return this;
}
+ public Builder minSuccessRatio(double minSuccessRatio) {
+ this.minSuccessRatio = minSuccessRatio;
+ return this;
+ }
+
public ReindexingConfiguration build() {
return new ReindexingConfiguration(
entities,
@@ -341,7 +355,8 @@ public ReindexingConfiguration build() {
slackBotToken,
slackChannel,
timeSeriesMaxDays,
- timeSeriesEntityDays);
+ timeSeriesEntityDays,
+ minSuccessRatio);
}
}
}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/EntityPromotionContext.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/EntityPromotionContext.java
new file mode 100644
index 000000000000..6e49684606d8
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/EntityPromotionContext.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed 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.openmetadata.service.apps.bundles.searchIndex.promotion;
+
+/**
+ * Per-entity record-level counts used to decide whether to promote a staged index. Built from
+ * {@code SearchIndexJob.EntityTypeStats} at finalize time. Kept deliberately small — the policy
+ * should not need to reach back into the executor for additional signals.
+ */
+public record EntityPromotionContext(
+ String entityType,
+ long totalRecords,
+ long successRecords,
+ long failedRecords,
+ long processedRecords) {
+
+ /**
+ * Fraction of records that landed in the staged index. Defaults to {@code 1.0} when nothing
+ * was scheduled (empty entity types are not failures).
+ */
+ public double successRatio() {
+ if (totalRecords <= 0) {
+ return 1.0;
+ }
+ return (double) successRecords / totalRecords;
+ }
+
+ /**
+ * Returns true if every scheduled record was accounted for (either succeeded or failed). A
+ * job that stopped early — e.g. operator stop, partition reclaimer, host crash — leaves
+ * {@code processedRecords < totalRecords} and must NOT be flagged fully successful even if
+ * the success ratio over the processed subset clears the threshold.
+ */
+ public boolean allRecordsAccountedFor() {
+ if (totalRecords <= 0) {
+ return true;
+ }
+ long accounted = Math.max(processedRecords, successRecords + failedRecords);
+ return accounted >= totalRecords;
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/PromotionPolicy.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/PromotionPolicy.java
new file mode 100644
index 000000000000..4f7aba423c5d
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/PromotionPolicy.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed 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.openmetadata.service.apps.bundles.searchIndex.promotion;
+
+/**
+ * Decides whether a per-entity reindex was "fully successful". The default implementation,
+ * {@link RatioPromotionPolicy}, declares success when the per-record success ratio clears a
+ * configurable threshold. Promotion itself remains unconditional — {@code DefaultRecreateHandler}
+ * always promotes a non-empty staged index via the existing doc-count rescue when this flag is
+ * false. The flag drives the operator-visible "did this entity run cleanly?" signal.
+ *
+ * Prior to this abstraction the strict rule was binary ("zero failures") and the rescue lived
+ * unannounced inside the handler. Centralizing the success threshold here makes it tunable and
+ * makes the rescue's existence explicit in the contract.
+ */
+public interface PromotionPolicy {
+
+ Decision evaluate(EntityPromotionContext context);
+
+ /**
+ * Outcome of {@link #evaluate(EntityPromotionContext)}.
+ *
+ * @param fullySuccessful true if the entity reindex met the policy's strict success bar;
+ * false if the rescue path (doc-count fallback in
+ * {@code DefaultRecreateHandler.promoteEntityIndex}) must decide whether the staged index
+ * is salvageable. Promotion is always attempted regardless of this flag — the flag
+ * controls how the run is logged / reported.
+ * @param reason human-readable rationale for the audit log
+ */
+ record Decision(boolean fullySuccessful, String reason) {}
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/RatioPromotionPolicy.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/RatioPromotionPolicy.java
new file mode 100644
index 000000000000..138475cf90c3
--- /dev/null
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/promotion/RatioPromotionPolicy.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed 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.openmetadata.service.apps.bundles.searchIndex.promotion;
+
+/**
+ * Per-entity reindex is "fully successful" when the per-record success ratio meets
+ * {@code minSuccessRatio}. The policy's {@link Decision#fullySuccessful()} captures that
+ * strict outcome; the caller hands it to {@code DefaultRecreateHandler.finalizeReindex} which
+ * already has a doc-count rescue path for cases where strict success was missed but the staged
+ * index still has data. Promotion is therefore unconditional at the policy level (the rescue
+ * decides whether to keep or drop the staged index), and the success flag carries the operator-
+ * visible "did this entity run cleanly" signal that {@code minSuccessRatio} controls.
+ *
+ *
The previous binary rule ({@code failedRecords == 0}) blocked the success signal on a
+ * single failed record. The ratio gives operators a tunable strict bar; below it, the
+ * downstream rescue still salvages a non-empty staged index.
+ */
+public class RatioPromotionPolicy implements PromotionPolicy {
+
+ public static final double DEFAULT_MIN_SUCCESS_RATIO = 0.95d;
+
+ private final double minSuccessRatio;
+
+ public RatioPromotionPolicy(double minSuccessRatio) {
+ if (minSuccessRatio < 0.0d || minSuccessRatio > 1.0d) {
+ throw new IllegalArgumentException(
+ "minSuccessRatio must be in [0.0, 1.0]; got " + minSuccessRatio);
+ }
+ this.minSuccessRatio = minSuccessRatio;
+ }
+
+ public static RatioPromotionPolicy withDefaultThreshold() {
+ return new RatioPromotionPolicy(DEFAULT_MIN_SUCCESS_RATIO);
+ }
+
+ public double minSuccessRatio() {
+ return minSuccessRatio;
+ }
+
+ @Override
+ public Decision evaluate(EntityPromotionContext context) {
+ if (context.totalRecords() <= 0L) {
+ return new Decision(true, "no records scheduled; nothing to evaluate");
+ }
+ if (!context.allRecordsAccountedFor()) {
+ return new Decision(
+ false,
+ "incomplete run: only %d of %d records processed; not fully successful"
+ .formatted(
+ Math.max(
+ context.processedRecords(),
+ context.successRecords() + context.failedRecords()),
+ context.totalRecords()));
+ }
+ double ratio = context.successRatio();
+ if (ratio >= minSuccessRatio) {
+ return new Decision(
+ true, "successRatio %.4f >= minSuccessRatio %.4f".formatted(ratio, minSuccessRatio));
+ }
+ return new Decision(
+ false,
+ "successRatio %.4f below threshold %.4f; DefaultRecreateHandler will rescue via doc-count"
+ .formatted(ratio, minSuccessRatio));
+ }
+}
diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityTimeSeriesRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityTimeSeriesRepository.java
index 57caa9870c07..99a0536644e6 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityTimeSeriesRepository.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityTimeSeriesRepository.java
@@ -463,7 +463,7 @@ public ResultList listFromSearchWithOffset(
searchListFilter, limit, offset, entityType, searchSortFilter, q, queryString);
total = results.getTotal();
for (Map json : results.getResults()) {
- T entity = setFieldsInternal(JsonUtils.readOrConvertValue(json, entityClass), fields);
+ T entity = setFieldsInternal(readTimeSeriesSource(json), fields);
try {
setInheritedFields(entity);
} catch (RuntimeException e) {
@@ -524,9 +524,7 @@ public ResultList listLatestFromSearch(
hitList -> {
for (Map hit : (List