You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@phoenix.apache.org by ap...@apache.org on 2026/07/24 00:23:40 UTC

(phoenix) 07/27: PHOENIX-7891 Explain the query optimizer's index selection rationale (#2513)

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

asf-gitbox-commits pushed a commit to branch PHOENIX-7876-feature
in repository https://gitbox.apache.org/repos/asf/phoenix.git

commit 9125dcb14644dbd91b5d3cba79af97c8e9eed5fc
Author: Andrew Purtell <ap...@apache.org>
AuthorDate: Wed Jun 10 17:21:22 2026 -0700

    PHOENIX-7891 Explain the query optimizer's index selection rationale (#2513)
    
    Co-authored-by: Claude Opus 4.8[1m] <no...@anthropic.com>
---
 .../phoenix/compile/ExplainPlanAttributes.java     | 100 ++++++----
 .../java/org/apache/phoenix/compile/QueryPlan.java |  14 ++
 .../org/apache/phoenix/execute/BaseQueryPlan.java  |  17 ++
 .../apache/phoenix/execute/DelegateQueryPlan.java  |  11 ++
 .../phoenix/iterate/BaseResultIterators.java       |   6 +
 .../org/apache/phoenix/iterate/ExplainTable.java   |  40 +++-
 .../apache/phoenix/optimize/OptimizerDecision.java |  81 ++++++++
 .../apache/phoenix/optimize/OptimizerReasons.java  |  61 ++++++
 .../apache/phoenix/optimize/QueryOptimizer.java    | 212 +++++++++++++++++++--
 .../phoenix/optimize/RejectedIndexEntry.java       |  65 +++++++
 .../end2end/BaseTenantSpecificViewIndexIT.java     |   7 +-
 .../phoenix/end2end/CostBasedDecisionIT.java       |  60 ++++--
 .../java/org/apache/phoenix/end2end/InListIT.java  |  78 +++++---
 .../org/apache/phoenix/end2end/ReverseScanIT.java  |   7 +-
 .../phoenix/end2end/TenantSpecificViewIndexIT.java |   7 +-
 .../index/ChildViewsUseParentViewIndexIT.java      |  10 +-
 .../end2end/index/GlobalIndexCheckerIT.java        |  18 +-
 .../end2end/index/GlobalIndexOptimizationIT.java   |  50 +++--
 .../apache/phoenix/end2end/index/IndexUsageIT.java |  60 ++++--
 .../apache/phoenix/end2end/index/LocalIndexIT.java |  81 +++++---
 .../phoenix/end2end/index/MutableIndexIT.java      |  40 ++--
 .../phoenix/end2end/index/PartialIndexIT.java      |   7 +-
 .../phoenix/end2end/index/SaltedIndexIT.java       |  27 ++-
 .../phoenix/end2end/index/SingleCellIndexIT.java   |  24 ++-
 .../apache/phoenix/end2end/index/ViewIndexIT.java  |   7 +-
 .../end2end/join/HashJoinGlobalIndexIT.java        |  18 +-
 .../phoenix/end2end/join/HashJoinLocalIndexIT.java |  18 +-
 .../phoenix/end2end/json/JsonFunctionsIT.java      |  16 +-
 .../apache/phoenix/compile/QueryCompilerTest.java  |  16 +-
 .../apache/phoenix/compile/QueryOptimizerTest.java |   5 +-
 .../compile/StatementHintsCompilationTest.java     |   4 +-
 .../TenantSpecificViewIndexCompileTest.java        |  22 ++-
 .../org/apache/phoenix/query/QueryPlanTest.java    |  22 ++-
 .../phoenix/query/explain/ExplainPlanTest.java     | 175 ++++++++++++++---
 .../phoenix/query/explain/ExplainPlanTestUtil.java |  67 +++++++
 35 files changed, 1179 insertions(+), 274 deletions(-)

diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
index 6c80e407d5..84012ddfa1 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/ExplainPlanAttributes.java
@@ -25,6 +25,7 @@ import java.util.List;
 import java.util.Set;
 import org.apache.hadoop.hbase.HRegionLocation;
 import org.apache.hadoop.hbase.client.Consistency;
+import org.apache.phoenix.optimize.RejectedIndexEntry;
 import org.apache.phoenix.parse.HintNode;
 import org.apache.phoenix.parse.HintNode.Hint;
 import org.apache.phoenix.schema.PColumn;
