diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index 366aeecad2dc..24d12d7dd100 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -353,10 +353,45 @@ closed when a crawl actually runs in a dual-write phase (1 or 2). In the window
twin that was never rebuilt, and never crawling, is a hard gap.
**Operational rule (pairs with the code):** before promoting the phase — especially into Phase 3 —
-ensure every Site Search index has been crawled at least once so its OS twin exists and is in sync. A
-targeted verify/repair job (detect twins that are missing **or** whose counts diverge → rebuild full)
-that closes the no-crawl window without depending on a crawl is deferred as a follow-up under the same
-issue.
+ensure every Site Search index has been crawled at least once so its OS counterpart exists and is in
+sync. The migration-readiness endpoint below is what tells the operator *which* indices still need
+that crawl, before they change the phase.
+
+#### Migration-readiness endpoint (pre-phase-change advisory)
+
+`GET /api/v1/index/migration/readiness` is an internal, read-only report a support technician runs
+**before changing the migration phase** to see whether it is safe and, if not, what to do. It never
+mutates anything — the fix is always the operator re-running the crawl / reindex, which self-heals
+through the write-path gate above.
+
+- **Not public.** The resource is `@Hidden` (absent from the OpenAPI / API-playground schema) and
+ gated to CMS administrators who **also** hold the migration support role
+ (`OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY`, default `os_migration_qa`) — a plain admin without the
+ role is not enough. Anyone else gets a 403, so regular users never learn a migration is running.
+- **What it reports.** The current phase with its read/write engines and a `dualWrite` flag; an
+ overall verdict — `safeToAdvance` (toward OpenSearch-only) and `safeToRollback` (downgrade) with an
+ `outOfSyncCount`, a human `summary`, and per-index `blockers`; and the per-index ES↔OS mirror diff
+ for **both** mirrored families — the versioned content indices (`working`/`live`) and the Site
+ Search indices. `content` is a keyed object by slot (`WORKING` / `LIVE` — a fixed pair);
+ `siteSearch` is a list (an open set). Each entry carries `{indexName, es:{exists,docCount,physicalName},
+ os:{exists,docCount,physicalName}, verdict, recommendation}` — `physicalName` is the full name as
+ stored on each server (cluster-prefixed; `.os`-tagged on OpenSearch) — with verdict `IN_SYNC` /
+ `MISSING_COUNTERPART` / `COUNT_DRIFT`. The top level also carries the `clusterId` embedded in every
+ physical name. The response is the model itself (no `ResponseEntityView` envelope).
+- **Stateless, from live counts.** Every field is derived at request time. Counts are **exact** — the
+ Site Search half uses `SiteSearchAPI.documentCount` and the content half reads each engine leaf's
+ `getIndicesStats()` (index `_stats` `primaries.docs.count`), never a search total (which the ES/OS
+ clients cap at 10,000 and would hide drift on large indices). Both reconcilers query the two engine
+ leaves directly, not the phase-aware router, so the report shows both sides in every phase.
+- **`safeToRollback` needs no history.** A downgrade routes reads back to Elasticsearch, so it is
+ unsafe when any index's ES copy is behind its OpenSearch counterpart (`esDocCount < osDocCount`, or
+ the ES copy missing) — that delta, typically content written while OpenSearch served reads, would be
+ silently absent after the downgrade until a full reindex. That is derivable from the same snapshot,
+ so no per-phase state is persisted.
+
+Because this endpoint is the source of truth for migration/QA, the index portlets no longer reveal
+`.os` indices by role: `MigrationIndexVisibility` is now purely phase-based (hidden in Phases 0/1/2,
+shown in Phase 3, for everyone). The role key is retained only to gate this endpoint.
#### Tag manipulation is the sole responsibility of `IndexTag`
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java b/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java
index 1d2672c2b6a1..d806e5a1b353 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/MigrationIndexVisibility.java
@@ -1,12 +1,6 @@
package com.dotcms.content.index;
import com.dotcms.content.index.IndexConfigHelper.MigrationPhase;
-import com.dotmarketing.business.APILocator;
-import com.dotmarketing.business.Role;
-import com.dotmarketing.util.Config;
-import com.dotmarketing.util.UtilMethods;
-import com.liferay.portal.model.User;
-import io.vavr.control.Try;
import java.util.List;
import java.util.stream.Collectors;
@@ -21,20 +15,20 @@
* (optimize-all, flush-all, {@code indexExists} validation, bulk fix). Filtering inside those
* methods would silently skip OS indices for those operations in phases 1/2 — a behavioural
* change disguised as a UI tweak. The complete, phase-correct set must stay intact at the API;
- * only the two display sinks (the maintenance JSP and {@code IndexResourceHelper.indexStatsList})
+ * only the display sinks (the maintenance JSP and {@code IndexResourceHelper.indexStatsList})
* apply this filter, and only those — see {@code docs/backend/OPENSEARCH_MIGRATION.md}.
*
- * Rule
+ * Rule — phase-based, for everyone
*
* - Phase 3 (OS-only): OS is the live store, so {@code .os} indices are always visible.
- * - Phases 0/1/2: {@code .os} indices are a migration/uniqueness artifact and are hidden,
- * unless the acting user holds the configured QA/preview role
- * ({@value #VISIBILITY_ROLE_KEY}, default {@value #DEFAULT_VISIBILITY_ROLE_KEY}).
+ * - Phases 0/1/2: {@code .os} indices are a migration/uniqueness artifact and are hidden
+ * from every user — regular admins never learn a migration is running.
*
*
- * The acting {@link User} is supplied explicitly by each display sink (both are authenticated
- * admin requests where the user is always available) — never resolved from a thread-local inside
- * this policy, so it is safe to unit-test and free of request-context coupling.
+ * The role-gated preview of {@code .os} indices was removed in issue #36360: support and QA now
+ * get migration detail from the dedicated, role-gated migration-readiness endpoint
+ * ({@code /api/v1/index/migration/readiness}), which is the single source of truth. This display
+ * policy is therefore purely phase-based and consults no user or role.
*
* OS-origin detection always goes through {@link IndexTag#isTagged(String)}, never
* {@code name.endsWith(".os")}, per the {@link IndexTag} contract.
@@ -42,13 +36,14 @@
public final class MigrationIndexVisibility {
/**
- * Config key holding the {@link Role#getRoleKey() role key} whose members may preview
- * OS-tagged ({@code .os}) indices before Phase 3. Defaults to
- * {@value #DEFAULT_VISIBILITY_ROLE_KEY}.
+ * Config key holding the {@link com.dotmarketing.business.Role#getRoleKey() role key} whose
+ * members may read the role-gated migration-readiness endpoint
+ * ({@code /api/v1/index/migration/readiness}). Defaults to {@value #DEFAULT_VISIBILITY_ROLE_KEY}.
+ * It no longer governs the index portlet display (which is purely phase-based since issue #36360).
*/
public static final String VISIBILITY_ROLE_KEY = "OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY";
- /** Default role key allowed to preview migration ({@code .os}) indices. */
+ /** Default role key allowed to read the migration-readiness endpoint. */
public static final String DEFAULT_VISIBILITY_ROLE_KEY = "os_migration_qa";
private MigrationIndexVisibility() {
@@ -56,44 +51,28 @@ private MigrationIndexVisibility() {
}
/**
- * Whether {@code user} may see OS-tagged ({@code .os}) indices in the current phase.
- *
- * @param user the acting user; {@code null} is treated as "not allowed" outside Phase 3
- * @return {@code true} in Phase 3, or when {@code user} holds the configured QA role
+ * Whether OS-tagged ({@code .os}) indices are shown in the current phase — only in Phase 3,
+ * where OpenSearch is the live store. Before Phase 3 they are a migration artifact and stay
+ * hidden from everyone.
*/
- public static boolean canSeeMigrationIndices(final User user) {
- if (MigrationPhase.current().isMigrationComplete()) {
- return true;
- }
- if (user == null) {
- return false;
- }
- final String roleKey = Config.getStringProperty(VISIBILITY_ROLE_KEY,
- DEFAULT_VISIBILITY_ROLE_KEY);
- if (!UtilMethods.isSet(roleKey)) {
- return false;
- }
- return Try.of(() -> {
- final Role role = APILocator.getRoleAPI().loadRoleByKey(roleKey);
- return role != null && APILocator.getRoleAPI().doesUserHaveRole(user, role);
- }).getOrElse(false);
+ public static boolean showMigrationIndices() {
+ return MigrationPhase.current().isMigrationComplete();
}
/**
- * Returns {@code indexNames} with OS-tagged ({@code .os}) entries removed when {@code user}
- * is not allowed to see them; otherwise returns the list unchanged.
+ * Returns {@code indexNames} with OS-tagged ({@code .os}) entries removed outside Phase 3;
+ * in Phase 3 (or for a null/empty input) the list is returned unchanged.
*
- * @param indexNames the full, phase-correct list of index names; {@code null}/empty is
- * returned as-is
- * @param user the acting user
+ * @param indexNames the full, phase-correct list of index names; {@code null}/empty is returned
+ * as-is
* @return a filtered copy, or the original list when no filtering applies
*/
- public static List filter(final List indexNames, final User user) {
- if (indexNames == null || indexNames.isEmpty() || canSeeMigrationIndices(user)) {
+ public static List filter(final List indexNames) {
+ if (indexNames == null || indexNames.isEmpty() || showMigrationIndices()) {
return indexNames;
}
return indexNames.stream()
.filter(name -> !IndexTag.OS.isTagged(name))
.collect(Collectors.toList());
}
-}
\ No newline at end of file
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
new file mode 100644
index 000000000000..15390b1113d6
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
@@ -0,0 +1,122 @@
+package com.dotcms.content.index.migration;
+
+import com.dotcms.cdi.CDIUtils;
+import com.dotcms.content.elasticsearch.business.ESIndexAPI;
+import com.dotcms.content.elasticsearch.business.IndiciesInfo;
+import com.dotcms.content.index.IndexAPI;
+import com.dotcms.content.index.IndexTag;
+import com.dotcms.content.index.domain.IndexStats;
+import com.dotcms.content.index.migration.MirrorStatus.IndexKind;
+import com.dotcms.content.index.migration.MirrorStatus.Verdict;
+import com.dotcms.content.index.opensearch.OSIndexAPIImpl;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.util.Logger;
+import com.dotmarketing.util.UtilMethods;
+import com.google.common.annotations.VisibleForTesting;
+import io.vavr.control.Try;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Content-index half of the migration-readiness report (issue #36360): compares the active versioned
+ * content indices (working and live) against their {@code .os} counterparts across both engines,
+ * mirroring {@link SiteSearchMirrorReconciler} but for the content store. Never mutates anything.
+ *
+ * How the counts are read (phase-independently)
+ * {@code IndiciesInfo} always holds the cluster-prefixed, un-tagged Elasticsearch name for
+ * working/live (its backing {@code indicies} table owns only the ES rows — {@code index_version IS
+ * NULL}); the OpenSearch counterpart is that name with the {@code .os} tag. Exact per-engine document
+ * counts come from each engine leaf's {@code getIndicesStats()} — the index {@code _stats}
+ * {@code primaries.docs.count}, an exact total not subject to the 10,000 search hit-count cap. Those
+ * stats maps are keyed by the cluster-stripped name (Elasticsearch un-tagged, OpenSearch
+ * carrying {@code .os}), so each raw name is stripped of the cluster prefix and then, for the
+ * OpenSearch lookup, tagged — the same strip-then-tag order the maintenance JSP uses.
+ *
+ * It queries the two engine leaves directly (never the phase-aware router) so the report shows both
+ * sides regardless of which engine the current phase reads from. Scope is the active working/live
+ * pair; reindex slots are out of scope for this report.
+ */
+public class ContentIndexMirrorReconciler {
+
+ private final IndexAPI esImpl;
+ private final IndexAPI osImpl;
+ private final Supplier indiciesSupplier;
+
+ public ContentIndexMirrorReconciler() {
+ this(new ESIndexAPI(), CDIUtils.getBeanThrows(OSIndexAPIImpl.class),
+ ContentIndexMirrorReconciler::loadIndiciesQuietly);
+ }
+
+ @VisibleForTesting
+ ContentIndexMirrorReconciler(final IndexAPI esImpl, final IndexAPI osImpl,
+ final Supplier indiciesSupplier) {
+ this.esImpl = esImpl;
+ this.osImpl = osImpl;
+ this.indiciesSupplier = indiciesSupplier;
+ }
+
+ /** Per-index mirror status for the active working and live content indices. */
+ public List statuses() {
+ final IndiciesInfo info = indiciesSupplier.get();
+ if (info == null) {
+ return List.of();
+ }
+ final Map esStats = esImpl.getIndicesStats();
+ final Map osStats = osImpl.getIndicesStats();
+ final List out = new ArrayList<>(2);
+ addStatus(out, IndexKind.CONTENT_WORKING, info.getWorking(), esStats, osStats);
+ addStatus(out, IndexKind.CONTENT_LIVE, info.getLive(), esStats, osStats);
+ return out;
+ }
+
+ private void addStatus(final List out, final IndexKind kind, final String rawName,
+ final Map esStats, final Map osStats) {
+ if (!UtilMethods.isSet(rawName)) {
+ return;
+ }
+ // IndiciesInfo holds the cluster-prefixed, un-tagged ES name — which IS the full ES physical
+ // name; the OS physical name is that + .os. The stats maps are keyed by the cluster-stripped
+ // name (ES un-tagged, OS carrying .os), so strip for the count lookup, tag for the OS key.
+ final String esPhysical = rawName;
+ final String osPhysical = IndexTag.OS.tag(rawName);
+ final String bare = esImpl.removeClusterIdFromName(rawName);
+ final String osKey = IndexTag.OS.tag(bare);
+
+ final boolean esExists = esStats.containsKey(bare);
+ final long esCount = esExists ? esStats.get(bare).documentCount() : 0L;
+ final boolean osExists = osStats.containsKey(osKey);
+ final long osCount = osExists ? osStats.get(osKey).documentCount() : 0L;
+
+ final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount);
+ out.add(new MirrorStatus(bare, kind,
+ new MirrorStatus.EngineCopy(esExists, esCount, esPhysical),
+ new MirrorStatus.EngineCopy(osExists, osCount, osPhysical),
+ verdict, recommend(bare, verdict, osExists)));
+ }
+
+ private static String recommend(final String name, final Verdict verdict, final boolean osExists) {
+ switch (verdict) {
+ case IN_SYNC:
+ return "In sync — no action needed.";
+ case MISSING_COUNTERPART:
+ final String missing = osExists ? "Elasticsearch" : "OpenSearch";
+ return String.format("The %s copy of content index '%s' is missing. Run a full "
+ + "reindex to rebuild it before promoting to the OpenSearch-only phase.",
+ missing, name);
+ case COUNT_DRIFT:
+ default:
+ return String.format("The two copies of content index '%s' hold a different number "
+ + "of documents. Run a full reindex to rebuild the OpenSearch copy before "
+ + "promoting the phase.", name);
+ }
+ }
+
+ private static IndiciesInfo loadIndiciesQuietly() {
+ return Try.of(() -> APILocator.getIndiciesAPI().loadIndicies())
+ .onFailure(e -> Logger.warn(ContentIndexMirrorReconciler.class,
+ "Could not load content indices for migration readiness: " + e.getMessage()))
+ .getOrNull();
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadiness.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadiness.java
new file mode 100644
index 000000000000..9dae7f4092c8
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadiness.java
@@ -0,0 +1,60 @@
+package com.dotcms.content.index.migration;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Support-facing ES→OS migration-readiness report (issue #36360): a point-in-time snapshot that a
+ * role-gated support technician reads before changing the migration phase, to see whether it
+ * is safe and, if not, what to do. Read-only and stateless — every field is derived from live index
+ * state at request time, nothing is persisted.
+ *
+ * @param clusterId the dotCMS cluster id embedded in every physical index name
+ * (the {@code } of the {@code cluster_.} prefix); identical for the
+ * Elasticsearch and OpenSearch backends
+ * @param phase the current migration phase and which engine it reads/writes
+ * @param content the versioned content indices keyed by slot ({@code WORKING} / {@code LIVE}) — a
+ * fixed pair, so a keyed object reads naturally
+ * @param siteSearch the Site Search indices as a list — an open set with no natural key, so a list
+ * (each entry carries its own {@code indexName})
+ * @param verdict the overall go/no-go for advancing and rolling back, with reasons
+ */
+public record MigrationReadiness(
+ String clusterId,
+ PhaseInfo phase,
+ Map content,
+ List siteSearch,
+ Verdict verdict) {
+
+ /**
+ * @param current the current phase ordinal (0–3)
+ * @param name the phase enum name (e.g. {@code PHASE_2_DUAL_WRITE_OS_READS})
+ * @param readEngine which engine currently serves reads ("Elasticsearch" or "OpenSearch")
+ * @param writeEngines which engines currently receive writes
+ * @param dualWrite whether this is a dual-write phase (1/2), where both engines are populated so a
+ * cross-engine mirror comparison is meaningful as a forward go/no-go; when false
+ * (phases 0 and 3) the mirror lists are advisory context, not a forward go/no-go
+ */
+ public record PhaseInfo(
+ int current,
+ String name,
+ String readEngine,
+ List writeEngines,
+ boolean dualWrite) {}
+
+ /**
+ * @param safeToAdvance whether it is safe to promote toward the OpenSearch-only phase
+ * @param safeToRollback whether it is safe to downgrade — false when any index's Elasticsearch
+ * copy is behind its OpenSearch counterpart, because a downgrade routes reads back
+ * to Elasticsearch and would silently drop that delta until a reindex
+ * @param outOfSyncCount how many indices need attention (missing counterpart or count drift)
+ * @param summary one human-readable sentence describing the overall state
+ * @param blockers per-index reasons that make advancing unsafe (empty when safe)
+ */
+ public record Verdict(
+ boolean safeToAdvance,
+ boolean safeToRollback,
+ int outOfSyncCount,
+ String summary,
+ List blockers) {}
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java
new file mode 100644
index 000000000000..5323816110f5
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java
@@ -0,0 +1,186 @@
+package com.dotcms.content.index.migration;
+
+import com.dotcms.content.index.IndexConfigHelper.MigrationPhase;
+import com.dotcms.enterprise.cluster.ClusterFactory;
+import com.dotcms.content.index.migration.MirrorStatus.IndexKind;
+import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * Composes the ES→OS migration-readiness report (issue #36360): current phase + the per-index mirror
+ * status of both mirrored index families (content and Site Search) + an overall go/no-go verdict for
+ * changing the phase. Read-only and stateless — the verdict is derived from live index state at
+ * request time, nothing is persisted (see {@link MigrationReadiness}).
+ *
+ * Verdict semantics
+ *
+ * - Advance (toward OpenSearch-only): meaningful in the dual-write phases (1/2), where it
+ * is safe only when no index needs attention. In Phase 0 there is nothing to reconcile yet
+ * (counterparts are built during dual-write) and in Phase 3 there is no further phase — both report
+ * safe with an explanatory summary.
+ * - Rollback (downgrade): a downgrade ultimately routes reads back to Elasticsearch
+ * (Phases 0/1), so it is unsafe when any index's ES copy is behind its OpenSearch counterpart — that
+ * delta (typically content written while OpenSearch served reads) would be silently missing
+ * until a full reindex. Derived from the same live counts, so no historical state is needed.
+ *
+ */
+public class MigrationReadinessService {
+
+ private final SiteSearchMirrorReconciler siteSearchReconciler;
+ private final ContentIndexMirrorReconciler contentReconciler;
+ private final Supplier clusterIdSupplier;
+
+ public MigrationReadinessService() {
+ this(new SiteSearchMirrorReconciler(), new ContentIndexMirrorReconciler(),
+ ClusterFactory::getClusterId);
+ }
+
+ @VisibleForTesting
+ MigrationReadinessService(final SiteSearchMirrorReconciler siteSearchReconciler,
+ final ContentIndexMirrorReconciler contentReconciler,
+ final Supplier clusterIdSupplier) {
+ this.siteSearchReconciler = siteSearchReconciler;
+ this.contentReconciler = contentReconciler;
+ this.clusterIdSupplier = clusterIdSupplier;
+ }
+
+ /** Builds the readiness report for the current phase. */
+ public MigrationReadiness evaluate() {
+ final MigrationPhase phase = MigrationPhase.current();
+ final List content = new ArrayList<>(contentReconciler.statuses());
+ final List siteSearch = new ArrayList<>(siteSearchReconciler.statuses());
+
+ final List all = new ArrayList<>(content.size() + siteSearch.size());
+ all.addAll(content);
+ all.addAll(siteSearch);
+
+ final List outOfSync = all.stream()
+ .filter(MirrorStatus::needsAttention)
+ .collect(Collectors.toList());
+ // A downgrade routes reads back to Elasticsearch; any index whose ES copy is missing or behind
+ // its OpenSearch counterpart would lose that delta after the downgrade (a failed ES count is -1, which
+ // is < any real OS count → flagged, fail-safe).
+ final boolean esBehindAnywhere = all.stream()
+ .anyMatch(s -> !s.es().exists() || s.es().docCount() < s.os().docCount());
+
+ // Content WORKING/LIVE are mandatory in every pre-OpenSearch-only phase: they are the source
+ // that gets mirrored to OpenSearch, so a missing slot (pointer unset, or its Elasticsearch copy
+ // gone) means there is nothing to migrate — a hard no-go, independent of the sync check, which
+ // would otherwise pass vacuously when there are no active indices at all. Site Search is an open
+ // set that may legitimately be empty, so it is not required here.
+ final List missingContent = requiredContentBlockers(content);
+
+ final boolean safeToAdvance;
+ final String summary;
+ final List blockers = new ArrayList<>();
+
+ if (phase.isMigrationComplete()) {
+ safeToAdvance = true; // no phase beyond 3
+ summary = "Phase 3 (OpenSearch only) — the final phase, nothing to advance to. "
+ + (esBehindAnywhere
+ ? "WARNING: OpenSearch holds content Elasticsearch does not; a downgrade would "
+ + "hide it until a full reindex."
+ : "No index shows Elasticsearch behind OpenSearch; still verify before any "
+ + "downgrade.");
+ } else if (phase.isMigrationNotStarted()) {
+ // Phase 0: OpenSearch counterparts are built later (during dual-write), so their absence is
+ // expected and NOT a blocker; only the mandatory Elasticsearch content pair is required.
+ blockers.addAll(missingContent);
+ safeToAdvance = blockers.isEmpty();
+ summary = safeToAdvance
+ ? "Phase 0 (Elasticsearch only). OpenSearch counterparts are built during the "
+ + "dual-write phases, so there is nothing to reconcile yet. Safe to advance "
+ + "to Phase 1."
+ : String.format("Not safe to advance from Phase 0: %s to resolve first (see the "
+ + "blockers list). Dual-write needs an active Elasticsearch content index "
+ + "to mirror from.", plural(blockers.size(), "blocker"));
+ } else {
+ // Phases 1/2 (dual-write): require the mandatory content pair AND every mirror in sync. Drop
+ // out-of-sync rows already reported as missing content (an ES-missing content slot surfaces
+ // both as a missing-source blocker and as MISSING_COUNTERPART) so it is not reported twice.
+ blockers.addAll(missingContent);
+ outOfSync.stream()
+ .filter(s -> !(isContent(s.kind()) && !s.es().exists()))
+ .forEach(s -> blockers.add(String.format("%s '%s': %s", s.kind(), s.indexName(),
+ s.recommendation())));
+ safeToAdvance = blockers.isEmpty();
+ summary = safeToAdvance
+ ? "All mirrors are in sync. Safe to advance toward the OpenSearch-only phase."
+ : String.format("Not safe to advance: %s to resolve first (see the blockers list). "
+ + "Phase 3 serves reads from OpenSearch only, so every index must be present "
+ + "and in sync before promoting.", plural(blockers.size(), "blocker"));
+ }
+
+ final MigrationReadiness.PhaseInfo phaseInfo = new MigrationReadiness.PhaseInfo(
+ phase.ordinal(), phase.name(), readEngine(phase), writeEngines(phase),
+ phase.isDualWrite());
+ final MigrationReadiness.Verdict verdict = new MigrationReadiness.Verdict(
+ safeToAdvance, !esBehindAnywhere, outOfSync.size(), summary, blockers);
+
+ // Content is keyed by slot (WORKING/LIVE — a fixed pair, so a keyed object reads naturally);
+ // Site Search stays a list (an open set with no natural key). LinkedHashMap keeps the
+ // reconciler's order for a stable response.
+ final Map contentBySlot = new LinkedHashMap<>();
+ for (final MirrorStatus s : content) {
+ contentBySlot.put(contentSlot(s.kind()), s);
+ }
+ return new MigrationReadiness(clusterIdSupplier.get(), phaseInfo, contentBySlot,
+ siteSearch, verdict);
+ }
+
+ private static String contentSlot(final IndexKind kind) {
+ return kind == IndexKind.CONTENT_WORKING ? "WORKING" : "LIVE";
+ }
+
+ /**
+ * Blockers for the mandatory content pair: WORKING and LIVE must each have a set pointer and an
+ * existing Elasticsearch copy (the migration source). Returns one message per missing/empty slot;
+ * an empty list means both are present. This is what stops a "no active content indices" state from
+ * passing the readiness check vacuously (an empty status list would otherwise leave nothing to flag).
+ */
+ private static List requiredContentBlockers(final List content) {
+ final List out = new ArrayList<>(2);
+ for (final IndexKind kind : List.of(IndexKind.CONTENT_WORKING, IndexKind.CONTENT_LIVE)) {
+ final String slot = contentSlot(kind);
+ final MirrorStatus status = content.stream()
+ .filter(s -> s.kind() == kind).findFirst().orElse(null);
+ if (status == null) {
+ out.add(String.format("No active %s content index — Elasticsearch has no %s index to "
+ + "migrate. Reindex to (re)create it before changing the phase.",
+ slot, slot.toLowerCase()));
+ } else if (!status.es().exists()) {
+ out.add(String.format("The active %s content index '%s' has no Elasticsearch copy — "
+ + "reindex to rebuild it before changing the phase.", slot, status.indexName()));
+ }
+ }
+ return out;
+ }
+
+ private static boolean isContent(final IndexKind kind) {
+ return kind == IndexKind.CONTENT_WORKING || kind == IndexKind.CONTENT_LIVE;
+ }
+
+ /** {@code "1 blocker"} / {@code "2 blockers"} — count with a correctly pluralized noun. */
+ private static String plural(final int count, final String noun) {
+ return count + " " + noun + (count == 1 ? "" : "s");
+ }
+
+ private static String readEngine(final MigrationPhase phase) {
+ return phase.isReadEnabled() ? "OpenSearch" : "Elasticsearch";
+ }
+
+ private static List writeEngines(final MigrationPhase phase) {
+ if (phase.isMigrationNotStarted()) {
+ return List.of("Elasticsearch");
+ }
+ if (phase.isMigrationComplete()) {
+ return List.of("OpenSearch");
+ }
+ return List.of("Elasticsearch", "OpenSearch");
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java
new file mode 100644
index 000000000000..7760a3f5cd18
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java
@@ -0,0 +1,78 @@
+package com.dotcms.content.index.migration;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Per-index ES↔OS mirror status for the migration-readiness report (issue #36360): how one logical
+ * index compares against its counterpart across the two engines. Purely factual — the phase-aware
+ * "is this a blocker for changing phase" interpretation is layered on top by
+ * {@link MigrationReadinessService}.
+ *
+ * Shared by every index family that is mirrored during the migration: the versioned content
+ * indices (working/live) and the Site Search indices. The {@link IndexKind} says which family this
+ * row belongs to. Each engine's side is a nested {@link EngineCopy} so the report reads as
+ * {@code es:{exists,docCount}} / {@code os:{exists,docCount}}.
+ *
+ * @param indexName the logical index name (no {@code .os} tag)
+ * @param kind which mirrored index family this row belongs to
+ * @param es the Elasticsearch copy (existence + exact document count)
+ * @param os the OpenSearch ({@code .os}) copy (existence + exact document count)
+ * @param verdict the diff verdict between the two copies
+ * @param recommendation human-readable, action-oriented advice for a support technician
+ */
+@JsonIgnoreProperties("kind") // internal grouping/label only — the report keys rows by it, never emits it
+public record MirrorStatus(
+ String indexName,
+ IndexKind kind,
+ EngineCopy es,
+ EngineCopy os,
+ Verdict verdict,
+ String recommendation) {
+
+ /** Which mirrored index family a status row belongs to. */
+ public enum IndexKind { CONTENT_WORKING, CONTENT_LIVE, SITE_SEARCH }
+
+ /** The diff outcome between an index and its counterpart. */
+ public enum Verdict {
+ /** Both copies exist with the same document count. */
+ IN_SYNC,
+ /** The index exists on one engine but its counterpart is missing on the other. */
+ MISSING_COUNTERPART,
+ /** Both copies exist but hold a different number of documents. */
+ COUNT_DRIFT
+ }
+
+ /**
+ * One engine's copy of the index.
+ *
+ * @param exists whether this engine holds the index
+ * @param docCount exact document count (0 when absent, -1 when the count query failed)
+ * @param physicalName the full index name as stored on that engine's server — cluster-prefixed and,
+ * for OpenSearch, {@code .os}-tagged (e.g. {@code cluster_08abc3.live_20260406}
+ * on ES, {@code cluster_08abc3.live_20260406.os} on OS). Reported whether or not
+ * the copy exists, so a missing copy shows the name to look for.
+ */
+ public record EngineCopy(boolean exists, long docCount, String physicalName) {}
+
+ /** Whether this index needs operator action (a re-crawl / reindex) before the phase change. */
+ public boolean needsAttention() {
+ return verdict != Verdict.IN_SYNC;
+ }
+
+ /**
+ * Classifies a mirror from raw existence + exact counts: a missing copy on either engine is
+ * {@link Verdict#MISSING_COUNTERPART}; both present with unequal counts is {@link Verdict#COUNT_DRIFT}
+ * (a failed count is reported as {@code -1}, which compares unequal and so surfaces as drift —
+ * fail-safe); otherwise {@link Verdict#IN_SYNC}.
+ */
+ public static Verdict verdictFor(final boolean esExists, final boolean osExists,
+ final long esDocCount, final long osDocCount) {
+ if (!esExists || !osExists) {
+ return Verdict.MISSING_COUNTERPART;
+ }
+ if (esDocCount != osDocCount) {
+ return Verdict.COUNT_DRIFT;
+ }
+ return Verdict.IN_SYNC;
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java
new file mode 100644
index 000000000000..d8f7526deaec
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java
@@ -0,0 +1,104 @@
+package com.dotcms.content.index.migration;
+
+import com.dotcms.cdi.CDIUtils;
+import com.dotcms.content.index.IndexConfigHelper;
+import com.dotcms.content.index.IndexTag;
+import com.dotcms.content.index.migration.MirrorStatus.IndexKind;
+import com.dotcms.content.index.migration.MirrorStatus.Verdict;
+import com.dotcms.enterprise.publishing.sitesearch.ESSiteSearchAPI;
+import com.dotcms.enterprise.publishing.sitesearch.OSSiteSearchAPI;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.sitesearch.business.SiteSearchAPI;
+import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeSet;
+import java.util.function.Supplier;
+
+/**
+ * Site Search half of the migration-readiness report (issue #36360): compares every logical
+ * site-search index against its {@code .os} counterpart across both engines and produces a factual
+ * {@link MirrorStatus} per index — does the ES copy exist, does the OpenSearch counterpart exist, do their
+ * exact document counts match — plus a re-crawl recommendation. Never mutates anything.
+ *
+ * It queries the two engine leaves directly (ES the plain name, OpenSearch the {@code .os} counterpart)
+ * rather than the phase-aware router, so the report shows both sides regardless of which
+ * engine the current phase reads from. Counts come from {@link SiteSearchAPI#documentCount(String)}
+ * — an exact total, not a search hit-count capped at 10,000 — so content drift on large indices is
+ * detected (issue #36360).
+ */
+public class SiteSearchMirrorReconciler {
+
+ private final SiteSearchAPI esImpl;
+ private final SiteSearchAPI osImpl;
+ private final Supplier clusterPrefixSupplier;
+
+ public SiteSearchMirrorReconciler() {
+ this(new ESSiteSearchAPI(), CDIUtils.getBeanThrows(OSSiteSearchAPI.class),
+ () -> APILocator.getESIndexAPI().getClusterPrefix());
+ }
+
+ @VisibleForTesting
+ SiteSearchMirrorReconciler(final SiteSearchAPI esImpl, final SiteSearchAPI osImpl,
+ final Supplier clusterPrefixSupplier) {
+ this.esImpl = esImpl;
+ this.osImpl = osImpl;
+ this.clusterPrefixSupplier = clusterPrefixSupplier;
+ }
+
+ /**
+ * Whether a cross-engine mirror comparison is meaningful for a forward phase change in the
+ * current phase. Only the dual-write phases (1 and 2) keep both engines populated as write
+ * providers; Phase 0 (ES only) and Phase 3 (OS only) have a single write engine, so a "missing
+ * counterpart" there is either expected (0) or unfixable in-phase (3).
+ */
+ public boolean canEvaluate() {
+ return IndexConfigHelper.MigrationPhase.current().isDualWrite();
+ }
+
+ /**
+ * The per-index mirror status for every logical site-search index that exists on either
+ * engine (so a counterpart missing on one side still appears). Purely factual and phase-independent.
+ */
+ public List statuses() {
+ final TreeSet names = new TreeSet<>(esImpl.listIndices());
+ names.addAll(osImpl.listIndices());
+ final List statuses = new ArrayList<>(names.size());
+ for (final String name : names) {
+ statuses.add(statusFor(name));
+ }
+ return statuses;
+ }
+
+ private MirrorStatus statusFor(final String name) {
+ final boolean esExists = esImpl.existsOnAllWriteEngines(name);
+ final boolean osExists = osImpl.existsOnAllWriteEngines(name);
+ final long esCount = esExists ? esImpl.documentCount(name) : 0L;
+ final long osCount = osExists ? osImpl.documentCount(name) : 0L;
+ // Physical names as stored: ES is the cluster-prefixed logical name; OS is that + the .os tag
+ // (applied via IndexTag, the sole owner of the marker).
+ final String esPhysical = clusterPrefixSupplier.get() + name;
+ final String osPhysical = IndexTag.OS.tag(esPhysical);
+ final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount);
+ return new MirrorStatus(name, IndexKind.SITE_SEARCH,
+ new MirrorStatus.EngineCopy(esExists, esCount, esPhysical),
+ new MirrorStatus.EngineCopy(osExists, osCount, osPhysical),
+ verdict, recommend(name, verdict));
+ }
+
+ private static String recommend(final String name, final Verdict verdict) {
+ switch (verdict) {
+ case IN_SYNC:
+ return "In sync — no action needed.";
+ case MISSING_COUNTERPART:
+ return String.format("A copy of site-search index '%s' is missing on one engine. "
+ + "Re-crawl it (Site Search → Run now) to rebuild the counterpart before "
+ + "promoting to the OpenSearch-only phase.", name);
+ case COUNT_DRIFT:
+ default:
+ return String.format("The two copies of site-search index '%s' hold a different "
+ + "number of documents. Re-crawl it (Site Search → Run now) to rebuild "
+ + "the counterpart before promoting the phase.", name);
+ }
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java
index 35700822adb7..c9bf0b08db87 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/ESIndexResource.java
@@ -644,7 +644,7 @@ public Response getIndexStatus(@Context final HttpServletRequest request,
final InitDataObject init = auth(request, response);
return Response.ok(new ResponseEntityView<>(
- IndexResourceHelper.getInstance().indexStatsList(init.getUser()))).build();
+ IndexResourceHelper.getInstance().indexStatsList())).build();
}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java
new file mode 100644
index 000000000000..27272d6e5c64
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java
@@ -0,0 +1,116 @@
+package com.dotcms.rest.api.v1.index;
+
+import com.dotcms.content.index.MigrationIndexVisibility;
+import com.dotcms.content.index.migration.MigrationReadinessService;
+import com.dotcms.rest.InitDataObject;
+import com.dotcms.rest.WebResource;
+import com.dotcms.rest.annotation.NoCache;
+import com.dotmarketing.business.APILocator;
+import com.dotmarketing.business.Role;
+import com.dotmarketing.util.Config;
+import com.dotmarketing.util.UtilMethods;
+import com.google.common.annotations.VisibleForTesting;
+import com.liferay.portal.model.User;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.vavr.control.Try;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import org.glassfish.jersey.server.JSONP;
+
+/**
+ * Internal, role-gated ES→OS migration-readiness endpoint (issue #36360). It condenses the migration
+ * status a support technician needs before changing the phase: the current phase and its
+ * read/write engines, the per-index ES↔OS mirror diff for both mirrored families (content and Site
+ * Search) with missing-counterpart / count-drift verdicts and re-crawl/reindex recommendations, and an
+ * overall safe-to-advance / safe-to-rollback verdict. Read-only; it never mutates any index.
+ *
+ * Not public. The class is {@link Hidden} so it never appears in the OpenAPI /
+ * API-playground schema, and every method requires a backend user who is a CMS administrator
+ * and a member of the migration support role
+ * ({@value com.dotcms.content.index.MigrationIndexVisibility#VISIBILITY_ROLE_KEY}, default
+ * {@value com.dotcms.content.index.MigrationIndexVisibility#DEFAULT_VISIBILITY_ROLE_KEY}) — a plain
+ * admin without the role is not enough. Anyone else gets a 403, so regular users never learn a
+ * migration is running.
+ */
+@Path("/v1/index/migration")
+@Hidden
+public class MigrationReadinessResource {
+
+ private final MigrationReadinessService readinessService;
+
+ public MigrationReadinessResource() {
+ this(new MigrationReadinessService());
+ }
+
+ @VisibleForTesting
+ MigrationReadinessResource(final MigrationReadinessService readinessService) {
+ this.readinessService = readinessService;
+ }
+
+ /**
+ * Returns the migration-readiness report for the current phase. Requires a CMS administrator who
+ * also holds the configured migration support role.
+ */
+ @GET
+ @JSONP
+ @NoCache
+ @Hidden
+ @Path("/readiness")
+ @Produces({MediaType.APPLICATION_JSON, "application/javascript"})
+ public Response readiness(@Context final HttpServletRequest request,
+ @Context final HttpServletResponse response) {
+
+ final InitDataObject initData = new WebResource.InitBuilder(request, response)
+ .requiredBackendUser(true)
+ .init();
+
+ final User user = initData.getUser();
+ if (!isMigrationSupportUser(user)) {
+ throw new ForbiddenException(
+ "Migration readiness is restricted to CMS administrators who also hold the "
+ + "migration support role.");
+ }
+
+ // Return the readiness model directly (no ResponseEntityView envelope) — this internal endpoint
+ // has no use for the errors/messages/pagination/permissions wrapper.
+ return Response.ok(readinessService.evaluate()).build();
+ }
+
+ /**
+ * Whether {@code user} may read the migration-readiness report: a CMS administrator who
+ * also holds the configured support role
+ * ({@link MigrationIndexVisibility#VISIBILITY_ROLE_KEY}, default
+ * {@link MigrationIndexVisibility#DEFAULT_VISIBILITY_ROLE_KEY}) — both are required. This is an
+ * internal support tool in every phase; it never opens up to everyone (a plain admin is not
+ * enough), unlike the phase-based index portlet display.
+ */
+ @VisibleForTesting
+ static boolean isMigrationSupportUser(final User user) {
+ if (user == null) {
+ return false;
+ }
+ return Try.of(() -> {
+ // Must be BOTH a CMS administrator AND a member of the migration support role — a plain
+ // admin without the role is not enough, and the role without admin is not enough, so a
+ // regular user never accesses or learns of the migration (issue #36360).
+ if (!APILocator.getUserAPI().isCMSAdmin(user)) {
+ return false;
+ }
+ final String roleKey = Config.getStringProperty(
+ MigrationIndexVisibility.VISIBILITY_ROLE_KEY,
+ MigrationIndexVisibility.DEFAULT_VISIBILITY_ROLE_KEY);
+ if (!UtilMethods.isSet(roleKey)) {
+ return false;
+ }
+ final Role role = APILocator.getRoleAPI().loadRoleByKey(roleKey);
+ return role != null && APILocator.getRoleAPI().doesUserHaveRole(user, role);
+ }).getOrElse(false);
+ }
+}
diff --git a/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java b/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java
index 40f8117bdbaa..7c4af7c4e075 100644
--- a/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java
+++ b/dotCMS/src/main/java/com/dotmarketing/common/reindex/IndexResourceHelper.java
@@ -13,7 +13,6 @@
import com.dotcms.content.index.domain.IndexStats;
import com.dotmarketing.business.APILocator;
import com.google.common.collect.ImmutableList;
-import com.liferay.portal.model.User;
import io.vavr.control.Try;
@@ -37,15 +36,15 @@ public static IndexResourceHelper getInstance() {
- public List