You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/07/31 21:25:18 UTC

(groovy) branch master updated: GROOVY-12191: Scope indy SwitchPoint invalidation (#2736)

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

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new c7ffaeef08 GROOVY-12191: Scope indy SwitchPoint invalidation (#2736)
c7ffaeef08 is described below

commit c7ffaeef08ac3af00ce3bf5a2031e12d1a110b81
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Aug 1 05:25:13 2026 +0800

    GROOVY-12191: Scope indy SwitchPoint invalidation (#2736)
    
    * GROOVY-12191: Scope indy SwitchPoint invalidation
    
    * GROOVY-12191: Address PR review and add index hierarchy for SwitchPoint fan-out
    
    * GROOVY-12191: Address blackdrag review on scoped SwitchPoint invalidation
    
    Remove the legacy process-wide IndyInterface.switchPoint (vmplugin is
    internal-by-intent), bind MOP guards at link time in applyMopSwitchPoints,
    document hierarchy fan-out for cross-class MOP visibility, and clarify the
    cold-path PIC sentinel policy. Extend unit coverage for these paths.
    
    * GROOVY-12191: MetaClass-aware SwitchPoint fan-out and PIC sentinel rename
    
    Narrow hierarchy invalidation to an allow-list of cross-class MOP cases
    (EMC, modified custom MetaClass, interface/array, global EMC) while pure
    MetaClassImpl replaces and per-instance MetaClass stay exact-class. Rename
    NULL_METHOD_HANDLE_WRAPPER to UNCACHEABLE_PIC_SENTINEL and document the
    invalidation matrix. Extend unit coverage for blackdrag's scenario and
    the refined policy.
    
    * GROOVY-12191: Document missing-method hierarchy and add blackdrag scenario tests
    
    Refine scoped SwitchPoint docs to match MetaClassImpl selection: hierarchy
    fan-out is for the missing-method walk under modified MutableMetaClass, not a
    shared MetaClassImpl table; construction-time C/D visibility remains pre-existing
    MOP behaviour. Add regression coverage for parent→child miss/hit paths, present-
    method stability after re-link, callsite MetaClass-change matrix, and Java subtype
    EMC fan-out.
    
    * GROOVY-12191: Attach indy MOP SwitchPoints to MetaClass instances
    
    * GROOVY-12191: Drop hierarchy SwitchPoint fan-out for stock metaclasses
    
    * GROOVY-12191: Guard null MetaClassRegistry in getMetaClassUnderLock
---
 src/main/java/groovy/lang/MetaClassImpl.java       |  17 +-
 .../groovy/runtime/indy/IndyInvalidation.java      | 480 ++++++++++++
 .../runtime/indy/SwitchPointInvalidator.java       | 134 ++++
 .../apache/groovy/runtime/indy/package-info.java   |  29 +
 .../org/codehaus/groovy/reflection/ClassInfo.java  | 224 +++++-
 .../v8/ColdReflectiveMethodHandleWrapper.java      |  23 +-
 .../groovy/vmplugin/v8/IndyCompoundAssign.java     |   4 +-
 .../codehaus/groovy/vmplugin/v8/IndyInterface.java | 117 ++-
 .../groovy/vmplugin/v8/MethodHandleWrapper.java    |  32 +-
 .../org/codehaus/groovy/vmplugin/v8/Selector.java  |  13 +-
 src/test/groovy/bugs/Groovy12191.groovy            | 847 +++++++++++++++++++++
 .../runtime/indy/IndyInvalidationTest.groovy       | 649 ++++++++++++++++
 .../runtime/indy/SwitchPointInvalidatorTest.groovy | 209 +++++
 .../ClassInfoMetaClassBootstrapTest.groovy         | 100 +++
 .../v8/IndyInterfaceCallSiteTargetTest.groovy      |   4 +-
 .../vmplugin/v8/IndyScopedSwitchPointTest.groovy   | 225 ++++++
 .../runtime/indy/IndyInvalidationStatsProbe.java   |  52 ++
 .../groovy/vmplugin/v8/IndyLoggingProbe.java       |  62 ++
 subprojects/performance/README.adoc                |  10 +
 .../groovy/bench/ScopedInvalidationBench.groovy    | 275 +++++++
 20 files changed, 3438 insertions(+), 68 deletions(-)

diff --git a/src/main/java/groovy/lang/MetaClassImpl.java b/src/main/java/groovy/lang/MetaClassImpl.java
index c70f14fee3..9297fd15eb 100644
--- a/src/main/java/groovy/lang/MetaClassImpl.java
+++ b/src/main/java/groovy/lang/MetaClassImpl.java
@@ -3846,7 +3846,9 @@ public class MetaClassImpl implements MetaClass, MutableMetaClass {
     }
 
     /**
-     * Searches the class hierarchy for a mutable meta-class bean property.
+     * Searches the class hierarchy for a mutable meta-class bean property on the
+     * missing-property path. Linked miss sites re-enter this walk live (no
+     * SwitchPoint fan-out); see {@link org.apache.groovy.runtime.indy.IndyInvalidation}.
      *
      * @param propertyName the property name
      * @param theClass the class to start searching from
@@ -3917,7 +3919,18 @@ public class MetaClassImpl implements MetaClass, MutableMetaClass {
     }
 
     /**
-     * Searches a meta-class hierarchy for the most specific visible method.
+     * Searches a meta-class hierarchy for the most specific visible method on the
+     * <em>missing-method</em> path.
+     * <p>
+     * For a starting {@link MetaClassImpl}, the walk opens only when some strong
+     * MetaClass in the receiver hierarchy is a modified {@link MutableMetaClass}
+     * (typically {@link ExpandoMetaClass}). This is how patterns such as
+     * {@code Object.metaClass.foo = …} / parent EMC methods become visible on
+     * subclass receivers without giving every subtype its own EMC.
+     * <p>
+     * <b>6.0 (GROOVY-12191):</b> this walk is retained. Linked miss sites re-enter
+     * it live without SwitchPoint fan-out — see
+     * {@link org.apache.groovy.runtime.indy.IndyInvalidation}.
      *
      * @param instanceKlazz the runtime receiver class
      * @param methodName the method name
diff --git a/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
new file mode 100644
index 0000000000..c45793f3e7
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
@@ -0,0 +1,480 @@
+/*
+ *  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.groovy.runtime.indy;
+
+import groovy.lang.AdaptingMetaClass;
+import groovy.lang.DelegatingMetaClass;
+import groovy.lang.MetaClass;
+import groovy.lang.MetaClassImpl;
+import groovy.lang.MetaClassRegistryChangeEvent;
+import org.apache.groovy.util.SystemUtil;
+import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
+import org.codehaus.groovy.reflection.ClassInfo;
+import org.codehaus.groovy.runtime.NullObject;
+
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.SwitchPoint;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Scoped invokedynamic {@link SwitchPoint} invalidation for the Groovy MOP
+ * (GROOVY-12191).
+ * <p>
+ * <b>Domain:</b> one SwitchPoint per {@link MetaClass} instance (weak identity
+ * map here). When no class-level MetaClass is installed yet, sites use a
+ * ClassInfo <em>pending</em> domain (retired on first install). Monomorphic
+ * indy sites install a single {@code guardWithTest} on the class-level domain
+ * observed at link time.
+ *
+ * <h2>Width policy (two axes only)</h2>
+ * <ul>
+ *   <li><b>Exact class</b> — {@link #invalidateClass(Class)}: stock
+ *       {@code MetaClassImpl} / EMC changes (including {@link ClassInfo#incVersion()},
+ *       registry replace, per-instance MetaClass).</li>
+ *   <li><b>All loaded</b> — process-wide retire used for category enter/leave
+ *       ({@link #invalidateCategory()}), unattributed registry events
+ *       ({@link #invalidateUnscoped()}), and non-stock custom MetaClass kinds
+ *       ({@link #invalidateBulk()}).</li>
+ * </ul>
+ * There is <em>no</em> parent→child SwitchPoint fan-out. Indy selection consults
+ * only the receiver’s own MetaClass tables; ancestor-dependent resolution on
+ * the missing-method / missing-property path re-enters
+ * {@code MetaClass.invokeMethod} / property miss and walks the hierarchy live
+ * from the registry each call. Construction-time snapshots of ancestor expando
+ * methods are pre-existing MOP behaviour and are not refreshed by SwitchPoint
+ * retirement. See also {@link MetaClassImpl#findMethodInClassHierarchy}.
+ * <p>
+ * Optional stats: {@code -Dgroovy.indy.invalidation.stats=true}.
+ * Production guards: {@code IndyInterface.applyMopSwitchPoints}; tests may use
+ * {@link #guardWithMopSwitchPoints}.
+ *
+ * @since 6.0.0
+ */
+public final class IndyInvalidation {
+
+    private static final Logger LOG = Logger.getLogger(IndyInvalidation.class.getName());
+    private static final boolean STATS_LOG = SystemUtil.getBooleanSafe("groovy.indy.invalidation.stats");
+
+    /** Exact-class invalidation events ({@link #invalidateClass(Class)}). */
+    private static final AtomicLong CLASS_INVALIDATIONS = new AtomicLong();
+    /** Category enter/leave bulk events ({@link #invalidateCategory()}). */
+    private static final AtomicLong CATEGORY_INVALIDATIONS = new AtomicLong();
+    /**
+     * Non-category process-wide bulk events
+     * ({@link #invalidateBulk()} / {@link #invalidateUnscoped()}).
+     */
+    private static final AtomicLong BULK_INVALIDATIONS = new AtomicLong();
+
+    private static final SwitchPoint[] EMPTY_SWITCH_POINTS = new SwitchPoint[0];
+
+    /**
+     * Weak identity map: each MetaClass instance → its SwitchPoint domain.
+     * Keys are weakly held so discarded MetaClasses do not pin domains.
+     */
+    private static final ManagedIdentityConcurrentMap<MetaClass, SwitchPointInvalidator> DOMAINS =
+            new ManagedIdentityConcurrentMap<>();
+
+    private IndyInvalidation() {
+    }
+
+    /**
+     * Creates a SwitchPoint invalidator for a ClassInfo pending domain
+     * (pre-MetaClass link only).
+     *
+     * @return a new invalidator
+     */
+    public static SwitchPointInvalidator newPendingInvalidator() {
+        return new SwitchPointInvalidator();
+    }
+
+    // -------------------------------------------------------------------------
+    // Width: exact class
+    // -------------------------------------------------------------------------
+
+    /**
+     * Retires only {@code type}'s MetaClass SwitchPoint domain (exact class;
+     * no subtype fan-out). No-op when {@code type} is {@code null}. Does not
+     * bump {@link ClassInfo#getVersion()}.
+     * <p>
+     * Used by {@link ClassInfo#incVersion()}, stock registry MetaClass changes,
+     * and per-instance MetaClass changes. Non-stock MetaClass kinds use
+     * {@link #invalidateBulk()} instead.
+     *
+     * @param type the class whose MetaClass changed
+     */
+    public static void invalidateClass(final Class<?> type) {
+        if (type == null) {
+            return;
+        }
+        List<SwitchPoint> batch = new ArrayList<>(1);
+        collectLiveForClass(type, batch);
+        invalidateBatch(batch);
+        CLASS_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("exact-class SwitchPoint invalidated for " + type.getName()
+                    + "; totalClass=" + CLASS_INVALIDATIONS.get());
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Width: all loaded (one primitive, reason-labelled public entries)
+    // -------------------------------------------------------------------------
+
+    /**
+     * Category enter/leave and {@code VMPlugin.invalidateCallSites()}.
+     * Retires every loaded class-level domain so sites re-link under the new
+     * category state. No separate category SwitchPoint on the hot path.
+     */
+    public static void invalidateCategory() {
+        retireAllLoadedDomains();
+        CATEGORY_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("category bulk invalidation; total=" + CATEGORY_INVALIDATIONS.get());
+        }
+    }
+
+    /**
+     * Process-wide bulk retirement for non-stock (custom) MetaClass registry
+     * events — correctness-first when selection may consult state outside the
+     * stock miss walk. Rare.
+     */
+    public static void invalidateBulk() {
+        retireAllLoadedDomains();
+        BULK_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("custom-MetaClass bulk invalidation; totalBulk=" + BULK_INVALIDATIONS.get());
+        }
+    }
+
+    /**
+     * Process-wide bulk retirement when a MetaClass registry event carries no
+     * {@code Class} attribution.
+     */
+    public static void invalidateUnscoped() {
+        retireAllLoadedDomains();
+        BULK_INVALIDATIONS.incrementAndGet();
+        if (STATS_LOG && LOG.isLoggable(Level.FINE)) {
+            LOG.fine("unscoped bulk invalidation; totalBulk=" + BULK_INVALIDATIONS.get());
+        }
+    }
+
+    /**
+     * Detaches and invalidates every live class-level MetaClass / pending domain
+     * for loaded types. Shared implementation for all process-wide entries.
+     */
+    private static void retireAllLoadedDomains() {
+        List<SwitchPoint> batch = new ArrayList<>();
+        try {
+            for (ClassInfo info : ClassInfo.getAllClassInfo()) {
+                info.collectLiveIndySwitchPoints(batch);
+            }
+        } finally {
+            invalidateBatch(batch);
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Registry policy
+    // -------------------------------------------------------------------------
+
+    /**
+     * Registry-driven invalidation: exact class for stock MetaClass kinds and
+     * per-instance changes; process-wide bulk for custom MetaClass kinds or
+     * unattributed events. See class javadoc.
+     *
+     * @param event the registry change event (must not be {@code null})
+     */
+    public static void invalidateForMetaClassChange(final MetaClassRegistryChangeEvent event) {
+        Class<?> type = event.getClassToUpdate();
+        if (type == null) {
+            invalidateUnscoped();
+            return;
+        }
+        if (event.isPerInstanceMetaClassChange()) {
+            invalidateClass(type);
+            return;
+        }
+        if (needsBulkInvalidation(event.getOldMetaClass(), event.getNewMetaClass())) {
+            invalidateBulk();
+        } else {
+            invalidateClass(type);
+        }
+    }
+
+    /**
+     * Whether a class-level MetaClass change involving {@code oldMc}/{@code newMc}
+     * requires process-wide bulk invalidation.
+     * <p>
+     * Bulk is reserved for <em>non-stock</em> MetaClass kinds: not
+     * {@link MetaClassImpl} after adapter unwrap. Stock pairs stay exact-class.
+     *
+     * @param oldMc previous MetaClass (may be {@code null})
+     * @param newMc new MetaClass (may be {@code null} on remove)
+     * @return {@code true} if bulk invalidation is required
+     */
+    public static boolean needsBulkInvalidation(final MetaClass oldMc, final MetaClass newMc) {
+        return !isStockMetaClass(oldMc) || !isStockMetaClass(newMc);
+    }
+
+    /**
+     * Whether {@code mc} is a stock MOP MetaClass for which exact-class
+     * invalidation is sufficient: {@code null}, or unwrapped
+     * {@link MetaClassImpl} (includes EMC and subclasses). Any other kind is
+     * non-stock and triggers bulk invalidation on registry replace.
+     *
+     * @param mc MetaClass to classify (may be {@code null})
+     * @return {@code true} if exact-class invalidation is sufficient
+     */
+    public static boolean isStockMetaClass(final MetaClass mc) {
+        MetaClass current = unwrapMetaClass(mc);
+        return current == null || current instanceof MetaClassImpl;
+    }
+
+    /**
+     * Unwraps {@link AdaptingMetaClass} / {@link DelegatingMetaClass} wrappers
+     * (bounded depth) to the underlying MetaClass.
+     *
+     * @param mc MetaClass to unwrap (may be {@code null})
+     * @return the unwrapped MetaClass, or {@code null}
+     */
+    static MetaClass unwrapMetaClass(final MetaClass mc) {
+        MetaClass current = mc;
+        // Bound unwrap depth: HandleMetaClass is typically one level; be defensive.
+        for (int i = 0; i < 4 && current != null; i++) {
+            MetaClass adaptee = adapteeOf(current);
+            if (adaptee == null || adaptee == current) {
+                return current;
+            }
+            current = adaptee;
+        }
+        return current;
+    }
+
+    private static MetaClass adapteeOf(final MetaClass mc) {
+        if (mc instanceof AdaptingMetaClass adapting) {
+            return adapting.getAdaptee();
+        }
+        if (mc instanceof DelegatingMetaClass delegating) {
+            return delegating.getAdaptee();
+        }
+        return null;
+    }
+
+    // -------------------------------------------------------------------------
+    // Domain map
+    // -------------------------------------------------------------------------
+
+    /**
+     * Domain key for a MetaClass (unwrapped adaptee when present).
+     */
+    private static MetaClass domainKey(final MetaClass mc) {
+        MetaClass unwrapped = unwrapMetaClass(mc);
+        return unwrapped != null ? unwrapped : mc;
+    }
+
+    private static SwitchPointInvalidator domainFor(final MetaClass key) {
+        return DOMAINS.applyIfAbsent(key, k -> new SwitchPointInvalidator());
+    }
+
+    /**
+     * Detaches the live SwitchPoint for {@code mc}'s domain (if any) into
+     * {@code out}. Does not allocate a domain when none exists yet.
+     *
+     * @param mc  MetaClass (may be {@code null})
+     * @param out destination list (must not be {@code null})
+     */
+    public static void collectLiveForMetaClass(final MetaClass mc, final List<SwitchPoint> out) {
+        if (mc == null) {
+            return;
+        }
+        SwitchPointInvalidator inv = DOMAINS.get(domainKey(mc));
+        if (inv == null) {
+            return;
+        }
+        SwitchPoint sp = inv.detachLive();
+        if (sp != null) {
+            out.add(sp);
+        }
+    }
+
+    /**
+     * Collects live SwitchPoint(s) for {@code type}: installed MetaClass domain
+     * and ClassInfo pending domain. Does not create a MetaClass.
+     *
+     * @param type class to inspect (must not be {@code null})
+     * @param out  destination list
+     */
+    public static void collectLiveForClass(final Class<?> type, final List<SwitchPoint> out) {
+        ClassInfo.getClassInfo(type).collectLiveIndySwitchPoints(out);
+    }
+
+    private static void invalidateBatch(final List<SwitchPoint> batch) {
+        if (batch.isEmpty()) {
+            return;
+        }
+        SwitchPoint.invalidateAll(batch.toArray(EMPTY_SWITCH_POINTS));
+    }
+
+    // -------------------------------------------------------------------------
+    // Link-time domain resolution and guards
+    // -------------------------------------------------------------------------
+
+    /**
+     * Resolves the class used for the MetaClass SwitchPoint domain of a receiver.
+     * {@code null} maps to {@link NullObject}; a {@link Class} receiver uses itself.
+     *
+     * @param receiver the call receiver (may be {@code null})
+     * @return the class whose class-level MetaClass SwitchPoint guards this site
+     */
+    public static Class<?> switchPointClassFor(final Object receiver) {
+        if (receiver == null) {
+            return NullObject.class;
+        }
+        if (receiver instanceof Class<?> c) {
+            return c;
+        }
+        return receiver.getClass();
+    }
+
+    /**
+     * Returns the SwitchPoint domain for a MetaClass instance (unwraps adapters).
+     *
+     * @param mc MetaClass (must not be {@code null})
+     * @return the MetaClass-domain SwitchPoint
+     */
+    public static SwitchPoint switchPointForMetaClass(final MetaClass mc) {
+        Objects.requireNonNull(mc, "mc");
+        return domainFor(domainKey(mc)).getSwitchPoint();
+    }
+
+    /**
+     * Retires the SwitchPoint domain for {@code mc} (if one was allocated).
+     * Does not bump counters (local domain retire; width policy is separate).
+     *
+     * @param mc MetaClass (may be {@code null})
+     */
+    public static void invalidateMetaClass(final MetaClass mc) {
+        if (mc == null) {
+            return;
+        }
+        List<SwitchPoint> batch = new ArrayList<>(1);
+        collectLiveForMetaClass(mc, batch);
+        invalidateBatch(batch);
+    }
+
+    /**
+     * Returns the class-level MetaClass SwitchPoint for the given receiver.
+     *
+     * @param receiver the call receiver (may be {@code null})
+     * @return the MetaClass-domain switch point
+     */
+    public static SwitchPoint classSwitchPointFor(final Object receiver) {
+        return classSwitchPointFor(switchPointClassFor(receiver));
+    }
+
+    /**
+     * Returns the SwitchPoint for the given class at link time.
+     * Prefers the installed class-level MetaClass domain; if none is installed
+     * yet, uses the ClassInfo pending domain (defineClass-safe).
+     *
+     * @param type the class (must not be {@code null})
+     * @return the SwitchPoint for monomorphic MOP guards
+     */
+    public static SwitchPoint classSwitchPointFor(final Class<?> type) {
+        ClassInfo info = ClassInfo.getClassInfo(type);
+        MetaClass mc = info.getMetaClassForClass();
+        if (mc != null) {
+            return switchPointForMetaClass(mc);
+        }
+        return info.getPendingIndySwitchPoint();
+    }
+
+    /**
+     * Installs a MetaClass SwitchPoint guard on {@code handle}.
+     * Public entry for tests; production uses {@code IndyInterface.applyMopSwitchPoints}.
+     *
+     * @param handle   the fast-path handle
+     * @param fallback the re-link / fallback handle
+     * @param receiver the receiver used to select the MetaClass-domain switch point
+     * @return the guarded handle
+     */
+    public static MethodHandle guardWithMopSwitchPoints(
+            final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
+        return guardWithMopSwitchPoints(handle, fallback, switchPointClassFor(receiver));
+    }
+
+    /**
+     * Installs a MetaClass SwitchPoint guard on {@code handle}.
+     *
+     * @param handle        the fast-path handle
+     * @param fallback      the re-link / fallback handle
+     * @param receiverClass the class whose class-level MetaClass domain guards this site
+     * @return the guarded handle
+     */
+    public static MethodHandle guardWithMopSwitchPoints(
+            final MethodHandle handle, final MethodHandle fallback, final Class<?> receiverClass) {
+        return classSwitchPointFor(receiverClass).guardWithTest(handle, fallback);
+    }
+
+    // -------------------------------------------------------------------------
+    // Stats (optional; tests)
+    // -------------------------------------------------------------------------
+
+    /**
+     * Exact-class invalidation event count ({@link #invalidateClass(Class)}).
+     *
+     * @return class invalidation count
+     */
+    public static long classInvalidationCount() {
+        return CLASS_INVALIDATIONS.get();
+    }
+
+    /**
+     * Non-category process-wide bulk event count
+     * ({@link #invalidateBulk()} / {@link #invalidateUnscoped()}).
+     *
+     * @return bulk invalidation count
+     */
+    public static long bulkInvalidationCount() {
+        return BULK_INVALIDATIONS.get();
+    }
+
+    /**
+     * Category-style bulk invalidation count ({@link #invalidateCategory()}).
+     *
+     * @return category invalidation count
+     */
+    public static long categoryInvalidationCount() {
+        return CATEGORY_INVALIDATIONS.get();
+    }
+
+    /**
+     * Resets process-wide counters (tests only). Does not reset live switch points.
+     */
+    public static void resetCountersForTesting() {
+        CLASS_INVALIDATIONS.set(0);
+        CATEGORY_INVALIDATIONS.set(0);
+        BULK_INVALIDATIONS.set(0);
+    }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
new file mode 100644
index 0000000000..79cb0ddbc0
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/SwitchPointInvalidator.java
@@ -0,0 +1,134 @@
+/*
+ *  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.groovy.runtime.indy;
+
+import java.lang.invoke.SwitchPoint;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Lazily allocates a {@link SwitchPoint} and retires it on demand.
+ * <p>
+ * Call sites install the switch point returned by {@link #getSwitchPoint()} in
+ * {@code guardWithTest} chains. {@link #invalidate()} retires the live switch
+ * point so linked sites fall back and re-link; the next {@link #getSwitchPoint()}
+ * allocates a fresh one.
+ * <p>
+ * Bulk paths (category enter/leave, custom MetaClass events) should prefer
+ * {@link #detachLive()} and a single {@link SwitchPoint#invalidateAll(SwitchPoint[])}
+ * over many individual {@link #invalidate()} calls (GROOVY-12191).
+ * <p>
+ * There is no permanent failover: after scoping stock invalidation to a single
+ * class, deopt storms from a process-wide SwitchPoint no longer apply, so a
+ * simpler replace-on-invalidate model is sufficient.
+ *
+ * @since 6.0.0
+ */
+public final class SwitchPointInvalidator {
+
+    /**
+     * Reusable one-element array for single-SP {@link SwitchPoint#invalidateAll},
+     * guarded by {@link #SINGLE_INVALIDATE_LOCK}. Invalidation is rare relative to
+     * steady-state dispatch, so the lock is not on a hot path.
+     */
+    private static final Object SINGLE_INVALIDATE_LOCK = new Object();
+    private static final SwitchPoint[] SINGLE_INVALIDATE_BUF = new SwitchPoint[1];
+
+    /** {@code null} means no live switch point (lazy allocation on next get). */
+    private final AtomicReference<SwitchPoint> current = new AtomicReference<>();
+    private final AtomicInteger retirementCount = new AtomicInteger();
+
+    /**
+     * Creates an invalidator with no live SwitchPoint until the first
+     * {@link #getSwitchPoint()} call. Field initializers own all state.
+     */
+    public SwitchPointInvalidator() {
+        // Intentionally empty: AtomicReference / AtomicInteger fields are ready.
+    }
+
+    /**
+     * Returns a live switch point to install in a call-site guard chain.
+     * Lazily allocates on first use after construction or after retirement.
+     *
+     * @return a live switch point (never {@code null})
+     */
+    public SwitchPoint getSwitchPoint() {
+        for (;;) {
+            SwitchPoint sp = current.get();
+            if (sp != null) {
+                return sp;
+            }
+            SwitchPoint created = new SwitchPoint();
+            if (current.compareAndSet(null, created)) {
+                return created;
+            }
+        }
+    }
+
+    /**
+     * Invalidates the current live switch point (if any) so linked sites re-link.
+     * A subsequent {@link #getSwitchPoint()} allocates a fresh switch point.
+     */
+    public void invalidate() {
+        SwitchPoint retired = detachLive();
+        invalidateIfLive(retired);
+    }
+
+    /**
+     * Atomically detaches the live switch point for bulk
+     * {@link SwitchPoint#invalidateAll} without invalidating it yet.
+     * The caller <em>must</em> invalidate any non-null return value.
+     *
+     * @return the detached live switch point, or {@code null} if none was live
+     */
+    public SwitchPoint detachLive() {
+        SwitchPoint sp = current.getAndSet(null);
+        if (sp != null) {
+            retirementCount.incrementAndGet();
+        }
+        return sp;
+    }
+
+    /**
+     * Returns how many live switch points have been retired.
+     *
+     * @return the retirement count
+     */
+    public int getRetirementCount() {
+        return retirementCount.get();
+    }
+
+    /**
+     * Invalidates {@code sp} when non-null and still valid.
+     *
+     * @param sp candidate switch point (may be {@code null})
+     */
+    public static void invalidateIfLive(final SwitchPoint sp) {
+        if (sp != null && !sp.hasBeenInvalidated()) {
+            synchronized (SINGLE_INVALIDATE_LOCK) {
+                SINGLE_INVALIDATE_BUF[0] = sp;
+                try {
+                    SwitchPoint.invalidateAll(SINGLE_INVALIDATE_BUF);
+                } finally {
+                    SINGLE_INVALIDATE_BUF[0] = null;
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/org/apache/groovy/runtime/indy/package-info.java b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
new file mode 100644
index 0000000000..980309572f
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/indy/package-info.java
@@ -0,0 +1,29 @@
+/*
+ *  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.
+ */
+
+/**
+ * Scoped invokedynamic SwitchPoint utilities for the Groovy MOP (GROOVY-12191).
+ * <p>
+ * Canonical policy and domain model: {@link IndyInvalidation}.
+ * Install guards via {@link IndyInvalidation#guardWithMopSwitchPoints} or
+ * {@code IndyInterface.applyMopSwitchPoints}.
+ *
+ * @since 6.0.0
+ */
+package org.apache.groovy.runtime.indy;
diff --git a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
index aa08713430..ec2042012a 100644
--- a/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
+++ b/src/main/java/org/codehaus/groovy/reflection/ClassInfo.java
@@ -25,6 +25,9 @@ import groovy.lang.GroovySystem;
 import groovy.lang.MetaClass;
 import groovy.lang.MetaClassRegistry;
 import groovy.lang.MetaMethod;
+import groovy.transform.Internal;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
+import org.apache.groovy.runtime.indy.SwitchPointInvalidator;
 import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
 import org.codehaus.groovy.reflection.GroovyClassValue.ComputeValue;
 import org.codehaus.groovy.reflection.stdclasses.ArrayCachedClass;
@@ -49,14 +52,16 @@ import org.codehaus.groovy.util.LockableObject;
 import org.codehaus.groovy.util.ManagedConcurrentLinkedQueue;
 import org.codehaus.groovy.util.ManagedReference;
 import org.codehaus.groovy.util.ReferenceBundle;
-import org.codehaus.groovy.vmplugin.VMPluginFactory;
 
 import java.io.Serial;
+import java.lang.invoke.SwitchPoint;
 import java.lang.ref.WeakReference;
 import java.math.BigDecimal;
 import java.math.BigInteger;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
+import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -77,6 +82,14 @@ public class ClassInfo implements Finalizable {
     public final int hash = -1;
     private final WeakReference<Class<?>> classRef;
     private final AtomicInteger version = new AtomicInteger();
+    /**
+     * Pending indy domain used only while no class-level MetaClass is installed
+     * (defineClass / pre-MC link). Retired on first MetaClass install. Once a
+     * MetaClass exists, domains live in {@link IndyInvalidation}'s MetaClass
+     * identity map only — this field is not used for post-MC generations.
+     */
+    private final SwitchPointInvalidator pendingIndySwitchPoint =
+            IndyInvalidation.newPendingInvalidator();
     private MetaClass strongMetaClass;
     private ManagedReference<MetaClass> weakMetaClass;
     MetaMethod[] dgmMetaMethods = MetaMethod.EMPTY_ARRAY;
@@ -124,12 +137,149 @@ public class ClassInfo implements Finalizable {
     }
 
     /**
-     * Increments the version number and invalidates call sites.
-     * Called when metaclass modifications occur (e.g., adding methods to an {@code ExpandoMetaClass}).
+     * Increments the version number and invalidates this class's MetaClass
+     * SwitchPoint domain (exact class only). Called when metaclass modifications
+     * occur (e.g., adding methods to an {@code ExpandoMetaClass}).
+     * <p>
+     * <strong>Behavioral change in 6.0 (GROOVY-12191):</strong> this method no longer
+     * triggers a process-wide call-site flush. It scopes invalidation to this class
+     * only. Subtype sites are not retired: stock missing-method / missing-property
+     * hierarchy walks re-resolve live on the dynamic route (see
+     * {@link IndyInvalidation}). Callers that previously relied on
+     * {@code incVersion()} as a global “flush everything” hammer should invoke
+     * {@link org.codehaus.groovy.vmplugin.VMPlugin#invalidateCallSites()} (or
+     * {@link IndyInvalidation#invalidateCategory()}) explicitly when a bulk flush
+     * is required. Unrelated classes keep their optimized targets.
+     * <p>
+     * Category enter/leave still bulk-invalidates MetaClass SwitchPoints via
+     * {@link org.codehaus.groovy.vmplugin.VMPlugin#invalidateCallSites()}.
+     * SwitchPoint policy is owned by {@link IndyInvalidation}; this method only
+     * bumps generation then delegates exact-class invalidation.
      */
     public void incVersion() {
         version.incrementAndGet();
-        VMPluginFactory.getPlugin().invalidateCallSites();
+        Class<?> type = getTheClass();
+        if (type != null) {
+            IndyInvalidation.invalidateClass(type);
+        } else {
+            invalidateIndySwitchPoint();
+        }
+    }
+
+    /**
+     * Bumps {@link #getVersion()} and retires SwitchPoint domains as needed.
+     * <ul>
+     *   <li>First MetaClass install: retire pending domain only (sites linked
+     *       pre-MC re-select onto the MetaClass domain).</li>
+     *   <li>Replace/clear: retire this ClassInfo’s domains locally. When the
+     *       change also fires a MetaClass registry event, the listener applies
+     *       width policy ({@link IndyInvalidation#invalidateForMetaClassChange})
+     *       — often a second detach of an already-empty domain (idempotent).</li>
+     * </ul>
+     */
+    private void bumpGenerationLocal(final boolean retireSwitchPoint) {
+        version.incrementAndGet();
+        if (retireSwitchPoint) {
+            // Local domain ownership: width policy is IndyInvalidation's job.
+            invalidateIndySwitchPoint();
+        } else {
+            // First MetaClass install — retire pending pre-MC domain only.
+            SwitchPoint pending = pendingIndySwitchPoint.detachLive();
+            SwitchPointInvalidator.invalidateIfLive(pending);
+        }
+    }
+
+    /**
+     * Whether this class currently has a class-level MetaClass (strong or weak).
+     */
+    private boolean hasClassLevelMetaClass() {
+        if (strongMetaClass != null) {
+            return true;
+        }
+        ManagedReference<MetaClass> weakRef = weakMetaClass;
+        return weakRef != null && weakRef.get() != null;
+    }
+
+    /**
+     * Returns the SwitchPoint for monomorphic indy MOP guards on this class
+     * (GROOVY-12191). Delegates to {@link IndyInvalidation#classSwitchPointFor}.
+     *
+     * @return MetaClass domain if installed, otherwise pending domain
+     * @since 6.0.0
+     */
+    @Internal
+    public SwitchPoint getIndySwitchPoint() {
+        Class<?> type = getTheClass();
+        if (type == null) {
+            return getPendingIndySwitchPoint();
+        }
+        return IndyInvalidation.classSwitchPointFor(type);
+    }
+
+    /**
+     * Pending domain for pre-MetaClass link. Live SwitchPoint allocated lazily.
+     *
+     * @return pending SwitchPoint
+     * @since 6.0.0
+     */
+    @Internal
+    public SwitchPoint getPendingIndySwitchPoint() {
+        return pendingIndySwitchPoint.getSwitchPoint();
+    }
+
+    /**
+     * Invalidates this class's class-level MetaClass domain and pending domain
+     * without bumping {@link #getVersion()}. Prefer {@link #incVersion()} when
+     * the MetaClass actually changed.
+     *
+     * @since 6.0.0
+     */
+    @Internal
+    public void invalidateIndySwitchPoint() {
+        List<SwitchPoint> batch = new ArrayList<>(2);
+        collectLiveIndySwitchPoints(batch);
+        if (!batch.isEmpty()) {
+            SwitchPoint.invalidateAll(batch.toArray(new SwitchPoint[0]));
+        }
+    }
+
+    /**
+     * Detaches live SwitchPoint(s) for this class into {@code out}: the installed
+     * MetaClass domain (if any) and the pending domain (if live). Does not create
+     * a MetaClass.
+     *
+     * @param out destination list (must not be {@code null})
+     * @since 6.0.0
+     */
+    @Internal
+    public void collectLiveIndySwitchPoints(final List<SwitchPoint> out) {
+        IndyInvalidation.collectLiveForMetaClass(getMetaClassForClass(), out);
+        SwitchPoint pending = pendingIndySwitchPoint.detachLive();
+        if (pending != null) {
+            out.add(pending);
+        }
+    }
+
+    /**
+     * Detaches live SwitchPoint(s) for this class. Prefer
+     * {@link #collectLiveIndySwitchPoints} for bulk paths.
+     *
+     * @return one detached SwitchPoint, or {@code null}; additional live domains
+     *         are invalidated immediately so they are not orphaned
+     * @since 6.0.0
+     */
+    @Internal
+    public SwitchPoint detachLiveIndySwitchPoint() {
+        List<SwitchPoint> batch = new ArrayList<>(2);
+        collectLiveIndySwitchPoints(batch);
+        if (batch.isEmpty()) {
+            return null;
+        }
+        SwitchPoint first = batch.get(0);
+        for (int i = 1; i < batch.size(); i++) {
+            SwitchPointInvalidator.invalidateIfLive(batch.get(i));
+        }
+        return first;
     }
 
     /**
@@ -254,11 +404,21 @@ public class ClassInfo implements Finalizable {
     /**
      * Sets the strong (immutable) metaclass for this class.
      * Increments the version number and manages ExpandoMetaClass registry.
+     * <p>
+     * On replacement or clear, also invalidates this class's indy SwitchPoint
+     * (GROOVY-12191). First install ({@code null →} MC) only bumps version;
+     * exact-class invalidation for the registry event is applied by MetaClass
+     * registry listeners or {@link #incVersion()}.
      *
      * @param answer the metaclass to set, or {@code null} to clear
      */
     public void setStrongMetaClass(MetaClass answer) {
-        version.incrementAndGet();
+        // Version always; SwitchPoint when replacing/clearing an established MC.
+        // Exact-class (stock) or bulk (custom) policy is applied by registry
+        // listeners / incVersion (GROOVY-12191). First install (null → MC) only
+        // bumps version.
+        boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
+        bumpGenerationLocal(replaceOrClear);
 
         // safe value here to avoid multiple reads with possibly
         // differing values due to concurrency
@@ -300,11 +460,16 @@ public class ClassInfo implements Finalizable {
     /**
      * Sets the weak (mutable) metaclass for this class.
      * Clears the strong metaclass and increments the version number.
+     * <p>
+     * On replacement or clear, also invalidates this class's indy SwitchPoint
+     * (GROOVY-12191). First install only bumps version.
      *
      * @param answer the metaclass to set, or {@code null} to clear
      */
     public void setWeakMetaClass(MetaClass answer) {
-        version.incrementAndGet();
+        // Version always; SwitchPoint when replacing/clearing (GROOVY-12191).
+        boolean replaceOrClear = hasClassLevelMetaClass() || answer == null;
+        bumpGenerationLocal(replaceOrClear);
 
         strongMetaClass = null;
         ManagedReference<MetaClass> newRef = null;
@@ -336,19 +501,48 @@ public class ClassInfo implements Finalizable {
         MetaClass strongMc = strongMetaClass;
         if (strongMc!=null) return strongMc;
         MetaClass weakMc = getWeakMetaClass();
-        if (isValidWeakMetaClass(weakMc)) {
+        // During GroovySystem bootstrap the registry is not yet published;
+        // treat a non-null weak MC as valid rather than querying the handler.
+        MetaClassRegistry registry = GroovySystem.getMetaClassRegistry();
+        if (registry == null) {
+            return weakMc;
+        }
+        if (isValidWeakMetaClass(weakMc, registry.getMetaClassCreationHandler())) {
             return weakMc;
         }
         return null;
     }
 
+    /**
+     * Creates or returns the class-level MetaClass while holding {@link #lock}.
+     * Callers must already hold the lock ({@link #getMetaClass()}).
+     * <p>
+     * Mirrors {@link #getMetaClassForClass()} bootstrap safety: when
+     * {@link GroovySystem#getMetaClassRegistry()} is not yet published, a
+     * cached weak MetaClass is returned if present; otherwise creation is
+     * impossible and an {@link IllegalStateException} is thrown rather than
+     * an NPE on a null registry (Sonar javabugs:S2259 / PR #2736).
+     */
     private MetaClass getMetaClassUnderLock() {
         MetaClass answer = getStrongMetaClass();
-        if (answer!=null) return answer;
+        if (answer != null) {
+            return answer;
+        }
 
         answer = getWeakMetaClass();
         final MetaClassRegistry metaClassRegistry = GroovySystem.getMetaClassRegistry();
-        MetaClassRegistry.MetaClassCreationHandle mccHandle = metaClassRegistry.getMetaClassCreationHandler();
+        // Same bootstrap window as getMetaClassForClass: registry may be null
+        // while GroovySystem is still initializing.
+        if (metaClassRegistry == null) {
+            if (answer != null) {
+                return answer;
+            }
+            throw new IllegalStateException(
+                    "MetaClassRegistry is not yet initialized; cannot create MetaClass for "
+                            + getTheClass());
+        }
+        MetaClassRegistry.MetaClassCreationHandle mccHandle =
+                metaClassRegistry.getMetaClassCreationHandler();
 
         if (isValidWeakMetaClass(answer, mccHandle)) {
             return answer;
@@ -365,16 +559,14 @@ public class ClassInfo implements Finalizable {
         return answer;
     }
 
-    private static boolean isValidWeakMetaClass(MetaClass metaClass) {
-        return isValidWeakMetaClass(metaClass, GroovySystem.getMetaClassRegistry().getMetaClassCreationHandler());
-    }
-
     /**
      * if EMC.enableGlobally() is OFF, return whatever the cached answer is.
      * but if EMC.enableGlobally() is ON and the cached answer is not an EMC, come up with a fresh answer
      */
     private static boolean isValidWeakMetaClass(MetaClass metaClass, MetaClassRegistry.MetaClassCreationHandle mccHandle) {
-        if(metaClass==null) return false;
+        if (metaClass == null) {
+            return false;
+        }
         boolean enableGloballyOn = (mccHandle instanceof ExpandoMetaClassCreationHandle);
         boolean cachedAnswerIsEMC = (metaClass instanceof ExpandoMetaClass);
         return (!enableGloballyOn || cachedAnswerIsEMC);
@@ -527,7 +719,9 @@ public class ClassInfo implements Finalizable {
      * @param metaClass the metaclass to set, or {@code null} to remove the association
      */
     public void setPerInstanceMetaClass(Object obj, MetaClass metaClass) {
-        version.incrementAndGet();
+        // Per-instance MetaClass changes always retire the class SwitchPoint so
+        // sites that may observe instance-level dispatch re-link (GROOVY-12191).
+        bumpGenerationLocal(true);
 
         if (metaClass != null) {
             if (perInstanceMetaClassMap == null)
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
index 6cbd8f3687..c07140cc3b 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/ColdReflectiveMethodHandleWrapper.java
@@ -21,7 +21,9 @@ package org.codehaus.groovy.vmplugin.v8;
 import groovy.lang.GroovyObject;
 import groovy.lang.MetaClass;
 import groovy.lang.MetaMethod;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
 import org.codehaus.groovy.reflection.CachedMethod;
+import org.codehaus.groovy.reflection.ClassInfo;
 import org.codehaus.groovy.runtime.GroovyCategorySupport;
 import org.codehaus.groovy.runtime.wrappers.Wrapper;
 import org.codehaus.groovy.vmplugin.VMPluginFactory;
@@ -48,10 +50,9 @@ import java.lang.reflect.Modifier;
  * hot path unchanged.
  * <p>
  * The plain-Java validity checks in {@link #isValidFor} mirror the guards the
- * full chain would install: switch-point freshness (which also covers
- * category enter/leave, as those invalidate the switch point), exact argument
- * classes (at least as strict as the chain's same-class guards), and
- * metaclass identity for {@code GroovyObject} receivers.
+ * full chain would install: per-class SwitchPoint freshness (GROOVY-12191;
+ * category enter/leave bulk-invalidates class SwitchPoints), exact argument
+ * classes, and metaclass identity for {@code GroovyObject} receivers.
  */
 class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
 
@@ -63,7 +64,7 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
     final Boolean thisCall;
     final Boolean spreadCall;
     private final MetaClass selectionMetaClass;
-    private final SwitchPoint validity;
+    private final SwitchPoint classValidity;
     private final Class<?>[] argClasses;
     private final MethodHandle reflectiveHandle;
     /**
@@ -76,7 +77,7 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
 
     private ColdReflectiveMethodHandleWrapper(MetaMethod method, CacheableCallSite callSite, Class<?> sender,
             String methodName, int callID, Boolean safeNavigation, Boolean thisCall, Boolean spreadCall,
-            MetaClass selectionMetaClass, SwitchPoint validity, Class<?>[] argClasses) {
+            MetaClass selectionMetaClass, SwitchPoint classValidity, Class<?>[] argClasses) {
         super(null, null, method, true);
         this.callSite = callSite;
         this.sender = sender;
@@ -86,7 +87,7 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
         this.thisCall = thisCall;
         this.spreadCall = spreadCall;
         this.selectionMetaClass = selectionMetaClass;
-        this.validity = validity;
+        this.classValidity = classValidity;
         this.argClasses = argClasses;
         this.reflectiveHandle = IndyInterface.COLD_REFLECTIVE_INVOKER.bindTo(this);
     }
@@ -112,7 +113,9 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
      * @return {@code true} if the cached selection is still valid for them
      */
     boolean isValidFor(Object[] arguments) {
-        if (validity.hasBeenInvalidated()) return false;
+        if (classValidity != null && classValidity.hasBeenInvalidated()) {
+            return false;
+        }
         Class<?>[] classes = argClasses;
         if (arguments.length != classes.length) return false;
         for (int i = 0; i < classes.length; i++) {
@@ -152,8 +155,10 @@ class ColdReflectiveMethodHandleWrapper extends MethodHandleWrapper {
             if (a instanceof Wrapper) return null; // cast markers need the full coercion path
             argClasses[i] = (a == null) ? null : a.getClass();
         }
+        Object receiver = arguments[0];
+        SwitchPoint classSp = ClassInfo.getClassInfo(IndyInvalidation.switchPointClassFor(receiver)).getIndySwitchPoint();
         return new ColdReflectiveMethodHandleWrapper(cm, callSite, sender, methodName, callID,
-                safeNavigation, thisCall, spreadCall, mc, IndyInterface.switchPoint, argClasses);
+                safeNavigation, thisCall, spreadCall, mc, classSp, argClasses);
     }
 
     /**
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
index 81325caa32..208c00b10a 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyCompoundAssign.java
@@ -146,8 +146,8 @@ public final class IndyCompoundAssign {
         Class<?> ac = (arg == null) ? null : arg.getClass();
         MethodHandle test = MethodHandles.insertArguments(GUARD, 0, rc, ac);
         MethodHandle guarded = MethodHandles.guardWithTest(test, invoke, elseTarget);
-        // a metaclass change invalidates the site, exactly as for a normal indy call.
-        guarded = IndyInterface.switchPoint.guardWithTest(guarded, elseTarget);
+        // Per-class SwitchPoint, same domain as a normal indy call (GROOVY-12191).
+        guarded = IndyInterface.applyMopSwitchPoints(guarded, elseTarget, receiver);
 
         // Per-instance metaclasses make a class-keyed cache unsound, so mark such
         // shapes uncacheable (the wrapper is still used for the current call) —
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
index 9379244204..05cd876150 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/IndyInterface.java
@@ -20,6 +20,7 @@ package org.codehaus.groovy.vmplugin.v8;
 
 import groovy.lang.GroovyRuntimeException;
 import groovy.lang.GroovySystem;
+import org.apache.groovy.runtime.indy.IndyInvalidation;
 import org.apache.groovy.util.SystemUtil;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.runtime.GeneratedClosure;
@@ -32,7 +33,6 @@ import java.lang.invoke.MethodHandle;
 import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
 import java.lang.invoke.MutableCallSite;
-import java.lang.invoke.SwitchPoint;
 import java.lang.reflect.Modifier;
 import java.util.Map;
 import java.util.function.Function;
@@ -66,7 +66,13 @@ public class IndyInterface {
      */
     public static final int SAFE_NAVIGATION=1, THIS_CALL=2, GROOVY_OBJECT=4, IMPLICIT_THIS=8, SPREAD_CALL=16, UNCACHED_CALL=32;
 
-    private static final MethodHandleWrapper NULL_METHOD_HANDLE_WRAPPER = MethodHandleWrapper.getNullMethodHandleWrapper();
+    /**
+     * PIC sentinel meaning "this receiver shape is not class-keyed-cacheable".
+     * Written when {@code canSetTarget} is false (per-instance MetaClass, spread
+     * call, …). Not a real method handle — {@code fromCacheHandle} re-runs
+     * selection when it observes this value.
+     */
+    private static final MethodHandleWrapper UNCACHEABLE_PIC_SENTINEL = MethodHandleWrapper.getUncacheablePicSentinel();
     private static final String NULL_OBJECT_CLASS_NAME = "org.codehaus.groovy.runtime.NullObject";
 
     /**
@@ -214,28 +220,63 @@ public class IndyInterface {
         }
     }
 
-    /**
-     * Shared switch point invalidated when metaclass state changes.
-     */
-    protected static SwitchPoint switchPoint = new SwitchPoint();
-
     static {
-        GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> invalidateSwitchPoints());
+        // MetaClass registry changes invalidate the affected class domain (GROOVY-12191).
+        // Stock MetaClassImpl/EMC → exact class; custom MetaClass kinds → bulk.
+        //
+        // Pre-6.0 this listener rotated a process-wide SwitchPoint field on this class.
+        // That field is removed (vmplugin is internal-by-intent): call sites now guard on
+        // MetaClass-owned domains via applyMopSwitchPoints.
+        GroovySystem.getMetaClassRegistry().addMetaClassRegistryChangeEventListener(cmcu -> {
+            // Exact-class (stock) vs process-wide bulk (custom / unscoped) — IndyInvalidation.
+            IndyInvalidation.invalidateForMetaClassChange(cmcu);
+            if (LOG_ENABLED) {
+                Class<?> type = cmcu.getClassToUpdate();
+                if (type != null) {
+                    LOG.info("MetaClass registry change invalidation for " + type.getName()
+                            + (cmcu.isPerInstanceMetaClassChange() ? " (per-instance)" : ""));
+                } else {
+                    LOG.info("unscoped SwitchPoint invalidation (unattributed MetaClass change)");
+                }
+            }
+        });
     }
 
     /**
-     * Callback for constant metaclass update change
+     * Category enter/leave and {@code VMPlugin.invalidateCallSites()}.
+     * Bulk-invalidates every loaded class SwitchPoint so sites re-link under the
+     * new category state. Per-class MetaClass changes use the MetaClass-aware
+     * registry path ({@link IndyInvalidation#invalidateForMetaClassChange}) or
+     * {@link org.codehaus.groovy.reflection.ClassInfo#incVersion()} (exact-class
+     * for in-place EMC updates).
+     * <p>
+     * Pre-6.0 this method also rotated a process-wide {@code switchPoint} field.
+     * That field is gone; bulk policy lives entirely in {@link IndyInvalidation}.
      */
     protected static void invalidateSwitchPoints() {
         if (LOG_ENABLED) {
-            LOG.info("invalidating switch point");
+            LOG.info("invalidating class SwitchPoints for category / invalidateCallSites");
         }
+        IndyInvalidation.invalidateCategory();
+    }
 
-        synchronized (IndyInterface.class) {
-            SwitchPoint old = switchPoint;
-            switchPoint = new SwitchPoint();
-            SwitchPoint.invalidateAll(new SwitchPoint[]{old});
-        }
+    /**
+     * Link-time install of the MetaClass MOP SwitchPoint guard.
+     * Production call-site wiring ({@link Selector}, {@link IndyCompoundAssign})
+     * enters here so the bytecode bootstrap surface owns how handles are guarded.
+     * <p>
+     * Domain resolution (receiver → class → class-level MetaClass instance
+     * SwitchPoint, or always-relink when none is installed) and invalidation
+     * policy stay in {@link IndyInvalidation}; this method only binds the
+     * resolved SwitchPoint with {@code guardWithTest}.
+     *
+     * @param handle   fast-path handle
+     * @param fallback re-link handle
+     * @param receiver call receiver (may be {@code null})
+     * @return guarded handle
+     */
+    static MethodHandle applyMopSwitchPoints(final MethodHandle handle, final MethodHandle fallback, final Object receiver) {
+        return IndyInvalidation.classSwitchPointFor(receiver).guardWithTest(handle, fallback);
     }
 
     /**
@@ -388,10 +429,10 @@ public class IndyInterface {
         MethodHandleWrapper mhw = callSite.getAndPut(receiverClassName, (theName) -> {
             MethodHandleWrapper fallback = fallbackSupplier.get();
             if (fallback.isCanSetTarget()) return fallback;
-            return NULL_METHOD_HANDLE_WRAPPER;
+            return UNCACHEABLE_PIC_SENTINEL;
         });
 
-        if (mhw == NULL_METHOD_HANDLE_WRAPPER) {
+        if (mhw == UNCACHEABLE_PIC_SENTINEL) {
             // The PIC stores a sentinel to remember "do not relink this receiver shape";
             // execution still needs a real handle for the current invocation.
             mhw = fallbackSupplier.get();
@@ -444,9 +485,16 @@ public class IndyInterface {
      * via {@code doMethodInvoke}, {@code GroovyRuntimeException} unwrapping via
      * {@code ScriptBytecodeAdapter}). The handle bound to the wrapper has the
      * same {@code (Object[])Object} shape for every call site, arity, and
-     * primitive pattern, so cold dispatch spins no per-shape LambdaForms. On a
-     * failed validity check it takes the standard re-selection route, exactly
-     * like a failed guard in the full chain.
+     * primitive pattern, so cold dispatch spins no per-shape LambdaForms.
+     * <p>
+     * On a failed validity check (or after the reflective-hit promotion
+     * threshold), re-selection uses {@code fallback(..., allowColdReflection=false)}
+     * and may write the full wrapper — or {@link #UNCACHEABLE_PIC_SENTINEL} —
+     * into the callsite PIC. That prevents an always-invalid class-domain
+     * SwitchPoint from recursing through cold {@code tryBuild → invokeColdReflective}
+     * (GROOVY-12191). This is a deliberate cold-tier promotion change beyond
+     * pure SwitchPoint scoping: validity failure no longer re-enters the cold
+     * reflective path on the same miss.
      */
     private static Object invokeColdReflective(ColdReflectiveMethodHandleWrapper cold, Object[] arguments) throws Throwable {
         if (cold.isValidFor(arguments)) {
@@ -472,9 +520,30 @@ public class IndyInterface {
                 throw ScriptBytecodeAdapter.unwrap(gre);
             }
         }
-        MethodHandle mh = selectMethodHandle(cold.callSite, cold.sender, cold.methodName, cold.callID,
-                cold.safeNavigation, cold.thisCall, cold.spreadCall, 1, arguments);
-        return mh.invokeExact(arguments);
+        // Re-select without the cold tier so an always-invalid SwitchPoint (or any
+        // permanent cold miss after class-domain failover) cannot recurse through
+        // tryBuild → invokeColdReflective (GROOVY-12191).
+        MethodHandleWrapper full = fallback(cold.callSite, cold.sender, cold.methodName, cold.callID,
+                cold.safeNavigation, cold.thisCall, cold.spreadCall, 1, arguments, false);
+        // PIC write policy — same as fromCacheHandle / selectMethodHandle:
+        //
+        // • canSetTarget == true  → store the full wrapper so the next hit for
+        //   this receiver class reuses the linked chain (and can promote the
+        //   call-site target).
+        // • canSetTarget == false → store UNCACHEABLE_PIC_SENTINEL, meaning
+        //   "this receiver shape is not class-keyed-cacheable". Typical causes:
+        //   per-instance MetaClass, spread-call (selector.cache=false).
+        //   Storing the real uncacheable wrapper would pin a selection that is
+        //   only valid for one instance / one spread shape and would skip
+        //   re-selection on the next PIC hit. The sentinel forces
+        //   fromCacheHandle to re-run fallback while this invocation still
+        //   uses full.getCachedMethodHandle() for the current call.
+        //
+        // UNCACHEABLE_PIC_SENTINEL is therefore the only safe PIC value when
+        // the re-selected wrapper must not become a class-keyed cache entry.
+        cold.callSite.put(receiverCacheKey(arguments[0]),
+                full.isCanSetTarget() ? full : UNCACHEABLE_PIC_SENTINEL);
+        return full.getCachedMethodHandle().invokeExact(arguments);
     }
 
     /**
@@ -507,7 +576,7 @@ public class IndyInterface {
             Object receiver = arguments[0];
 
             // Avoid PIC pollution: don't write back uncached wrappers, e.g. for instance-level metaClass dispatches.
-            callSite.put(receiverCacheKey(receiver), mhw.isCanSetTarget() ? mhw : NULL_METHOD_HANDLE_WRAPPER);
+            callSite.put(receiverCacheKey(receiver), mhw.isCanSetTarget() ? mhw : UNCACHEABLE_PIC_SENTINEL);
         }
 
         return mhw.getCachedMethodHandle();
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
index 71c3cb5dbb..33ff56fb1f 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/MethodHandleWrapper.java
@@ -112,21 +112,35 @@ class MethodHandleWrapper {
     }
 
     /**
-     * Returns the sentinel wrapper used when no cacheable handle is available.
+     * Returns the PIC sentinel used when a selection must not be stored under a
+     * receiver-class cache key ({@code canSetTarget == false}).
+     * <p>
+     * Renamed from the historical {@code getNullMethodHandleWrapper} name: the
+     * value is not “null work”, it is the deliberate “do not class-key-cache
+     * this shape” marker consumed by {@code fromCacheHandle} (GROOVY-12191).
      *
-     * @return the null sentinel wrapper
+     * @return the uncacheable PIC sentinel (identity-comparable singleton)
      */
+    public static MethodHandleWrapper getUncacheablePicSentinel() {
+        return UncacheablePicSentinel.INSTANCE;
+    }
+
+    /**
+     * @deprecated use {@link #getUncacheablePicSentinel()}
+     */
+    @Deprecated
     public static MethodHandleWrapper getNullMethodHandleWrapper() {
-        return NullMethodHandleWrapper.INSTANCE;
+        return getUncacheablePicSentinel();
     }
 
-    private static class NullMethodHandleWrapper extends MethodHandleWrapper {
-        /**
-         * Shared sentinel wrapper representing the absence of a reusable method handle.
-         */
-        public static final NullMethodHandleWrapper INSTANCE = new NullMethodHandleWrapper();
+    /**
+     * Shared sentinel wrapper: not a real handle, only a PIC marker for
+     * uncacheable receiver shapes (per-instance MetaClass, spread-call, …).
+     */
+    private static final class UncacheablePicSentinel extends MethodHandleWrapper {
+        static final UncacheablePicSentinel INSTANCE = new UncacheablePicSentinel();
 
-        private NullMethodHandleWrapper() {
+        private UncacheablePicSentinel() {
             super(null, null, null, false);
         }
     }
diff --git a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
index 989e1eb0f5..9a717fd814 100644
--- a/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
+++ b/src/main/java/org/codehaus/groovy/vmplugin/v8/Selector.java
@@ -100,7 +100,7 @@ import static org.codehaus.groovy.vmplugin.v8.IndyGuardsFiltersAndSignatures.unw
 import static org.codehaus.groovy.vmplugin.v8.IndyInterface.CallType;
 import static org.codehaus.groovy.vmplugin.v8.IndyInterface.LOG;
 import static org.codehaus.groovy.vmplugin.v8.IndyInterface.LOG_ENABLED;
-import static org.codehaus.groovy.vmplugin.v8.IndyInterface.switchPoint;
+import static org.codehaus.groovy.vmplugin.v8.IndyInterface.applyMopSwitchPoints;
 
 /**
  * Base state holder for invokedynamic method, property, constructor, and cast selection.
@@ -1283,9 +1283,11 @@ public abstract class Selector {
                 }
             }
 
-            // handle constant metaclass and category changes
-            handle = switchPoint.guardWithTest(handle, fallback);
-            if (LOG_ENABLED) LOG.info("added switch point guard");
+            // Per-class MetaClass SwitchPoint (GROOVY-12191). Category enter/leave
+            // bulk-invalidates class SwitchPoints so sites re-link without a second
+            // hot-path guard.
+            handle = applyMopSwitchPoints(handle, fallback, receiver);
+            if (LOG_ENABLED) LOG.info("added class switch point guard");
 
             java.util.function.Predicate<Class<?>> nonFinalOrNullUnsafe = (t) -> {
                 return !Modifier.isFinal(t.getModifiers())
@@ -1444,7 +1446,8 @@ public abstract class Selector {
      * ({@code arguments[0]} is the receiver). The result has type
      * {@code callSite.type()}. Guard and switch-point wrapping are intentionally
      * omitted — the caller (compound-assignment) applies its own per-shape guard
-     * and shares the global MOP {@link IndyInterface#switchPoint}. The caller must
+     * and the scoped per-class MOP SwitchPoint (see
+     * {@link org.apache.groovy.runtime.indy.IndyInvalidation}). The caller must
      * have already established that {@code methodName} resolves for this receiver
      * (e.g. via {@code respondsTo}); this routes the actual invocation through the
      * same selection, coercion and wrapping path as a normal call.
diff --git a/src/test/groovy/bugs/Groovy12191.groovy b/src/test/groovy/bugs/Groovy12191.groovy
new file mode 100644
index 0000000000..2f66eb0c90
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12191.groovy
@@ -0,0 +1,847 @@
+/*
+ *  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 bugs
+
+import groovy.lang.MetaClassImpl
+import org.apache.groovy.runtime.indy.IndyInvalidation
+import org.codehaus.groovy.reflection.ClassInfo
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.SwitchPoint
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * GROOVY-12191: scoped indy SwitchPoint invalidation.
+ * Metaclass changes for class A must not invalidate class B's SwitchPoint;
+ * category enter/leave bulk-invalidates class SwitchPoints so category methods
+ * become visible to previously linked sites. Stock MetaClassImpl/EMC changes
+ * are exact-class only — no parent→child SwitchPoint fan-out (PR #2736).
+ */
+final class Groovy12191 {
+
+    @Test
+    void metaclassChange_invalidatesOnlyAffectedClassSwitchPoint() {
+        SwitchPoint spA = ClassInfo.getClassInfo(TypeA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(TypeB).indySwitchPoint
+        assertFalse(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+
+        TypeA.metaClass.extra = { -> 'from-emc' }
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+
+        assertScript '''
+            class TypeA { }
+            class TypeB {
+                String id() { 'B' }
+            }
+            TypeA.metaClass.extra = { -> 'from-emc' }
+            assert new TypeA().extra() == 'from-emc'
+            assert new TypeB().id() == 'B'
+        '''
+    }
+
+    @Test
+    void registrySetMetaClass_invalidatesClassDomain() {
+        SwitchPoint sp = ClassInfo.getClassInfo(TypeC).indySwitchPoint
+        def mc = new ExpandoMetaClass(TypeC, true, true)
+        mc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(TypeC, mc)
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void categoryEnterLeave_bulkInvalidatesClassSwitchPoints() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
+
+        use(TypeDCategory) {
+            assert 'decorated' == new TypeD().label()
+        }
+
+        assertTrue(classSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void categoryMethods_areVisibleToDynamicCalls() {
+        assertScript '''
+            class Host {
+                String name() { 'host' }
+            }
+            class HostCategory {
+                static String name(Host self) { 'category' }
+            }
+            def h = new Host()
+            assert h.name() == 'host'
+            use (HostCategory) {
+                assert h.name() == 'category'
+            }
+            assert h.name() == 'host'
+        '''
+    }
+
+    @Test
+    void indyCalls_pickUpNewMetaClassMethodsAfterChange() {
+        assertScript '''
+            class Svc {
+                int add(int a, int b) { a + b }
+            }
+            def s = new Svc()
+            assert s.add(1, 2) == 3
+            Svc.metaClass.mul = { int a, int b -> a * b }
+            s.metaClass = null
+            assert s.mul(3, 4) == 12
+            assert s.add(3, 4) == 7
+            assert new Svc().mul(2, 5) == 10
+        '''
+    }
+
+    @Test
+    void parentMetaClassChange_doesNotInvalidateSubclassSwitchPoint() {
+        // Stock policy: parent EMC mutation retires only the parent's domain.
+        // Child sites stay warm; the live miss route observes the new method.
+        SwitchPoint childSp = ClassInfo.getClassInfo(HierChild).indySwitchPoint
+        assertFalse(childSp.hasBeenInvalidated())
+
+        HierParent.metaClass.hello = { -> 'from-parent' }
+
+        assertFalse(childSp.hasBeenInvalidated(),
+                'parent EMC must not retire subclass SwitchPoint (stock exact-class)')
+        assertScript '''
+            class HierParent {}
+            class HierChild extends HierParent {}
+            HierParent.metaClass.hello = { -> 'from-parent' }
+            assert new HierChild().hello() == 'from-parent'
+        '''
+    }
+
+    @Test
+    void parentMetaClassImplReplace_doesNotFanOutToSubclassSwitchPoint() {
+        SwitchPoint parentSp = ClassInfo.getClassInfo(HierParentMcImpl).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(HierChildMcImpl).indySwitchPoint
+        assertFalse(parentSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated())
+
+        def mc = new MetaClassImpl(HierParentMcImpl)
+        mc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(HierParentMcImpl, mc)
+        try {
+            assertTrue(parentSp.hasBeenInvalidated(),
+                    'MetaClassImpl replacement must retire the exact class domain')
+            assertFalse(childSp.hasBeenInvalidated(),
+                    'pure MetaClassImpl parent replace must not retire subclass SwitchPoint')
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(HierParentMcImpl)
+        }
+    }
+
+    @Test
+    void parentEmcReplace_doesNotFanOutToSubclassSwitchPoint() {
+        SwitchPoint childSp = ClassInfo.getClassInfo(HierChildEmc).indySwitchPoint
+        assertFalse(childSp.hasBeenInvalidated())
+
+        def emc = new ExpandoMetaClass(HierParentEmc, true, true)
+        emc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(HierParentEmc, emc)
+        try {
+            assertFalse(childSp.hasBeenInvalidated(),
+                    'EMC install on parent must not retire subclass SwitchPoint')
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(HierParentEmc)
+        }
+    }
+
+    @Test
+    void unrelatedTypeMetaClassChange_doesNotInvalidateSiblingHierarchy() {
+        SwitchPoint hotSp = ClassInfo.getClassInfo(HierChildScope).indySwitchPoint
+        assertFalse(hotSp.hasBeenInvalidated())
+
+        TypeC.metaClass.siblingOnly = { -> 1 }
+
+        assertFalse(hotSp.hasBeenInvalidated(),
+                'MetaClass change on an unrelated type must not retire HierChildScope')
+    }
+
+    @Test
+    void indyCalls_overrideViaFreshMetaClassInstance() {
+        assertScript '''
+            class Svc {
+                String greet() { 'hello' }
+            }
+            def s = new Svc()
+            assert s.greet() == 'hello'
+            def emc = new ExpandoMetaClass(Svc, true, true)
+            emc.greet = { -> 'world' }
+            emc.initialize()
+            GroovySystem.metaClassRegistry.setMetaClass(Svc, emc)
+            s.metaClass = null
+            assert s.greet() == 'world'
+        '''
+    }
+
+    @Test
+    void unrelatedMetaClassChurn_doesNotInvalidateHotClass() {
+        SwitchPoint hotSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
+        assertFalse(hotSp.hasBeenInvalidated())
+
+        10.times { i ->
+            TypeC.metaClass."dyn${i}" = { -> i }
+        }
+        assertFalse(hotSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void objectArrayMetaClassChange_doesNotInvalidateStringArraySwitchPoint() {
+        // Stock exact-class: Object[] EMC does not retire String[] domain.
+        // Dispatch still sees the method via the live miss hierarchy walk.
+        SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
+        assertFalse(stringArraySp.hasBeenInvalidated())
+
+        def emc = new ExpandoMetaClass(Object[], true, true)
+        emc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(Object[], emc)
+        try {
+            assertFalse(stringArraySp.hasBeenInvalidated(),
+                    'Object[] MetaClass change must not retire String[] SwitchPoint')
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(Object[])
+        }
+    }
+
+    @Test
+    void incVersion_scopesToExactClass_notGlobal() {
+        SwitchPoint hotSp = ClassInfo.getClassInfo(TypeD).indySwitchPoint
+        SwitchPoint targetSp = ClassInfo.getClassInfo(TypeC).indySwitchPoint
+        assertFalse(hotSp.hasBeenInvalidated())
+        assertFalse(targetSp.hasBeenInvalidated())
+
+        ClassInfo.getClassInfo(TypeC).incVersion()
+
+        assertTrue(targetSp.hasBeenInvalidated())
+        assertFalse(hotSp.hasBeenInvalidated(),
+                'incVersion must not act as a process-wide flush (GROOVY-12191)')
+    }
+
+    /**
+     * Blackdrag scenario matrix (PR #2736): {@code call(c)} uses receiver class C.
+     * Mutations on B/A must not retire C's domain; methods added on B/A must not
+     * become visible on a pure C instance. Stock policy is exact-class throughout.
+     */
+    @Test
+    void blackdragScenario_callOnC_notInvalidatedBySubclassMetaClassChurn() {
+        def c = new BdC()
+        assert c.fooC() == 'fooC'
+        new BdB()
+        new BdA()
+        SwitchPoint spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(BdB).indySwitchPoint
+        SwitchPoint spA = ClassInfo.getClassInfo(BdA).indySwitchPoint
+        assertFalse(spC.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+        assertFalse(spA.hasBeenInvalidated())
+
+        // B.metaClass = EMC + method — exact-class B only (not A, not C).
+        def emcB = new ExpandoMetaClass(BdB, true, true)
+        emcB.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(BdB, emcB)
+        try {
+            assertTrue(spB.hasBeenInvalidated())
+            assertFalse(spA.hasBeenInvalidated(), 'EMC on B must not fan out to A')
+            assertFalse(spC.hasBeenInvalidated(), 'EMC on B must not retire C (supertype)')
+            assert c.fooC() == 'fooC'
+            assert !c.metaClass.respondsTo(c, 'fooB0')
+
+            spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
+            spA = ClassInfo.getClassInfo(BdA).indySwitchPoint
+            BdB.metaClass.fooB0 = { -> 'fooB0' }
+            // in-place EMC update retires B's domain again; A/C stay warm
+            assertFalse(spA.hasBeenInvalidated(), 'B EMC update must not fan out to A')
+            assertFalse(spC.hasBeenInvalidated())
+            assert new BdB().fooB0() == 'fooB0'
+            assert new BdA().fooB0() == 'fooB0' // live hierarchy walk
+            assert !c.metaClass.respondsTo(c, 'fooB0')
+            assert c.fooC() == 'fooC'
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(BdB)
+        }
+
+        spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
+        BdA.metaClass.fooA = { -> 'fooA' }
+        try {
+            assertFalse(spC.hasBeenInvalidated())
+            assert new BdA().fooA() == 'fooA'
+            assert !c.metaClass.respondsTo(c, 'fooA')
+            assert c.fooC() == 'fooC'
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(BdA)
+        }
+
+        spC = ClassInfo.getClassInfo(BdC).indySwitchPoint
+        def b = new BdB()
+        def emcInst = new ExpandoMetaClass(BdB, false, true)
+        emcInst.initialize()
+        emcInst.fooB2 = { -> 'fooB2' }
+        b.metaClass = emcInst
+        assertFalse(spC.hasBeenInvalidated(),
+                'per-instance MetaClass on B must not retire C SwitchPoint')
+        assert b.fooB2() == 'fooB2'
+        assert b.fooC() == 'fooC'
+        assert !c.metaClass.respondsTo(c, 'fooB2')
+        assert c.fooC() == 'fooC'
+        b.metaClass = null
+    }
+
+    @Test
+    void objectArrayEmcMethod_visibleOnStringArray() {
+        assertScript '''
+            Object[].metaClass.arrHello = { -> 'arr' }
+            try {
+                assert (new Object[0]).arrHello() == 'arr'
+                assert (new String[0]).arrHello() == 'arr'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(Object[])
+            }
+        '''
+    }
+
+    /**
+     * Behaviour-only: linked miss on String[] then Object[] EMC add must become
+     * visible without String[] SwitchPoint retirement (PR #2736 experiment).
+     */
+    @Test
+    void linkedMiss_stringArray_thenObjectArrayEmcAdd_isVisible() {
+        assertScript '''
+            def probe(x) {
+                try { return x.arrHello() } catch (MissingMethodException e) { return 'miss' }
+            }
+            String[] arr = ['a', 'b']
+            200.times { assert probe(arr) == 'miss' }
+            try {
+                Object[].metaClass.arrHello = { -> 'array-visible' }
+                assert probe(arr) == 'array-visible'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(Object[])
+                GroovySystem.metaClassRegistry.removeMetaClass(String[])
+            }
+        '''
+    }
+
+    /**
+     * Blackdrag PR #2736 comment (corrected parent→child direction): hierarchy
+     * walk is for <em>missing</em> methods only. Present methods on the child
+     * keep winning; a new overload on the parent is found without needing a
+     * prior site; MetaClassImpl construction timing yields C vs D divergence
+     * (pre-existing MOP, not fixed by SwitchPoint scoping).
+     *
+     * @see <a href="https://github.com/apache/groovy/pull/2736#issuecomment-5108446270">PR comment</a>
+     */
+    @Test
+    void blackdragScenario_missingMethodOnly_andMetaClassImplTiming() {
+        assertScript '''
+            class A {}
+            class B extends A {
+                def m1() { 1 }
+                def m2(Integer x) { 2 }
+            }
+            class C extends A {
+                def m2(x) { 40 }
+            }
+            class D extends A {
+                def m2(x) { 50 }
+            }
+            def c = new C()
+            def b = new B()
+            assert b.m1() == 1
+            assert b.m2(0) == 2
+
+            A.metaClass.m1 = { -> -1 }
+            A.metaClass.m2 = { Integer x -> -2 }
+            A.metaClass.m2 = { String x -> -3 }
+            try {
+                def a = new A()
+                assert a.m1() == -1
+                assert a.m2(0) == -2
+                assert a.m2('') == -3
+
+                b.metaClass = null
+                assert b.m1() == 1
+                assert b.m2(0) == 2
+                assert b.m2('') == -3
+
+                assert c.m2(null) == 40
+                assert c.m2(0) == 40
+                assert c.m2('') == 40
+                c = new C()
+                assert c.m2(null) == 40
+                assert c.m2(0) == 40
+                assert c.m2('') == 40
+
+                c.metaClass.m1 = { -> -40 }
+                assert c.m1() == -40
+                assert c.m2(null) == 40
+                assert c.m2(0) == -2
+                assert c.m2('') == -3
+
+                def d = new D()
+                assert d.m2(null) == 50
+                assert d.m2(0) == -2
+                assert d.m2('') == -3
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(A)
+                GroovySystem.metaClassRegistry.removeMetaClass(B)
+                GroovySystem.metaClassRegistry.removeMetaClass(C)
+                GroovySystem.metaClassRegistry.removeMetaClass(D)
+            }
+        '''
+    }
+
+    /**
+     * Blackdrag callsite-caching matrix: receiver MetaClass change (exact class)
+     * drives re-link; parent EMC remove does not rewrite a child MetaClassImpl
+     * snapshot. Stock policy does not require hierarchy SwitchPoint for the
+     * miss path.
+     */
+    @Test
+    void blackdragScenario_callsiteCaching_metaClassChangeNotHierarchyVersion() {
+        assertScript '''
+            import org.codehaus.groovy.runtime.metaclass.MetaClassRegistryImpl
+            import groovy.lang.MetaClassImpl
+            import groovy.lang.ExpandoMetaClass
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class A {}
+            class C extends A {
+                def m2(x) { 40 }
+            }
+            def call(x, y) { x.m2(y) }
+            def c = new C()
+
+            A.metaClass.m2 = { Integer x -> -2 }
+            A.metaClass.m2 = { String x -> -3 }
+            try {
+                assert call(c, 0) == 40
+                c = new C()
+                assert call(c, 0) == 40
+
+                def mc = C.metaClass.delegate
+                assert mc instanceof MetaClassImpl
+
+                SwitchPoint spC = ClassInfo.getClassInfo(C).indySwitchPoint
+                c.metaClass.m1 = { -> -40 }
+                assert spC.hasBeenInvalidated()
+                assert call(c, 0) == -2
+
+                def emc = c.metaClass.delegate
+                assert emc instanceof ExpandoMetaClass
+
+                spC = ClassInfo.getClassInfo(C).indySwitchPoint
+                GroovySystem.metaClassRegistry.setMetaClass(C, mc)
+                c.metaClass = null
+                assert spC.hasBeenInvalidated()
+                assert call(c, 0) == 40
+
+                def registry = MetaClassRegistryImpl.getInstance(0)
+                spC = ClassInfo.getClassInfo(C).indySwitchPoint
+                registry.removeMetaClass(C)
+                c.metaClass = null
+                assert spC.hasBeenInvalidated()
+                assert call(c, 0) == -2
+                def newMc = c.metaClass.delegate
+                assert newMc instanceof MetaClassImpl
+                assert newMc != mc
+
+                // Reset A: stock exact-class — C's SwitchPoint is NOT retired.
+                // Child MetaClassImpl still holds the construction-time snapshot.
+                SwitchPoint spAfterA = ClassInfo.getClassInfo(C).indySwitchPoint
+                A.metaClass = null
+                assert !spAfterA.hasBeenInvalidated() :
+                        'parent EMC remove must not retire child SwitchPoint'
+                assert call(c, 0) == -2
+
+                def catched = false
+                try {
+                    new A().m2(0)
+                } catch (MissingMethodException ignored) {
+                    catched = true
+                }
+                assert catched
+
+                spC = ClassInfo.getClassInfo(C).indySwitchPoint
+                GroovySystem.metaClassRegistry.setMetaClass(C, emc)
+                c.metaClass = null
+                assert spC.hasBeenInvalidated()
+                assert call(c, 0) == -2
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(A)
+                GroovySystem.metaClassRegistry.removeMetaClass(C)
+            }
+        '''
+    }
+
+    /**
+     * Linked hit via live miss route: after parent EMC is removed, the next
+     * call misses again — without requiring child SwitchPoint retirement.
+     */
+    @Test
+    void parentEmcRemoveAfterLinkedHit_childCallSiteMissesAgain() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class ParentHit {}
+            class ChildHit extends ParentHit {}
+            def call(x) { x.hello() }
+            def c = new ChildHit()
+            ParentHit.metaClass.hello = { -> 'from-parent' }
+            try {
+                assert call(c) == 'from-parent'
+                SwitchPoint childSp = ClassInfo.getClassInfo(ChildHit).indySwitchPoint
+                GroovySystem.metaClassRegistry.removeMetaClass(ParentHit)
+                assert !childSp.hasBeenInvalidated() :
+                        'parent remove must not retire child SwitchPoint'
+                def missed = false
+                try {
+                    call(c)
+                } catch (MissingMethodException ignored) {
+                    missed = true
+                }
+                assert missed
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(ParentHit)
+                GroovySystem.metaClassRegistry.removeMetaClass(ChildHit)
+            }
+        '''
+    }
+
+    /**
+     * Linked miss then parent EMC add: live miss route observes the new method
+     * without child SwitchPoint retirement.
+     */
+    @Test
+    void parentEmcAfterLinkedMiss_childCallSiteSeesNewMethod() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class ParentMiss {}
+            class ChildMiss extends ParentMiss {}
+            def call(x) { x.hello() }
+            def c = new ChildMiss()
+            def missed = false
+            try {
+                call(c)
+            } catch (MissingMethodException ignored) {
+                missed = true
+            }
+            assert missed
+
+            SwitchPoint childSp = ClassInfo.getClassInfo(ChildMiss).indySwitchPoint
+            ParentMiss.metaClass.hello = { -> 'from-parent' }
+            try {
+                assert !childSp.hasBeenInvalidated()
+                assert call(c) == 'from-parent'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(ParentMiss)
+                GroovySystem.metaClassRegistry.removeMetaClass(ChildMiss)
+            }
+        '''
+    }
+
+    /**
+     * Present child method must keep winning after parent EMC add (no SP
+     * fan-out needed; re-link would not change the outcome either).
+     */
+    @Test
+    void parentEmc_doesNotReplacePresentChildMethod() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class ParentPresent {}
+            class ChildPresent extends ParentPresent {
+                def m2(x) { 40 }
+            }
+            def call(x, y) { x.m2(y) }
+            def c = new ChildPresent()
+            assert call(c, 0) == 40
+
+            SwitchPoint childSp = ClassInfo.getClassInfo(ChildPresent).indySwitchPoint
+            ParentPresent.metaClass.m2 = { Integer x -> -2 }
+            try {
+                assert !childSp.hasBeenInvalidated()
+                assert call(c, 0) == 40
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(ParentPresent)
+                GroovySystem.metaClassRegistry.removeMetaClass(ChildPresent)
+            }
+        '''
+    }
+
+    /**
+     * Pure Java inheritance: parent EMC visible on subtype via live hierarchy
+     * walk, without subtype SwitchPoint retirement.
+     */
+    @Test
+    void javaSubtype_seesParentEmc_withoutHierarchyFanOut() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            def lhm = new LinkedHashMap()
+            def call(x) { x.jHello() }
+            def missed = false
+            try {
+                call(lhm)
+            } catch (MissingMethodException ignored) {
+                missed = true
+            }
+            assert missed
+
+            SwitchPoint lhmSp = ClassInfo.getClassInfo(LinkedHashMap).indySwitchPoint
+            HashMap.metaClass.jHello = { -> 'jh' }
+            try {
+                assert new HashMap().jHello() == 'jh'
+                assert !lhmSp.hasBeenInvalidated()
+                assert call(lhm) == 'jh'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(HashMap)
+                GroovySystem.metaClassRegistry.removeMetaClass(LinkedHashMap)
+            }
+        '''
+    }
+
+    static class TypeA {}
+    static class TypeB {
+        String id() { 'B' }
+    }
+    static class TypeC {}
+    static class TypeD {
+        String label() { 'plain' }
+    }
+    static class TypeDCategory {
+        static String label(TypeD self) { 'decorated' }
+    }
+    static class HierParent {}
+    static class HierChild extends HierParent {}
+    static class HierParentMcImpl {}
+    static class HierChildMcImpl extends HierParentMcImpl {}
+    static class HierParentEmc {}
+    static class HierChildEmc extends HierParentEmc {}
+    static class HierParentScope {}
+    static class HierChildScope extends HierParentScope {}
+    /** blackdrag scenario types: A extends B extends C */
+    static class BdC {
+        String fooC() { 'fooC' }
+    }
+    static class BdB extends BdC {}
+    static class BdA extends BdB {}
+
+    /**
+     * SwitchPoint domain is owned by the MetaClass instance, not solely by
+     * ClassInfo. ClassInfo.getIndySwitchPoint delegates to the installed
+     * MetaClass when present.
+     */
+    @Test
+    void metaClassOwnsSwitchPoint_singleDomainViaIdentityMap() {
+        def info = ClassInfo.getClassInfo(McOwnerHost)
+        try {
+            info.strongMetaClass = null
+            info.weakMetaClass = null
+            def mc = new MetaClassImpl(McOwnerHost)
+            mc.initialize()
+            SwitchPoint onMc = IndyInvalidation.switchPointForMetaClass(mc)
+            info.strongMetaClass = mc
+            assertSame(onMc, info.indySwitchPoint)
+            assertSame(onMc, IndyInvalidation.classSwitchPointFor(McOwnerHost))
+            def custom = new groovy.lang.DelegatingMetaClass(mc) {}
+            assertSame(onMc, IndyInvalidation.switchPointForMetaClass(custom))
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(McOwnerHost)
+        }
+    }
+
+    /**
+     * MetaClassImpl continues to observe ancestor MetaClass state on the
+     * missing-method path ({@code Object.metaClass.foo} visible on subtypes).
+     */
+    @Test
+    void metaClassImplStillObservesAncestorEmc_objectMetaClassPattern() {
+        assertScript '''
+            class FollowUp2Child {}
+            try {
+                Object.metaClass.followUp2Marker = { -> 'from-object' }
+                assert new Object().followUp2Marker() == 'from-object'
+                assert new FollowUp2Child().followUp2Marker() == 'from-object'
+                assert 'x'.followUp2Marker() == 'from-object'
+            } finally {
+                Object.metaClass = null
+                GroovySystem.metaClassRegistry.removeMetaClass(FollowUp2Child)
+            }
+        '''
+    }
+
+    // ------------------------------------------------------------------
+    // Property-site probes (blackdrag: "True we need to be sure the
+    // property case is covered." — PR #2736 follow-up)
+    // ------------------------------------------------------------------
+
+    /**
+     * Linked property miss on child, then parent EMC property add: live property
+     * miss walk observes the new property without child SwitchPoint retirement.
+     */
+    @Test
+    void property_linkedMissThenParentEmcAdd_isVisible() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class PropParent {}
+            class PropChild extends PropParent {}
+            def probe(x) {
+                try { return x.onlyOnParentProp } catch (MissingPropertyException e) { return 'miss' }
+            }
+            def c = new PropChild()
+            200.times { assert probe(c) == 'miss' }
+            SwitchPoint childSp = ClassInfo.getClassInfo(PropChild).indySwitchPoint
+            try {
+                PropParent.metaClass.onlyOnParentProp = 'now-visible'
+                assert !childSp.hasBeenInvalidated() :
+                        'parent property EMC must not retire child SwitchPoint'
+                assert probe(c) == 'now-visible'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParent)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropChild)
+            }
+        '''
+    }
+
+    /**
+     * Linked property hit via live miss route, then parent EMC remove: next
+     * access misses again without child SwitchPoint retirement.
+     */
+    @Test
+    void property_parentEmcRemoveAfterLinkedHit_missesAgain() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class PropParentHit {}
+            class PropChildHit extends PropParentHit {}
+            def probe(x) {
+                try { return x.helloProp } catch (MissingPropertyException e) { return 'miss' }
+            }
+            def c = new PropChildHit()
+            PropParentHit.metaClass.helloProp = 'from-parent'
+            try {
+                assert probe(c) == 'from-parent'
+                SwitchPoint childSp = ClassInfo.getClassInfo(PropChildHit).indySwitchPoint
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParentHit)
+                assert !childSp.hasBeenInvalidated()
+                assert probe(c) == 'miss'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParentHit)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropChildHit)
+            }
+        '''
+    }
+
+    /**
+     * Array-lattice <em>method</em> form of a property getter on Object[] is
+     * visible on String[] via the live miss route (same as
+     * {@link #linkedMiss_stringArray_thenObjectArrayEmcAdd_isVisible}). Plain
+     * {@code arr.foo} on a Java array is GPath collect, not MetaClass property
+     * miss — so property-style EMC on arrays is exercised through getXxx.
+     */
+    @Test
+    void propertyGetter_linkedMiss_stringArray_thenObjectArrayEmcAdd_isVisible() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            def probe(x) {
+                try { return x.getArrOnlyProp() } catch (MissingMethodException e) { return 'miss' }
+            }
+            def arr = new String[0]
+            50.times { assert probe(arr) == 'miss' }
+            SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
+            try {
+                Object[].metaClass.getArrOnlyProp = { -> 'array-visible' }
+                assert !stringArraySp.hasBeenInvalidated()
+                assert probe(arr) == 'array-visible'
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(Object[])
+                GroovySystem.metaClassRegistry.removeMetaClass(String[])
+            }
+        '''
+    }
+
+    /**
+     * Present child property keeps winning after parent EMC adds a same-name
+     * property (construction-time / present-property selection).
+     */
+    @Test
+    void property_parentEmc_doesNotReplacePresentChildProperty() {
+        assertScript '''
+            import org.codehaus.groovy.reflection.ClassInfo
+            import java.lang.invoke.SwitchPoint
+
+            class PropParentPresent {}
+            class PropChildPresent extends PropParentPresent {
+                def getM2() { 40 }
+            }
+            def probe(x) { x.m2 }
+            def c = new PropChildPresent()
+            assert probe(c) == 40
+
+            SwitchPoint childSp = ClassInfo.getClassInfo(PropChildPresent).indySwitchPoint
+            PropParentPresent.metaClass.m2 = -2
+            try {
+                assert !childSp.hasBeenInvalidated()
+                assert probe(c) == 40
+            } finally {
+                GroovySystem.metaClassRegistry.removeMetaClass(PropParentPresent)
+                GroovySystem.metaClassRegistry.removeMetaClass(PropChildPresent)
+            }
+        '''
+    }
+
+    /**
+     * Exact-class property update on the receiver class itself must retire that
+     * class domain and make the property visible (same as methods).
+     */
+    @Test
+    void property_receiverEmcAdd_invalidatesOwnSwitchPointAndIsVisible() {
+        SwitchPoint sp = ClassInfo.getClassInfo(PropOwnHost).indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        PropOwnHost.metaClass.ownProp = 'yes'
+        try {
+            assertTrue(sp.hasBeenInvalidated())
+            assert new PropOwnHost().ownProp == 'yes'
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(PropOwnHost)
+        }
+    }
+
+    static class McOwnerHost {}
+    static class PropOwnHost {}
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
new file mode 100644
index 0000000000..3cf15effd1
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/IndyInvalidationTest.groovy
@@ -0,0 +1,649 @@
+/*
+ *  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.groovy.runtime.indy
+
+import groovy.lang.ExpandoMetaClass
+import groovy.lang.GroovySystem
+import groovy.lang.MetaClass
+import org.codehaus.groovy.reflection.ClassInfo
+import org.codehaus.groovy.runtime.NullObject
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.SwitchPoint
+import java.lang.ref.WeakReference
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link IndyInvalidation} — MetaClass-owned SwitchPoint model
+ * with exact-class stock policy and bulk invalidation for custom MetaClasses
+ * (GROOVY-12191).
+ */
+final class IndyInvalidationTest {
+
+    @BeforeEach
+    void resetCounters() {
+        IndyInvalidation.resetCountersForTesting()
+    }
+
+    @Test
+    void switchPointClassFor_null_classReceiver_andInstance() {
+        assertSame(NullObject, IndyInvalidation.switchPointClassFor(null))
+        assertSame(String, IndyInvalidation.switchPointClassFor(String))
+        assertSame(String, IndyInvalidation.switchPointClassFor('hi'))
+        assertSame(Integer, IndyInvalidation.switchPointClassFor(42))
+    }
+
+    @Test
+    void invalidateClass_onlyAffectsThatClass_notSiblings() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        assertFalse(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+
+        long before = IndyInvalidation.classInvalidationCount()
+        IndyInvalidation.invalidateClass(ClassA)
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+        assertEquals(before + 1, IndyInvalidation.classInvalidationCount())
+
+        SwitchPoint spA2 = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        assertNotSame(spA, spA2)
+        assertFalse(spA2.hasBeenInvalidated())
+        assertSame(spB, ClassInfo.getClassInfo(ClassB).indySwitchPoint)
+    }
+
+    @Test
+    void invalidateClass_doesNotFanOutToSubtypes() {
+        // Stock policy: parent invalidation is exact-class only (PR #2736).
+        SwitchPoint parentSp = ClassInfo.getClassInfo(Parent).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(Child).indySwitchPoint
+        SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(Parent)
+
+        assertTrue(parentSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'stock invalidateClass must not retire subtype SwitchPoints')
+        assertFalse(siblingSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClass_null_isNoOp() {
+        long before = IndyInvalidation.classInvalidationCount()
+        IndyInvalidation.invalidateClass(null)
+        assertEquals(before, IndyInvalidation.classInvalidationCount())
+    }
+
+    @Test
+    void invalidateClass_objectArray_doesNotRetireStringArray() {
+        SwitchPoint objectArraySp = ClassInfo.getClassInfo(Object[]).indySwitchPoint
+        SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
+        SwitchPoint siblingSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(Object[])
+
+        assertTrue(objectArraySp.hasBeenInvalidated())
+        assertFalse(stringArraySp.hasBeenInvalidated(),
+                'array lattice fan-out is not required; miss path is live')
+        assertFalse(siblingSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClass_stringArray_doesNotRetireUnrelatedArrays() {
+        SwitchPoint stringArraySp = ClassInfo.getClassInfo(String[]).indySwitchPoint
+        SwitchPoint integerArraySp = ClassInfo.getClassInfo(Integer[]).indySwitchPoint
+        SwitchPoint objectArraySp = ClassInfo.getClassInfo(Object[]).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(String[])
+
+        assertTrue(stringArraySp.hasBeenInvalidated())
+        assertFalse(integerArraySp.hasBeenInvalidated())
+        assertFalse(objectArraySp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClass_primitiveArray_retiresOnlyThatArrayType() {
+        SwitchPoint intArraySp = ClassInfo.getClassInfo(int[]).indySwitchPoint
+        SwitchPoint longArraySp = ClassInfo.getClassInfo(long[]).indySwitchPoint
+        SwitchPoint objectArraySp = ClassInfo.getClassInfo(Object[]).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(int[])
+
+        assertTrue(intArraySp.hasBeenInvalidated())
+        assertFalse(longArraySp.hasBeenInvalidated())
+        assertFalse(objectArraySp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateClass_interface_doesNotFanOutToImplementors() {
+        SwitchPoint ifaceSp = ClassInfo.getClassInfo(Marker).indySwitchPoint
+        SwitchPoint implSp = ClassInfo.getClassInfo(MarkerImpl).indySwitchPoint
+        SwitchPoint otherSp = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+
+        IndyInvalidation.invalidateClass(Marker)
+
+        assertTrue(ifaceSp.hasBeenInvalidated())
+        assertFalse(implSp.hasBeenInvalidated())
+        assertFalse(otherSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_stringArrayFallsBackOnOwnInvalidateOnly() {
+        def target = MethodHandles.constant(int, 11)
+        def fallback = MethodHandles.constant(int, 12)
+        def receiver = new String[0]
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+        assertEquals(11, guarded.invokeWithArguments())
+
+        // Parent array-type invalidation must not deopt String[] sites.
+        IndyInvalidation.invalidateClass(Object[])
+        assertEquals(11, guarded.invokeWithArguments(),
+                'String[] site must stay linked when only Object[] domain retires')
+
+        IndyInvalidation.invalidateClass(String[])
+        assertEquals(12, guarded.invokeWithArguments(),
+                'String[] site must fall back after its own domain retires')
+    }
+
+    @Test
+    void invalidateCategory_bulkInvalidatesClassDomains() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        long catCount = IndyInvalidation.categoryInvalidationCount()
+
+        IndyInvalidation.invalidateCategory()
+
+        assertTrue(classSp.hasBeenInvalidated())
+        assertEquals(catCount + 1, IndyInvalidation.categoryInvalidationCount())
+        SwitchPoint fresh = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        assertFalse(fresh.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateUnscoped_retiresLoadedClassSwitchPoints() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        long classBefore = IndyInvalidation.classInvalidationCount()
+        IndyInvalidation.invalidateUnscoped()
+        assertTrue(spA.hasBeenInvalidated())
+        assertTrue(spB.hasBeenInvalidated())
+        assertEquals(bulkBefore + 1, IndyInvalidation.bulkInvalidationCount(),
+                'unscoped is process-wide bulk (non-category)')
+        assertEquals(classBefore, IndyInvalidation.classInvalidationCount(),
+                'unscoped must not increment exact-class counter')
+    }
+
+    @Test
+    void invalidateBulk_retiresAllLoadedClassDomains() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        long classBefore = IndyInvalidation.classInvalidationCount()
+        long catBefore = IndyInvalidation.categoryInvalidationCount()
+
+        IndyInvalidation.invalidateBulk()
+
+        assertTrue(spA.hasBeenInvalidated())
+        assertTrue(spB.hasBeenInvalidated())
+        assertEquals(bulkBefore + 1, IndyInvalidation.bulkInvalidationCount())
+        assertEquals(classBefore, IndyInvalidation.classInvalidationCount())
+        assertEquals(catBefore, IndyInvalidation.categoryInvalidationCount())
+    }
+
+    @Test
+    void invalidateCategory_doesNotIncrementBulkCounter() {
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        IndyInvalidation.invalidateCategory()
+        assertEquals(bulkBefore, IndyInvalidation.bulkInvalidationCount(),
+                'category has its own counter; must not share bulk tally')
+    }
+
+    @Test
+    void classInfoIncVersion_invalidatesClassDomain_notSiblingsOrSubtypes() {
+        SwitchPoint spA = ClassInfo.getClassInfo(Parent).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(Child).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        int versionBefore = ClassInfo.getClassInfo(Parent).version
+
+        ClassInfo.getClassInfo(Parent).incVersion()
+
+        assertEquals(versionBefore + 1, ClassInfo.getClassInfo(Parent).version)
+        assertTrue(spA.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated())
+        assertFalse(spB.hasBeenInvalidated())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_fallsBackOnClassInvalidate() {
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def receiver = new ClassA()
+
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+        assertEquals(1, guarded.invokeWithArguments())
+
+        IndyInvalidation.invalidateClass(ClassA)
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_fallsBackOnCategoryInvalidate() {
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def receiver = new ClassA()
+
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+        assertEquals(1, guarded.invokeWithArguments())
+
+        IndyInvalidation.invalidateCategory()
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_classOverload() {
+        def target = MethodHandles.constant(int, 3)
+        def fallback = MethodHandles.constant(int, 4)
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, ClassA)
+        assertEquals(3, guarded.invokeWithArguments())
+        IndyInvalidation.invalidateClass(ClassA)
+        assertEquals(4, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void classSwitchPointFor_classAndReceiverOverloads() {
+        SwitchPoint byClass = IndyInvalidation.classSwitchPointFor(String)
+        SwitchPoint byReceiver = IndyInvalidation.classSwitchPointFor('x')
+        assertSame(byClass, byReceiver)
+        assertFalse(byClass.hasBeenInvalidated())
+        SwitchPoint byNull = IndyInvalidation.classSwitchPointFor((Object) null)
+        assertSame(ClassInfo.getClassInfo(NullObject).indySwitchPoint, byNull)
+    }
+
+    @Test
+    void resetCountersForTesting_zerosCounters() {
+        IndyInvalidation.invalidateClass(ClassA)
+        IndyInvalidation.invalidateCategory()
+        IndyInvalidation.invalidateBulk()
+        IndyInvalidation.invalidateUnscoped()
+        assertTrue(IndyInvalidation.classInvalidationCount() > 0)
+        assertTrue(IndyInvalidation.categoryInvalidationCount() > 0)
+        assertTrue(IndyInvalidation.bulkInvalidationCount() > 0)
+        IndyInvalidation.resetCountersForTesting()
+        assertEquals(0, IndyInvalidation.classInvalidationCount())
+        assertEquals(0, IndyInvalidation.categoryInvalidationCount())
+        assertEquals(0, IndyInvalidation.bulkInvalidationCount())
+    }
+
+    @Test
+    void isStockMetaClass_andNeedsBulkInvalidation() {
+        def mcImpl = new groovy.lang.MetaClassImpl(ClassA)
+        mcImpl.initialize()
+        def emc = new ExpandoMetaClass(ClassA, true, true)
+        emc.initialize()
+        def custom = new PureCustomMetaClass(ClassA)
+
+        assertTrue(IndyInvalidation.isStockMetaClass(mcImpl))
+        assertTrue(IndyInvalidation.isStockMetaClass(null))
+        assertTrue(IndyInvalidation.isStockMetaClass(emc),
+                'EMC is stock MetaClassImpl; exact-class is sufficient')
+        assertFalse(IndyInvalidation.isStockMetaClass(custom),
+                'non-MetaClassImpl custom kinds require bulk')
+
+        assertFalse(IndyInvalidation.needsBulkInvalidation(mcImpl, mcImpl))
+        assertFalse(IndyInvalidation.needsBulkInvalidation(null, mcImpl))
+        assertFalse(IndyInvalidation.needsBulkInvalidation(null, emc),
+                'EMC install is stock → exact-class')
+        assertFalse(IndyInvalidation.needsBulkInvalidation(emc, mcImpl))
+        assertFalse(IndyInvalidation.needsBulkInvalidation(mcImpl, mcImpl))
+        assertTrue(IndyInvalidation.needsBulkInvalidation(mcImpl, custom),
+                'custom MetaClass must bulk-invalidate')
+        assertTrue(IndyInvalidation.needsBulkInvalidation(custom, null))
+    }
+
+    @Test
+    void isStockMetaClass_unwrapsHandleMetaClass() {
+        def emc = new ExpandoMetaClass(ClassA, true, true)
+        emc.initialize()
+        def handle = new org.codehaus.groovy.runtime.HandleMetaClass(emc)
+        assertTrue(IndyInvalidation.isStockMetaClass(handle),
+                'HandleMetaClass wrapping EMC must unwrap to stock MetaClassImpl')
+        assertFalse(IndyInvalidation.needsBulkInvalidation(null, handle))
+    }
+
+    @Test
+    void invalidateForMetaClassChange_exactForPureMetaClassImpl() {
+        SwitchPoint parentSp = ClassInfo.getClassInfo(PolicyParent).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(PolicyChild).indySwitchPoint
+        def oldMc = new groovy.lang.MetaClassImpl(PolicyParent)
+        oldMc.initialize()
+        def newMc = new groovy.lang.MetaClassImpl(PolicyParent)
+        newMc.initialize()
+        def event = new groovy.lang.MetaClassRegistryChangeEvent(
+                GroovySystem.metaClassRegistry, null, PolicyParent, oldMc, newMc)
+
+        IndyInvalidation.invalidateForMetaClassChange(event)
+
+        assertTrue(parentSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'pure MetaClassImpl replace must stay exact-class')
+    }
+
+    @Test
+    void invalidateForMetaClassChange_exactForEmc() {
+        SwitchPoint parentSp = ClassInfo.getClassInfo(PolicyParentEmc).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(PolicyChildEmc).indySwitchPoint
+        def emc = new ExpandoMetaClass(PolicyParentEmc, true, true)
+        emc.initialize()
+        def event = new groovy.lang.MetaClassRegistryChangeEvent(
+                GroovySystem.metaClassRegistry, null, PolicyParentEmc, null, emc)
+
+        IndyInvalidation.invalidateForMetaClassChange(event)
+
+        assertTrue(parentSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'EMC install is stock → exact-class, no subtype fan-out')
+    }
+
+    @Test
+    void invalidateForMetaClassChange_bulkForCustomMetaClass() {
+        SwitchPoint spA = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        SwitchPoint spB = ClassInfo.getClassInfo(ClassB).indySwitchPoint
+        def custom = new PureCustomMetaClass(ClassA)
+        def event = new groovy.lang.MetaClassRegistryChangeEvent(
+                GroovySystem.metaClassRegistry, null, ClassA, null, custom)
+
+        long bulkBefore = IndyInvalidation.bulkInvalidationCount()
+        IndyInvalidation.invalidateForMetaClassChange(event)
+
+        assertTrue(spA.hasBeenInvalidated())
+        assertTrue(spB.hasBeenInvalidated(),
+                'custom MetaClass must bulk-retire loaded class domains')
+        assertEquals(bulkBefore + 1, IndyInvalidation.bulkInvalidationCount())
+    }
+
+    @Test
+    void invalidateForMetaClassChange_perInstance_exactOnly() {
+        SwitchPoint parentSp = ClassInfo.getClassInfo(PolicyParentPerInst).indySwitchPoint
+        SwitchPoint childSp = ClassInfo.getClassInfo(PolicyChildPerInst).indySwitchPoint
+        def instance = new PolicyParentPerInst()
+        def emc = new ExpandoMetaClass(PolicyParentPerInst, false, true)
+        emc.initialize()
+        def event = new groovy.lang.MetaClassRegistryChangeEvent(
+                GroovySystem.metaClassRegistry, instance, PolicyParentPerInst, null, emc)
+
+        assertTrue(event.isPerInstanceMetaClassChange())
+        IndyInvalidation.invalidateForMetaClassChange(event)
+
+        assertTrue(parentSp.hasBeenInvalidated())
+        assertFalse(childSp.hasBeenInvalidated(),
+                'per-instance MetaClass must not fan out to subtypes')
+    }
+
+    @Test
+    void invalidateForMetaClassChange_nullType_unscoped() {
+        SwitchPoint sp = ClassInfo.getClassInfo(ClassA).indySwitchPoint
+        def event = new groovy.lang.MetaClassRegistryChangeEvent(
+                GroovySystem.metaClassRegistry, null, null, null, null)
+        IndyInvalidation.invalidateForMetaClassChange(event)
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void setStrongMetaClass_firstInstall_bumpsVersionWithoutSwitchPoint() {
+        def info = ClassInfo.getClassInfo(LocalGen)
+        info.setStrongMetaClass(null)
+        info.setWeakMetaClass(null)
+        int versionBefore = info.version
+
+        def mc = new groovy.lang.MetaClassImpl(LocalGen)
+        mc.initialize()
+        SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(mc)
+        assertFalse(sp.hasBeenInvalidated())
+        info.setStrongMetaClass(mc)
+
+        assertEquals(versionBefore + 1, info.version)
+        assertFalse(sp.hasBeenInvalidated(), 'first install must not invalidate MetaClass SwitchPoint')
+        assertSame(sp, info.indySwitchPoint)
+        info.setStrongMetaClass(null)
+    }
+
+    @Test
+    void setStrongMetaClass_replace_invalidatesLocalSwitchPoint() {
+        def info = ClassInfo.getClassInfo(LocalGenReplace)
+        def mc1 = new groovy.lang.MetaClassImpl(LocalGenReplace)
+        mc1.initialize()
+        info.setStrongMetaClass(mc1)
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+
+        def mc2 = new groovy.lang.MetaClassImpl(LocalGenReplace)
+        mc2.initialize()
+        info.setStrongMetaClass(mc2)
+
+        assertTrue(sp.hasBeenInvalidated(), 'replacement must invalidate SwitchPoint')
+        info.setStrongMetaClass(null)
+    }
+
+    @Test
+    void setWeakMetaClass_replace_invalidatesLocalSwitchPoint() {
+        def info = ClassInfo.getClassInfo(LocalGenWeak)
+        def mc1 = new groovy.lang.MetaClassImpl(LocalGenWeak)
+        mc1.initialize()
+        info.setWeakMetaClass(mc1)
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+
+        def mc2 = new groovy.lang.MetaClassImpl(LocalGenWeak)
+        mc2.initialize()
+        info.setWeakMetaClass(mc2)
+
+        assertTrue(sp.hasBeenInvalidated())
+        info.setWeakMetaClass(null)
+    }
+
+    @Test
+    void bulkWhenNoLiveSwitchPoints_isSafeNoOp() {
+        // Detach every tracked type used by this suite so the bulk batch is empty.
+        ClassInfo.getClassInfo(ClassA).detachLiveIndySwitchPoint()
+        ClassInfo.getClassInfo(ClassB).detachLiveIndySwitchPoint()
+        IndyInvalidation.invalidateBulk()
+        IndyInvalidation.invalidateCategory()
+        IndyInvalidation.invalidateUnscoped()
+    }
+
+    @Test
+    void newPendingInvalidator_createsIndependentDomain() {
+        def a = IndyInvalidation.newPendingInvalidator()
+        def b = IndyInvalidation.newPendingInvalidator()
+        SwitchPoint spa = a.switchPoint
+        SwitchPoint spb = b.switchPoint
+        a.invalidate()
+        assertTrue(spa.hasBeenInvalidated())
+        assertFalse(spb.hasBeenInvalidated())
+    }
+
+    @Test
+    void statsLogging_enabled_inChildProcess() {
+        def javaBin = System.getProperty('java.home') + '/bin/java'
+        def cp = System.getProperty('java.class.path')
+        def pb = new ProcessBuilder(
+                javaBin,
+                '-Dgroovy.indy.invalidation.stats=true',
+                '-Djava.util.logging.config.file=',
+                '-cp', cp,
+                'org.apache.groovy.runtime.indy.IndyInvalidationStatsProbe')
+        pb.redirectErrorStream(true)
+        def proc = pb.start()
+        def out = proc.inputStream.text
+        assertEquals(0, proc.waitFor(), "stats probe failed: $out")
+        assertTrue(out.contains('OK'), out)
+    }
+
+    @Test
+    void classInfoIncVersion_whenTheClassCleared_invalidatesLocally() {
+        def info = ClassInfo.getClassInfo(ClearedIncVersionHost)
+        SwitchPoint sp = info.indySwitchPoint
+        def field = ClassInfo.getDeclaredField('classRef')
+        field.accessible = true
+        ((WeakReference) field.get(info)).clear()
+        assertEquals(null, info.theClass)
+        info.incVersion()
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void setPerInstanceMetaClass_retiresClassSwitchPoint() {
+        def type = Groovy12191PerInstHost
+        def info = ClassInfo.getClassInfo(type)
+        def instance = new Groovy12191PerInstHost()
+        SwitchPoint sp = info.indySwitchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        def emc = new ExpandoMetaClass(type, false, true)
+        emc.initialize()
+        info.setPerInstanceMetaClass(instance, emc)
+        assertTrue(sp.hasBeenInvalidated())
+        info.setPerInstanceMetaClass(instance, null)
+    }
+
+    @Test
+    void hasClassLevelMetaClass_weakOnly_firstInstallDoesNotRetire() {
+        def info = ClassInfo.getClassInfo(WeakOnlyHost)
+        info.setStrongMetaClass(null)
+        info.setWeakMetaClass(null)
+        def mc = new groovy.lang.MetaClassImpl(WeakOnlyHost)
+        mc.initialize()
+        SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(mc)
+        info.setWeakMetaClass(mc)
+        assertFalse(sp.hasBeenInvalidated())
+        assertSame(sp, info.indySwitchPoint)
+        info.setWeakMetaClass(null)
+    }
+
+    @Test
+    void guardWithMopSwitchPoints_nullReceiver_usesNullObjectDomain() {
+        def target = MethodHandles.constant(int, 5)
+        def fallback = MethodHandles.constant(int, 6)
+        def guarded = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, (Object) null)
+        assertEquals(5, guarded.invokeWithArguments())
+        IndyInvalidation.invalidateClass(NullObject)
+        assertEquals(6, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void switchPointForMetaClass_identityMapDomain() {
+        def mc = new groovy.lang.MetaClassImpl(ClassA)
+        mc.initialize()
+        SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(mc)
+        assertSame(sp, IndyInvalidation.switchPointForMetaClass(mc))
+        assertFalse(sp.hasBeenInvalidated())
+        IndyInvalidation.invalidateMetaClass(mc)
+        assertTrue(sp.hasBeenInvalidated())
+        assertFalse(IndyInvalidation.switchPointForMetaClass(mc).hasBeenInvalidated())
+    }
+
+    @Test
+    void classSwitchPointFor_pendingUntilMetaClassInstalled() {
+        def type = FreshMcHost
+        def info = ClassInfo.getClassInfo(type)
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+        SwitchPoint pending = IndyInvalidation.classSwitchPointFor(type)
+        assertSame(info.pendingIndySwitchPoint, pending)
+        assertFalse(pending.hasBeenInvalidated())
+        def mc = new groovy.lang.MetaClassImpl(type)
+        mc.initialize()
+        info.strongMetaClass = mc
+        assertTrue(pending.hasBeenInvalidated())
+        SwitchPoint post = IndyInvalidation.classSwitchPointFor(type)
+        assertSame(IndyInvalidation.switchPointForMetaClass(mc), post)
+        assertFalse(post.hasBeenInvalidated())
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+    }
+
+    @Test
+    void classSwitchPointFor_doesNotForceMetaClassDuringLink() {
+        def type = NoForceMcHost
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+        assertEquals(null, ClassInfo.getClassInfo(type).metaClassForClass)
+        IndyInvalidation.classSwitchPointFor(type)
+        assertEquals(null, ClassInfo.getClassInfo(type).metaClassForClass)
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+    }
+
+    @Test
+    void customNonMetaClassImpl_usesSameIdentityDomain() {
+        def impl = new groovy.lang.MetaClassImpl(ClassB)
+        impl.initialize()
+        def custom = new groovy.lang.DelegatingMetaClass(impl) {}
+        SwitchPoint sp = IndyInvalidation.switchPointForMetaClass(custom)
+        assertSame(sp, IndyInvalidation.switchPointForMetaClass(impl))
+        IndyInvalidation.invalidateMetaClass(impl)
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateMetaClass_null_isNoOp() {
+        IndyInvalidation.invalidateMetaClass(null)
+    }
+
+    private static final class ClassA {}
+    private static final class ClassB {}
+    private static class Parent {}
+    private static final class Child extends Parent {}
+    private static final class LocalGen {}
+    private static final class LocalGenReplace {}
+    private static final class LocalGenWeak {}
+    private static final class WeakOnlyHost {}
+    private static final class ClearedIncVersionHost {}
+    private static interface Marker {}
+    private static final class MarkerImpl implements Marker {}
+    private static class PolicyParent {}
+    private static final class PolicyChild extends PolicyParent {}
+    private static class PolicyParentEmc {}
+    private static final class PolicyChildEmc extends PolicyParentEmc {}
+    private static class PolicyParentPerInst {}
+    private static final class PolicyChildPerInst extends PolicyParentPerInst {}
+    private static final class FreshMcHost {}
+    private static final class NoForceMcHost {}
+}
+
+/** Top-level host for per-instance MetaClass SwitchPoint coverage. */
+class Groovy12191PerInstHost {}
+
+/**
+ * Pure custom MetaClass (not a {@code MetaClassImpl}) used to exercise bulk
+ * invalidation for non-stock kinds. {@code @Delegate} fills the MetaClass
+ * surface; {@link IndyInvalidation#isStockMetaClass} sees this type itself
+ * (not the adaptee), so it is classified as non-stock.
+ */
+class PureCustomMetaClass implements MetaClass {
+    @Delegate
+    final MetaClass delegate
+
+    PureCustomMetaClass(Class theClass) {
+        this.delegate = new groovy.lang.MetaClassImpl(theClass)
+        this.delegate.initialize()
+    }
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy b/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy
new file mode 100644
index 0000000000..4d2caeb4f6
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/indy/SwitchPointInvalidatorTest.groovy
@@ -0,0 +1,209 @@
+/*
+ *  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.groovy.runtime.indy
+
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.SwitchPoint
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.CyclicBarrier
+import java.util.concurrent.atomic.AtomicInteger
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Unit tests for {@link SwitchPointInvalidator}.
+ */
+final class SwitchPointInvalidatorTest {
+
+    @Test
+    void lazyAllocation_returnsValidSwitchPointOnFirstUse() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint sp = inv.switchPoint
+        assertFalse(sp.hasBeenInvalidated())
+        assertSame(sp, inv.switchPoint)
+        assertEquals(0, inv.retirementCount)
+    }
+
+    @Test
+    void invalidate_withoutPriorGet_isSafe_andDoesNotCount() {
+        def inv = new SwitchPointInvalidator()
+        inv.invalidate()
+        assertEquals(0, inv.retirementCount)
+        SwitchPoint sp = inv.switchPoint
+        assertFalse(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidate_invalidatesCapturedSwitchPoint_andAllocatesFresh() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint first = inv.switchPoint
+        inv.invalidate()
+        assertTrue(first.hasBeenInvalidated())
+        assertEquals(1, inv.retirementCount)
+        SwitchPoint second = inv.switchPoint
+        assertNotSame(first, second)
+        assertFalse(second.hasBeenInvalidated())
+    }
+
+    @Test
+    void detachLive_returnsLiveSpWithoutInvalidating_andCountsRetirement() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint live = inv.switchPoint
+        assertFalse(live.hasBeenInvalidated())
+        SwitchPoint detached = inv.detachLive()
+        assertSame(live, detached)
+        assertFalse(detached.hasBeenInvalidated(), 'detach must not invalidate; caller batches')
+        assertEquals(1, inv.retirementCount)
+        assertNull(inv.detachLive())
+        SwitchPoint.invalidateAll(detached)
+        assertTrue(detached.hasBeenInvalidated())
+        SwitchPoint fresh = inv.switchPoint
+        assertNotSame(live, fresh)
+        assertFalse(fresh.hasBeenInvalidated())
+    }
+
+    @Test
+    void detachLive_whenNothingLive_returnsNull() {
+        def inv = new SwitchPointInvalidator()
+        assertNull(inv.detachLive())
+        assertEquals(0, inv.retirementCount)
+    }
+
+    @Test
+    void guardWithTest_fallsBackAfterInvalidate() {
+        def inv = new SwitchPointInvalidator()
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def guarded = inv.switchPoint.guardWithTest(target, fallback)
+        assertEquals(1, guarded.invokeWithArguments())
+        inv.invalidate()
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void concurrentGetAndInvalidate_neverLeavesOrphanValidSp() {
+        def inv = new SwitchPointInvalidator()
+        int threads = 8
+        int rounds = 200
+        def start = new CyclicBarrier(threads)
+        def errors = new AtomicInteger()
+        def workers = (0..<threads).collect { i ->
+            Thread.start {
+                start.await()
+                rounds.times { r ->
+                    try {
+                        if ((i + r) % 3 == 0) {
+                            inv.invalidate()
+                        } else {
+                            SwitchPoint sp = inv.switchPoint
+                            // After a concurrent invalidate, a previously captured SP may
+                            // be invalid; a freshly returned SP from get must be valid.
+                            if (!sp.hasBeenInvalidated()) {
+                                // ok
+                            }
+                        }
+                    } catch (Throwable t) {
+                        errors.incrementAndGet()
+                    }
+                }
+            }
+        }
+        workers*.join()
+        assertEquals(0, errors.get())
+        SwitchPoint after = inv.switchPoint
+        assertFalse(after.hasBeenInvalidated())
+    }
+
+    @Test
+    void concurrentDetach_onlyOneThreadReceivesSp() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint live = inv.switchPoint
+        int threads = 4
+        def start = new CountDownLatch(1)
+        def got = Collections.synchronizedList([])
+        def workers = (0..<threads).collect {
+            Thread.start {
+                start.await()
+                SwitchPoint d = inv.detachLive()
+                if (d != null) {
+                    got << d
+                }
+            }
+        }
+        start.countDown()
+        workers*.join()
+        assertEquals(1, got.size())
+        assertSame(live, got[0])
+        assertEquals(1, inv.retirementCount)
+    }
+
+    @Test
+    void invalidateIfLive_null_isNoOp() {
+        SwitchPointInvalidator.invalidateIfLive(null)
+    }
+
+    @Test
+    void invalidateIfLive_alreadyInvalidated_isNoOp() {
+        def inv = new SwitchPointInvalidator()
+        SwitchPoint sp = inv.switchPoint
+        inv.invalidate()
+        assertTrue(sp.hasBeenInvalidated())
+        // second call must not throw; covers !hasBeenInvalidated() false branch
+        SwitchPointInvalidator.invalidateIfLive(sp)
+    }
+
+    @Test
+    void invalidate_afterDetach_passesNullToInvalidateIfLive() {
+        def inv = new SwitchPointInvalidator()
+        inv.switchPoint
+        inv.detachLive()
+        inv.invalidate() // detachLive returns null → invalidateIfLive(null)
+        assertEquals(1, inv.retirementCount)
+    }
+
+    @Test
+    void concurrentGet_retryOnCasLoss() {
+        // Hammer getSwitchPoint from many threads so some lose the CAS and retry.
+        def inv = new SwitchPointInvalidator()
+        inv.invalidate() // ensure current is null
+        int threads = 16
+        def start = new CyclicBarrier(threads)
+        def seen = Collections.synchronizedSet([] as Set)
+        def workers = (0..<threads).collect {
+            Thread.start {
+                start.await()
+                50.times {
+                    seen << inv.switchPoint
+                }
+            }
+        }
+        workers*.join()
+        // All threads must observe a non-null valid SP; typically a single identity
+        // after the first successful CAS, but concurrent invalidate is not used here.
+        assertFalse(seen.isEmpty())
+        seen.each { SwitchPoint sp -> assertFalse(sp.hasBeenInvalidated()) }
+    }
+}
diff --git a/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoMetaClassBootstrapTest.groovy b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoMetaClassBootstrapTest.groovy
new file mode 100644
index 0000000000..12ec9a373f
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/reflection/ClassInfoMetaClassBootstrapTest.groovy
@@ -0,0 +1,100 @@
+/*
+ *  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.codehaus.groovy.reflection
+
+import groovy.lang.GroovySystem
+import groovy.lang.MetaClassImpl
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * ClassInfo MetaClass lookup / creation paths used by GROOVY-12191 and
+ * Sonar javabugs:S2259 (null MetaClassRegistry on bootstrap).
+ * <p>
+ * The true bootstrap window (registry not yet published) cannot be re-entered
+ * once {@link GroovySystem} has finished class initialization; these tests
+ * lock the post-init contracts that the null-registry guards preserve:
+ * {@link ClassInfo#getMetaClassForClass()} never forces creation, and
+ * {@link ClassInfo#getMetaClass()} creates under lock when the registry is live.
+ */
+final class ClassInfoMetaClassBootstrapTest {
+
+    @Test
+    void getMetaClassForClass_doesNotCreate_whenAbsent() {
+        def type = FreshMetaClassHost
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+        def info = ClassInfo.getClassInfo(type)
+        assertNull(info.metaClassForClass)
+        // Second look still does not install a MetaClass.
+        assertNull(info.metaClassForClass)
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+    }
+
+    @Test
+    void getMetaClass_createsWhenAbsent() {
+        def type = CreateMetaClassHost
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+        def info = ClassInfo.getClassInfo(type)
+        assertNull(info.metaClassForClass)
+        def mc = info.metaClass
+        assertNotNull(mc)
+        assertTrue(mc instanceof MetaClassImpl)
+        assertSame(mc, info.metaClassForClass)
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+    }
+
+    @Test
+    void getMetaClass_returnsStrongWhenPresent() {
+        def type = StrongMetaClassHost
+        def info = ClassInfo.getClassInfo(type)
+        def mc = new MetaClassImpl(type)
+        mc.initialize()
+        info.strongMetaClass = mc
+        try {
+            assertSame(mc, info.metaClassForClass)
+            assertSame(mc, info.metaClass)
+        } finally {
+            info.strongMetaClass = null
+            GroovySystem.metaClassRegistry.removeMetaClass(type)
+        }
+    }
+
+    @Test
+    void getMetaClassUnderLock_path_createsViaRegistryWhenLive() {
+        // Exercises getMetaClass → getMetaClassUnderLock with a live registry
+        // (the path Sonar flagged for missing null-check). Must not NPE.
+        def type = UnderLockHost
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+        assertNotNull(GroovySystem.metaClassRegistry,
+                'registry is live after GroovySystem init')
+        def mc = ClassInfo.getClassInfo(type).metaClass
+        assertNotNull(mc)
+        assertSame(type, mc.theClass)
+        GroovySystem.metaClassRegistry.removeMetaClass(type)
+    }
+
+    private static final class FreshMetaClassHost {}
+    private static final class CreateMetaClassHost {}
+    private static final class StrongMetaClassHost {}
+    private static final class UnderLockHost {}
+}
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
index 2a499a222a..e54e44a5d8 100644
--- a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyInterfaceCallSiteTargetTest.groovy
@@ -385,7 +385,7 @@ final class IndyInterfaceCallSiteTargetTest {
         )
 
         assertEquals(staticEcho('bar'), methodHandle.invokeWithArguments([args] as Object[]))
-        assertSame(MethodHandleWrapper.getNullMethodHandleWrapper(), requireCachedWrapper(callSite, IndyInterfaceCallSiteTargetTest))
+        assertSame(MethodHandleWrapper.getUncacheablePicSentinel(), requireCachedWrapper(callSite, IndyInterfaceCallSiteTargetTest))
         assertSame(callSite.defaultTarget, callSite.target)
     }
 
@@ -451,7 +451,7 @@ final class IndyInterfaceCallSiteTargetTest {
         AtomicInteger providerCalls = new AtomicInteger()
         MethodHandleWrapper wrapper = callSite.getAndPut(receiverClassName(receiver), { key ->
             providerCalls.incrementAndGet()
-            MethodHandleWrapper.getNullMethodHandleWrapper()
+            MethodHandleWrapper.getUncacheablePicSentinel()
         })
         assertEquals(0, providerCalls.get())
         wrapper
diff --git a/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
new file mode 100644
index 0000000000..37e2799517
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/vmplugin/v8/IndyScopedSwitchPointTest.groovy
@@ -0,0 +1,225 @@
+/*
+ *  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.codehaus.groovy.vmplugin.v8
+
+import org.apache.groovy.runtime.indy.IndyInvalidation
+import org.codehaus.groovy.reflection.CachedMethod
+import org.codehaus.groovy.reflection.ClassInfo
+import org.codehaus.groovy.vmplugin.VMPluginFactory
+import org.junit.jupiter.api.Test
+
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.MethodType
+import java.lang.invoke.SwitchPoint
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+import static org.junit.jupiter.api.Assertions.assertSame
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Coverage for GROOVY-12191 wiring in {@link IndyInterface} and package-local
+ * cold-tier validity (same package as production classes under test).
+ */
+final class IndyScopedSwitchPointTest {
+
+    @Test
+    void registryListener_withClass_invalidatesClassDomain() {
+        // Ensure the MetaClass registry listener path (type != null) runs.
+        SwitchPoint sp = ClassInfo.getClassInfo(RegistryHost).indySwitchPoint
+        def mc = new ExpandoMetaClass(RegistryHost, true, true)
+        mc.initialize()
+        GroovySystem.metaClassRegistry.setMetaClass(RegistryHost, mc)
+        assertTrue(sp.hasBeenInvalidated())
+        GroovySystem.metaClassRegistry.removeMetaClass(RegistryHost)
+    }
+
+    @Test
+    void indyLogging_enabled_inChildProcess_coversLogBranches() {
+        // LOG_ENABLED is frozen at IndyInterface class init — cover the logging
+        // branches in a fresh JVM with -Dgroovy.indy.logging=true.
+        def javaBin = System.getProperty('java.home') + '/bin/java'
+        def cp = System.getProperty('java.class.path')
+        def pb = new ProcessBuilder(
+                javaBin,
+                '-Dgroovy.indy.logging=true',
+                '-cp', cp,
+                'org.codehaus.groovy.vmplugin.v8.IndyLoggingProbe')
+        pb.redirectErrorStream(true)
+        def proc = pb.start()
+        def out = proc.inputStream.text
+        def code = proc.waitFor()
+        assertEquals(0, code, "probe failed: $out")
+        assertTrue(out.contains('OK'), out)
+    }
+
+    @Test
+    void applyMopSwitchPoints_installsClassGuard() {
+        def target = MethodHandles.constant(int, 1)
+        def fallback = MethodHandles.constant(int, 2)
+        def receiver = new ApplyHost()
+        def guarded = IndyInterface.applyMopSwitchPoints(target, fallback, receiver)
+        assertEquals(1, guarded.invokeWithArguments())
+        IndyInvalidation.invalidateClass(ApplyHost)
+        assertEquals(2, guarded.invokeWithArguments())
+    }
+
+    @Test
+    void applyMopSwitchPoints_matchesPublicGuardApi() {
+        // Production path (applyMopSwitchPoints) and public IndyInvalidation API
+        // must install the same class-domain SwitchPoint.
+        def target = MethodHandles.constant(int, 7)
+        def fallback = MethodHandles.constant(int, 8)
+        def receiver = new ApplyHost()
+        def viaInterface = IndyInterface.applyMopSwitchPoints(target, fallback, receiver)
+        def viaInvalidation = IndyInvalidation.guardWithMopSwitchPoints(target, fallback, receiver)
+        assertEquals(7, viaInterface.invokeWithArguments())
+        assertEquals(7, viaInvalidation.invokeWithArguments())
+        IndyInvalidation.invalidateClass(ApplyHost)
+        assertEquals(8, viaInterface.invokeWithArguments())
+        assertEquals(8, viaInvalidation.invokeWithArguments())
+    }
+
+    @Test
+    void invalidateSwitchPoints_bulkInvalidatesClassDomains() {
+        // Legacy process-wide IndyInterface.switchPoint is removed; bulk path
+        // only retires per-class domains (GROOVY-12191 / blackdrag review).
+        SwitchPoint classSp = ClassInfo.getClassInfo(LegacyHost).indySwitchPoint
+        assertFalse(classSp.hasBeenInvalidated())
+
+        IndyInterface.invalidateSwitchPoints()
+
+        assertTrue(classSp.hasBeenInvalidated())
+        SwitchPoint fresh = ClassInfo.getClassInfo(LegacyHost).indySwitchPoint
+        assertFalse(fresh.hasBeenInvalidated())
+    }
+
+    @Test
+    void invalidateSwitchPoints_concurrentBulk_isSafe() {
+        int threads = 8
+        def start = new java.util.concurrent.CyclicBarrier(threads)
+        def done = new java.util.concurrent.CountDownLatch(threads)
+        def errors = new java.util.concurrent.ConcurrentLinkedQueue<Throwable>()
+        SwitchPoint before = ClassInfo.getClassInfo(LegacyHost).indySwitchPoint
+
+        threads.times {
+            Thread.start {
+                try {
+                    start.await()
+                    IndyInterface.invalidateSwitchPoints()
+                } catch (Throwable t) {
+                    errors.add(t)
+                } finally {
+                    done.countDown()
+                }
+            }
+        }
+        assertTrue(done.await(30, java.util.concurrent.TimeUnit.SECONDS))
+        assertTrue(errors.isEmpty(), "concurrent invalidate failed: $errors")
+        assertTrue(before.hasBeenInvalidated())
+    }
+
+    @Test
+    void vmPlugin_invalidateCallSites_routesToScopedCategoryPath() {
+        SwitchPoint classSp = ClassInfo.getClassInfo(PluginHost).indySwitchPoint
+        VMPluginFactory.plugin.invalidateCallSites()
+        assertTrue(classSp.hasBeenInvalidated())
+    }
+
+    @Test
+    void registryListener_unscoped_path_viaInvalidateUnscoped() {
+        SwitchPoint sp = ClassInfo.getClassInfo(UnscopedHost).indySwitchPoint
+        // Null-class registry events call IndyInvalidation.invalidateUnscoped().
+        IndyInvalidation.invalidateUnscoped()
+        assertTrue(sp.hasBeenInvalidated())
+    }
+
+    @Test
+    void coldReflective_isValidFor_falseWhenSwitchPointInvalidated() {
+        def sp = ClassInfo.getClassInfo(ColdHost).indySwitchPoint
+        def cold = newColdWrapper(sp)
+        def args = [new ColdHost()] as Object[]
+        assertTrue(cold.isValidFor(args))
+        SwitchPoint.invalidateAll(sp)
+        assertFalse(cold.isValidFor(args))
+    }
+
+    @Test
+    void coldReflective_isValidFor_falseOnArityMismatch() {
+        def sp = ClassInfo.getClassInfo(ColdHost).indySwitchPoint
+        def cold = newColdWrapper(sp)
+        assertFalse(cold.isValidFor([new ColdHost(), 'extra'] as Object[]))
+    }
+
+    @Test
+    void coldReflective_isValidFor_falseOnClassMismatch() {
+        def sp = ClassInfo.getClassInfo(ColdHost).indySwitchPoint
+        def cold = newColdWrapper(sp)
+        assertFalse(cold.isValidFor(['not-a-ColdHost'] as Object[]))
+    }
+
+    @Test
+    void coldReflective_picWrite_usesUncacheableSentinelWhenUncacheable() {
+        // Mirrors invokeColdReflective / selectMethodHandle PIC policy:
+        // uncacheable wrappers must not be stored under the receiver class key.
+        def type = MethodType.methodType(Object, Object)
+        def site = new CacheableCallSite(type, MethodHandles.lookup())
+        def uncacheable = new MethodHandleWrapper(
+                MethodHandles.constant(Object, 'cached'),
+                MethodHandles.identity(Object).asType(type),
+                null,
+                false)
+        assertFalse(uncacheable.isCanSetTarget())
+        def key = ColdHost.name
+        def sentinel = MethodHandleWrapper.uncacheablePicSentinel
+        site.put(key, uncacheable.isCanSetTarget() ? uncacheable : sentinel)
+        // Next PIC hit must observe the sentinel, not the uncacheable wrapper.
+        def fromPic = site.getAndPut(key, { k -> uncacheable })
+        assertSame(sentinel, fromPic)
+    }
+
+    /**
+     * Minimal cold wrapper for validity tests (same package as production class).
+     */
+    private static ColdReflectiveMethodHandleWrapper newColdWrapper(SwitchPoint sp) {
+        def javaMethod = ColdHost.getDeclaredMethod('ping')
+        def metaMethod = CachedMethod.find(javaMethod)
+        assert metaMethod != null
+        def ctor = ColdReflectiveMethodHandleWrapper.getDeclaredConstructor(
+                groovy.lang.MetaMethod, CacheableCallSite, Class, String, int,
+                Boolean, Boolean, Boolean, groovy.lang.MetaClass, SwitchPoint, Class[])
+        ctor.accessible = true
+        def type = MethodType.methodType(Object, Object)
+        def site = new CacheableCallSite(type, MethodHandles.lookup())
+        def mc = GroovySystem.metaClassRegistry.getMetaClass(ColdHost)
+        (ColdReflectiveMethodHandleWrapper) ctor.newInstance(
+                metaMethod, site, ColdHost, 'ping', IndyInterface.CallType.METHOD.orderNumber,
+                Boolean.FALSE, Boolean.FALSE, Boolean.FALSE, mc, sp, [ColdHost] as Class[])
+    }
+
+    static class ApplyHost {}
+    static class LegacyHost {}
+    static class PluginHost {}
+    static class UnscopedHost {}
+    static class RegistryHost {}
+    /** Java-style host so {@link CachedMethod#find} is straightforward. */
+    static class ColdHost {
+        String ping() { 'ok' }
+    }
+}
diff --git a/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java b/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
new file mode 100644
index 0000000000..1ff3bc7cb9
--- /dev/null
+++ b/src/test/java/org/apache/groovy/runtime/indy/IndyInvalidationStatsProbe.java
@@ -0,0 +1,52 @@
+/*
+ *  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.groovy.runtime.indy;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Child-process probe for {@code -Dgroovy.indy.invalidation.stats=true} log branches.
+ * Invoked from {@link IndyInvalidationTest}.
+ */
+public final class IndyInvalidationStatsProbe {
+
+    private IndyInvalidationStatsProbe() {
+    }
+
+    public static void main(final String[] args) {
+        Logger log = Logger.getLogger(IndyInvalidation.class.getName());
+        log.setLevel(Level.FINE);
+        // Ensure a parent handler does not filter FINE.
+        Logger root = Logger.getLogger("");
+        root.setLevel(Level.FINE);
+        for (var h : root.getHandlers()) {
+            h.setLevel(Level.FINE);
+        }
+
+        IndyInvalidation.invalidateClass(ProbeHost.class);
+        IndyInvalidation.invalidateBulk();
+        IndyInvalidation.invalidateCategory();
+        IndyInvalidation.invalidateUnscoped();
+        System.out.println("OK");
+    }
+
+    public static final class ProbeHost {
+    }
+}
diff --git a/src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java b/src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java
new file mode 100644
index 0000000000..6829d6c520
--- /dev/null
+++ b/src/test/java/org/codehaus/groovy/vmplugin/v8/IndyLoggingProbe.java
@@ -0,0 +1,62 @@
+/*
+ *  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.codehaus.groovy.vmplugin.v8;
+
+import groovy.lang.GroovySystem;
+import groovy.lang.MetaClassRegistryChangeEvent;
+import org.codehaus.groovy.reflection.ClassInfo;
+
+/**
+ * Child-process probe for {@code -Dgroovy.indy.logging=true} branches
+ * (GROOVY-12191). Invoked only from {@link IndyScopedSwitchPointTest}.
+ */
+public final class IndyLoggingProbe {
+
+    private IndyLoggingProbe() {
+    }
+
+    public static void main(final String[] args) {
+        if (!IndyInterface.LOG_ENABLED) {
+            System.err.println("LOG_ENABLED expected true");
+            System.exit(2);
+        }
+        // type != null listener log path
+        ClassInfo.getClassInfo(ProbeHost.class).getIndySwitchPoint();
+        GroovySystem.getMetaClassRegistry().setMetaClass(
+                ProbeHost.class,
+                GroovySystem.getMetaClassRegistry().getMetaClass(ProbeHost.class));
+        // category / invalidateCallSites log path
+        IndyInterface.invalidateSwitchPoints();
+        // unscoped log path via synthetic null-class event. Other registry listeners
+        // may NPE on a null Class; the IndyInterface listener must still run.
+        MetaClassRegistryChangeEvent event = new MetaClassRegistryChangeEvent(
+                GroovySystem.getMetaClassRegistry(), null, null, null, null);
+        for (var listener : GroovySystem.getMetaClassRegistry().getMetaClassRegistryChangeEventListeners()) {
+            try {
+                listener.updateConstantMetaClass(event);
+            } catch (Throwable ignore) {
+                // non-indy listeners may reject null class
+            }
+        }
+        System.out.println("OK");
+    }
+
+    public static final class ProbeHost {
+    }
+}
diff --git a/subprojects/performance/README.adoc b/subprojects/performance/README.adoc
index 76a50d9c76..5c545989a2 100644
--- a/subprojects/performance/README.adoc
+++ b/subprojects/performance/README.adoc
@@ -138,6 +138,16 @@ threshold (`groovy.indy.optimize.threshold`, default 1000). Static and
 instance dispatch take different cold paths (static sites promote on first
 hit, GROOVY-11935), hence the two benches.
 
+== Scoped invalidation (GROOVY-12191)
+
+`ScopedInvalidationBench` measures per-class SwitchPoint scoping: unrelated
+MetaClass churn must not deoptimize a hot monomorphic site; same-type re-link
+and category enter/leave (bulk class-domain invalidation) must still force
+re-link; parent MetaClass churn must not deoptimize subclass sites (stock
+exact-class policy — no hierarchy fan-out). Steady-state monomorphic sites
+carry a single SwitchPoint guard. Complements the Grails-oriented
+`CallSiteInvalidationBench` under `org.apache.groovy.perf.grails`.
+
 == Dashboard pages
 
 The `dashboard/` directory is the source of truth for the hand-crafted pages
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
new file mode 100644
index 0000000000..a46754c777
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ScopedInvalidationBench.groovy
@@ -0,0 +1,275 @@
+/*
+ *  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.groovy.bench
+
+import groovy.lang.GroovySystem
+
+import org.openjdk.jmh.annotations.Benchmark
+import org.openjdk.jmh.annotations.BenchmarkMode
+import org.openjdk.jmh.annotations.Fork
+import org.openjdk.jmh.annotations.Level
+import org.openjdk.jmh.annotations.Measurement
+import org.openjdk.jmh.annotations.Mode
+import org.openjdk.jmh.annotations.OutputTimeUnit
+import org.openjdk.jmh.annotations.Scope
+import org.openjdk.jmh.annotations.Setup
+import org.openjdk.jmh.annotations.State
+import org.openjdk.jmh.annotations.Warmup
+import org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Steady-state cost of scoped invokedynamic SwitchPoint invalidation (GROOVY-12191).
+ * <p>
+ * Complements the Grails-oriented {@code CallSiteInvalidationBench} (AverageTime,
+ * grails suite) with a bench-suite Throughput regression set for the single
+ * per-class SwitchPoint domain. Baseline / cross-type / same-type rows
+ * intentionally overlap that bench so ratios are available under the same
+ * {@code .bench.} include pattern; unique coverage here is category enter/leave
+ * (bulk class-domain invalidation), parent-vs-child isolation (stock exact-class
+ * — no hierarchy fan-out), and setup-isolated post-burst steady state:
+ * <ul>
+ *   <li><b>Per-class</b> — MetaClass changes on an unrelated type must not
+ *       deoptimize a hot monomorphic site;</li>
+ *   <li><b>Same-type</b> — MetaClass changes on the hot type must re-link;</li>
+ *   <li><b>Category</b> — {@code use(Category)} enter/leave bulk-invalidates
+ *       class SwitchPoints so category methods become visible;</li>
+ *   <li><b>Parent churn</b> — parent MetaClass changes must <em>not</em>
+ *       deoptimize subclass-linked sites (stock exact-class policy).</li>
+ * </ul>
+ * Compare {@link #hotLoop_afterUnrelatedBurst} / {@link #hotLoop_unrelatedMetaClassChurn}
+ * against {@link #hotLoop_baseline} (should stay close after scoping) and against
+ * {@link #hotLoop_sameTypeMetaClassChurn} (must pay re-link cost).
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/GROOVY-12191">GROOVY-12191</a>
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+class ScopedInvalidationBench {
+
+    /** Iterations of the hot call loop per benchmark invocation. */
+    static final int ITERATIONS = 100_000
+
+    /** How often to perform a MetaClass mutation inside the churn loops. */
+    static final int CHURN_EVERY = 1_000
+
+    /**
+     * Hot monomorphic call-site receiver.
+     */
+    static class Hot {
+        int value = 42
+        int compute() { value * 2 }
+    }
+
+    /**
+     * Unrelated type used only for cross-type MetaClass churn.
+     */
+    static class Unrelated {
+        String label = 'cold'
+    }
+
+    /**
+     * Parent of {@link HierChild} for parent-vs-child isolation churn.
+     */
+    static class HierParent {
+        int base() { 1 }
+    }
+
+    /**
+     * Subclass receiver whose SwitchPoint must stay live when only the parent
+     * MetaClass changes (stock exact-class policy).
+     */
+    static class HierChild extends HierParent {
+        int leaf() { 2 }
+    }
+
+    /**
+     * Category that decorates {@link Hot#compute()}.
+     */
+    static class HotCategory {
+        static int compute(Hot self) { self.value * 3 }
+    }
+
+    Hot hot
+    HierChild child
+
+    /**
+     * Resets MetaClasses and allocates fresh receivers each iteration so
+     * SwitchPoint state from a previous sample does not bleed into the next.
+     */
+    @Setup(Level.Iteration)
+    void setup() {
+        GroovySystem.metaClassRegistry.removeMetaClass(Hot)
+        GroovySystem.metaClassRegistry.removeMetaClass(Unrelated)
+        GroovySystem.metaClassRegistry.removeMetaClass(HierParent)
+        GroovySystem.metaClassRegistry.removeMetaClass(HierChild)
+        hot = new Hot()
+        child = new HierChild()
+    }
+
+    /**
+     * Steady-state monomorphic calls with no MetaClass mutation.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_baseline(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += hot.compute()
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Hot monomorphic loop with MetaClass changes on an <em>unrelated</em> type.
+     * With scoped invalidation the hot site should keep its linked target;
+     * residual cost vs baseline is mostly MetaClass mutation work, not deopt.
+     * Prefer {@link #hotLoop_afterUnrelatedBurst} for a pure no-deopt check.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_unrelatedMetaClassChurn(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += hot.compute()
+            if (i % CHURN_EVERY == 0) {
+                Unrelated.metaClass."dyn${i % 5}" = { -> i }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Pure hot loop after a burst of unrelated MetaClass changes performed in
+     * {@link AfterUnrelatedBurstState#setup()}. After scoping (GROOVY-12191),
+     * this should approach {@link #hotLoop_baseline}.
+     * @param state state that applied the unrelated MetaClass burst
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_afterUnrelatedBurst(AfterUnrelatedBurstState state, Blackhole bh) {
+        int sum = 0
+        Hot h = state.hot
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += h.compute()
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Hot monomorphic loop with MetaClass changes on the <em>same</em> type —
+     * each change must retire the class-domain SwitchPoint and re-link.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_sameTypeMetaClassChurn(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += hot.compute()
+            if (i % CHURN_EVERY == 0) {
+                Hot.metaClass."dyn${i % 5}" = { -> i }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Category enter/leave interleaved with hot calls — exercises
+     * {@code invalidateCategory()} bulk retirement of loaded class domains.
+     * Uses a modest number of {@code use} blocks so category re-link cost
+     * is visible without dominating wall-clock.
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void hotLoop_categoryEnterLeave(Blackhole bh) {
+        int sum = 0
+        int callsPerUse = ITERATIONS / 10
+        for (int b = 0; b < 10; b++) {
+            use(HotCategory) {
+                for (int i = 0; i < callsPerUse; i++) {
+                    sum += hot.compute()
+                }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Subclass receiver calls with periodic parent MetaClass changes. Under
+     * stock exact-class policy this should stay close to
+     * {@link #parentChild_baseline} (parent churn must not deopt the child).
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void parentChild_parentMetaClassChurn(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += child.leaf()
+            sum += child.base()
+            if (i % CHURN_EVERY == 0) {
+                HierParent.metaClass."dyn${i % 5}" = { -> i }
+            }
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Subclass receiver calls with no MetaClass mutation (parent/child baseline).
+     * @param bh blackhole for the accumulated result
+     */
+    @Benchmark
+    void parentChild_baseline(Blackhole bh) {
+        int sum = 0
+        for (int i = 0; i < ITERATIONS; i++) {
+            sum += child.leaf()
+            sum += child.base()
+        }
+        bh.consume(sum)
+    }
+
+    /**
+     * Iteration state for {@link ScopedInvalidationBench#hotLoop_afterUnrelatedBurst}:
+     * resets MetaClasses, then applies a burst of unrelated MetaClass mutations
+     * <em>outside</em> the measured loop so residual deopt (if any) is isolated.
+     */
+    @State(Scope.Thread)
+    static class AfterUnrelatedBurstState {
+        /** Hot receiver used by the measured loop. */
+        Hot hot
+
+        /**
+         * Resets MetaClasses, allocates a fresh hot receiver, and applies
+         * unrelated MetaClass churn before the benchmark body runs.
+         */
+        @Setup(Level.Iteration)
+        void setup() {
+            GroovySystem.metaClassRegistry.removeMetaClass(Hot)
+            GroovySystem.metaClassRegistry.removeMetaClass(Unrelated)
+            hot = new Hot()
+            for (int i = 0; i < 50; i++) {
+                Unrelated.metaClass."startup${i % 10}" = { -> i }
+            }
+        }
+    }
+}