@@ -35,16 +36,16 @@ import org.apache.phoenix.schema.PColumn;
  * Strings containing entire plan.
  */
 @JsonPropertyOrder({ "abstractExplainPlan", "hint", "explainScanType", "consistency", "tableName",
-  "keyRanges", "indexName", "indexKind", "saltBuckets", "regionsPlanned", "scanTimeRangeMin",
-  "scanTimeRangeMax", "splitsChunk", "useRoundRobinIterator", "samplingRate", "hexStringRVCOffset",
-  "iteratorTypeAndScanSize", "estimatedRows", "estimatedSizeInBytes", "serverWhereFilter",
-  "serverDistinctFilter", "serverMergeColumns", "serverArrayElementProjection",
-  "serverFirstKeyOnlyProjection", "serverEmptyColumnOnlyProjection", "serverAggregate",
-  "serverGroupByLimit", "serverSortedBy", "serverOffset", "serverRowLimit", "clientFilterBy",
-  "clientAggregate", "clientDistinctFilter", "clientAfterAggregate", "clientSortAlgo",
-  "clientSortedBy", "clientOffset", "clientRowLimit", "clientSequenceCount", "clientCursorName",
-  "clientSteps", "lhsJoinQueryExplainPlan", "rhsJoinQueryExplainPlan", "subPlans",
-  "dynamicServerFilter", "afterJoinFilter", "joinScannerLimit", "sortMergeSkipMerge",
+  "keyRanges", "indexName", "indexKind", "indexRule", "indexRejected", "saltBuckets",
+  "regionsPlanned", "scanTimeRangeMin", "scanTimeRangeMax", "splitsChunk", "useRoundRobinIterator",
+  "samplingRate", "hexStringRVCOffset", "iteratorTypeAndScanSize", "estimatedRows",
+  "estimatedSizeInBytes", "serverWhereFilter", "serverDistinctFilter", "serverMergeColumns",
+  "serverArrayElementProjection", "serverFirstKeyOnlyProjection", "serverEmptyColumnOnlyProjection",
+  "serverAggregate", "serverGroupByLimit", "serverSortedBy", "serverOffset", "serverRowLimit",
+  "clientFilterBy", "clientAggregate", "clientDistinctFilter", "clientAfterAggregate",
+  "clientSortAlgo", "clientSortedBy", "clientOffset", "clientRowLimit", "clientSequenceCount",
+  "clientCursorName", "clientSteps", "lhsJoinQueryExplainPlan", "rhsJoinQueryExplainPlan",
+  "subPlans", "dynamicServerFilter", "afterJoinFilter", "joinScannerLimit", "sortMergeSkipMerge",
   "regionLocations", "regionLocationsTotalSize", "numRegionLocationLookups" })
 public class ExplainPlanAttributes {
 
@@ -57,6 +58,8 @@ public class ExplainPlanAttributes {
   private final String keyRanges;
   private final String indexName;
   private final String indexKind;
+  private final String indexRule;
+  private final List<RejectedIndexEntry> indexRejected;
   private final Integer saltBuckets;
   private final Integer regionsPlanned;
   private final Long scanTimeRangeMin;
@@ -122,6 +125,8 @@ public class ExplainPlanAttributes {
     this.keyRanges = null;
     this.indexName = null;
     this.indexKind = null;
+    this.indexRule = null;
+    this.indexRejected = null;
     this.saltBuckets = null;
     this.regionsPlanned = null;
     this.scanTimeRangeMin = null;
@@ -169,20 +174,20 @@ public class ExplainPlanAttributes {
 
   public ExplainPlanAttributes(String abstractExplainPlan, Hint hint, String explainScanType,
     Consistency consistency, String tableName, String keyRanges, String indexName, String indexKind,
-    Integer saltBuckets, Integer regionsPlanned, Long scanTimeRangeMin, Long scanTimeRangeMax,
-    Integer splitsChunk, boolean useRoundRobinIterator, Double samplingRate,
-    String hexStringRVCOffset, String iteratorTypeAndScanSize, Long estimatedRows,
-    Long estimatedSizeInBytes, String serverWhereFilter, String serverDistinctFilter,
-    Set<PColumn> serverMergeColumns, boolean serverArrayElementProjection,
-    boolean serverFirstKeyOnlyProjection, boolean serverEmptyColumnOnlyProjection,
-    String serverAggregate, Integer serverGroupByLimit, String serverSortedBy, Integer serverOffset,
-    Long serverRowLimit, String clientFilterBy, String clientAggregate, String clientDistinctFilter,
-    String clientAfterAggregate, String clientSortAlgo, String clientSortedBy, Integer clientOffset,
-    Integer clientRowLimit, Integer clientSequenceCount, String clientCursorName,
-    List<String> clientSteps, ExplainPlanAttributes lhsJoinQueryExplainPlan,
-    ExplainPlanAttributes rhsJoinQueryExplainPlan, List<ExplainPlanAttributes> subPlans,
-    String dynamicServerFilter, String afterJoinFilter, Long joinScannerLimit,
-    boolean sortMergeSkipMerge, List<HRegionLocation> regionLocations,
+    String indexRule, List<RejectedIndexEntry> indexRejected, Integer saltBuckets,
+    Integer regionsPlanned, Long scanTimeRangeMin, Long scanTimeRangeMax, Integer splitsChunk,
+    boolean useRoundRobinIterator, Double samplingRate, String hexStringRVCOffset,
+    String iteratorTypeAndScanSize, Long estimatedRows, Long estimatedSizeInBytes,
+    String serverWhereFilter, String serverDistinctFilter, Set<PColumn> serverMergeColumns,
+    boolean serverArrayElementProjection, boolean serverFirstKeyOnlyProjection,
+    boolean serverEmptyColumnOnlyProjection, String serverAggregate, Integer serverGroupByLimit,
+    String serverSortedBy, Integer serverOffset, Long serverRowLimit, String clientFilterBy,
+    String clientAggregate, String clientDistinctFilter, String clientAfterAggregate,
+    String clientSortAlgo, String clientSortedBy, Integer clientOffset, Integer clientRowLimit,
+    Integer clientSequenceCount, String clientCursorName, List<String> clientSteps,
+    ExplainPlanAttributes lhsJoinQueryExplainPlan, ExplainPlanAttributes rhsJoinQueryExplainPlan,
+    List<ExplainPlanAttributes> subPlans, String dynamicServerFilter, String afterJoinFilter,
+    Long joinScannerLimit, boolean sortMergeSkipMerge, List<HRegionLocation> regionLocations,
     Integer regionLocationsTotalSize, int numRegionLocationLookups) {
     this.abstractExplainPlan = abstractExplainPlan;
     this.hint = hint;
@@ -192,6 +197,10 @@ public class ExplainPlanAttributes {
     this.keyRanges = keyRanges;
     this.indexName = indexName;
     this.indexKind = indexKind;
+    this.indexRule = indexRule;
+    this.indexRejected = (indexRejected == null || indexRejected.isEmpty())
+      ? null
+      : Collections.unmodifiableList(new ArrayList<>(indexRejected));
     this.saltBuckets = saltBuckets;
     this.regionsPlanned = regionsPlanned;
     this.scanTimeRangeMin = scanTimeRangeMin;
@@ -271,6 +280,14 @@ public class ExplainPlanAttributes {
     return indexKind;
   }
 
+  public String getIndexRule() {
+    return indexRule;
+  }
+
+  public List<RejectedIndexEntry> getIndexRejected() {
+    return indexRejected;
+  }
+
   public Integer getSaltBuckets() {
     return saltBuckets;
   }
@@ -458,6 +475,8 @@ public class ExplainPlanAttributes {
     private String keyRanges;
     private String indexName;
     private String indexKind;
+    private String indexRule;
+    private List<RejectedIndexEntry> indexRejected;
     private Integer saltBuckets;
     private Integer regionsPlanned;
     private Long scanTimeRangeMin;
@@ -515,6 +534,9 @@ public class ExplainPlanAttributes {
       this.keyRanges = explainPlanAttributes.getKeyRanges();
       this.indexName = explainPlanAttributes.getIndexName();
       this.indexKind = explainPlanAttributes.getIndexKind();
+      this.indexRule = explainPlanAttributes.getIndexRule();
+      List<RejectedIndexEntry> srcIndexRejected = explainPlanAttributes.getIndexRejected();
+      this.indexRejected = srcIndexRejected == null ? null : new ArrayList<>(srcIndexRejected);
       this.saltBuckets = explainPlanAttributes.getSaltBuckets();
       this.regionsPlanned = explainPlanAttributes.getRegionsPlanned();
       this.scanTimeRangeMin = explainPlanAttributes.getScanTimeRangeMin();
@@ -602,6 +624,16 @@ public class ExplainPlanAttributes {
       return this;
     }
 
+    public ExplainPlanAttributesBuilder setIndexRule(String indexRule) {
+      this.indexRule = indexRule;
+      return this;
+    }
+
+    public ExplainPlanAttributesBuilder setIndexRejected(List<RejectedIndexEntry> indexRejected) {
+      this.indexRejected = indexRejected == null ? null : new ArrayList<>(indexRejected);
+      return this;
+    }
+
     public ExplainPlanAttributesBuilder setSaltBuckets(Integer saltBuckets) {
       this.saltBuckets = saltBuckets;
       return this;
@@ -833,16 +865,16 @@ public class ExplainPlanAttributes {
 
     public ExplainPlanAttributes build() {
       return new ExplainPlanAttributes(abstractExplainPlan, hint, explainScanType, consistency,
-        tableName, keyRanges, indexName, indexKind, saltBuckets, regionsPlanned, scanTimeRangeMin,
-        scanTimeRangeMax, splitsChunk, useRoundRobinIterator, samplingRate, hexStringRVCOffset,
-        iteratorTypeAndScanSize, estimatedRows, estimatedSizeInBytes, serverWhereFilter,
-        serverDistinctFilter, serverMergeColumns, serverArrayElementProjection,
-        serverFirstKeyOnlyProjection, serverEmptyColumnOnlyProjection, serverAggregate,
-        serverGroupByLimit, serverSortedBy, serverOffset, serverRowLimit, clientFilterBy,
-        clientAggregate, clientDistinctFilter, clientAfterAggregate, clientSortAlgo, clientSortedBy,
-        clientOffset, clientRowLimit, clientSequenceCount, clientCursorName, clientSteps,
-        lhsJoinQueryExplainPlan, rhsJoinQueryExplainPlan, subPlans, dynamicServerFilter,
-        afterJoinFilter, joinScannerLimit, sortMergeSkipMerge, regionLocations,
+        tableName, keyRanges, indexName, indexKind, indexRule, indexRejected, saltBuckets,
+        regionsPlanned, scanTimeRangeMin, scanTimeRangeMax, splitsChunk, useRoundRobinIterator,
+        samplingRate, hexStringRVCOffset, iteratorTypeAndScanSize, estimatedRows,
+        estimatedSizeInBytes, serverWhereFilter, serverDistinctFilter, serverMergeColumns,
+        serverArrayElementProjection, serverFirstKeyOnlyProjection, serverEmptyColumnOnlyProjection,
+        serverAggregate, serverGroupByLimit, serverSortedBy, serverOffset, serverRowLimit,
+        clientFilterBy, clientAggregate, clientDistinctFilter, clientAfterAggregate, clientSortAlgo,
+        clientSortedBy, clientOffset, clientRowLimit, clientSequenceCount, clientCursorName,
+        clientSteps, lhsJoinQueryExplainPlan, rhsJoinQueryExplainPlan, subPlans,
+        dynamicServerFilter, afterJoinFilter, joinScannerLimit, sortMergeSkipMerge, regionLocations,
         regionLocationsTotalSize, numRegionLocationLookups);
     }
   }
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryPlan.java b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryPlan.java
index a56a262b80..1b2d315c9b 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryPlan.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/QueryPlan.java
@@ -28,6 +28,7 @@ import org.apache.phoenix.execute.visitor.QueryPlanVisitor;
 import org.apache.phoenix.iterate.ParallelScanGrouper;
 import org.apache.phoenix.iterate.ResultIterator;
 import org.apache.phoenix.optimize.Cost;
+import org.apache.phoenix.optimize.OptimizerDecision;
 import org.apache.phoenix.parse.FilterableStatement;
 import org.apache.phoenix.parse.SelectStatement;
 import org.apache.phoenix.query.KeyRange;
@@ -103,4 +104,17 @@ public interface QueryPlan extends StatementPlan {
    * </pre>
    */
   public List<OrderBy> getOutputOrderBys();
+
+  /**
+   * The optimizer's index selection rationale for this plan, or {@code null} if this plan did not
+   * participate in optimizer index selection.
+   */
+  default OptimizerDecision getOptimizerDecision() {
+    return null;
+  }
+
+  /** Records the optimizer's index selection rationale on this plan. */
+  default void setOptimizerDecision(OptimizerDecision decision) {
+    // no-op
+  }
 }
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
index e0dbc5afad..e7ca83c84d 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/BaseQueryPlan.java
@@ -57,6 +57,7 @@ import org.apache.phoenix.iterate.ParallelScanGrouper;
 import org.apache.phoenix.iterate.ResultIterator;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixStatement.Operation;
+import org.apache.phoenix.optimize.OptimizerDecision;
 import org.apache.phoenix.parse.FilterableStatement;
 import org.apache.phoenix.parse.HintNode.Hint;
 import org.apache.phoenix.parse.ParseNodeFactory;
@@ -110,6 +111,7 @@ public abstract class BaseQueryPlan implements QueryPlan {
   protected Long estimateInfoTimestamp;
   private boolean getEstimatesCalled;
   protected boolean isApplicable = true;
+  private OptimizerDecision optimizerDecision;
 
   protected BaseQueryPlan(StatementContext context, FilterableStatement statement, TableRef table,
     RowProjector projection, ParameterMetaData paramMetaData, Integer limit, Integer offset,
@@ -543,6 +545,11 @@ public abstract class BaseQueryPlan implements QueryPlan {
     List<String> planSteps = Lists.newArrayListWithExpectedSize(5);
     ExplainPlanAttributesBuilder builder = new ExplainPlanAttributesBuilder();
     iterator.explain(planSteps, builder);
+    OptimizerDecision decision = getOptimizerDecision();
+    if (decision != null) {
+      builder.setIndexRule(decision.getRule());
+      builder.setIndexRejected(decision.getRejectedIndexes());
+    }
     return Pair.of(planSteps, builder.build());
   }
 
@@ -585,6 +592,16 @@ public abstract class BaseQueryPlan implements QueryPlan {
     this.isApplicable = isApplicable;
   }
 
+  @Override
+  public OptimizerDecision getOptimizerDecision() {
+    return optimizerDecision;
+  }
+
+  @Override
+  public void setOptimizerDecision(OptimizerDecision decision) {
+    this.optimizerDecision = decision;
+  }
+
   private void getEstimates() throws SQLException {
     getEstimatesCalled = true;
     // Initialize a dummy iterator to get the estimates based on stats.
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/DelegateQueryPlan.java b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/DelegateQueryPlan.java
index b78a4ab614..3ca9b8e059 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/execute/DelegateQueryPlan.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/execute/DelegateQueryPlan.java
@@ -32,6 +32,7 @@ import org.apache.phoenix.iterate.ParallelScanGrouper;
 import org.apache.phoenix.iterate.ResultIterator;
 import org.apache.phoenix.jdbc.PhoenixStatement.Operation;
 import org.apache.phoenix.optimize.Cost;
+import org.apache.phoenix.optimize.OptimizerDecision;
 import org.apache.phoenix.parse.FilterableStatement;
 import org.apache.phoenix.query.KeyRange;
 import org.apache.phoenix.schema.TableRef;
@@ -171,4 +172,14 @@ public abstract class DelegateQueryPlan implements QueryPlan {
   public boolean isApplicable() {
     return delegate.isApplicable();
   }
+
+  @Override
+  public OptimizerDecision getOptimizerDecision() {
+    return delegate.getOptimizerDecision();
+  }
+
+  @Override
+  public void setOptimizerDecision(OptimizerDecision decision) {
+    delegate.setOptimizerDecision(decision);
+  }
 }
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
index 7e81d3a534..97f1dbf0b7 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/BaseResultIterators.java
@@ -86,6 +86,7 @@ import org.apache.phoenix.hbase.index.util.ImmutableBytesPtr;
 import org.apache.phoenix.hbase.index.util.VersionUtil;
 import org.apache.phoenix.join.HashCacheClient;
 import org.apache.phoenix.monitoring.OverAllQueryMetrics;
+import org.apache.phoenix.optimize.OptimizerDecision;
 import org.apache.phoenix.parse.FilterableStatement;
 import org.apache.phoenix.parse.HintNode;
 import org.apache.phoenix.parse.HintNode.Hint;
@@ -645,6 +646,11 @@ public abstract class BaseResultIterators extends ExplainTable implements Result
     return splits == null ? 0 : splits.size();
   }
 
+  @Override
+  protected OptimizerDecision getOptimizerDecision() {
+    return plan.getOptimizerDecision();
+  }
+
   @Override
   public List<List<Scan>> getScans() {
     if (scans == null) return Collections.emptyList();
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
index badc779744..ce20eb4571 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/iterate/ExplainTable.java
@@ -43,6 +43,9 @@ import org.apache.phoenix.coprocessorclient.BaseScannerRegionObserverConstants;
 import org.apache.phoenix.filter.BooleanExpressionFilter;
 import org.apache.phoenix.filter.DistinctPrefixFilter;
 import org.apache.phoenix.filter.EmptyColumnOnlyFilter;
+import org.apache.phoenix.optimize.OptimizerDecision;
+import org.apache.phoenix.optimize.OptimizerReasons;
+import org.apache.phoenix.optimize.RejectedIndexEntry;
 import org.apache.phoenix.parse.HintNode;
 import org.apache.phoenix.parse.HintNode.Hint;
 import org.apache.phoenix.query.KeyRange;
@@ -131,6 +134,27 @@ public abstract class ExplainTable {
     return 0;
   }
 
+  /**
+   * The optimizer's index selection rationale for the plan this scan belongs to, used to render the
+   * per-scan {@code INDEX} rule comment and the {@code !INDEX} rejection comments. Returns
+   * {@code null} when the plan did not participate in optimizer index selection (DML, DDL, and
+   * non-optimizer plans).
+   * @return the decision, or {@code null} when unavailable
+   */
+  protected OptimizerDecision getOptimizerDecision() {
+    return null;
+  }
+
+  /**
+   * Whether {@code rule} is a default rule whose {@code INDEX} comment is suppressed. The default
+   * rules are {@link OptimizerReasons#RULE_DATA_TABLE} (no candidate indexes considered) and
+   * {@link OptimizerReasons#RULE_ONLY_CANDIDATE} (a single viable candidate).
+   */
+  private static boolean isDefaultRule(String rule) {
+    return OptimizerReasons.RULE_DATA_TABLE.equals(rule)
+      || OptimizerReasons.RULE_ONLY_CANDIDATE.equals(rule);
+  }
+
   /**
    * Logical name used to render a table or index in EXPLAIN output. Shared by both the scan
    * {@code OVER} line's local index decoration and the per scan {@code INDEX} line.
@@ -216,7 +240,21 @@ public abstract class ExplainTable {
           indexKind = null;
       }
     }
-    planSteps.add("    INDEX " + explainIndexName + (indexKind == null ? "" : " " + indexKind));
+    OptimizerDecision decision = getOptimizerDecision();
+    StringBuilder indexLine = new StringBuilder("    INDEX ").append(explainIndexName);
+    if (indexKind != null) {
+      indexLine.append(" ").append(indexKind);
+    }
+    if (decision != null && !isDefaultRule(decision.getRule())) {
+      indexLine.append("  /* ").append(decision.getRule()).append(" */");
+    }
+    planSteps.add(indexLine.toString());
+    if (decision != null) {
+      for (RejectedIndexEntry rejected : decision.getRejectedIndexes()) {
+        planSteps
+          .add("    /* !INDEX " + rejected.getName() + " -- " + rejected.getReason() + " */");
+      }
+    }
     Integer bucketNum = tableRef.getTable().getBucketNum();
     if (bucketNum != null) {
       planSteps.add("    SALT BUCKETS " + bucketNum);
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java
new file mode 100644
index 0000000000..be137a0bca
--- /dev/null
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerDecision.java
@@ -0,0 +1,81 @@
+/*
+ * 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.phoenix.optimize;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Records the optimizer's index selection rationale: the chosen index (or data table), the rule
+ * that selected it, and the indexes that were considered but rejected. The {@code rule} is one of
+ * the closed-set {@code RULE_*} labels in {@link OptimizerReasons}. The entry for each rejected
+ * index carries a {@code REASON_*} label.
+ */
+public final class OptimizerDecision {
+  private final String chosenIndex;
+  private final String rule;
+  private final List<RejectedIndexEntry> rejectedIndexes;
+
+  public OptimizerDecision(String chosenIndex, String rule,
+    List<RejectedIndexEntry> rejectedIndexes) {
+    this.chosenIndex = chosenIndex;
+    this.rule = rule;
+    this.rejectedIndexes = rejectedIndexes == null
+      ? Collections.emptyList()
+      : Collections.unmodifiableList(new ArrayList<>(rejectedIndexes));
+  }
+
+  public String getChosenIndex() {
+    return chosenIndex;
+  }
+
+  public String getRule() {
+    return rule;
+  }
+
+  /** Never null; unmodifiable; possibly empty. */
+  public List<RejectedIndexEntry> getRejectedIndexes() {
+    return rejectedIndexes;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof OptimizerDecision)) {
+      return false;
+    }
+    OptimizerDecision that = (OptimizerDecision) o;
+    return Objects.equals(chosenIndex, that.chosenIndex) && Objects.equals(rule, that.rule)
+      && rejectedIndexes.equals(that.rejectedIndexes);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(chosenIndex, rule, rejectedIndexes);
+  }
+
+  @Override
+  public String toString() {
+    return "OptimizerDecision{chosenIndex=" + chosenIndex + ", rule=" + rule + ", rejectedIndexes="
+      + rejectedIndexes + "}";
+  }
+}
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerReasons.java b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerReasons.java
new file mode 100644
index 0000000000..6084183956
--- /dev/null
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/OptimizerReasons.java
@@ -0,0 +1,61 @@
+/*
+ * 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.phoenix.optimize;
+
+/**
+ * Closed-set label vocabulary for {@link OptimizerDecision}. The {@code RULE_*} constants name the
+ * rule the optimizer used to choose a plan. The {@code REASON_*} constants explain why a candidate
+ * index was rejected.
+ */
+public final class OptimizerReasons {
+
+  // RULE_* — chosen-plan rule labels.
+  public static final String RULE_POINT_LOOKUP = "point lookup";
+  public static final String RULE_ONLY_CANDIDATE = "only candidate";
+  public static final String RULE_CDC_INDEX = "CDC index";
+  public static final String RULE_COST_BASED = "cost-based";
+  public static final String RULE_HINT = "hint";
+  public static final String RULE_DATA_TABLE = "data table";
+  public static final String RULE_MORE_BOUND_PK_COLUMNS = "more bound PK columns";
+  public static final String RULE_ORDER_PRESERVING = "order-preserving";
+  public static final String RULE_NON_LOCAL_PREFERRED = "non-local preferred";
+  public static final String RULE_PARTIAL_INDEX_APPLICABLE = "partial index applicable";
+
+  // REASON_* — rejected-index reason labels.
+  public static final String REASON_NO_PK_PREFIX_BOUND = "no PK prefix bound";
+  public static final String REASON_DOES_NOT_COVER_PROJECTION = "does not cover projection";
+  public static final String REASON_PARTIAL_INDEX_PREDICATE_NOT_SATISFIED =
+    "partial index predicate not satisfied";
+  public static final String REASON_EXCLUDED_BY_NO_INDEX_HINT = "excluded by NO_INDEX hint";
+  public static final String REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE =
+    "local index loses to global by rule";
+  public static final String REASON_COST_BASED_LOSS = "cost-based loss";
+  public static final String REASON_DEGENERATE_RANGE = "degenerate range";
+  public static final String REASON_FULL_SCAN_WOULD_BE_REQUIRED = "full scan would be required";
+  public static final String REASON_NOT_APPLICABLE_TO_JOIN = "not applicable to join";
+  public static final String REASON_PATH_EXPRESSION_DOES_NOT_MATCH =
+    "path expression does not match";
+
+  /** Builds the functional index rule label of the form {@code "matches <expr>"}. */
+  public static String matches(String expression) {
+    return "matches " + expression;
+  }
+
+  private OptimizerReasons() {
+  }
+}
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
index aec0c0edfc..28372949d1 100644
--- a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
@@ -20,6 +20,7 @@ package org.apache.phoenix.optimize;
 import static org.apache.phoenix.query.QueryConstants.CDC_JSON_COL_NAME;
 
 import java.sql.SQLException;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
@@ -156,7 +157,7 @@ public class QueryOptimizer {
         && (select.getWhere() == null || !select.getWhere().hasSubquery())
     ) {
       return getApplicablePlansForSingleFlatQuery(dataPlan, statement, targetColumns,
-        parallelIteratorFactory, stopAtBestPlan);
+        parallelIteratorFactory, stopAtBestPlan, new DecisionState());
     }
 
     Map<TableRef, QueryPlan> dataPlans = null;
@@ -224,7 +225,8 @@ public class QueryOptimizer {
 
   private List<QueryPlan> getApplicablePlansForSingleFlatQuery(QueryPlan dataPlan,
     PhoenixStatement statement, List<? extends PDatum> targetColumns,
-    ParallelIteratorFactory parallelIteratorFactory, boolean stopAtBestPlan) throws SQLException {
+    ParallelIteratorFactory parallelIteratorFactory, boolean stopAtBestPlan, DecisionState state)
+    throws SQLException {
     SelectStatement select = (SelectStatement) dataPlan.getStatement();
     String indexHint = select.getHint().getHint(Hint.INDEX);
     // Exit early if we have a point lookup w/o index hint as we can't get better than that
@@ -232,7 +234,8 @@ public class QueryOptimizer {
       indexHint == null && dataPlan.getContext().getScanRanges().isPointLookup() && stopAtBestPlan
         && dataPlan.isApplicable()
     ) {
-      return Collections.<QueryPlan> singletonList(dataPlan);
+      return Collections.<QueryPlan> singletonList(
+        recordDecision(dataPlan, OptimizerReasons.RULE_POINT_LOOKUP, state));
     }
 
     ColumnResolver indexResolver = null;
@@ -271,7 +274,11 @@ public class QueryOptimizer {
       dataPlan.isApplicable() && (indexes.isEmpty() || dataPlan.isDegenerate()
         || dataPlan.getTableRef().hasDynamicCols() || select.getHint().hasHint(Hint.NO_INDEX))
     ) {
-      return Collections.<QueryPlan> singletonList(dataPlan);
+      if (select.getHint().hasHint(Hint.NO_INDEX)) {
+        state.rejectAll(indexes, OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
+      }
+      return Collections.<
+        QueryPlan> singletonList(recordDecision(dataPlan, OptimizerReasons.RULE_DATA_TABLE, state));
     }
     // The targetColumns is set for UPSERT SELECT to ensure that the proper type conversion takes
     // place.
@@ -297,7 +304,7 @@ public class QueryOptimizer {
     if (!forCDC) {
       plans.add(dataPlan);
       hintedPlan = getHintedQueryPlan(statement, translatedIndexSelect, indexes, targetColumns,
-        parallelIteratorFactory, plans);
+        parallelIteratorFactory, plans, state);
       if (hintedPlan != null) {
         PTable index = hintedPlan.getTableRef().getTable();
         // Ignore any index hint with a CDC index as it is a truncated index, it only
@@ -307,7 +314,8 @@ public class QueryOptimizer {
             stopAtBestPlan && hintedPlan.isApplicable()
               && (index.getIndexWhere() == null || isPartialIndexUsable(select, dataPlan, index))
           ) {
-            return Collections.singletonList(hintedPlan);
+            return Collections
+              .singletonList(recordDecision(hintedPlan, OptimizerReasons.RULE_HINT, state));
           }
           plans.add(0, hintedPlan);
         }
@@ -321,8 +329,9 @@ public class QueryOptimizer {
         // the data table mutations outside the max lookback window
         continue;
       }
-      QueryPlan plan = addPlan(statement, translatedIndexSelect, index, targetColumns,
+      AddPlanResult result = addPlan(statement, translatedIndexSelect, index, targetColumns,
         parallelIteratorFactory, dataPlan, false, indexResolver);
+      QueryPlan plan = result.plan;
       if (
         plan != null
           && (index.getIndexWhere() == null || isPartialIndexUsable(select, dataPlan, index))
@@ -332,6 +341,12 @@ public class QueryOptimizer {
           return Collections.singletonList(plan);
         }
         plans.add(plan);
+      } else if (plan == null) {
+        // Index candidate could not be built into a usable plan; record why it was rejected.
+        state.reject(result.rejection);
+      } else {
+        // Plan built, but the partial-index predicate is not satisfied by this query's WHERE.
+        state.reject(index, OptimizerReasons.REASON_PARTIAL_INDEX_PREDICATE_NOT_SATISFIED);
       }
     }
 
@@ -350,13 +365,14 @@ public class QueryOptimizer {
 
     // OrderPlans
     return hintedPlan == null
-      ? orderPlansBestToWorst(select, applicablePlans, stopAtBestPlan)
+      ? orderPlansBestToWorst(select, applicablePlans, stopAtBestPlan, state, forCDC)
       : applicablePlans;
   }
 
   private QueryPlan getHintedQueryPlan(PhoenixStatement statement, SelectStatement select,
     List<PTable> indexes, List<? extends PDatum> targetColumns,
-    ParallelIteratorFactory parallelIteratorFactory, List<QueryPlan> plans) throws SQLException {
+    ParallelIteratorFactory parallelIteratorFactory, List<QueryPlan> plans, DecisionState state)
+    throws SQLException {
     QueryPlan dataPlan = plans.get(0);
     String indexHint = select.getHint().getHint(Hint.INDEX);
     if (indexHint == null) {
@@ -395,11 +411,12 @@ public class QueryOptimizer {
           // Hinted index is applicable, so return it's index
           PTable index = indexes.get(indexPos);
           indexes.remove(indexPos);
-          QueryPlan plan = addPlan(statement, select, index, targetColumns, parallelIteratorFactory,
-            dataPlan, true, null);
-          if (plan != null) {
-            return plan;
+          AddPlanResult result = addPlan(statement, select, index, targetColumns,
+            parallelIteratorFactory, dataPlan, true, null);
+          if (result.plan != null) {
+            return result.plan;
           }
+          state.reject(result.rejection);
         }
         startIndex = endIndex + 1;
       }
@@ -416,7 +433,7 @@ public class QueryOptimizer {
     return -1;
   }
 
-  private QueryPlan addPlan(PhoenixStatement statement, SelectStatement select, PTable index,
+  private AddPlanResult addPlan(PhoenixStatement statement, SelectStatement select, PTable index,
     List<? extends PDatum> targetColumns, ParallelIteratorFactory parallelIteratorFactory,
     QueryPlan dataPlan, boolean isHinted, ColumnResolver indexResolver) throws SQLException {
     String tableAlias = dataPlan.getTableRef().getTableAlias();
@@ -436,7 +453,7 @@ public class QueryOptimizer {
       isHinted, indexSelect, resolver);
   }
 
-  private QueryPlan addPlan(PhoenixStatement statement, SelectStatement select, PTable index,
+  private AddPlanResult addPlan(PhoenixStatement statement, SelectStatement select, PTable index,
     List<? extends PDatum> targetColumns, ParallelIteratorFactory parallelIteratorFactory,
     QueryPlan dataPlan, boolean isHinted, SelectStatement indexSelect, ColumnResolver resolver)
     throws SQLException {
@@ -547,7 +564,7 @@ public class QueryOptimizer {
               indexTableRef.getCurrentTime(), indexTable.getIndexDisableTimestamp()))
         ) {
           if (plan.getProjector().getColumnCount() == nColumns) {
-            return plan;
+            return AddPlanResult.success(plan);
           } else {
             String schemaNameStr =
               index.getSchemaName() == null ? null : index.getSchemaName().getString();
@@ -619,15 +636,15 @@ public class QueryOptimizer {
               new QueryCompiler(statement, rewriteResult.getRewrittenSelectStatement(),
                 rewriteResult.getColumnResolver(), targetColumns, parallelIteratorFactory,
                 dataPlan.getContext().getSequenceManager(), isProjected, true, dataPlans).compile();
-            return plan;
+            return AddPlanResult.success(plan);
           }
         }
       } catch (RowValueConstructorOffsetNotCoercibleException e) {
         // Could not coerce the user provided RVC Offset so we do not have a plan to add.
-        return null;
+        return AddPlanResult.rejected(index, OptimizerReasons.REASON_DEGENERATE_RANGE);
       }
     }
-    return null;
+    return AddPlanResult.rejected(index, OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
   }
 
   // returns true if we can still use the index
@@ -650,9 +667,11 @@ public class QueryOptimizer {
    * @return list of plans ordered from best to worst.
    */
   private List<QueryPlan> orderPlansBestToWorst(SelectStatement select, List<QueryPlan> plans,
-    boolean stopAtBestPlan) {
+    boolean stopAtBestPlan, DecisionState state, boolean forCDC) {
     final QueryPlan dataPlan = plans.get(0);
     if (plans.size() == 1) {
+      recordDecision(plans.get(0),
+        forCDC ? OptimizerReasons.RULE_CDC_INDEX : OptimizerReasons.RULE_ONLY_CANDIDATE, state);
       return plans;
     }
 
@@ -666,6 +685,14 @@ public class QueryOptimizer {
       // Return ordered list based on cost if stats are available; otherwise fall
       // back to static ordering.
       if (!plans.get(0).getCost().isUnknown()) {
+        QueryPlan costWinner = plans.get(0);
+        for (int i = 1; i < plans.size(); i++) {
+          PTable losingTable = plans.get(i).getTableRef().getTable();
+          if (losingTable.getType() == PTableType.INDEX) {
+            state.reject(losingTable, OptimizerReasons.REASON_COST_BASED_LOSS);
+          }
+        }
+        recordDecision(costWinner, OptimizerReasons.RULE_COST_BASED, state);
         return stopAtBestPlan ? plans.subList(0, 1) : plans;
       }
     }
@@ -789,9 +816,154 @@ public class QueryOptimizer {
 
     });
 
+    // Capture the optimizer's rationale for the chosen plan without altering the ordering above.
+    QueryPlan winner = bestCandidates.get(0);
+    QueryPlan runnerUp =
+      bestCandidates.size() > 1 ? bestCandidates.get(1) : firstOther(plans, winner);
+    tagComparatorRejections(winner, plans, boundRanges, state);
+    recordDecision(winner, labelComparatorRule(winner, runnerUp, boundRanges), state);
+
     return stopAtBestPlan ? bestCandidates.subList(0, 1) : bestCandidates;
   }
 
+  /**
+   * Outcome of attempting to build an index candidate plan in {@link #addPlan}. Carries either the
+   * successfully built {@code plan} or a {@code rejection} explaining why the candidate index was
+   * not usable.
+   */
+  private static final class AddPlanResult {
+    final QueryPlan plan;
+    final RejectedIndexEntry rejection;
+
+    private AddPlanResult(QueryPlan plan, RejectedIndexEntry rejection) {
+      this.plan = plan;
+      this.rejection = rejection;
+    }
+
+    static AddPlanResult success(QueryPlan plan) {
+      return new AddPlanResult(plan, null);
+    }
+
+    static AddPlanResult rejected(PTable index, String reason) {
+      return new AddPlanResult(null,
+        new RejectedIndexEntry(index.getTableName().getString(), reason));
+    }
+  }
+
+  /**
+   * Accumulates the rejected index entries gathered while selecting an index for a single flat
+   * query. Rejections are deduplicated by index name.
+   */
+  private static final class DecisionState {
+    private final List<RejectedIndexEntry> rejections = new ArrayList<>();
+
+    void reject(String name, String reason) {
+      for (RejectedIndexEntry existing : rejections) {
+        if (existing.getName().equals(name)) {
+          return;
+        }
+      }
+      rejections.add(new RejectedIndexEntry(name, reason));
+    }
+
+    void reject(PTable index, String reason) {
+      reject(index.getTableName().getString(), reason);
+    }
+
+    void reject(RejectedIndexEntry entry) {
+      if (entry != null) {
+        reject(entry.getName(), entry.getReason());
+      }
+    }
+
+    void rejectAll(List<PTable> indexes, String reason) {
+      for (PTable index : indexes) {
+        reject(index, reason);
+      }
+    }
+
+    List<RejectedIndexEntry> getRejections() {
+      return rejections;
+    }
+  }
+
+  /**
+   * Records the optimizer's index selection rationale on the chosen plan and returns it so callers
+   * can use this inline at a {@code return} site.
+   */
+  private static QueryPlan recordDecision(QueryPlan winner, String rule, DecisionState state) {
+    winner.setOptimizerDecision(
+      new OptimizerDecision(winner.getTableRef().getTable().getTableName().getString(), rule,
+        state == null ? null : state.getRejections()));
+    return winner;
+  }
+
+  /** First plan in {@code plans} that is not {@code exclude}, or {@code null} if there is none. */
+  private static QueryPlan firstOther(List<QueryPlan> plans, QueryPlan exclude) {
+    for (QueryPlan plan : plans) {
+      if (plan != exclude) {
+        return plan;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * The bound-PK-column count for {@code plan}.
+   */
+  private static int adjustedBoundCount(QueryPlan plan, int boundRanges) {
+    PTable table = plan.getTableRef().getTable();
+    int boundCount = plan.getContext().getScanRanges().getBoundPkColumnCount();
+    boundCount += table.getViewIndexId() == null ? 0 : (boundRanges - 1);
+    boundCount -= plan.getContext().getScanRanges().isSalted() ? 1 : 0;
+    return boundCount;
+  }
+
+  /**
+   * Returns the {@code RULE_*} label for the chosen plan by replaying the comparator ladder in
+   * {@link #orderPlansBestToWorst} against the runner up and returning the first decisive branch.
+   */
+  private static String labelComparatorRule(QueryPlan winner, QueryPlan runnerUp, int boundRanges) {
+    if (runnerUp == null) {
+      return OptimizerReasons.RULE_ONLY_CANDIDATE;
+    }
+    PTable winnerTable = winner.getTableRef().getTable();
+    PTable runnerUpTable = runnerUp.getTableRef().getTable();
+    if (adjustedBoundCount(winner, boundRanges) != adjustedBoundCount(runnerUp, boundRanges)) {
+      return OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS;
+    }
+    if (winner.getGroupBy() != null && runnerUp.getGroupBy() != null) {
+      if (winner.getGroupBy().isOrderPreserving() != runnerUp.getGroupBy().isOrderPreserving()) {
+        return OptimizerReasons.RULE_ORDER_PRESERVING;
+      }
+    }
+    if (winnerTable.getIndexWhere() != null && runnerUpTable.getIndexWhere() == null) {
+      return OptimizerReasons.RULE_PARTIAL_INDEX_APPLICABLE;
+    }
+    return OptimizerReasons.RULE_NON_LOCAL_PREFERRED;
+  }
+
+  /** Tags every index candidate that lost to {@code winner} with a rejection reason. */
+  private static void tagComparatorRejections(QueryPlan winner, List<QueryPlan> plans,
+    int boundRanges, DecisionState state) {
+    int winnerBound = adjustedBoundCount(winner, boundRanges);
+    boolean winnerNonLocal = winner.getTableRef().getTable().getIndexType() != IndexType.LOCAL;
+    for (QueryPlan plan : plans) {
+      if (plan == winner) {
+        continue;
+      }
+      PTable table = plan.getTableRef().getTable();
+      if (table.getType() != PTableType.INDEX) {
+        continue;
+      }
+      if (adjustedBoundCount(plan, boundRanges) < winnerBound) {
+        state.reject(table, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
+      } else if (table.getIndexType() == IndexType.LOCAL && winnerNonLocal) {
+        state.reject(table, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
+      }
+    }
+  }
+
   private static class WhereConditionRewriter extends AndRewriterBooleanParseNodeVisitor {
     private final ColumnResolver dataResolver;
     private final ExpressionCompiler expressionCompiler;
diff --git a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/RejectedIndexEntry.java b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/RejectedIndexEntry.java
new file mode 100644
index 0000000000..81568ac7c9
--- /dev/null
+++ b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/RejectedIndexEntry.java
@@ -0,0 +1,65 @@
+/*
+ * 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.phoenix.optimize;
+
+import java.util.Objects;
+
+/**
+ * An index the optimizer considered as a candidate but did not choose, paired with the reason it
+ * was rejected. The reason is one of the closed-set {@code REASON_*} labels in
+ * {@link OptimizerReasons}.
+ */
+public final class RejectedIndexEntry {
+  private final String name;
+  private final String reason;
+
+  public RejectedIndexEntry(String name, String reason) {
+    this.name = Objects.requireNonNull(name, "name");
+    this.reason = Objects.requireNonNull(reason, "reason");
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getReason() {
+    return reason;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof RejectedIndexEntry)) {
+      return false;
+    }
+    RejectedIndexEntry that = (RejectedIndexEntry) o;
+    return name.equals(that.name) && reason.equals(that.reason);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(name, reason);
+  }
+
+  @Override
+  public String toString() {
+    return "RejectedIndexEntry{name=" + name + ", reason=" + reason + "}";
+  }
+}
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/BaseTenantSpecificViewIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/BaseTenantSpecificViewIndexIT.java
index 70708610c2..3c9b80282c 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/BaseTenantSpecificViewIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/BaseTenantSpecificViewIndexIT.java
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Properties;
 import java.util.Set;
 import org.apache.hadoop.hbase.util.Pair;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.schema.types.PVarbinary;
 import org.apache.phoenix.util.PhoenixRuntime;
 import org.apache.phoenix.util.SchemaUtil;
@@ -197,7 +198,8 @@ public abstract class BaseTenantSpecificViewIndexIT extends SplitSystemCatalogIT
     assertPlan(conn, "SELECT k1, k2, v2 FROM " + viewName + " WHERE v2='" + valuePrefix + "v2-1'")
       .iteratorType(iteratorTypeAndScanSize).serverFirstKeyOnlyProjection(true)
       .scanType("RANGE SCAN").clientSortAlgo(clientSortAlgo).table(expectedTableName)
-      .keyRanges(keyRanges);
+      .keyRanges(keyRanges).indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .indexRejectedNone();
   }
 
   private void createAndVerifyIndexNonStringTenantId(Connection conn, String viewName,
@@ -213,7 +215,8 @@ public abstract class BaseTenantSpecificViewIndexIT extends SplitSystemCatalogIT
       .table(SchemaUtil.getTableName(SchemaUtil.getSchemaNameFromFullName(viewName), indexName)
         + "(" + tableName + ")")
       .keyRanges(" [1," + tenantId + ",'" + valuePrefix + "v2-1']")
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .indexRejectedNone();
   }
 
   private Connection createTenantConnection(String tenantId) throws SQLException {
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
index 15fe361aaa..0556e706a5 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/CostBasedDecisionIT.java
@@ -31,6 +31,7 @@ import java.util.Properties;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.util.PropertiesUtil;
@@ -79,7 +80,9 @@ public class CostBasedDecisionIT extends BaseTest {
       String query =
         "SELECT rowkey, c1, c2 FROM " + tableName + " where c1 LIKE 'X0%' ORDER BY rowkey";
       // Use the data table plan that opts out order-by when stats are not available.
-      assertPlan(conn, query).scanType("FULL SCAN");
+      assertPlan(conn, query).scanType("FULL SCAN")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedCount(1).indexRejected(
+          0, tableName + "_IDX", OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) VALUES (?, ?, ?)");
@@ -94,7 +97,8 @@ public class CostBasedDecisionIT extends BaseTest {
       conn.createStatement().execute("UPDATE STATISTICS " + tableName);
 
       // Use the index table plan that has a lower cost when stats become available.
-      assertPlan(conn, query).scanType("RANGE SCAN");
+      assertPlan(conn, query).scanType("RANGE SCAN").indexRule(OptimizerReasons.RULE_COST_BASED)
+        .indexRejectedNone();
     } finally {
       conn.close();
     }
@@ -243,7 +247,8 @@ public class CostBasedDecisionIT extends BaseTest {
         .keyRanges(" [2,*] - [2,9,000]")
         .serverWhereFilter(
           "SERVER FILTER BY ((\"C1\" >= 10 AND \"C1\" <= 20) AND TO_INTEGER(\"C3\") < 5000)")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+        .indexRejectedNone();
 
       PreparedStatement stmt = conn
         .prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2, c3) VALUES (?, ?, ?, ?)");
@@ -262,7 +267,9 @@ public class CostBasedDecisionIT extends BaseTest {
         .scanType("RANGE SCAN").table(indexName1 + "(" + tableName + ")")
         .keyRanges(" [1,10] - [1,20]")
         .serverWhereFilter("SERVER FILTER BY (\"C2\" < 9000 AND \"C3\" < 5000)")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_COST_BASED)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexName2, OptimizerReasons.REASON_COST_BASED_LOSS);
     } finally {
       conn.close();
     }
@@ -292,7 +299,8 @@ public class CostBasedDecisionIT extends BaseTest {
         .keyRanges(" [2,*] - [2,9,000]")
         .serverWhereFilter(
           "SERVER FILTER BY ((\"C1\" >= 10 AND \"C1\" <= 20) AND TO_INTEGER(\"C3\") < 5000)")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+        .indexRejectedNone();
 
       PreparedStatement stmt = conn
         .prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2, c3) VALUES (?, ?, ?, ?)");
@@ -311,7 +319,9 @@ public class CostBasedDecisionIT extends BaseTest {
         .iteratorType("PARALLEL").scanType("RANGE SCAN").table(indexName1 + "(" + tableName + ")")
         .keyRanges(" [1,10] - [1,20]")
         .serverWhereFilter("SERVER FILTER BY (\"C2\" < 9000 AND \"C3\" < 5000)")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_COST_BASED)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexName2, OptimizerReasons.REASON_COST_BASED_LOSS);
     } finally {
       conn.close();
     }
@@ -337,10 +347,14 @@ public class CostBasedDecisionIT extends BaseTest {
       assertPlan(conn, query).abstractExplainPlan("UNION ALL OVER 2 QUERIES").subPlanCount(2)
         .subPlan(0).iteratorType("PARALLEL").scanType("RANGE SCAN").table(tableName)
         .keyRanges(" [*] - ['z']").serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
-        .clientSortAlgo("CLIENT MERGE SORT").end().subPlan(1).iteratorType("PARALLEL")
-        .scanType("RANGE SCAN").table(tableName).keyRanges(" ['a'] - [*]")
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexName, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND).end().subPlan(1)
+        .iteratorType("PARALLEL").scanType("RANGE SCAN").table(tableName).keyRanges(" ['a'] - [*]")
         .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
-        .clientSortAlgo("CLIENT MERGE SORT").end();
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexName, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND).end();
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) VALUES (?, ?, ?)");
@@ -360,12 +374,13 @@ public class CostBasedDecisionIT extends BaseTest {
         .table(indexName + "(" + tableName + ")").keyRanges(" [1]").serverMergeColumns("[0.C2]")
         .serverFirstKeyOnlyProjection(true).serverWhereFilter("SERVER FILTER BY \"ROWKEY\" <= 'z'")
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"C1\"]")
-        .clientSortAlgo("CLIENT MERGE SORT").end().subPlan(1).iteratorType("PARALLEL")
-        .scanType("RANGE SCAN").table(indexName + "(" + tableName + ")").keyRanges(" [1]")
-        .serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
-        .serverWhereFilter("SERVER FILTER BY \"ROWKEY\" >= 'a'")
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_COST_BASED)
+        .indexRejectedNone().end().subPlan(1).iteratorType("PARALLEL").scanType("RANGE SCAN")
+        .table(indexName + "(" + tableName + ")").keyRanges(" [1]").serverMergeColumns("[0.C2]")
+        .serverFirstKeyOnlyProjection(true).serverWhereFilter("SERVER FILTER BY \"ROWKEY\" >= 'a'")
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"C1\"]")
-        .clientSortAlgo("CLIENT MERGE SORT").end();
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_COST_BASED)
+        .indexRejectedNone().end();
     } finally {
       conn.close();
     }
@@ -391,11 +406,14 @@ public class CostBasedDecisionIT extends BaseTest {
       // Use the default plan when stats are not available.
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN").table(tableName)
         .serverWhereFilter("SERVER FILTER BY C1 LIKE 'X0%'")
-        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T1.ROWKEY IN (T2.MRK)").subPlanCount(1)
-        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY T1.ROWKEY IN (T2.MRK)").indexRule(null)
+        .indexRejectedNone().subPlanCount(1).subPlan(0)
+        .abstractExplainPlan("PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
         .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table(tableName)
         .keyRanges(" [*] - ['z']").serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [C1]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexName, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
 
       PreparedStatement stmt =
         conn.prepareStatement("UPSERT INTO " + tableName + " (rowkey, c1, c2) VALUES (?, ?, ?)");
@@ -414,13 +432,15 @@ public class CostBasedDecisionIT extends BaseTest {
         .table(indexName + "(" + tableName + ")").keyRanges(" [1,'X0'] - [1,'X1']")
         .serverMergeColumns("[0.C2]").serverFirstKeyOnlyProjection(true)
         .serverSortedBy("[\"T1.:ROWKEY\"]").clientSortAlgo("CLIENT MERGE SORT")
-        .dynamicServerFilter("DYNAMIC SERVER FILTER BY \"T1.:ROWKEY\" IN (T2.MRK)").subPlanCount(1)
-        .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
+        .dynamicServerFilter("DYNAMIC SERVER FILTER BY \"T1.:ROWKEY\" IN (T2.MRK)").indexRule(null)
+        .indexRejectedNone().subPlanCount(1).subPlan(0)
+        .abstractExplainPlan("PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
         .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
         .table(indexName + "(" + tableName + ")").keyRanges(" [1]").serverMergeColumns("[0.C2]")
         .serverFirstKeyOnlyProjection(true).serverWhereFilter("SERVER FILTER BY \"ROWKEY\" <= 'z'")
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"C1\"]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_COST_BASED)
+        .indexRejectedNone();
     } finally {
       conn.close();
     }
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/InListIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/InListIT.java
index ccbd52caf0..8b3541e73c 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/InListIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/InListIT.java
@@ -53,6 +53,7 @@ import org.apache.phoenix.compile.StatementContext;
 import org.apache.phoenix.expression.Expression;
 import org.apache.phoenix.iterate.ExplainTable;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.parse.ColumnParseNode;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.QueryServices;
@@ -1024,13 +1025,15 @@ public class InListIT extends ParallelStatsDisabledIT {
         try (PreparedStatement preparedStmt = viewConn.prepareStatement("SELECT * FROM "
           + tenantView + " WHERE (ID1, ID2) " + "IN (('005xx000001Sv6o', '000000000000500'))")) {
           assertPlan(PhoenixRuntime.getOptimizedQueryPlan(preparedStmt))
-            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+            .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
         }
 
         try (PreparedStatement preparedStmt = viewConn.prepareStatement("SELECT * FROM "
           + tenantView + " WHERE (ID2, ID1) " + "IN (('000000000000500', '005xx000001Sv6o'))")) {
           assertPlan(PhoenixRuntime.getOptimizedQueryPlan(preparedStmt))
-            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+            .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
         }
 
         stmt.execute(
@@ -1065,14 +1068,16 @@ public class InListIT extends ParallelStatsDisabledIT {
           + tenantView + " WHERE (ID1, ID2) " + "IN (('005xx000001Sv6o', '000000000000500'),"
           + "('bar', '000000000000400')," + "('foo', '000000000000300'))")) {
           assertPlan(PhoenixRuntime.getOptimizedQueryPlan(preparedStmt))
-            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+            .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
         }
 
         try (PreparedStatement preparedStmt =
           viewConn.prepareStatement("SELECT * FROM " + tenantView + " WHERE (ID2, ID1) IN "
             + "(('bar', '005xx000001Sv6o')," + "('foo', '005xx000001Sv6o'))")) {
           assertPlan(PhoenixRuntime.getOptimizedQueryPlan(preparedStmt))
-            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+            .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
         }
 
         stmt.execute("DELETE FROM " + tenantView + " WHERE (ID2, ID1) IN "
@@ -1111,14 +1116,16 @@ public class InListIT extends ParallelStatsDisabledIT {
             + "(('005xx000001Sv6o', '000000000000500')," + "('005xx000001Sv6o', '000000000000400'),"
             + "('005xx000001Sv6o', '000000000000300'))")) {
           assertPlan(PhoenixRuntime.getOptimizedQueryPlan(preparedStmt))
-            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+            .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
         }
 
         try (PreparedStatement preparedStmt = viewConn.prepareStatement("SELECT * FROM "
           + tenantView + " WHERE (ID2, ID1) IN " + "(('000000000000400', '005xx000001Sv6o'),"
           + "('000000000000300', '005xx000001Sv6o'))")) {
           assertPlan(PhoenixRuntime.getOptimizedQueryPlan(preparedStmt))
-            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+            .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+            .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
         }
         ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + tenantView
           + " WHERE (ID2, ID1) IN " + "(('000000000000400', '005xx000001Sv6o'),"
@@ -1159,13 +1166,15 @@ public class InListIT extends ParallelStatsDisabledIT {
           + "(('005xx000001Sv6o', '000000000000500')," + "('005xx000001Sv6o', '000000000000400'))");
       QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
       assertEquals(numberOfRowsToScan, queryPlan.getEstimatedRowsToScan());
-      assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+      assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+        .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
       viewConn.prepareStatement("DELETE FROM " + tenantView + " WHERE (ID1, ID2) IN "
         + "(('005xx000001Sv6o', '000000000000500')," + "('005xx000001Sv6o', '000000000000400'))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
       assertEquals(numberOfRowsToScan, queryPlan.getEstimatedRowsToScan());
-      assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+      assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+        .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
     }
   }
 
@@ -1174,22 +1183,26 @@ public class InListIT extends ParallelStatsDisabledIT {
       PreparedStatement preparedStmt = viewConn.prepareStatement("SELECT * FROM " + tenantView
         + " WHERE (ID1) IN " + "(('005xx000001Sv6o')," + "('005xx000001Sv6o'))");
       QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       viewConn.prepareStatement("DELETE FROM " + tenantView + " WHERE (ID1) IN "
         + "(('005xx000001Sv6o')," + "('005xx000001Sv6o'))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       preparedStmt = viewConn.prepareStatement("SELECT * FROM " + tenantView + " WHERE (ID2) IN "
         + "(('000000000000500')," + "('000000000000400'))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       viewConn.prepareStatement("DELETE FROM " + tenantView + " WHERE (ID2) IN "
         + "(('000000000000500')," + "('000000000000400'))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     }
   }
 
@@ -1198,22 +1211,26 @@ public class InListIT extends ParallelStatsDisabledIT {
       PreparedStatement preparedStmt = viewConn.prepareStatement("SELECT * FROM " + tenantView
         + " WHERE (ID1, ID3) IN " + "(('005xx000001Sv6o', 1)," + "('005xx000001Sv6o', 2))");
       QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       viewConn.prepareStatement("DELETE FROM " + tenantView + " WHERE (ID1, ID3) IN "
         + "(('005xx000001Sv6o', 1)," + "('005xx000001Sv6o', 2))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       preparedStmt = viewConn.prepareStatement("SELECT * FROM " + tenantView
         + " WHERE (ID2, ID3) IN " + "(('000000000000500', 1)," + "('000000000000400', 2))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       viewConn.prepareStatement("DELETE FROM " + tenantView + " WHERE (ID2, ID3) IN "
         + "(('000000000000500', 1)," + "('000000000000400', 2))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     }
   }
 
@@ -1223,12 +1240,14 @@ public class InListIT extends ParallelStatsDisabledIT {
       PreparedStatement preparedStmt = viewConn.prepareStatement(
         "SELECT * FROM " + tenantView + " WHERE (ID3, ID4) IN " + "((1, 1)," + "(2, 2))");
       QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       viewConn.prepareStatement(
         "DELETE FROM " + tenantView + " WHERE (ID3, ID4) IN " + "((1, 1)," + "(2, 2))");
       queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+      assertPlan(queryPlan).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     }
   }
 
@@ -1730,19 +1749,22 @@ public class InListIT extends ParallelStatsDisabledIT {
           "SELECT * FROM " + view + " WHERE (ID1, ID2) IN " + "((1, 1)," + "(2, 2))");
         QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
         assertEquals(new Long(2), queryPlan.getEstimatedRowsToScan());
-        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+          .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
         preparedStmt =
           conn.prepareStatement("SELECT * FROM " + view + " WHERE (ID2, ID1) IN ((1, 1),(2, 2))");
         queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
         assertEquals(new Long(2), queryPlan.getEstimatedRowsToScan());
-        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+          .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
         preparedStmt =
           conn.prepareStatement("SELECT * FROM " + view + " WHERE (ID2, ID1) IN ((1, 1),(2, 2))");
         queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
         assertEquals(new Long(2), queryPlan.getEstimatedRowsToScan());
-        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+          .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
         ResultSet rs = stmt.executeQuery("SELECT ID1, ID2, ID5, ID4 FROM " + view
           + " WHERE (POWER(ID2, 2), ID1) IN " + "((4.0, 9)," + "(10, 12))");
@@ -1788,19 +1810,22 @@ public class InListIT extends ParallelStatsDisabledIT {
         PreparedStatement preparedStmt = conn.prepareStatement(
           "SELECT * FROM " + view + " WHERE (ID4, ID2) IN " + "((1, 1)," + "(2, 2))");
         QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
-        assertPlan(queryPlan).scanType("RANGE SCAN");
+        assertPlan(queryPlan).scanType("RANGE SCAN").indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE)
+          .indexRejectedCount(1);
 
         preparedStmt = conn.prepareStatement(
           "SELECT ID1,ID5 FROM " + view + " WHERE (ID1, ID2) IN " + "((1, 1),(2, 2))");
         queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
         assertEquals(new Long(2), queryPlan.getEstimatedRowsToScan());
-        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+          .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
         preparedStmt =
           conn.prepareStatement("SELECT * FROM " + view + " WHERE (ID2, ID1) IN ((1, 1),(2, 2))");
         queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
         assertEquals(new Long(2), queryPlan.getEstimatedRowsToScan());
-        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+        assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+          .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
         ResultSet rs = stmt.executeQuery("SELECT ID1, ID2, ID5, ID4 FROM " + view
           + " WHERE (POWER(ID2, 2), ID1) IN " + "((4.0, 9)," + "(10, 12))");
@@ -1839,7 +1864,8 @@ public class InListIT extends ParallelStatsDisabledIT {
         "SELECT VAL2 FROM " + fullTableName + " WHERE (ID2, ID1) IN ((1, 1),(2, 2))");
       QueryPlan queryPlan = PhoenixRuntime.getOptimizedQueryPlan(preparedStmt);
       queryPlan.getTableRef().getTable().getType();
-      assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
+      assertPlan(queryPlan).scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING)
+        .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
     }
   }
 
@@ -2181,6 +2207,8 @@ public class InListIT extends ParallelStatsDisabledIT {
       int lastBoundCol = 0;
       setBindVariables(stmt, lastBoundCol, numInLists, testPKTypes);
       QueryPlan plan = stmt.compileQuery(query.toString());
+      // NOTE: compileQuery() does not run the QueryOptimizer, so the plan carries no
+      // OptimizerDecision (indexRule is null); intentionally no indexRule assertion here.
       if (expectSkipScan) {
         assertPlan(plan).iteratorType("PARALLEL 1-WAY")
           .scanTypeStartsWith(ExplainTable.POINT_LOOKUP_ON_STRING);
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/ReverseScanIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/ReverseScanIT.java
index c1bab87569..5d87cddd23 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/ReverseScanIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/ReverseScanIT.java
@@ -38,6 +38,7 @@ import java.sql.ResultSet;
 import java.sql.Statement;
 import java.util.Properties;
 import org.apache.hadoop.hbase.util.Bytes;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.util.PropertiesUtil;
 import org.junit.Test;
 import org.junit.experimental.categories.Category;
@@ -81,7 +82,8 @@ public class ReverseScanIT extends ParallelStatsDisabledIT {
 
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").clientSortedBy("REVERSE")
         .scanType("FULL SCAN").table(tableName).serverFirstKeyOnlyProjection(true)
-        .serverWhereFilter("SERVER FILTER BY ENTITY_ID >= '00A323122312312'");
+        .serverWhereFilter("SERVER FILTER BY ENTITY_ID >= '00A323122312312'")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       PreparedStatement statement = conn.prepareStatement("SELECT entity_id FROM " + tableName
         + " WHERE organization_id = ? AND entity_id >= ? ORDER BY organization_id DESC, entity_id DESC");
@@ -179,7 +181,8 @@ public class ReverseScanIT extends ParallelStatsDisabledIT {
 
       assertPlan(conn, query).iteratorType("SERIAL 1-WAY").clientSortedBy("REVERSE")
         .scanType("RANGE SCAN").table(indexName).keyRanges(" [not null]")
-        .serverFirstKeyOnlyProjection(true).serverRowLimit(1L).clientRowLimit(1);
+        .serverFirstKeyOnlyProjection(true).serverRowLimit(1L).clientRowLimit(1)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
     }
 
   }
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
index 000dad55ab..84f6131ec6 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/TenantSpecificViewIndexIT.java
@@ -37,6 +37,7 @@ import org.apache.hadoop.hbase.util.Bytes;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.ColumnNotFoundException;
 import org.apache.phoenix.schema.PNameFactory;
@@ -367,7 +368,8 @@ public class TenantSpecificViewIndexIT extends BaseTenantSpecificViewIndexIT {
         .keyRanges(" ['tenant1        ','001','2011-01-01 00:00:00.001']"
           + " - ['tenant1        ','001','2016-10-31 00:00:00.000']")
         .serverFirstKeyOnlyProjection(true).serverRowLimit(501L).clientRowLimit(501)
-        .clientSteps("CLIENT 501 ROW LIMIT");
+        .clientSteps("CLIENT 501 ROW LIMIT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
 
       String query2 = "SELECT PARENT_ID FROM " + viewName + " WHERE PARENT_TYPE='001' "
         + " AND (CREATED_DATE >= to_date('2011-01-01') AND CREATED_DATE <= to_date('2016-01-01'))"
@@ -378,7 +380,8 @@ public class TenantSpecificViewIndexIT extends BaseTenantSpecificViewIndexIT {
         .keyRanges(" ['tenant1        ','001','2012-10-21 00:00:00.001']"
           + " - ['tenant1        ','001','2016-01-01 00:00:00.000']")
         .serverFirstKeyOnlyProjection(true).serverRowLimit(501L).clientRowLimit(501)
-        .clientSteps("CLIENT 501 ROW LIMIT");
+        .clientSteps("CLIENT 501 ROW LIMIT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
     }
   }
 
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
index f0bcbab7ee..08471a9e5b 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ChildViewsUseParentViewIndexIT.java
@@ -32,6 +32,7 @@ import java.util.Properties;
 import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.schema.PTable;
 import org.junit.Test;
 import org.junit.experimental.categories.Category;
@@ -167,7 +168,8 @@ public class ChildViewsUseParentViewIndexIT extends ParallelStatsDisabledIT {
     assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").serverFirstKeyOnlyProjection(true)
       .scanType("SKIP SCAN ON 3 KEYS").table("_IDX_" + baseTableName)
       .keyRanges(" [" + Short.MIN_VALUE + ",'1'" + childViewScanKey + "] - [" + Short.MIN_VALUE
-        + ",'3'" + childViewScanKey + "]");
+        + ",'3'" + childViewScanKey + "]")
+      .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
 
     ResultSet rs = conn.createStatement().executeQuery(sql);
     assertTrue(rs.next());
@@ -189,7 +191,8 @@ public class ChildViewsUseParentViewIndexIT extends ParallelStatsDisabledIT {
     assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY")
       .serverWhereFilter(
         "SERVER FILTER BY (A4 IN ('1','2','3') AND ((A1 = 'X' AND A2 = 'Y') AND A3 = 'Z'))")
-      .scanType("FULL SCAN").table(baseTableName);
+      .scanType("FULL SCAN").table(baseTableName).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone();
 
     ResultSet rs = conn.createStatement().executeQuery(sql);
     assertTrue(rs.next());
@@ -264,7 +267,8 @@ public class ChildViewsUseParentViewIndexIT extends ParallelStatsDisabledIT {
     assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").serverFirstKeyOnlyProjection(true)
       .scanType("SKIP SCAN ON 5 RANGES").table("_IDX_" + baseTableName)
       .keyRanges(" [" + Short.MIN_VALUE + ",'00Dxxxxxxxxxxx1','003xxxxxxxxxxx1',*] - ["
-        + Short.MIN_VALUE + ",'00Dxxxxxxxxxxx1','003xxxxxxxxxxx5',~'2016-01-01 06:00:00.000']");
+        + Short.MIN_VALUE + ",'00Dxxxxxxxxxxx1','003xxxxxxxxxxx5',~'2016-01-01 06:00:00.000']")
+      .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
 
     ResultSet rs = conn.createStatement().executeQuery(sql);
     for (int i = 0; i < expectedRows; ++i) {
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
index 31e55b55d3..f0ffe1fd9f 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexCheckerIT.java
@@ -60,6 +60,7 @@ import org.apache.phoenix.end2end.IndexToolIT;
 import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
 import org.apache.phoenix.hbase.index.IndexRegionObserver;
 import org.apache.phoenix.mapreduce.index.IndexTool;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.PTableImpl;
@@ -143,9 +144,15 @@ public class GlobalIndexCheckerIT extends BaseTest {
 
   public static void assertExplainPlan(Connection conn, String selectSql, String dataTableFullName,
     String indexTableFullName) throws SQLException {
+    assertExplainPlan(conn, selectSql, dataTableFullName, indexTableFullName,
+      OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS);
+  }
+
+  public static void assertExplainPlan(Connection conn, String selectSql, String dataTableFullName,
+    String indexTableFullName, String expectedRule) throws SQLException {
     // Verify the query is served by a RANGE SCAN over the index table not the data table.
     ExplainPlanAttributes attributes = getExplainAttributes(conn, selectSql);
-    assertPlan(attributes).scanType("RANGE SCAN");
+    assertPlan(attributes).scanType("RANGE SCAN").indexRule(expectedRule);
     String actualTable =
       attributes.getTableName() == null ? null : attributes.getTableName().replaceAll(":", ".");
     String expectedTable = SchemaUtil.normalizeIdentifier(indexTableFullName);
@@ -287,7 +294,9 @@ public class GlobalIndexCheckerIT extends BaseTest {
         + dataTableName + " WHERE val1 = 'bc' AND " + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('"
         + after.toString() + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
       // Verify that we will read from the data table
-      assertPlan(conn, noIndexQuery).scanType("FULL SCAN").table(dataTableName);
+      assertPlan(conn, noIndexQuery).scanType("FULL SCAN").table(dataTableName)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedCount(1)
+        .indexRejected(0, indexTableName, OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
       rs = conn.createStatement().executeQuery(noIndexQuery);
       assertTrue(rs.next());
       assertEquals("bc", rs.getString(1));
@@ -374,7 +383,7 @@ public class GlobalIndexCheckerIT extends BaseTest {
         + "PHOENIX_ROW_TIMESTAMP() > TO_DATE('" + initial.toString()
         + "','yyyy-MM-dd HH:mm:ss.SSS', '" + timeZoneID + "')";
 
-      assertExplainPlan(conn, query, dataTableName, indexTableName);
+      assertExplainPlan(conn, query, dataTableName, indexTableName, OptimizerReasons.RULE_HINT);
       waitForEventualConsistency();
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -746,7 +755,8 @@ public class GlobalIndexCheckerIT extends BaseTest {
 
       // now read the same row from data table
       selectSql = "SELECT * from " + dataTableName + " WHERE id  = 'a'";
-      assertPlan(conn, selectSql).table(dataTableName);
+      assertPlan(conn, selectSql).table(dataTableName).indexRule(OptimizerReasons.RULE_POINT_LOOKUP)
+        .indexRejectedNone();
       try (ResultSet rs = conn.createStatement().executeQuery(selectSql)) {
         assertTrue(rs.next());
         assertEquals("a", rs.getString(1));
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexOptimizationIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexOptimizationIT.java
index 39800cf2c4..6fd1de6f90 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexOptimizationIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/GlobalIndexOptimizationIT.java
@@ -30,6 +30,7 @@ import java.sql.SQLException;
 import java.util.regex.Pattern;
 import org.apache.phoenix.end2end.NeedsOwnMiniClusterTest;
 import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.explain.ExplainPlanTestUtil.ExplainPlanAssert;
 import org.apache.phoenix.util.PhoenixRuntime;
 import org.apache.phoenix.util.SchemaUtil;
@@ -83,7 +84,9 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       assertMutationPlan(conn1, query).abstractExplainPlan("DELETE ROWS CLIENT SELECT")
         .scanType("RANGE SCAN").table(indexTableName + "L(" + dataTableName + ")")
         .keyRanges(" [1,*] - [1,100]").serverFirstKeyOnlyProjection(true)
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexTableName + "G", OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
       ResultSet rs = conn1.createStatement().executeQuery("SELECT COUNT(*) FROM " + dataTableName);
       rs.next();
       int count = rs.getInt(1);
@@ -154,7 +157,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       String query = "SELECT /*+ INDEX(" + dataTableName + " " + indexTableName + ")*/ * FROM "
         + dataTableName + " where v1='a'";
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName).keyRanges(" ['a']")
-        .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true);
+        .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
 
       ResultSet rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -172,7 +176,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       query = "SELECT /*+ INDEX(" + dataTableName + " " + indexTableName + ")*/ * FROM "
         + dataTableName + " where v1='a'";
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName).keyRanges(" ['a']")
-        .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true);
+        .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -193,7 +198,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
         + dataTableName + " where v1='a' limit 1";
       assertPlan(conn1, query).iteratorType("SERIAL").scanType("RANGE SCAN").table(indexTableName)
         .keyRanges(" ['a']").serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .serverRowLimit(1L).clientRowLimit(1);
+        .serverRowLimit(1L).clientRowLimit(1).indexRule(OptimizerReasons.RULE_HINT)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -209,7 +215,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
         + "  where v1<='z' and k3 > 1 order by V1,t_id";
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName)
         .keyRanges(" [*] - ['z']").serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .serverWhereFilter("SERVER FILTER BY \"K3\" > 1");
+        .serverWhereFilter("SERVER FILTER BY \"K3\" > 1").indexRule(OptimizerReasons.RULE_HINT)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -239,11 +246,13 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       ExplainPlanAssert skipScanJoinPlan = assertPlan(conn1, query).scanType("FULL SCAN")
         .table(dataTableName).serverWhereFilter("SERVER FILTER BY K3 > 1")
         .serverSortedBy("[" + dataTableName + ".V1, " + dataTableName + ".T_ID]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_HINT)
+        .indexRejectedNone();
       skipScanJoinPlan.subPlanCount(1).subPlan(0)
         .abstractExplainPlan("SKIP-SCAN-JOIN TABLE 0  /* HASH BUILD RIGHT */")
         .scanType("RANGE SCAN").table(indexTableName).keyRanges(" [*] - ['z']")
-        .serverFirstKeyOnlyProjection(true).end();
+        .serverFirstKeyOnlyProjection(true).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+        .indexRejectedNone().end();
       // The dynamic server filter references compiler-generated positional aliases ($N.$N) whose
       // numbers are not stable, so match the structural shape of the attribute with a regex.
       String dynamicServerFilter = skipScanJoinPlan.attributes().getDynamicServerFilter();
@@ -282,7 +291,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName)
         .keyRanges(" [*] - ['z']").serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
         .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [\"V1\", \"T_ID\", \"K3\"]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_HINT)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -309,7 +319,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
 
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName)
         .keyRanges(" [*] - ['z']").serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"V1\"]");
+        .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"V1\"]")
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -342,7 +353,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       String query = "SELECT /*+ INDEX(" + dataTableName + " " + indexTableName
         + ")*/ k1,k2,k3,v1 FROM " + dataTableName + " where v1='a'";
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName)
-        .keyRanges(" ['tid1','a']").serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true);
+        .keyRanges(" ['tid1','a']").serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
 
       ResultSet rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -395,7 +407,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
 
       assertPlan(conn1, query).scanType("SKIP SCAN ON 2 KEYS").table("_IDX_" + dataTableName)
         .keyRanges(" [" + Short.MIN_VALUE + ",1] - [" + Short.MIN_VALUE + ",2]")
-        .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true);
+        .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -433,7 +446,8 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       String query = "SELECT /*+ INDEX(" + dataTableName + " " + indexTableName
         + ")*/ t_id, k1, k2, V1 FROM " + dataTableName + " where v1='a'";
       assertPlan(conn1, query).scanType("RANGE SCAN").table(indexTableName).keyRanges(" ['a']")
-        .serverFirstKeyOnlyProjection(true);
+        .serverFirstKeyOnlyProjection(true).indexRule(OptimizerReasons.RULE_HINT)
+        .indexRejectedNone();
 
       ResultSet rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -449,7 +463,9 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       // No INDEX hint specified
       query = "SELECT t_id, k1, k2, k3, V1 FROM " + dataTableName + " where v1='a'";
       assertPlan(conn1, query).scanType("FULL SCAN").table(dataTableName)
-        .serverWhereFilter("SERVER FILTER BY V1 = 'a'");
+        .serverWhereFilter("SERVER FILTER BY V1 = 'a'")
+        .indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE).indexRejectedCount(1)
+        .indexRejected(0, indexTableName, OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -467,7 +483,9 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
       // No where clause
       query = "SELECT  t_id, k1, k2, k3, V1 from " + dataTableFullName + " order by V1,t_id";
       assertPlan(conn1, query).scanType("FULL SCAN").table(dataTableName)
-        .serverSortedBy("[V1, T_ID]").clientSortAlgo("CLIENT MERGE SORT");
+        .serverSortedBy("[V1, T_ID]").clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE).indexRejectedCount(1)
+        .indexRejected(0, indexTableName, OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -501,7 +519,9 @@ public class GlobalIndexOptimizationIT extends ParallelStatsDisabledIT {
         "SELECT t_id, k1, k2, k3, V1 from " + dataTableFullName + "  where k3 > 1 order by V1,t_id";
       assertPlan(conn1, query).scanType("FULL SCAN").table(dataTableName)
         .serverWhereFilter("SERVER FILTER BY K3 > 1").serverSortedBy("[V1, T_ID]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE)
+        .indexRejectedCount(1)
+        .indexRejected(0, indexTableName, OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java
index f1fbc4175c..74826e9f65 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/IndexUsageIT.java
@@ -38,6 +38,7 @@ import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.exception.SQLExceptionCode;
 import org.apache.phoenix.execute.CommitException;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.query.explain.ExplainPlanTestUtil;
 import org.apache.phoenix.util.DateUtil;
@@ -133,7 +134,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, groupBySql)
         .iteratorType("PARALLEL 1-WAY").serverFirstKeyOnlyProjection(true)
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY "
-          + "[TO_BIGINT(\"(A.INT_COL1 + B.INT_COL2)\")]");
+          + "[TO_BIGINT(\"(A.INT_COL1 + B.INT_COL2)\")]")
+        .indexRule(OptimizerReasons.RULE_ORDER_PRESERVING).indexRejectedNone();
       if (localIndex) {
         basePlan.scanType("RANGE SCAN")
           .table("INDEX_TEST." + indexName + "(" + fullDataTableName + ")")
@@ -189,7 +191,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
         .serverDistinctFilter(
           "SERVER DISTINCT PREFIX FILTER OVER " + "[TO_BIGINT(\"(A.INT_COL1 + 1)\")]")
         .serverAggregate(
-          "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY " + "[TO_BIGINT(\"(A.INT_COL1 + 1)\")]");
+          "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY " + "[TO_BIGINT(\"(A.INT_COL1 + 1)\")]")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       if (localIndex) {
         basePlan.table("INDEX_TEST." + indexName + "(" + fullDataTableName + ")")
           .keyRanges(" [1,0] - [1,*]").clientSortAlgo("CLIENT MERGE SORT");
@@ -243,7 +246,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       String sql = "SELECT int_col1+1 FROM " + fullDataTableName + " where int_col1+1 IN (2)";
 
       ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, sql)
-        .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").serverFirstKeyOnlyProjection(true);
+        .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       if (localIndex) {
         basePlan.table("INDEX_TEST." + indexName + "(" + fullDataTableName + ")")
           .keyRanges(" [1,2]").clientSortAlgo("CLIENT MERGE SORT");
@@ -296,7 +300,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       String sql = "SELECT int_col1+1 AS foo FROM " + fullDataTableName + " ORDER BY foo";
 
       ExplainPlanTestUtil.ExplainPlanAssert basePlan =
-        assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").serverFirstKeyOnlyProjection(true);
+        assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").serverFirstKeyOnlyProjection(true)
+          .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       if (localIndex) {
         basePlan.scanType("RANGE SCAN")
           .table("INDEX_TEST." + indexName + "(" + fullDataTableName + ")").keyRanges(" [1]")
@@ -370,7 +375,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       query = "SELECT (\"V1\" || '_' || \"v2\"), k, \"V1\", \"v2\"  FROM " + dataTableName
         + " WHERE (\"V1\" || '_' || \"v2\") = 'x_1'";
       ExplainPlanTestUtil.ExplainPlanAssert basePlan =
-        assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+        assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+          .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       if (localIndex) {
         basePlan.table(indexName + "(" + dataTableName + ")").keyRanges(" [1,'x_1']")
           .clientSortAlgo("CLIENT MERGE SORT");
@@ -394,7 +400,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       query =
         "SELECT \"V1\", \"V1\" as foo1, (\"V1\" || '_' || \"v2\") as foo, (\"V1\" || '_' || \"v2\") as \"Foo1\", (\"V1\" || '_' || \"v2\") FROM "
           + dataTableName + " ORDER BY foo";
-      basePlan = assertPlan(conn, query).iteratorType("PARALLEL 1-WAY");
+      basePlan = assertPlan(conn, query).iteratorType("PARALLEL 1-WAY")
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       if (localIndex) {
         basePlan.scanType("RANGE SCAN").table(indexName + "(" + dataTableName + ")")
           .keyRanges(" [1]").clientSortAlgo("CLIENT MERGE SORT");
@@ -474,10 +481,13 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         basePlan.scanType("RANGE SCAN")
           .table("INDEX_TEST." + indexName + "(" + fullDataTableName + ")").keyRanges(" [1,2]")
-          .clientSortAlgo("CLIENT MERGE SORT").serverFirstKeyOnlyProjection(true);
+          .clientSortAlgo("CLIENT MERGE SORT").serverFirstKeyOnlyProjection(true)
+          .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       } else {
         basePlan.scanType("FULL SCAN").table(fullDataTableName).clientSortAlgo(null)
-          .serverWhereFilter("SERVER FILTER BY (A.INT_COL1 + 1) = 2");
+          .serverWhereFilter("SERVER FILTER BY (A.INT_COL1 + 1) = 2")
+          .indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE).indexRejectedCount(1)
+          .indexRejected(0, indexName, OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
       }
 
       ResultSet rs = conn.createStatement().executeQuery(sql);
@@ -526,7 +536,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       String query = "SELECT k1, k2, k3, s1, s2 FROM " + viewName + " WHERE k1+k2+k3 = 173.0";
       ExplainPlanTestUtil.ExplainPlanAssert basePlan =
-        assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN");
+        assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
+          .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       if (local) {
         basePlan.table(indexName1 + "(" + dataTableName + ")").keyRanges(" [1,173]")
           .clientSortAlgo("CLIENT MERGE SORT");
@@ -549,7 +560,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
 
       query = "SELECT k1, k2, s1||'_'||s2 FROM " + viewName + " WHERE (s1||'_'||s2)='foo2_bar2'";
       basePlan = assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
-        .serverFirstKeyOnlyProjection(true);
+        .serverFirstKeyOnlyProjection(true).indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedCount(1);
       if (local) {
         basePlan.table(indexName2 + "(" + dataTableName + ")")
           .keyRanges(" [" + (2) + ",'foo2_bar2']").clientSortAlgo("CLIENT MERGE SORT");
@@ -612,7 +624,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
         "SELECT s2||'_'||s3 FROM " + viewName + " WHERE k2=1 AND (s2||'_'||s3)='abc_cab'";
 
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
-        .table(indexName2).keyRanges(" [1,'abc_cab','foo']").serverFirstKeyOnlyProjection(true);
+        .table(indexName2).keyRanges(" [1,'abc_cab','foo']").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedCount(1);
 
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -622,8 +635,10 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       conn.createStatement().execute("ALTER VIEW " + viewName + " DROP COLUMN s4");
       // i2 cannot be used since s4 has been dropped from the view, so i1 will be used
       assertPlan(conn, query).scanType("RANGE SCAN").tableContains(indexName1).keyRanges(" [1]")
-        .serverFirstKeyOnlyProjection(true).serverWhereFilter(
-          "SERVER FILTER BY ((\"S2\" || '_' || \"S3\") = 'abc_cab' AND \"S1\" = 'foo')");
+        .serverFirstKeyOnlyProjection(true)
+        .serverWhereFilter(
+          "SERVER FILTER BY ((\"S2\" || '_' || \"S3\") = 'abc_cab' AND \"S1\" = 'foo')")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals("abc_cab", rs.getString(1));
@@ -708,7 +723,8 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       query = "SELECT k FROM " + dataTableName + " WHERE REGEXP_SUBSTR(v,'id:\\\\w+') = 'id:id1'";
 
       ExplainPlanTestUtil.ExplainPlanAssert basePlan = assertPlan(conn, query)
-        .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").serverFirstKeyOnlyProjection(true);
+        .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       if (localIndex) {
         basePlan.table(indexName + "(" + dataTableName + ")").keyRanges(" [1,'id:id1']")
           .clientSortAlgo("CLIENT MERGE SORT");
@@ -779,17 +795,19 @@ public class IndexUsageIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).scanType("RANGE SCAN")
           .tableContains(indexName + "(" + tableName + ")").keyRanges(" [1,'1David']")
-          .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT").subPlanCount(1)
-          .subPlan(0).scanType("RANGE SCAN").tableContains(indexName + "(" + tableName + ")")
-          .keyRanges(" [1]").serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+          .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT").indexRule(null)
+          .indexRejectedNone().subPlanCount(1).subPlan(0).scanType("RANGE SCAN")
+          .tableContains(indexName + "(" + tableName + ")").keyRanges(" [1]")
+          .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
           .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT, SKIP MERGE */")
-          .end();
+          .indexRule(null).indexRejectedNone().end();
       } else {
         assertPlan(conn, query).scanType("RANGE SCAN").tableContains(indexName)
-          .keyRanges(" ['1David']").serverFirstKeyOnlyProjection(true).subPlanCount(1).subPlan(0)
-          .scanType("FULL SCAN").tableContains(indexName).serverFirstKeyOnlyProjection(true)
+          .keyRanges(" ['1David']").serverFirstKeyOnlyProjection(true).indexRule(null)
+          .indexRejectedNone().subPlanCount(1).subPlan(0).scanType("FULL SCAN")
+          .tableContains(indexName).serverFirstKeyOnlyProjection(true)
           .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT, SKIP MERGE */")
-          .end();
+          .indexRule(null).indexRejectedNone().end();
       }
 
       rs = conn.createStatement().executeQuery(query);
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/LocalIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/LocalIndexIT.java
index 02c38fe89e..1c099561a9 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/LocalIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/LocalIndexIT.java
@@ -64,6 +64,7 @@ import org.apache.phoenix.hbase.index.IndexRegionSplitPolicy;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
 import org.apache.phoenix.jdbc.PhoenixStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.QueryConstants;
 import org.apache.phoenix.schema.PNameFactory;
 import org.apache.phoenix.schema.PTable;
@@ -437,18 +438,23 @@ public class LocalIndexIT extends BaseLocalIndexIT {
     // 1. same prefix length, no other restrictions, but v3 is in the SELECT. Use the main table.
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE pk1 = 3 AND pk2 = 4")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table(physicalTableName.toString())
-      .keyRanges(" [3,4]");
+      .keyRanges(" [3,4]").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+      .indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
     // 2. same prefix length, no other restrictions. Only index columns used. Use the index.
     assertPlan(conn, "SELECT v2 FROM " + tableName + " WHERE pk1 = 3 AND pk2 = 4")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
       .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,3,4]")
-      .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT");
+      .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
     // 3. same prefix length, but there's a column not on the index
     assertPlan(conn, "SELECT v2 FROM " + tableName + " WHERE pk1 = 3 AND pk2 = 4 AND v3 = 1")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table(physicalTableName.toString())
-      .keyRanges(" [3,4]").serverWhereFilter("SERVER FILTER BY V3 = 1");
+      .keyRanges(" [3,4]").serverWhereFilter("SERVER FILTER BY V3 = 1")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
     // 4. Longer prefix on the index, use it.
     assertPlan(conn,
@@ -456,7 +462,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
         .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,3,4,3]")
         .serverMergeColumns("[0.V3]").serverFirstKeyOnlyProjection(true)
-        .serverWhereFilter("SERVER FILTER BY \"V3\" = 1").clientSortAlgo("CLIENT MERGE SORT");
+        .serverWhereFilter("SERVER FILTER BY \"V3\" = 1").clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
   }
 
   @Test
@@ -486,7 +493,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       .iteratorType("PARALLEL").scanType("RANGE SCAN")
       .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,2,3]")
       .serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .indexRejectedNone();
   }
 
   @Test
@@ -513,45 +521,57 @@ public class LocalIndexIT extends BaseLocalIndexIT {
     assertPlan(conn, "SELECT COUNT(*) FROM " + tableName).iteratorType("PARALLEL 1-WAY")
       .scanType("RANGE SCAN").table(fullIndexName + "(" + indexPhysicalTableName + ")")
       .keyRanges(" [1]").serverFirstKeyOnlyProjection(true)
-      .serverAggregate("SERVER AGGREGATE INTO SINGLE ROW");
+      .serverAggregate("SERVER AGGREGATE INTO SINGLE ROW")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
     // 2. All column projected, no filtering by indexed column, not using the index
     assertPlan(conn, "SELECT * FROM " + tableName).iteratorType("PARALLEL 1-WAY")
-      .scanType("FULL SCAN").table(physicalTableName.toString());
+      .scanType("FULL SCAN").table(physicalTableName.toString())
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
     // 3. if the index can avoid a sort operation, use it
     assertPlan(conn, "SELECT * FROM " + tableName + " ORDER BY v2").iteratorType("PARALLEL 1-WAY")
       .scanType("RANGE SCAN").table(fullIndexName + "(" + indexPhysicalTableName + ")")
       .keyRanges(" [1]").serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+      .indexRejectedNone();
 
     // 4. but can't use the index if not ORDERing by a prefix of the index key.
     assertPlan(conn, "SELECT * FROM " + tableName + " ORDER BY v3").iteratorType("PARALLEL 1-WAY")
       .scanType("FULL SCAN").table(physicalTableName.toString()).serverSortedBy("[V3]")
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+      .indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
     // 5. If we pin the prefix of the index key we use the index avoiding sorting on the postfix
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v2 = 2 ORDER BY v3")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
       .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,2]")
       .serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .indexRejectedNone();
 
     // 6. Filtering by a non-indexed column will not use the index
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v1 = 3").iteratorType("PARALLEL 1-WAY")
       .scanType("FULL SCAN").table(physicalTableName.toString())
-      .serverWhereFilter("SERVER FILTER BY V1 = 3.0");
+      .serverWhereFilter("SERVER FILTER BY V1 = 3.0")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
     // 7. Also don't use an index if not filtering on a prefix of the key
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v3 = 1").iteratorType("PARALLEL 1-WAY")
       .scanType("FULL SCAN").table(physicalTableName.toString())
-      .serverWhereFilter("SERVER FILTER BY V3 = 1");
+      .serverWhereFilter("SERVER FILTER BY V3 = 1")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
 
     // 8. Filtering along a prefix of the index key can use the index
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v2 = 2").iteratorType("PARALLEL 1-WAY")
       .scanType("RANGE SCAN").table(fullIndexName + "(" + indexPhysicalTableName + ")")
       .keyRanges(" [1,2]").serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .indexRejectedNone();
 
     // 9. Make sure a gap in the index columns still uses the index as long as a prefix is specified
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v2 = 2 AND v4 = 4")
@@ -559,19 +579,23 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,2]")
       .serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
       .serverWhereFilter("SERVER FILTER BY TO_INTEGER(\"V4\") = 4")
-      .clientSortAlgo("CLIENT MERGE SORT");
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+      .indexRejectedNone();
 
     // 10. Use index even when also filtering on non-indexed column
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v2 = 2 AND v1 = 3")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
       .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,2]")
       .serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
-      .serverWhereFilter("SERVER FILTER BY \"V1\" = 3.0").clientSortAlgo("CLIENT MERGE SORT");
+      .serverWhereFilter("SERVER FILTER BY \"V1\" = 3.0").clientSortAlgo("CLIENT MERGE SORT")
+      .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
 
     // 11. Another case of not using a prefix of the index key
     assertPlan(conn, "SELECT * FROM " + tableName + " WHERE v1 = 3 AND v3 = 1 AND v4 = 1")
       .iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN").table(physicalTableName.toString())
-      .serverWhereFilter("SERVER FILTER BY (V1 = 3.0 AND V3 = 1 AND V4 = 1)");
+      .serverWhereFilter("SERVER FILTER BY (V1 = 3.0 AND V3 = 1 AND V4 = 1)")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
   }
 
   @Test
@@ -779,7 +803,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       assertPlan(conn1, query).scanType("RANGE SCAN")
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1]")
         .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+        .indexRejectedNone();
 
       ResultSet rs = conn1.createStatement().executeQuery(query);
       String v = "";
@@ -798,7 +823,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       assertPlan(conn1, query).scanType("RANGE SCAN").clientSortedBy("REVERSE")
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1]")
         .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       v = "zz";
@@ -838,7 +864,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       String query = "SELECT V1 FROM " + tableName + " ORDER BY V1 DESC NULLS LAST";
       assertPlan(conn1, query).scanType("RANGE SCAN").clientSortedBy("REVERSE")
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1]")
-        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT");
+        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
       ResultSet rs = conn1.createStatement().executeQuery(query);
       String v = "zz";
@@ -884,7 +911,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       assertPlan(conn1, query).scanType("RANGE SCAN")
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,'a']")
         .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -903,7 +931,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
       assertPlan(conn1, query).scanType("RANGE SCAN")
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,*] - [1,'z']")
         .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -936,7 +965,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,*] - [1,'z']")
         .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
         .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [\"V1\", \"T_ID\", \"K3\"]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
 
       rs = conn1.createStatement().executeQuery(query);
       assertTrue(rs.next());
@@ -962,7 +992,8 @@ public class LocalIndexIT extends BaseLocalIndexIT {
         .table(fullIndexName + "(" + indexPhysicalTableName + ")").keyRanges(" [1,*] - [1,'z']")
         .serverMergeColumns("[0.K3]").serverFirstKeyOnlyProjection(true)
         .serverAggregate("SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [\"V1\"]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
 
       PhoenixStatement stmt = conn1.createStatement().unwrap(PhoenixStatement.class);
       rs = stmt.executeQuery(query);
@@ -1007,7 +1038,9 @@ public class LocalIndexIT extends BaseLocalIndexIT {
     assertPlan(conn1, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
       .table(
         SchemaUtil.getPhysicalTableName(Bytes.toBytes(indexTableName), isNamespaceMapped) + "2")
-      .keyRanges(" ['a']").serverFirstKeyOnlyProjection(true);
+      .keyRanges(" ['a']").serverFirstKeyOnlyProjection(true)
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedCount(1)
+      .indexRejected(0, indexName, OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
     conn1.close();
   }
 
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/MutableIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/MutableIndexIT.java
index 39225c97e4..402073d4c4 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/MutableIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/MutableIndexIT.java
@@ -39,6 +39,7 @@ import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixDatabaseMetaData;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.PTable;
 import org.apache.phoenix.schema.PTableKey;
@@ -118,10 +119,12 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1]")
-          .clientSortAlgo("CLIENT MERGE SORT");
+          .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       } else {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
-          .table(fullIndexName);
+          .table(fullIndexName).indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       }
 
       ResultSet rs = conn.createStatement().executeQuery(query);
@@ -189,7 +192,8 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1,'chara',4]")
           .serverMergeColumns(
             "[B.VARCHAR_COL2, B.CHAR_COL2, B.INT_COL2, B.DECIMAL_COL2, B.DATE_COL]")
-          .clientSortAlgo("CLIENT MERGE SORT");
+          .clientSortAlgo("CLIENT MERGE SORT")
+          .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
         rs = conn.createStatement().executeQuery(query);
         assertTrue(rs.next());
         assertEquals("varchar_b", rs.getString(1));
@@ -289,10 +293,12 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1]")
-          .clientSortAlgo("CLIENT MERGE SORT");
+          .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       } else {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
-          .table(fullIndexName);
+          .table(fullIndexName).indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       }
 
       rs = conn.createStatement().executeQuery(query);
@@ -312,10 +318,12 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1]")
-          .clientSortAlgo("CLIENT MERGE SORT");
+          .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       } else {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
-          .table(fullIndexName);
+          .table(fullIndexName).indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       }
 
       rs = conn.createStatement().executeQuery(query);
@@ -335,10 +343,12 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1]")
-          .clientSortAlgo("CLIENT MERGE SORT");
+          .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       } else {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
-          .table(fullIndexName);
+          .table(fullIndexName).indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+          .indexRejectedNone();
       }
 
       rs = conn.createStatement().executeQuery(query);
@@ -408,10 +418,12 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1]")
-          .serverProjectionFilter(columnEncoded).clientSortAlgo("CLIENT MERGE SORT");
+          .serverProjectionFilter(columnEncoded).clientSortAlgo("CLIENT MERGE SORT")
+          .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       } else {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
-          .table(fullIndexName).serverProjectionFilter(columnEncoded);
+          .table(fullIndexName).serverProjectionFilter(columnEncoded)
+          .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       }
       // make sure the data table looks like what we expect
       rs = conn.createStatement().executeQuery(query);
@@ -534,10 +546,12 @@ public class MutableIndexIT extends ParallelStatsDisabledIT {
       if (localIndex) {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
           .table(fullIndexName + "(" + fullTableName + ")").keyRanges(" [1]")
-          .serverProjectionFilter(columnEncoded).clientSortAlgo("CLIENT MERGE SORT");
+          .serverProjectionFilter(columnEncoded).clientSortAlgo("CLIENT MERGE SORT")
+          .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       } else {
         assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
-          .table(fullIndexName).serverProjectionFilter(columnEncoded);
+          .table(fullIndexName).serverProjectionFilter(columnEncoded)
+          .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       }
 
       // check that the data table matches as expected
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java
index efa8528f0d..eeeb02bdf0 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/PartialIndexIT.java
@@ -55,6 +55,7 @@ import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
 import org.apache.phoenix.jdbc.PhoenixStatement;
 import org.apache.phoenix.mapreduce.index.IndexTool;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseTest;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.query.explain.ExplainPlanTestUtil;
@@ -1013,7 +1014,7 @@ public class PartialIndexIT extends BaseTest {
       String selectSql = "SELECT  /*+ INDEX(" + dataTableName + " " + indexTableName + ")*/ "
         + "A from " + dataTableName + " WHERE id2 = 100 AND id1 = 'id12'";
       ExplainPlanTestUtil.assertPlan(conn, selectSql).scanType("POINT LOOKUP ON 1 KEY")
-        .table(indexTableName);
+        .table(indexTableName).indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
       ResultSet rs = stmt.executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(2, rs.getInt(1));
@@ -1022,13 +1023,13 @@ public class PartialIndexIT extends BaseTest {
       selectSql = "SELECT  /*+ INDEX(" + dataTableName + " " + indexTableName + ")*/ " + "A from "
         + dataTableName + " WHERE id2 = 10 AND id1 = 'id11'";
       ExplainPlanTestUtil.assertPlan(conn, selectSql).scanType("POINT LOOKUP ON 1 KEY")
-        .table(indexTableName);
+        .table(indexTableName).indexRule(null).indexRejectedNone();
       rs = stmt.executeQuery(selectSql);
       assertFalse(rs.next());
       // No index hint so, use data table only as its point lookup
       selectSql = "SELECT A from " + dataTableName + " WHERE id2 = 10 AND id1 = 'id11'";
       ExplainPlanTestUtil.assertPlan(conn, selectSql).scanType("POINT LOOKUP ON 1 KEY")
-        .table(dataTableName);
+        .table(dataTableName).indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
       rs = stmt.executeQuery(selectSql);
       assertTrue(rs.next());
       assertEquals(1, rs.getInt(1));
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SaltedIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SaltedIndexIT.java
index 3dbd13f5bc..9803685178 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SaltedIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SaltedIndexIT.java
@@ -32,6 +32,7 @@ import java.util.Properties;
 import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.PTableKey;
 import org.apache.phoenix.schema.types.PVarbinary;
@@ -160,14 +161,16 @@ public class SaltedIndexIT extends ParallelStatsDisabledIT {
 
     if (indexSaltBuckets == null) {
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
-        .table(indexTableFullName).keyRanges(" [~'y']").serverFirstKeyOnlyProjection(true);
+        .table(indexTableFullName).keyRanges(" [~'y']").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
     } else {
       assertPlan(conn, query).iteratorType("PARALLEL 4-WAY").scanType("RANGE SCAN")
         .table(indexTableFullName)
         .keyRanges(" [X'00',~'y'] - ["
           + PVarbinary.INSTANCE.toStringLiteral(new byte[] { (byte) (indexSaltBuckets - 1) })
           + ",~'y']")
-        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT");
+        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
     }
 
     // Will use index, so rows returned in DESC order.
@@ -184,14 +187,16 @@ public class SaltedIndexIT extends ParallelStatsDisabledIT {
     assertFalse(rs.next());
     if (indexSaltBuckets == null) {
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN")
-        .table(indexTableFullName).keyRanges(" [*] - [~'x']").serverFirstKeyOnlyProjection(true);
+        .table(indexTableFullName).keyRanges(" [*] - [~'x']").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
     } else {
       assertPlan(conn, query).iteratorType("PARALLEL 4-WAY").scanType("RANGE SCAN")
         .table(indexTableFullName)
         .keyRanges(" [X'00',*] - ["
           + PVarbinary.INSTANCE.toStringLiteral(new byte[] { (byte) (indexSaltBuckets - 1) })
           + ",~'x']")
-        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT");
+        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
     }
 
     // Use data table, since point lookup trumps order by
@@ -202,7 +207,8 @@ public class SaltedIndexIT extends ParallelStatsDisabledIT {
     assertEquals("x", rs.getString(2));
     assertFalse(rs.next());
     assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("POINT LOOKUP ON 1 KEY")
-      .table(dataTableFullName).serverSortedBy("[V]").clientSortAlgo("CLIENT MERGE SORT");
+      .table(dataTableFullName).serverSortedBy("[V]").clientSortAlgo("CLIENT MERGE SORT")
+      .indexRule(OptimizerReasons.RULE_POINT_LOOKUP).indexRejectedNone();
 
     // Will use data table now, since there's a LIMIT clause and
     // we're able to optimize out the ORDER BY, unless the data
@@ -219,11 +225,13 @@ public class SaltedIndexIT extends ParallelStatsDisabledIT {
     if (tableSaltBuckets == null) {
       assertPlan(conn, query).iteratorType("PARALLEL 1-WAY").scanType("FULL SCAN")
         .table(dataTableFullName).serverWhereFilter("SERVER FILTER BY V >= 'x'").serverRowLimit(2L)
-        .clientRowLimit(2);
+        .clientRowLimit(2).indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
     } else {
       assertPlan(conn, query).iteratorType("PARALLEL 3-WAY").scanType("FULL SCAN")
         .table(dataTableFullName).serverWhereFilter("SERVER FILTER BY V >= 'x'").serverRowLimit(2L)
-        .clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(2);
+        .clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(2)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
     }
 
     // PHOENIX-6604
@@ -231,11 +239,12 @@ public class SaltedIndexIT extends ParallelStatsDisabledIT {
     if (indexSaltBuckets == null) {
       assertPlan(conn, query).iteratorType("SERIAL 1-WAY").scanType("FULL SCAN")
         .table(indexTableFullName).serverFirstKeyOnlyProjection(true).serverRowLimit(1L)
-        .clientRowLimit(1);
+        .clientRowLimit(1).indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
     } else {
       assertPlan(conn, query).iteratorType("PARALLEL 4-WAY").scanType("FULL SCAN")
         .table(indexTableFullName).serverFirstKeyOnlyProjection(true).serverRowLimit(1L)
-        .clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(1);
+        .clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(1)
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
     }
   }
 }
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SingleCellIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SingleCellIndexIT.java
index c62d7fec75..070707aac0 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SingleCellIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/SingleCellIndexIT.java
@@ -51,6 +51,7 @@ import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.util.PhoenixRuntime;
 import org.apache.phoenix.util.PropertiesUtil;
@@ -108,7 +109,9 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
 
       String selectFromData = "SELECT /*+ NO_INDEX */ PK1, INT_PK, V1, V2, V4 FROM " + tableName
         + " where V2 >= 3 and V4 LIKE 'V4%'";
-      assertPlan(conn, selectFromData).table(tableName);
+      assertPlan(conn, selectFromData).table(tableName).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+        .indexRejectedCount(1)
+        .indexRejected(0, idxName, OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
 
       ResultSet rs = conn.createStatement().executeQuery(selectFromData);
       assertTrue(rs.next());
@@ -121,7 +124,8 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
 
       String selectFromIndex =
         "SELECT PK1, INT_PK, V1, V2, V4 FROM " + tableName + " where V2 >= 3 and V4 LIKE 'V4%'";
-      assertPlan(conn, selectFromIndex).table(idxName);
+      assertPlan(conn, selectFromIndex).table(idxName)
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
       rs = conn.createStatement().executeQuery(selectFromIndex);
       assertTrue(rs.next());
@@ -162,7 +166,8 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
 
       String selectFromIndex =
         "SELECT PK1, INT_PK, V1, V2, V4, V_NEW FROM " + tableName + " where V1='V199' AND V2=100";
-      assertPlan(conn, selectFromIndex).table(idxName);
+      assertPlan(conn, selectFromIndex).table(idxName)
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
       ResultSet rs = conn.createStatement().executeQuery(selectFromIndex);
       assertTrue(rs.next());
@@ -201,7 +206,8 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
       assertFalse(rs.next());
 
       String selectFromIndex = "SELECT PK1, INT_PK, V1, V4 FROM " + tableName + " where V1='V11'";
-      assertPlan(conn, selectFromIndex).table(idxName);
+      assertPlan(conn, selectFromIndex).table(idxName)
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
       rs = conn.createStatement().executeQuery(selectFromIndex);
       assertTrue(rs.next());
@@ -400,7 +406,8 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
 
       String selectFromIndex = "SELECT PK1, INT_PK, A.V2, B.V3, A.V4, B.V5 FROM " + tableName
         + " where A.V4='V42' and B.V3 >= 3";
-      assertPlan(conn, selectFromIndex).table(idxName);
+      assertPlan(conn, selectFromIndex).table(idxName)
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
       rs = conn.createStatement().executeQuery(selectFromIndex);
       assertTrue(rs.next());
       assertEquals("PK2", rs.getString(1));
@@ -432,7 +439,9 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
       conn.commit();
       String selectFromData = "SELECT /*+ NO_INDEX */ PK1, INT_PK, V1, V2, V4 FROM " + tableName
         + " where INT_PK = 1 and V4 LIKE 'UpdatedV4'";
-      assertPlan(conn, selectFromData).table(tableName);
+      assertPlan(conn, selectFromData).table(tableName).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+        .indexRejectedCount(1)
+        .indexRejected(0, idxName, OptimizerReasons.REASON_EXCLUDED_BY_NO_INDEX_HINT);
 
       ResultSet rs = conn.createStatement().executeQuery(selectFromData);
       assertTrue(rs.next());
@@ -444,7 +453,8 @@ public class SingleCellIndexIT extends ParallelStatsDisabledIT {
 
       String selectFromIndex =
         "SELECT PK1, INT_PK, V1, V2, V4 FROM " + tableName + " where V2 >= 2 and V4 = 'UpdatedV4'";
-      assertPlan(conn, selectFromIndex).table(idxName);
+      assertPlan(conn, selectFromIndex).table(idxName)
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
 
       rs = conn.createStatement().executeQuery(selectFromIndex);
       assertTrue(rs.next());
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ViewIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ViewIndexIT.java
index 7566387a62..3645db6de3 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ViewIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/index/ViewIndexIT.java
@@ -64,6 +64,7 @@ import org.apache.phoenix.index.GlobalIndexChecker;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixDatabaseMetaData;
 import org.apache.phoenix.jdbc.PhoenixStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.KeyRange;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.PNameFactory;
@@ -260,7 +261,8 @@ public class ViewIndexIT extends SplitSystemCatalogIT {
         .table(fullIndexName + "("
           + SchemaUtil.getPhysicalTableName(Bytes.toBytes(fullTableName), isNamespaceMapped) + ")")
         .keyRanges(" [1,'10',100]").serverMergeColumns("[0.V1]").serverFirstKeyOnlyProjection(true)
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
       ResultSet rs = conn1.prepareStatement(sql).executeQuery();
       assertTrue(rs.next());
       assertFalse(rs.next());
@@ -585,7 +587,8 @@ public class ViewIndexIT extends SplitSystemCatalogIT {
       // Uses index and finds correct number of rows
       assertPlan(conn, "SELECT COUNT(*) FROM " + fullTableName).iteratorType("PARALLEL 1-WAY")
         .scanType("RANGE SCAN")
-        .table(Bytes.toString(MetaDataUtil.getViewIndexPhysicalName(Bytes.toBytes(fullBaseName))));
+        .table(Bytes.toString(MetaDataUtil.getViewIndexPhysicalName(Bytes.toBytes(fullBaseName))))
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone();
     }
 
     // Force it not to use index and still finds correct number of rows
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinGlobalIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinGlobalIndexIT.java
index a6319944e0..588cac984d 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinGlobalIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinGlobalIndexIT.java
@@ -26,6 +26,7 @@ import java.util.Map;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.junit.experimental.categories.Category;
 import org.junit.runners.Parameterized.Parameters;
 
@@ -226,7 +227,8 @@ public class HashJoinGlobalIndexIT extends HashJoinIT {
       .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [I.NAME]")
       .clientSortAlgo("CLIENT MERGE SORT").subPlanCount(1).subPlan(0)
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT */")
-      .scanType("FULL SCAN").table(idxItem).serverFirstKeyOnlyProjection(true).end();
+      .scanType("FULL SCAN").table(idxItem).serverFirstKeyOnlyProjection(true)
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone().end();
   }
 
   @Override
@@ -238,7 +240,8 @@ public class HashJoinGlobalIndexIT extends HashJoinIT {
       .clientSortAlgo("CLIENT MERGE SORT").clientSortedBy("[SUM(O.QUANTITY) DESC]").subPlanCount(1)
       .subPlan(0)
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT, SKIP MERGE */")
-      .scanType("FULL SCAN").table(idxItem).serverFirstKeyOnlyProjection(true).end();
+      .scanType("FULL SCAN").table(idxItem).serverFirstKeyOnlyProjection(true)
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone().end();
   }
 
   @Override
@@ -251,7 +254,8 @@ public class HashJoinGlobalIndexIT extends HashJoinIT {
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT */")
       .scanType("FULL SCAN").table(order)
       .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [\"item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end();
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone().end();
   }
 
   @Override
@@ -263,7 +267,8 @@ public class HashJoinGlobalIndexIT extends HashJoinIT {
       .subPlan(0).abstractExplainPlan("PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD LEFT */")
       .scanType("FULL SCAN").table(order)
       .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [\"item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end();
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone().end();
   }
 
   @Override
@@ -282,7 +287,10 @@ public class HashJoinGlobalIndexIT extends HashJoinIT {
       .scanType("FULL SCAN").table(idxItem).serverWhereFilter("SERVER FILTER BY \"NAME\" != 'T3'")
       .subPlanCount(1).subPlan(0)
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD LEFT */")
-      .scanType("FULL SCAN").table(supplier).end().end().end();
+      .scanType("FULL SCAN").table(supplier).indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE)
+      .indexRejectedCount(1)
+      .indexRejected(0, "idx_supplier", OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION).end()
+      .end().end();
   }
 
   @Override
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinLocalIndexIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinLocalIndexIT.java
index 2ae8c4d42f..898fc33885 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinLocalIndexIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/join/HashJoinLocalIndexIT.java
@@ -34,6 +34,7 @@ import java.util.Properties;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.util.PropertiesUtil;
 import org.apache.phoenix.util.SchemaUtil;
 import org.junit.Test;
@@ -275,7 +276,8 @@ public class HashJoinLocalIndexIT extends HashJoinIT {
       .clientSortAlgo("CLIENT MERGE SORT").subPlanCount(1).subPlan(0)
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT */")
       .scanType("RANGE SCAN").table(itemIndex + "(" + item + ")").keyRanges(" [1]")
-      .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT").end();
+      .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone().end();
   }
 
   @Override
@@ -289,7 +291,8 @@ public class HashJoinLocalIndexIT extends HashJoinIT {
       .subPlan(0)
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT, SKIP MERGE */")
       .scanType("RANGE SCAN").table(itemIndex + "(" + item + ")").keyRanges(" [1]")
-      .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT").end();
+      .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+      .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED).indexRejectedNone().end();
   }
 
   @Override
@@ -304,7 +307,8 @@ public class HashJoinLocalIndexIT extends HashJoinIT {
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD RIGHT */")
       .scanType("FULL SCAN").table(order)
       .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [\"item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end();
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone().end();
   }
 
   @Override
@@ -318,7 +322,8 @@ public class HashJoinLocalIndexIT extends HashJoinIT {
       .abstractExplainPlan("PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD LEFT */")
       .scanType("FULL SCAN").table(order)
       .serverAggregate("SERVER AGGREGATE INTO DISTINCT ROWS BY [\"item_id\"]")
-      .clientSortAlgo("CLIENT MERGE SORT").end();
+      .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone().end();
   }
 
   @Override
@@ -339,7 +344,10 @@ public class HashJoinLocalIndexIT extends HashJoinIT {
       .serverWhereFilter("SERVER FILTER BY \"NAME\" != 'T3'").clientSortAlgo("CLIENT MERGE SORT")
       .subPlanCount(1).subPlan(0)
       .abstractExplainPlan("PARALLEL LEFT-JOIN TABLE 0  /* HASH BUILD LEFT */")
-      .scanType("FULL SCAN").table(supplier).end().end().end();
+      .scanType("FULL SCAN").table(supplier).indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED)
+      .indexRejectedCount(1).indexRejected(0, JOIN_SUPPLIER_INDEX,
+        OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE)
+      .end().end().end();
   }
 
   @Override
diff --git a/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java b/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
index 612c521867..f452aacace 100644
--- a/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
+++ b/phoenix-core/src/it/java/org/apache/phoenix/end2end/json/JsonFunctionsIT.java
@@ -45,6 +45,7 @@ import org.apache.phoenix.end2end.ParallelStatsDisabledIT;
 import org.apache.phoenix.end2end.ParallelStatsDisabledTest;
 import org.apache.phoenix.exception.SQLExceptionCode;
 import org.apache.phoenix.jdbc.PhoenixConnection;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.schema.PTable;
 import org.apache.phoenix.schema.PTableKey;
 import org.apache.phoenix.schema.TableNotFoundException;
@@ -367,7 +368,8 @@ public class JsonFunctionsIT extends ParallelStatsDisabledIT {
       String selectSql =
         "SELECT JSON_VALUE(JSONCOL,'$.type'), " + "JSON_VALUE(JSONCOL,'$.info.address.town') FROM "
           + tableName + " WHERE JSON_VALUE(JSONCOL,'$.type') = 'Basic'";
-      assertPlan(conn, selectSql).scanType("RANGE SCAN").table(indexName);
+      assertPlan(conn, selectSql).scanType("RANGE SCAN").table(indexName)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
       // Validate the total count of rows
       String countSql = "SELECT COUNT(1) FROM " + tableName;
       ResultSet rs = conn.createStatement().executeQuery(countSql);
@@ -567,7 +569,8 @@ public class JsonFunctionsIT extends ParallelStatsDisabledIT {
       ResultSet rs = conn.createStatement().executeQuery("EXPLAIN " + query);
       String explainPlan = QueryUtil.getExplainPlan(rs);
       assertFalse(explainPlan.contains("    SERVER JSON FUNCTION PROJECTION"));
-      assertPlan(conn, query).serverArrayElementProjection(false);
+      assertPlan(conn, query).serverArrayElementProjection(false)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
       rs = conn.createStatement().executeQuery(query);
       assertTrue(rs.next());
       assertEquals(conn.createArrayOf("INTEGER", new Integer[] { 1, 2 }), rs.getArray(1));
@@ -581,7 +584,8 @@ public class JsonFunctionsIT extends ParallelStatsDisabledIT {
       rs = conn.createStatement().executeQuery("EXPLAIN " + query);
       explainPlan = QueryUtil.getExplainPlan(rs);
       assertTrue(explainPlan.contains("    SERVER JSON FUNCTION PROJECTION"));
-      assertPlan(conn, query).serverArrayElementProjection(true);
+      assertPlan(conn, query).serverArrayElementProjection(true)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       // only Array optimization and not Json
       query = "SELECT arr[1], jsoncol, JSON_VALUE(jsoncol, '$.type')" + " FROM " + tableName
@@ -589,7 +593,8 @@ public class JsonFunctionsIT extends ParallelStatsDisabledIT {
       rs = conn.createStatement().executeQuery("EXPLAIN " + query);
       explainPlan = QueryUtil.getExplainPlan(rs);
       assertFalse(explainPlan.contains("    SERVER JSON FUNCTION PROJECTION"));
-      assertPlan(conn, query).serverArrayElementProjection(true);
+      assertPlan(conn, query).serverArrayElementProjection(true)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       // only Json optimization and not Array Index
       query = "SELECT arr, arr[1], JSON_VALUE(jsoncol, '$.type')" + " FROM " + tableName
@@ -597,7 +602,8 @@ public class JsonFunctionsIT extends ParallelStatsDisabledIT {
       rs = conn.createStatement().executeQuery("EXPLAIN " + query);
       explainPlan = QueryUtil.getExplainPlan(rs);
       assertTrue(explainPlan.contains("    SERVER JSON FUNCTION PROJECTION"));
-      assertPlan(conn, query).serverArrayElementProjection(false);
+      assertPlan(conn, query).serverArrayElementProjection(false)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     }
   }
 
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryCompilerTest.java b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryCompilerTest.java
index 495363b3b5..d806e615f1 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryCompilerTest.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryCompilerTest.java
@@ -84,6 +84,7 @@ import org.apache.phoenix.iterate.MergeSortTopNResultIterator;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.jdbc.PhoenixStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.parse.ParseNodeFactory;
 import org.apache.phoenix.query.BaseConnectionlessQueryTest;
 import org.apache.phoenix.query.QueryConstants;
@@ -7150,7 +7151,8 @@ public class QueryCompilerTest extends BaseConnectionlessQueryTest {
         + " where ts >= TIMESTAMP '2023-02-23 13:30:00'  and ts < TIMESTAMP '2023-02-23 13:40:00'";
       assertPlan(conn, query).scanType("RANGE SCAN").table(indexName)
         .keyRanges(" [~1,677,159,600,000] - [~1,677,159,000,000]")
-        .serverFirstKeyOnlyProjection(true);
+        .serverFirstKeyOnlyProjection(true).indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS)
+        .indexRejectedNone();
     }
   }
 
@@ -7168,7 +7170,8 @@ public class QueryCompilerTest extends BaseConnectionlessQueryTest {
       assertEquals("\\x9E\\x9E\\x9F\\x00", Bytes.toStringBinary(openScan.getStartRow()));
       assertEquals("\\x9E\\xFF", Bytes.toStringBinary(openScan.getStopRow()));
       assertPlan(conn, openQry).scanType("RANGE SCAN").table(tableName)
-        .keyRanges(" [~'aaa'] - [~'a']").serverFirstKeyOnlyProjection(true);
+        .keyRanges(" [~'aaa'] - [~'a']").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
       String closedQry = "select * from " + tableName + " where k >= 'a' and k <= 'aaa'";
       Scan closedScan =
@@ -7176,7 +7179,8 @@ public class QueryCompilerTest extends BaseConnectionlessQueryTest {
       assertEquals("\\x9E\\x9E\\x9E\\xFF", Bytes.toStringBinary(closedScan.getStartRow()));
       assertEquals("\\x9F\\x00", Bytes.toStringBinary(closedScan.getStopRow()));
       assertPlan(conn, closedQry).scanType("RANGE SCAN").table(tableName)
-        .keyRanges(" [~'aaa'] - [~'a']").serverFirstKeyOnlyProjection(true);
+        .keyRanges(" [~'aaa'] - [~'a']").serverFirstKeyOnlyProjection(true)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     }
   }
 
@@ -7195,7 +7199,8 @@ public class QueryCompilerTest extends BaseConnectionlessQueryTest {
       assertPlan(conn, query).scanType("RANGE SCAN").table("II").keyRanges(" [1]")
         .serverMergeColumns("[0.V1, 0.V2, 0.V3, 0.V4]").serverFirstKeyOnlyProjection(true)
         .serverWhereFilter("SERVER FILTER BY \"K2\" = 1").serverSortedBy("[\"K1\", \"V1\"]")
-        .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE SORT");
+        .serverRowLimit(1L).clientRowLimit(1).clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
     }
   }
 
@@ -7263,7 +7268,8 @@ public class QueryCompilerTest extends BaseConnectionlessQueryTest {
       stmt.execute("create index i on d(v2) include (v3)");
       String query = "select /*+ index(d i) */ * from d where v2=1 and v3=1";
       assertPlan(conn, query).scanType("RANGE SCAN").table("I").keyRanges(" [1]")
-        .serverMergeColumns("[0.V1, 0.V4]").serverWhereFilter("SERVER FILTER BY \"V3\" = 1");
+        .serverMergeColumns("[0.V1, 0.V4]").serverWhereFilter("SERVER FILTER BY \"V3\" = 1")
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
     }
   }
 
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
index bc793f0a55..9ee22f54f7 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
@@ -45,6 +45,7 @@ import org.apache.phoenix.compile.OrderByCompiler.OrderBy;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
 import org.apache.phoenix.jdbc.PhoenixResultSet;
 import org.apache.phoenix.jdbc.PhoenixStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.parse.DeleteStatement;
 import org.apache.phoenix.parse.HintNode.Hint;
 import org.apache.phoenix.parse.SQLParser;
@@ -505,7 +506,9 @@ public class QueryOptimizerTest extends BaseConnectionlessQueryTest {
     assertPlan(conn2,
       "select * from INDEX_TEST_TABLE where A in ('1','2','3','4','5') and F in ('1111','2222','3333')")
         .scanType("SKIP SCAN ON 15 KEYS").table("INDEX_TEST_TABLE_INDEX_F")
-        .keyRanges(" ['1','1111'] - ['5','3333']");
+        .keyRanges(" ['1','1111'] - ['5','3333']")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedCount(1)
+        .indexRejected(0, "INDEX_TEST_TABLE_INDEX_D", OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
   }
 
   @Test
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/compile/StatementHintsCompilationTest.java b/phoenix-core/src/test/java/org/apache/phoenix/compile/StatementHintsCompilationTest.java
index abbb829c9a..694475ddc3 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/compile/StatementHintsCompilationTest.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/compile/StatementHintsCompilationTest.java
@@ -34,6 +34,7 @@ import org.apache.hadoop.hbase.filter.FilterList;
 import org.apache.phoenix.filter.SkipScanFilter;
 import org.apache.phoenix.jdbc.PhoenixConnection;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseConnectionlessQueryTest;
 import org.apache.phoenix.util.PropertiesUtil;
 import org.apache.phoenix.util.TestUtil;
@@ -107,7 +108,8 @@ public class StatementHintsCompilationTest extends BaseConnectionlessQueryTest {
       .serverWhereFilter("SERVER FILTER BY (CREATED_DATE >= DATE"
         + " '2012-11-01 00:00:00.000' AND CREATED_DATE < DATE '2012-11-30 00:00:00.000')")
       .serverSortedBy("[ORGANIZATION_ID, PARENT_ID, CREATED_DATE DESC, ENTITY_HISTORY_ID]")
-      .serverRowLimit(100L).clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(100);
+      .serverRowLimit(100L).clientSortAlgo("CLIENT MERGE SORT").clientRowLimit(100)
+      .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
   }
 
   @Test
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java b/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
index 1a3e82c9c2..6665d18d88 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/compile/TenantSpecificViewIndexCompileTest.java
@@ -29,6 +29,7 @@ import java.util.Calendar;
 import java.util.List;
 import java.util.Properties;
 import java.util.TimeZone;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseConnectionlessQueryTest;
 import org.apache.phoenix.query.explain.ExplainPlanTestUtil;
 import org.apache.phoenix.util.DateUtil;
@@ -53,7 +54,8 @@ public class TenantSpecificViewIndexCompileTest extends BaseConnectionlessQueryT
 
     assertPlan(conn, "SELECT v1,v2 FROM v WHERE v2 > 'a' ORDER BY v2")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table("_IDX_T")
-      .keyRanges(" [-9223372036854775808,'me','a'] - [-9223372036854775808,'me',*]");
+      .keyRanges(" [-9223372036854775808,'me','a'] - [-9223372036854775808,'me',*]")
+      .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS);
   }
 
   @Test
@@ -172,7 +174,8 @@ public class TenantSpecificViewIndexCompileTest extends BaseConnectionlessQueryT
     assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table("T")
       .clientSortedBy("REVERSE")
       .keyRanges(" ['tenant123456789','xyz','abcde',~'2015-01-01 08:00:00.000'] - "
-        + "['tenant123456789','xyz','abcde',*]");
+        + "['tenant123456789','xyz','abcde',*]")
+      .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     assertOrderByHasBeenOptimizedOut(conn, sql);
 
   }
@@ -194,14 +197,17 @@ public class TenantSpecificViewIndexCompileTest extends BaseConnectionlessQueryT
     assertPlan(conn, "SELECT v2 FROM v WHERE v2 > 'a' and k2 = 'a' ORDER BY v2,k2")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table("_IDX_T")
       .keyRanges(" [-9223372036854775808,'me','a'] - [-9223372036854775808,'me',*]")
-      .serverFirstKeyOnlyProjection(true);
+      .serverFirstKeyOnlyProjection(true).indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS);
 
     // Won't use index b/c v1 is not in index, but should optimize out k2 still from the order by
     // K2 will still be referenced in the filter, as these are automatically tacked on to the where
     // clause.
+    // The index i1 is rejected because it does not cover v1, leaving the data table as the only
+    // surviving candidate.
     assertPlan(conn, "SELECT v1 FROM v WHERE v2 > 'a' ORDER BY k2").iteratorType("PARALLEL 1-WAY")
       .scanType("RANGE SCAN").table("T").keyRanges(" ['me']")
-      .serverWhereFilter("SERVER FILTER BY (V2 > 'a' AND K2 = 'a')");
+      .serverWhereFilter("SERVER FILTER BY (V2 > 'a' AND K2 = 'a')")
+      .indexRule(OptimizerReasons.RULE_ONLY_CANDIDATE).indexRejectedCount(1);
 
     // If we match K2 against a constant not equal to it's view constant, we should get a degenerate
     // plan. The DEGENERATE SCAN ExplainPlan does not populate structured attributes, so this
@@ -232,7 +238,8 @@ public class TenantSpecificViewIndexCompileTest extends BaseConnectionlessQueryT
     // the updatable view
     assertPlan(conn, "SELECT v2 FROM v2 WHERE v3 > 'a' and k2 = 'a' ORDER BY v3,k2")
       .iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table("_IDX_T")
-      .keyRanges(" [-9223372036854775808,'me','a'] - [-9223372036854775808,'me',*]");
+      .keyRanges(" [-9223372036854775808,'me','a'] - [-9223372036854775808,'me',*]")
+      .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS);
   }
 
   // -----------------------------------------------------------------
@@ -249,13 +256,14 @@ public class TenantSpecificViewIndexCompileTest extends BaseConnectionlessQueryT
 
   private void assertRangeScan(Connection conn, String sql, String keyRanges) throws SQLException {
     assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table("T")
-      .keyRanges(keyRanges);
+      .keyRanges(keyRanges).indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
   }
 
   private void assertRangeScanWithFilter(Connection conn, String sql, String keyRanges,
     String serverWhereFilter) throws SQLException {
     assertPlan(conn, sql).iteratorType("PARALLEL 1-WAY").scanType("RANGE SCAN").table("T")
-      .keyRanges(keyRanges).serverWhereFilter(serverWhereFilter);
+      .keyRanges(keyRanges).serverWhereFilter(serverWhereFilter)
+      .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
   }
 
   private void assertOrderByHasBeenOptimizedOut(Connection conn, String sql) throws SQLException {
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/query/QueryPlanTest.java b/phoenix-core/src/test/java/org/apache/phoenix/query/QueryPlanTest.java
index 09849e3db1..19aca69bc2 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/query/QueryPlanTest.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/query/QueryPlanTest.java
@@ -22,6 +22,7 @@ import static org.apache.phoenix.query.explain.ExplainPlanTestUtil.assertPlan;
 import java.sql.Connection;
 import java.sql.DriverManager;
 import java.util.Properties;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.util.PhoenixRuntime;
 import org.apache.phoenix.util.PropertiesUtil;
 import org.junit.Test;
@@ -49,21 +50,25 @@ public class QueryPlanTest extends BaseConnectionlessQueryTest {
     // LIMIT 1 uses a SERIAL iterator and pushes the limit to both server and client.
     assertPlan(conn, "SELECT * FROM TENANT_VIEW LIMIT 1").iteratorType("SERIAL")
       .scanType("RANGE SCAN").table("BASE_MULTI_TENANT_TABLE").keyRanges(" ['tenantId']")
-      .serverRowLimit(1L).clientRowLimit(1);
+      .serverRowLimit(1L).clientRowLimit(1).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone();
 
     // A very large limit falls back to a PARALLEL iterator.
     assertPlan(conn, "SELECT * FROM TENANT_VIEW LIMIT " + Integer.MAX_VALUE)
       .iteratorType("PARALLEL").scanType("RANGE SCAN").table("BASE_MULTI_TENANT_TABLE")
       .keyRanges(" ['tenantId']").serverRowLimit((long) Integer.MAX_VALUE)
-      .clientRowLimit(Integer.MAX_VALUE);
+      .clientRowLimit(Integer.MAX_VALUE).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+      .indexRejectedNone();
 
     assertPlan(conn, "SELECT * FROM TENANT_VIEW WHERE username = 'Joe' LIMIT 1")
       .scanType("RANGE SCAN").table("BASE_MULTI_TENANT_TABLE").keyRanges(" ['tenantId']")
-      .serverWhereFilter("SERVER FILTER BY USERNAME = 'Joe'").serverRowLimit(1L).clientRowLimit(1);
+      .serverWhereFilter("SERVER FILTER BY USERNAME = 'Joe'").serverRowLimit(1L).clientRowLimit(1)
+      .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
 
     assertPlan(conn, "SELECT * FROM TENANT_VIEW WHERE col = 'Joe' LIMIT 1").scanType("RANGE SCAN")
       .table("BASE_MULTI_TENANT_TABLE").keyRanges(" ['tenantId']")
-      .serverWhereFilter("SERVER FILTER BY COL = 'Joe'").serverRowLimit(1L).clientRowLimit(1);
+      .serverWhereFilter("SERVER FILTER BY COL = 'Joe'").serverRowLimit(1L).clientRowLimit(1)
+      .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
   }
 
   @Test
@@ -82,7 +87,8 @@ public class QueryPlanTest extends BaseConnectionlessQueryTest {
       assertPlan(conn, query).scanType("RANGE SCAN").table("FOO")
         .keyRanges(
           " [X'00','a',~'2016-01-28 23:59:59.999'] -" + " [X'13','a',~'2016-01-28 00:00:00.000']")
-        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT");
+        .serverFirstKeyOnlyProjection(true).clientSortAlgo("CLIENT MERGE SORT")
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
     } finally {
       conn.close();
     }
@@ -107,7 +113,8 @@ public class QueryPlanTest extends BaseConnectionlessQueryTest {
       assertPlan(conn, query).useRoundRobinIterator(true).scanType("RANGE SCAN").table(tableName)
         .keyRanges(
           " [X'00','a',~'2016-01-28 23:59:59.999'] -" + " [X'13','a',~'2016-01-28 00:00:00.000']")
-        .serverFirstKeyOnlyProjection(true);
+        .serverFirstKeyOnlyProjection(true).indexRule(OptimizerReasons.RULE_DATA_TABLE)
+        .indexRejectedNone();
     } finally {
       conn.close();
     }
@@ -127,7 +134,8 @@ public class QueryPlanTest extends BaseConnectionlessQueryTest {
       // server sort + client merge sort are planned.
       assertPlan(conn, query).iteratorType("PARALLEL").scanType("RANGE SCAN").table("FOO")
         .keyRanges(" ['a']").serverFirstKeyOnlyProjection(true).serverSortedBy("[B, C]")
-        .clientSortAlgo("CLIENT MERGE SORT");
+        .clientSortAlgo("CLIENT MERGE SORT").indexRule(OptimizerReasons.RULE_DATA_TABLE)
+        .indexRejectedNone();
     } finally {
       conn.close();
     }
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
index 8434f2040e..bb7f8133c4 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTest.java
@@ -22,6 +22,7 @@ import static org.apache.phoenix.query.QueryServices.DATE_FORMAT_ATTRIB;
 import static org.apache.phoenix.util.PhoenixRuntime.TENANT_ID_ATTRIB;
 import static org.apache.phoenix.util.TestUtil.TEST_PROPERTIES;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -46,6 +47,7 @@ import org.apache.hadoop.hbase.client.RegionInfoBuilder;
 import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.compile.ExplainPlanAttributes.ExplainPlanAttributesBuilder;
+import org.apache.phoenix.optimize.OptimizerReasons;
 import org.apache.phoenix.query.BaseConnectionlessQueryTest;
 import org.apache.phoenix.query.QueryServices;
 import org.apache.phoenix.schema.PColumn;
@@ -109,8 +111,9 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       "SELECT a_string, b_string FROM atable"
         + " WHERE organization_id = '00D000000000001' AND entity_id = '00E00000000001'"
         + " AND x_integer = 2 AND a_integer < 5",
-      text("CLIENT PARALLEL <N>-WAY POINT LOOKUP ON 1 KEY OVER ATABLE", "    INDEX ATABLE",
-        "    REGIONS PLANNED <N>", "    SERVER FILTER BY (X_INTEGER = 2 AND A_INTEGER < 5)"),
+      text("CLIENT PARALLEL <N>-WAY POINT LOOKUP ON 1 KEY OVER ATABLE",
+        "    INDEX ATABLE  /* point lookup */", "    REGIONS PLANNED <N>",
+        "    SERVER FILTER BY (X_INTEGER = 2 AND A_INTEGER < 5)"),
       scanAttrs("POINT LOOKUP ON 1 KEY ", "ATABLE", null).put("serverWhereFilter",
         "SERVER FILTER BY (X_INTEGER = 2 AND A_INTEGER < 5)"));
   }
@@ -121,8 +124,8 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       "SELECT a_string, b_string FROM atable"
         + " WHERE organization_id IN ('00D000000000001', '00D000000000005')"
         + " AND entity_id IN ('00E00000000000X','00E00000000000Z')",
-      text("CLIENT PARALLEL <N>-WAY POINT LOOKUP ON 4 KEYS OVER ATABLE", "    INDEX ATABLE",
-        "    REGIONS PLANNED <N>"),
+      text("CLIENT PARALLEL <N>-WAY POINT LOOKUP ON 4 KEYS OVER ATABLE",
+        "    INDEX ATABLE  /* point lookup */", "    REGIONS PLANNED <N>"),
       scanAttrs("POINT LOOKUP ON 4 KEYS ", "ATABLE", null));
   }
 
@@ -473,8 +476,11 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
   public void testSortMergeJoin() throws Exception {
     // After the SMJ reshape the root is a synthetic node carrying just the join header and the
     // two operand plans as separate lhs / rhs children.
-    ObjectNode lhs = scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']");
-    ObjectNode rhs = scanAttrs("FULL SCAN ", "ATABLE", "");
+    // Join operand leaves are compiled through the join path, not optimizer index selection, so
+    // they carry no decision rule.
+    ObjectNode lhs =
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']").putNull("indexRule");
+    ObjectNode rhs = scanAttrs("FULL SCAN ", "ATABLE", "").putNull("indexRule");
     ObjectNode expected = defaultAttrs();
     expected.put("abstractExplainPlan", "SORT-MERGE-JOIN (INNER)");
     expected.set("lhsJoinQueryExplainPlan", lhs);
@@ -495,9 +501,13 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
   public void testHashJoinInner() throws Exception {
     // HashJoinPlan root attributes come from the delegate scan. Each hash/skip-scan child
     // is recorded under subPlans with its join header on abstractExplainPlan.
-    ObjectNode child = scanAttrs("FULL SCAN ", "ATABLE", "").put("abstractExplainPlan",
-      "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */");
-    ObjectNode expected = scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']");
+    // Hash join nodes (the delegate root and the build-side child) are compiled through the join
+    // path, not optimizer index selection, so they carry no decision rule.
+    ObjectNode child = scanAttrs("FULL SCAN ", "ATABLE", "")
+      .put("abstractExplainPlan", "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
+      .putNull("indexRule");
+    ObjectNode expected =
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']").putNull("indexRule");
     expected.set("subPlans", mapper.createArrayNode().add(child));
     expected.put("dynamicServerFilter",
       "DYNAMIC SERVER FILTER BY A.ORGANIZATION_ID IN (B.ORGANIZATION_ID)");
@@ -521,7 +531,10 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       .put("abstractExplainPlan", "SKIP-SCAN-JOIN TABLE 0  /* HASH BUILD RIGHT */")
       .put("serverWhereFilter", "SERVER FILTER BY A_INTEGER = 1")
       .put("serverAggregate", "SERVER AGGREGATE INTO ORDERED DISTINCT ROWS BY [ORGANIZATION_ID]");
-    ObjectNode expected = scanAttrs("FULL SCAN ", "ATABLE", "");
+    // The outer hash-join scan is compiled through the subquery/join path and carries no decision
+    // rule; the aggregate subquery build side is optimized separately and keeps its data-table
+    // rule.
+    ObjectNode expected = scanAttrs("FULL SCAN ", "ATABLE", "").putNull("indexRule");
     expected.set("subPlans", mapper.createArrayNode().add(child));
     expected.put("dynamicServerFilter",
       "DYNAMIC SERVER FILTER BY ATABLE.ORGANIZATION_ID IN ($1.$2)");
@@ -562,15 +575,21 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
     // A UNION ALL whose branches are each hash joins. Exercises recursive explain composition end
     // to end and
     // confirms each branch carries its own subPlans and dynamicServerFilter.
-    ObjectNode joinChild1 = scanAttrs("FULL SCAN ", "ATABLE", "").put("abstractExplainPlan",
-      "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */");
-    ObjectNode branch1 = scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']");
+    // Every node in a union-of-hash-joins is compiled through the join path, so none carry a
+    // decision rule.
+    ObjectNode joinChild1 = scanAttrs("FULL SCAN ", "ATABLE", "")
+      .put("abstractExplainPlan", "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
+      .putNull("indexRule");
+    ObjectNode branch1 =
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']").putNull("indexRule");
     branch1.set("subPlans", mapper.createArrayNode().add(joinChild1));
     branch1.put("dynamicServerFilter",
       "DYNAMIC SERVER FILTER BY A.ORGANIZATION_ID IN (B.ORGANIZATION_ID)");
-    ObjectNode joinChild2 = scanAttrs("FULL SCAN ", "ATABLE", "").put("abstractExplainPlan",
-      "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */");
-    ObjectNode branch2 = scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000002']");
+    ObjectNode joinChild2 = scanAttrs("FULL SCAN ", "ATABLE", "")
+      .put("abstractExplainPlan", "PARALLEL INNER-JOIN TABLE 0  /* HASH BUILD RIGHT */")
+      .putNull("indexRule");
+    ObjectNode branch2 =
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000002']").putNull("indexRule");
     branch2.set("subPlans", mapper.createArrayNode().add(joinChild2));
     branch2.put("dynamicServerFilter",
       "DYNAMIC SERVER FILTER BY A.ORGANIZATION_ID IN (B.ORGANIZATION_ID)");
@@ -630,8 +649,8 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       true,
       text("UPSERT ROWS", "CLIENT PARALLEL <N>-WAY RANGE SCAN OVER ATABLE ['00D000000000001']",
         "    INDEX ATABLE", "    REGIONS PLANNED <N>"),
-      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']").put("abstractExplainPlan",
-        "UPSERT ROWS"));
+      scanAttrs("RANGE SCAN ", "ATABLE", " ['00D000000000001']")
+        .put("abstractExplainPlan", "UPSERT ROWS").putNull("indexRule"));
   }
 
   @Test
@@ -651,7 +670,7 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
         "    SERVER PROJECTION FILTER BY FIRST KEY ONLY", "    SERVER FILTER BY ENTITY_ID = 'abc'"),
       scanAttrs("FULL SCAN ", "ATABLE", "").put("abstractExplainPlan", "DELETE ROWS SERVER SELECT")
         .put("serverFirstKeyOnlyProjection", true)
-        .put("serverWhereFilter", "SERVER FILTER BY ENTITY_ID = 'abc'"));
+        .put("serverWhereFilter", "SERVER FILTER BY ENTITY_ID = 'abc'").putNull("indexRule"));
   }
 
   @Test
@@ -698,8 +717,9 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
         "    REGIONS PLANNED <N>", "    SERVER 1 ROW LIMIT", "CLIENT 1 ROW LIMIT"),
       attrs().put("iteratorTypeAndScanSize", "SERIAL <N>-WAY").put("consistency", "STRONG")
         .put("explainScanType", "RANGE SCAN ").put("tableName", "EO_MT_BASE")
-        .put("indexName", "EO_MT_VIEW").put("keyRanges", " ['tenant42']").put("serverRowLimit", 1)
-        .put("clientRowLimit", 1).set("clientSteps", clientSteps("CLIENT 1 ROW LIMIT")));
+        .put("indexName", "EO_MT_VIEW").put("indexRule", "data table")
+        .put("keyRanges", " ['tenant42']").put("serverRowLimit", 1).put("clientRowLimit", 1)
+        .set("clientSteps", clientSteps("CLIENT 1 ROW LIMIT")));
   }
 
   @Test
@@ -710,8 +730,11 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       String idx = generateUniqueName();
       stmt.execute("CREATE TABLE " + base + " (k VARCHAR PRIMARY KEY, v1 VARCHAR, v2 VARCHAR)");
       stmt.execute("CREATE INDEX " + idx + " ON " + base + " (v1) INCLUDE (v2)");
-      ExplainPlanTestUtil.assertPlan(conn, "SELECT v1, v2 FROM " + base + " WHERE v1 = 'x'")
-        .table(idx).indexName(idx).indexKind("GLOBAL");
+      String query = "SELECT v1, v2 FROM " + base + " WHERE v1 = 'x'";
+      ExplainPlanTestUtil.assertPlan(conn, query).table(idx).indexName(idx).indexKind("GLOBAL")
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedNone();
+      assertPlanContainsLine(conn, query,
+        "    INDEX " + idx + " GLOBAL  /* more bound PK columns */");
     }
   }
 
@@ -724,10 +747,11 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       stmt.execute("CREATE TABLE " + base + " (k VARCHAR PRIMARY KEY, v1 VARCHAR, v2 VARCHAR)");
       stmt.execute("CREATE LOCAL INDEX " + idx + " ON " + base + " (v1)");
       // The OVER line decorates a local index as <idx>(<phys>); the INDEX line prints just <idx>.
-      ExplainPlanTestUtil
-        .assertPlan(conn,
-          "SELECT /*+ INDEX(" + base + " " + idx + ") */ k, v1 FROM " + base + " WHERE v1 = 'x'")
-        .indexName(idx).indexKind("LOCAL");
+      String query =
+        "SELECT /*+ INDEX(" + base + " " + idx + ") */ k, v1 FROM " + base + " WHERE v1 = 'x'";
+      ExplainPlanTestUtil.assertPlan(conn, query).indexName(idx).indexKind("LOCAL")
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
+      assertPlanContainsLine(conn, query, "    INDEX " + idx + " LOCAL  /* hint */");
     }
   }
 
@@ -739,10 +763,74 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
       String idx = generateUniqueName();
       stmt.execute("CREATE TABLE " + base + " (k VARCHAR PRIMARY KEY, v1 VARCHAR, v2 VARCHAR)");
       stmt.execute("CREATE UNCOVERED INDEX " + idx + " ON " + base + " (v1)");
-      ExplainPlanTestUtil
-        .assertPlan(conn,
-          "SELECT /*+ INDEX(" + base + " " + idx + ") */ k, v2 FROM " + base + " WHERE v1 = 'x'")
-        .indexName(idx).indexKind("UNCOVERED GLOBAL");
+      String query =
+        "SELECT /*+ INDEX(" + base + " " + idx + ") */ k, v2 FROM " + base + " WHERE v1 = 'x'";
+      ExplainPlanTestUtil.assertPlan(conn, query).indexName(idx).indexKind("UNCOVERED GLOBAL")
+        .indexRule(OptimizerReasons.RULE_HINT).indexRejectedNone();
+      assertPlanContainsLine(conn, query, "    INDEX " + idx + " UNCOVERED GLOBAL  /* hint */");
+    }
+  }
+
+  /**
+   * A full scan over a table with no indexes records the {@code "data table"} default rule, which
+   * is suppressed. The INDEX line must be bare and carry no {@code !INDEX} rejection notes.
+   */
+  @Test
+  public void testNoCandidateDataTableBareIndexLine() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String base = generateUniqueName();
+      stmt.execute("CREATE TABLE " + base + " (k VARCHAR PRIMARY KEY, v1 VARCHAR)");
+      String query = "SELECT v1 FROM " + base;
+      ExplainPlanTestUtil.assertPlan(conn, query).indexName(base).indexKind(null)
+        .indexRule(OptimizerReasons.RULE_DATA_TABLE).indexRejectedNone();
+      assertPlanContainsLine(conn, query, "    INDEX " + base);
+      assertNoPlanLineContains(conn, query, "/*");
+      assertNoPlanLineContains(conn, query, "!INDEX");
+    }
+  }
+
+  /**
+   * A query whose {@code ORDER BY} can be served by a covered global index picks that index by the
+   * {@code "non-local preferred"} rule, which is non default and renders a rule comment on the
+   * INDEX line.
+   */
+  @Test
+  public void testIndexRuleNonLocalPreferred() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String base = generateUniqueName();
+      String idx = generateUniqueName();
+      stmt.execute("CREATE TABLE " + base + " (k VARCHAR PRIMARY KEY, v1 VARCHAR, v2 VARCHAR)");
+      stmt.execute("CREATE INDEX " + idx + " ON " + base + " (v1) INCLUDE (v2)");
+      String query = "SELECT v1, v2 FROM " + base + " ORDER BY v1";
+      ExplainPlanTestUtil.assertPlan(conn, query).indexName(idx).indexKind("GLOBAL")
+        .indexRule(OptimizerReasons.RULE_NON_LOCAL_PREFERRED);
+      assertPlanContainsLine(conn, query,
+        "    INDEX " + idx + " GLOBAL  /* non-local preferred */");
+    }
+  }
+
+  /**
+   * A {@code GROUP BY} on a column with a LOCAL index chooses the data table by the
+   * {@code "more bound PK columns"} rule, which is non default and renders a rule comment.
+   */
+  @Test
+  public void testIndexRejectedRendered() throws Exception {
+    try (Connection conn = DriverManager.getConnection(getUrl(), defaultProps());
+      java.sql.Statement stmt = conn.createStatement()) {
+      String base = generateUniqueName();
+      String idx = base + "_IDX";
+      stmt
+        .execute("CREATE TABLE " + base + " (rowkey VARCHAR PRIMARY KEY, c1 VARCHAR, c2 VARCHAR)");
+      stmt.execute("CREATE LOCAL INDEX " + idx + " ON " + base + " (c1)");
+      String query =
+        "SELECT c1, max(rowkey), max(c2) FROM " + base + " WHERE rowkey <= 'z' GROUP BY c1";
+      ExplainPlanTestUtil.assertPlan(conn, query).indexName(base)
+        .indexRule(OptimizerReasons.RULE_MORE_BOUND_PK_COLUMNS).indexRejectedCount(1)
+        .indexRejected(0, idx, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
+      assertPlanContainsLine(conn, query, "    INDEX " + base + "  /* more bound PK columns */");
+      assertPlanContainsLine(conn, query, "    /* !INDEX " + idx + " -- no PK prefix bound */");
     }
   }
 
@@ -1012,6 +1100,25 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
     return ExplainPlanTestUtil.getMutationExplainPlan(conn, query);
   }
 
+  /** Assert that the optimized plan-steps text for {@code query} contains {@code expectedLine}. */
+  private static void assertPlanContainsLine(Connection conn, String query, String expectedLine)
+    throws SQLException {
+    List<String> steps = ExplainPlanTestUtil.getPlanSteps(conn, query);
+    assertTrue("expected plan to contain line '" + expectedLine + "' but was " + steps,
+      steps.contains(expectedLine));
+  }
+
+  /** Assert that no plan-steps text line for {@code query} contains {@code needle}. */
+  private static void assertNoPlanLineContains(Connection conn, String query, String needle)
+    throws SQLException {
+    List<String> steps = ExplainPlanTestUtil.getPlanSteps(conn, query);
+    for (String s : steps) {
+      assertFalse(
+        "expected no plan line containing '" + needle + "' but found '" + s + "' in " + steps,
+        s.contains(needle));
+    }
+  }
+
   private static Properties defaultProps() {
     Properties props = PropertiesUtil.deepCopy(TEST_PROPERTIES);
     props.setProperty(DATE_FORMAT_ATTRIB, "yyyy-MM-dd");
@@ -1044,6 +1151,8 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
     n.putNull("keyRanges");
     n.putNull("indexName");
     n.putNull("indexKind");
+    n.putNull("indexRule");
+    n.putNull("indexRejected");
     n.putNull("saltBuckets");
     n.putNull("regionsPlanned");
     n.putNull("scanTimeRangeMin");
@@ -1100,6 +1209,10 @@ public class ExplainPlanTest extends BaseConnectionlessQueryTest {
     // For a data table scan the per scan INDEX line names the same entity as tableName. View and
     // index scans that diverge override indexName on the returned node.
     n.put("indexName", table);
+    // A data-table scan that participated in optimizer index selection records its decision rule.
+    // A point lookup short-circuits to the point-lookup rule; any other data-table target lands on
+    // the data-table default. Index targets set their own rule on the returned node.
+    n.put("indexRule", scanType.trim().startsWith("POINT LOOKUP") ? "point lookup" : "data table");
     if (keys != null) {
       n.put("keyRanges", keys);
     }
diff --git a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
index 25a3347255..e01fa9d693 100644
--- a/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
+++ b/phoenix-core/src/test/java/org/apache/phoenix/query/explain/ExplainPlanTestUtil.java
@@ -30,6 +30,7 @@ import org.apache.phoenix.compile.ExplainPlan;
 import org.apache.phoenix.compile.ExplainPlanAttributes;
 import org.apache.phoenix.compile.QueryPlan;
 import org.apache.phoenix.jdbc.PhoenixPreparedStatement;
+import org.apache.phoenix.optimize.RejectedIndexEntry;
 
 /**
  * Test helpers for retrieving the {@link ExplainPlan} and its structured
@@ -190,6 +191,72 @@ public final class ExplainPlanTestUtil {
       return this;
     }
 
+    /**
+     * Assert the optimizer's index-selection rule label, e.g. one of the
+     * {@code OptimizerReasons.RULE_*} constants (null when the plan did not participate in
+     * optimizer index selection).
+     */
+    public ExplainPlanAssert indexRule(String expected) {
+      assertEquals(at("indexRule"), expected, attributes.getIndexRule());
+      return this;
+    }
+
+    /**
+     * Assert the index-selection rule label starts with {@code prefix}. Useful for the functional
+     * index {@code "matches <expr>"} rule whose suffix is expression-dependent.
+     */
+    public ExplainPlanAssert indexRuleStartsWith(String prefix) {
+      String actual = attributes.getIndexRule();
+      assertNotNull(at("indexRule") + " must not be null", actual);
+      assertTrue(
+        at("indexRule") + " expected to start with '" + prefix + "' but was '" + actual + "'",
+        actual.startsWith(prefix));
+      return this;
+    }
+
+    /** Assert the number of rejected index candidates recorded for this plan. */
+    public ExplainPlanAssert indexRejectedCount(int expected) {
+      List<RejectedIndexEntry> rejected = attributes.getIndexRejected();
+      int actual = rejected == null ? 0 : rejected.size();
+      assertEquals(at("indexRejected.size"), expected, actual);
+      return this;
+    }
+
+    /** Assert the i-th rejected index candidate's name and reason. */
+    public ExplainPlanAssert indexRejected(int i, String name, String reason) {
+      List<RejectedIndexEntry> rejected = attributes.getIndexRejected();
+      assertNotNull(at("indexRejected") + " must not be null", rejected);
+      assertTrue(at("indexRejected") + " has no index " + i + " (size=" + rejected.size() + ")",
+        i >= 0 && i < rejected.size());
+      RejectedIndexEntry entry = rejected.get(i);
+      assertEquals(at("indexRejected[" + i + "].name"), name, entry.getName());
+      assertEquals(at("indexRejected[" + i + "].reason"), reason, entry.getReason());
+      return this;
+    }
+
+    /** Assert that no index candidates were rejected (null or empty list). */
+    public ExplainPlanAssert indexRejectedNone() {
+      List<RejectedIndexEntry> rejected = attributes.getIndexRejected();
+      assertTrue(at("indexRejected") + " expected none but was " + rejected,
+        rejected == null || rejected.isEmpty());
+      return this;
+    }
+
+    /**
+     * Assert that the rejected index list contains an entry with {@code name} and {@code reason}.
+     */
+    public ExplainPlanAssert indexRejectedContains(String name, String reason) {
+      List<RejectedIndexEntry> rejected = attributes.getIndexRejected();
+      assertNotNull(at("indexRejected") + " must not be null", rejected);
+      for (RejectedIndexEntry entry : rejected) {
+        if (entry.getName().equals(name) && entry.getReason().equals(reason)) {
+          return this;
+        }
+      }
+      throw new AssertionError(at("indexRejected") + " expected to contain {" + name + ", " + reason
+        + "} but was " + rejected);
+    }
+
     /** Assert the salt bucket count of the scanned table (null when not salted). */
     public ExplainPlanAssert saltBuckets(Integer expected) {
       assertEquals(at("saltBuckets"), expected, attributes.getSaltBuckets());