Ctrl K
ComunidadGuías…

pkg/k8score/dynamic_cache.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"log"
	"maps"
	"runtime"
	"slices"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/sync/singleflight"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/client-go/dynamic/dynamicinformer"
	"k8s.io/client-go/tools/cache"
)

var ErrResourceNotFound = errors.New("resource not found")
var ErrResourceCountUnavailable = errors.New("resource count unavailable")
var ErrCollectionNotSupported = errors.New("collection is not supported")

const directCountProbeLimit int64 = 2

var scopeProbeBaseBudget = 5 * time.Second

// deniedScopeTTL is how long a complete refusal answers later reads of the same
// scope. Without it every read of a refused kind repeats the walk, one
// cluster-wide check plus one per candidate namespace, and a page reading a
// dozen such kinds pays that dozen times on every poll. It is also how long a
// new grant can go unnoticed.
const deniedScopeTTL = 60 * time.Second

// informerKey identifies one informer. ns == "" means a cluster-wide watch;
// a non-empty ns is a namespace-scoped watch. A GVR can have one cluster-wide
// informer OR several namespace-scoped ones (for users denied cluster-wide
// list but allowed in specific namespaces) — never both, since the
// cluster-wide informer already covers every namespace.
type informerKey struct {
	gvr schema.GroupVersionResource
	ns  string
}

// informerEntry is one running informer plus its lifecycle handle. synced is
// guarded by DynamicResourceCache.mu.
type informerEntry struct {
	informer  cache.SharedIndexInformer
	cancel    context.CancelFunc
	synced    bool
	startedAt time.Time
}

type retainedObservation struct {
	observedAt time.Time
	state      DynamicObservationState
	reasonCode string
	truncated  bool
}

// scopeDenial is a complete refusal: every check of the walk was answered and
// every answer was Forbidden.
type scopeDenial struct {
	at  time.Time
	err error
}

// DynamicResourceCache provides on-demand caching for CRDs and other dynamic
// resources. It is safe for concurrent use. Application-specific callbacks
// (timeline, metrics) are injected via DynamicCacheConfig.
type DynamicResourceCache struct {
	factory            dynamicinformer.DynamicSharedInformerFactory
	nsFactories        map[string]dynamicinformer.DynamicSharedInformerFactory // one per watched namespace, lazily created
	informers          map[informerKey]*informerEntry
	informerGeneration uint64
	// fallbackResolved marks GVRs whose "all namespaces" scope was already
	// probed and fanned out across the fallback namespaces. Without it every
	// all-namespaces read of a fallback-scoped GVR would re-probe cluster-wide
	// plus each candidate namespace. Guarded by mu.
	fallbackResolved map[schema.GroupVersionResource]bool
	// observations retain watch decisions and probe outcomes for installed GVRs
	// that do not currently have an informer. Active informers take precedence.
	observations map[schema.GroupVersionResource]retainedObservation
	// deniedScopes holds recent complete refusals by (gvr, preferred namespace),
	// which is the scope a walk answered for: a walk for all namespaces says
	// nothing about a namespace outside its candidates. The cache belongs to
	// one identity, so an entry never outlives it. Guarded by mu.
	deniedScopes map[informerKey]scopeDenial
	// fanoutIncomplete records that a namespace fallback walk ended before all
	// configured candidates were classified. Guarded by mu.
	fanoutIncomplete map[schema.GroupVersionResource]bool
	// probeRetries tracks access checks that got no answer, which the cache
	// re-runs itself on a bounded schedule. Guarded by mu.
	probeRetries    map[schema.GroupVersionResource]*probeRetry
	probeSlots      chan struct{}
	probeCtx        context.Context
	stopProbes      context.CancelFunc
	stopCh          chan struct{} // global shutdown; parent of every per-informer context
	stopOnce        sync.Once
	stopped         bool // set under mu by Stop; startWatching refuses new informers after
	mu              sync.RWMutex
	config          DynamicCacheConfig
	discoveryStatus CRDDiscoveryStatus
	discoveryMu     sync.RWMutex
	discoveryDone   chan struct{} // closed when DiscoverAllCRDs() completes

	// gvrHandlers holds change handlers registered via AddGVRChangeHandler,
	// keyed by GVR. They are re-applied to every informer started for that GVR
	// — including namespace-scoped informers created lazily after registration
	// — so derived caches keep receiving events. Guarded by mu.
	gvrHandlers map[schema.GroupVersionResource][]cache.ResourceEventHandler

	// watchStarts collapses concurrent ensureWatching calls for the same
	// (gvr, ns) into one probe + informer start. The access probe runs
	// outside d.mu, so without this a parallel fan-out over N objects of an
	// unwatched kind would issue N redundant limit=1 list probes before one
	// caller wins startWatching.
	watchStarts singleflight.Group

	// CRD discovery completion callbacks
	crdCallbacks   []func()
	crdCallbacksMu sync.RWMutex

	// CRDs installed after startup discovery. seenCRDs holds the group/resource
	// of every CRD that already got a watch decision; pendingCRDs the new ones
	// waiting for a scan. Guarded by lateCRDMu.
	seenCRDs        map[string]struct{}
	pendingCRDs     map[string]struct{}
	lateCRDTimer    *time.Timer
	lateCRDScanning bool
	lateCRDRetries  int
	lateCRDMu       sync.Mutex
}

// NewDynamicResourceCache creates a dynamic resource cache with the given config.
func NewDynamicResourceCache(cfg DynamicCacheConfig) (*DynamicResourceCache, error) {
	if cfg.DynamicClient == nil {
		return nil, fmt.Errorf("dynamic client must not be nil")
	}
	if cfg.NamespaceScoped && cfg.Namespace == "" {
		return nil, fmt.Errorf("namespace must be set when NamespaceScoped is true")
	}

	factory := dynamicinformer.NewDynamicSharedInformerFactory(
		cfg.DynamicClient,
		0, // no resync — updates come via watch
	)
	if cfg.NamespaceScoped && cfg.Namespace != "" {
		log.Printf("Using namespace-scoped dynamic informers for namespace %q", cfg.Namespace)
	} else if len(cfg.NamespaceFallbacks) > 0 {
		log.Printf("Using namespace fallbacks for dynamic informers: %q", cfg.NamespaceFallbacks)
	} else if cfg.NamespaceFallback != "" {
		log.Printf("Using namespace fallback for dynamic informers: %q", cfg.NamespaceFallback)
	}

	probeCtx, stopProbes := context.WithCancel(context.Background())
	d := &DynamicResourceCache{
		factory:          factory,
		nsFactories:      make(map[string]dynamicinformer.DynamicSharedInformerFactory),
		fallbackResolved: make(map[schema.GroupVersionResource]bool),
		observations:     make(map[schema.GroupVersionResource]retainedObservation),
		deniedScopes:     make(map[informerKey]scopeDenial),
		fanoutIncomplete: make(map[schema.GroupVersionResource]bool),
		probeRetries:     make(map[schema.GroupVersionResource]*probeRetry),
		probeCtx:         probeCtx,
		probeSlots:       make(chan struct{}, 50),
		stopProbes:       stopProbes,
		informers:        make(map[informerKey]*informerEntry),
		stopCh:           make(chan struct{}),
		config:           cfg,
		discoveryStatus:  CRDDiscoveryIdle,
		discoveryDone:    make(chan struct{}),
		gvrHandlers:      make(map[schema.GroupVersionResource][]cache.ResourceEventHandler),
		seenCRDs:         make(map[string]struct{}),
		pendingCRDs:      make(map[string]struct{}),
	}

	log.Println("Dynamic resource cache initialized")
	return d, nil
}

func (d *DynamicResourceCache) retainObservation(gvr schema.GroupVersionResource, state DynamicObservationState, reasonCode string, truncated bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	delete(d.deniedScopes, informerKey{gvr: gvr})
	d.observations[gvr] = retainedObservation{state: state, reasonCode: reasonCode, truncated: truncated, observedAt: time.Now().UTC()}
}

// retainAuthProbeFailure records an access probe that failed authentication
// or authorization, and reports whether err was one. A 401 rejects Radar's
// credentials, which no grant on this kind fixes, so it is recorded apart from
// an RBAC denial.
func (d *DynamicResourceCache) retainAuthProbeFailure(gvr schema.GroupVersionResource, preferredNS string, complete bool, err error) bool {
	switch {
	case apierrors.IsUnauthorized(err):
		d.retainObservation(gvr, DynamicObservationDenied, "credentials_rejected", false)
	case isAuthProbeError(err):
		d.retainDeniedObservation(gvr, preferredNS, complete, err)
	default:
		return false
	}
	return true
}

func (d *DynamicResourceCache) retainDeniedObservation(gvr schema.GroupVersionResource, preferredNS string, complete bool, err error) {
	// A preferred-namespace denial proves only that request scope; it cannot
	// replace the observation retained for the GVR as a whole.
	if preferredNS != "" {
		if complete {
			d.rememberDenial(gvr, preferredNS, err)
		}
		return
	}
	if !complete {
		d.retainObservation(gvr, DynamicObservationDeferred, "scope_probe_incomplete", true)
		return
	}
	truncated := d.namespaceProbeTruncated(gvr, preferredNS, complete)
	reasonCode := "access_denied"
	if truncated {
		reasonCode = "access_denied_partial_probe"
	}
	d.retainObservation(gvr, DynamicObservationDenied, reasonCode, truncated)
	d.rememberDenial(gvr, "", err)
}

func (d *DynamicResourceCache) rememberDenial(gvr schema.GroupVersionResource, preferredNS string, err error) {
	now := time.Now()
	d.mu.Lock()
	defer d.mu.Unlock()
	maps.DeleteFunc(d.deniedScopes, func(_ informerKey, denial scopeDenial) bool {
		return now.Sub(denial.at) >= deniedScopeTTL
	})
	d.deniedScopes[informerKey{gvr: gvr, ns: preferredNS}] = scopeDenial{at: now, err: err}
}

// recentDenial returns the Forbidden a walk of this scope ended with, if it
// did within deniedScopeTTL.
func (d *DynamicResourceCache) recentDenial(gvr schema.GroupVersionResource, preferredNS string) error {
	d.mu.RLock()
	defer d.mu.RUnlock()
	denial, ok := d.deniedScopes[informerKey{gvr: gvr, ns: preferredNS}]
	if !ok || d.stopped || time.Since(denial.at) >= deniedScopeTTL {
		return nil
	}
	return noAccessError(gvr, denial.err)
}

func noAccessError(gvr schema.GroupVersionResource, err error) error {
	return fmt.Errorf("no access to %s.%s/%s: %w", gvr.Resource, gvr.Group, gvr.Version, err)
}

// Observation returns the current observation contract for one exact GVR.
// It only reads cache bookkeeping; it never probes the API server, creates an
// informer, or waits for sync.
func (d *DynamicResourceCache) Observation(gvr schema.GroupVersionResource) DynamicResourceObservation {
	return d.observation(gvr, "")
}

// NamespaceObservation is Observation with State, and the loading or stall
// ReasonCode, taken only from the informer that serves reads of gvr in
// namespace: the cluster-wide informer when there is one, otherwise
// namespace's own. Per-namespace informers sync independently, so a
// namespace's readiness never reflects an informer started for another
// namespace, which in a shared cache may be another user's. Scope, Namespaces
// and Truncated still describe the whole kind, and so does a synced
// namespace's ReasonCode (such as namespace_partial). When the kind has informers but none serves namespace, State is
// DynamicObservationUnwatched. An empty namespace, or a kind with no
// informers, gets Observation(gvr).
func (d *DynamicResourceCache) NamespaceObservation(gvr schema.GroupVersionResource, namespace string) DynamicResourceObservation {
	return d.observation(gvr, namespace)
}

// syncStallAfter is how long an informer may stay unsynced before its
// observation reports sync_stalled.
func (d *DynamicResourceCache) syncStallAfter() time.Duration {
	if d.config.SyncStallAfter > 0 {
		return d.config.SyncStallAfter
	}
	return 30 * time.Second
}

func (d *DynamicResourceCache) observation(gvr schema.GroupVersionResource, namespace string) (observation DynamicResourceObservation) {
	if d == nil {
		return DynamicResourceObservation{State: DynamicObservationUnwatched, ReasonCode: "not_observed"}
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	if d.stopped {
		return DynamicResourceObservation{State: DynamicObservationUnwatched, ReasonCode: "cache_stopped"}
	}
	scope := dynamicObservationScope{}
	if entry := d.informers[informerKey{gvr: gvr}]; entry != nil {
		scope.clusterWide = true
		scope.entries = []*informerEntry{entry}
	} else {
		for key, entry := range d.informers {
			if key.gvr == gvr {
				scope.entries = append(scope.entries, entry)
				scope.namespaces = append(scope.namespaces, key.ns)
			}
		}
		sort.Strings(scope.namespaces)
	}
	return d.observationLocked(gvr, namespace, scope)
}

func (d *DynamicResourceCache) observationLocked(gvr schema.GroupVersionResource, namespace string, scope dynamicObservationScope) (observation DynamicResourceObservation) {
	defer func() {
		r := d.probeRetries[gvr]
		if r == nil {
			return
		}
		if namespace != "" && !r.whole && !slices.Contains(r.unanswered, namespace) {
			if observation.ReasonCode == "scope_probe_incomplete" {
				observation.State, observation.ReasonCode = DynamicObservationUnwatched, "not_observed"
				observation.Scope = DynamicObservationScopeExplicitNamespaces
			}
			return
		}
		if namespace != "" {
			if _, ok := d.informers[informerKey{gvr: gvr, ns: namespace}]; ok && r.whole {
				return
			}
		}
		observation.UnansweredNamespaces = slices.Clone(r.unanswered)
		observation.ProbeRetrying = r.running && !r.settled()
		if r.rejected {
			// Watches that started before the 401 keep their own state, but
			// the checks still outstanding were refused, not unanswered.
			observation.ReasonCode = "credentials_rejected"
		}
		if (r.whole || namespace == "" && !r.fullWalk) && !observation.ProbeRetrying && observation.ReasonCode != "credentials_rejected" {
			observation.ReasonCode = "scope_probe_incomplete"
		}
	}()

	if len(scope.entries) == 0 {
		if retained, ok := d.observations[gvr]; ok {
			observation = DynamicResourceObservation{State: retained.state, Truncated: retained.truncated, ReasonCode: retained.reasonCode, ObservedAt: &retained.observedAt}
			if r := d.probeRetries[gvr]; r != nil && !r.whole && len(r.unanswered) == 0 && len(d.fallbackNamespaces()) > 0 && d.gvrIsNamespaced(gvr) {
				observation.Scope = DynamicObservationScopeExplicitNamespaces
			}
			return observation
		}
		if d.config.Discovery != nil && d.config.Discovery.GetKindForGVR(gvr) != "" && !d.config.Discovery.SupportsWatchGVR(gvr) {
			return DynamicResourceObservation{State: DynamicObservationUnsupported, ReasonCode: "list_watch_unsupported"}
		}
		return DynamicResourceObservation{State: DynamicObservationUnwatched, ReasonCode: "not_observed"}
	}
	readiness := scope.entries
	var serving [1]*informerEntry
	if namespace != "" {
		readiness = nil
		entry := d.informers[informerKey{gvr: gvr}]
		if entry == nil {
			entry = d.informers[informerKey{gvr: gvr, ns: namespace}]
		}
		if entry != nil {
			serving[0] = entry
			readiness = serving[:]
		}
	}

	state := DynamicObservationSynced
	if len(readiness) == 0 {
		state = DynamicObservationUnwatched
	}
	var oldestUnsynced *informerEntry
	for _, candidate := range readiness {
		if !candidate.informer.HasSynced() {
			if oldestUnsynced == nil || candidate.startedAt.Before(oldestUnsynced.startedAt) {
				oldestUnsynced = candidate
			}
			state = DynamicObservationSyncing
		}
	}

	observation = DynamicResourceObservation{State: state}
	if scope.clusterWide {
		observation.Scope = DynamicObservationScopeCluster
	} else {
		observation.Scope = DynamicObservationScopeExplicitNamespaces
		observation.Namespaces = scope.namespaces
		observation.Truncated = !d.config.NamespaceScoped && (d.config.NamespaceFallbacksTruncated || d.fanoutIncomplete[gvr])
	}

	switch {
	case state == DynamicObservationUnwatched:
		observation.ReasonCode = "not_observed"
	case state == DynamicObservationSyncing && time.Since(oldestUnsynced.startedAt) >= d.syncStallAfter():
		observation.ReasonCode = "sync_stalled"
	case state == DynamicObservationSyncing:
		observation.ReasonCode = "initial_sync"
	case observation.Truncated:
		observation.ReasonCode = "namespace_fanout_truncated"
	case observation.Scope == DynamicObservationScopeExplicitNamespaces:
		observation.ReasonCode = "namespace_partial"
	default:
		observation.ReasonCode = "informer_synced"
	}
	return observation
}

// factoryForNs returns the informer factory for ns, creating and caching a
// namespace-filtered factory on first use. ns == "" is the cluster-wide
// factory. Caller must hold d.mu.
func (d *DynamicResourceCache) factoryForNs(ns string) dynamicinformer.DynamicSharedInformerFactory {
	if ns == "" {
		return d.factory
	}
	if f, ok := d.nsFactories[ns]; ok {
		return f
	}
	f := dynamicinformer.NewFilteredDynamicSharedInformerFactory(d.config.DynamicClient, 0, ns, nil)
	d.nsFactories[ns] = f
	return f
}

// ---------------------------------------------------------------------------
// EnsureWatching / startWatching / probeAccess
// ---------------------------------------------------------------------------

// EnsureWatching starts watching a resource type if not already watching.
// The sync happens asynchronously — callers should use WaitForSync if they need to wait.
func (d *DynamicResourceCache) EnsureWatching(gvr schema.GroupVersionResource) error {
	return d.ensureWatching(gvr, "")
}

// ensureWatching guarantees an informer covering preferredNS exists for gvr.
// preferredNS == "" means "any/all namespaces": probe cluster-wide and, if
// denied, fall back to the configured NamespaceFallback. A non-empty
// preferredNS is a specific request: a cluster-wide informer (if the identity
// can list cluster-wide) covers it; otherwise we watch that one namespace.
// This is what lets a namespace-restricted user read a CRD in the namespaces
// they actually have access to, instead of being pinned to a single fallback.
func (d *DynamicResourceCache) ensureWatching(gvr schema.GroupVersionResource, preferredNS string) error {
	return d.EnsureWatchingContext(context.Background(), gvr, preferredNS)
}

// EnsureWatchingContext starts watching gvr if no watch covers preferredNS yet
// ("" for every namespace). ctx bounds only how long this caller waits for
// discovery and for Radar's access check; the check and any watches it starts
// belong to the cache and outlive ctx.
func (d *DynamicResourceCache) EnsureWatchingContext(ctx context.Context, gvr schema.GroupVersionResource, preferredNS string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	if d == nil {
		return fmt.Errorf("dynamic resource cache not initialized")
	}

	// Check if resource supports list/watch before attempting to watch
	if d.config.Discovery != nil && !d.config.Discovery.SupportsWatchGVR(gvr) {
		return fmt.Errorf("%w: resource %s.%s/%s does not support list/watch", ErrCollectionNotSupported, gvr.Resource, gvr.Group, gvr.Version)
	}

	if d.hasCoveringInformer(gvr, preferredNS) {
		return nil
	}
	if err := d.recentDenial(gvr, preferredNS); err != nil {
		return err
	}

	// If CRD discovery is in progress, wait for it to finish
	if d.GetDiscoveryStatus() == CRDDiscoveryInProgress {
		select {
		case <-d.discoveryDone:
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(45 * time.Second):
			log.Printf("[dynamic cache] Timeout waiting for CRD discovery, probing %s independently", gvr.Resource)
		}
		if d.hasCoveringInformer(gvr, preferredNS) {
			return nil
		}
		if err := d.recentDenial(gvr, preferredNS); err != nil {
			return err
		}
	}

	// Probe access (cluster-wide first, then fallback namespaces) BEFORE
	// acquiring the write lock; the result tells us which scopes to watch.
	// Singleflight per (gvr, preferredNS): concurrent callers — e.g. a
	// parallel enrichment fan-out over many objects of one unwatched kind —
	// share one probe fan-out + informer start instead of stampeding the
	// apiserver with redundant limit=1 lists. The shared probe runs under the
	// cache's own budget, never a caller's context: every caller that joined
	// it would otherwise inherit the deadline and cancellation of whichever
	// request started it. ctx bounds only this caller's wait.
	result := d.watchStarts.DoChan(gvr.String()+"|"+preferredNS, func() (any, error) {
		if d.hasCoveringInformer(gvr, preferredNS) {
			return nil, nil
		}
		if preferredNS == "" {
			d.mu.RLock()
			r := d.probeRetries[gvr]
			retrying := r != nil && r.running
			d.mu.RUnlock()
			if retrying {
				if len(d.readEntries(gvr, "")) > 0 {
					return nil, nil
				}
				if d.Observation(gvr).State == DynamicObservationDenied {
					return nil, apierrors.NewForbidden(gvr.GroupResource(), "", errors.New("collector cannot list"))
				}
				return nil, errScopeProbeIncomplete
			}
		}
		o := d.walkScopes(gvr, preferredNS)
		scopes, complete, err := o.scopes, o.complete, o.err
		defer d.noteWalk(gvr, o)
		if err != nil {
			if !d.retainAuthProbeFailure(gvr, preferredNS, complete, err) && errors.Is(err, errScopeProbeIncomplete) && preferredNS == "" {
				d.retainObservation(gvr, DynamicObservationDeferred, "scope_probe_incomplete", true)
			}
			return nil, noAccessError(gvr, err)
		}
		for _, scopeNS := range scopes {
			if err := d.startWatching(gvr, scopeNS); err != nil {
				truncated := preferredNS == "" && len(scopes) > 0 && scopes[0] != "" && (!complete || d.config.NamespaceFallbacksTruncated)
				d.retainObservation(gvr, DynamicObservationDeferred, "watch_start_failed", truncated)
				return nil, err
			}
		}
		return nil, nil
	})
	select {
	case <-ctx.Done():
		return ctx.Err()
	case r := <-result:
		return r.Err
	}
}

// HasCoveringInformer reports whether an informer serves reads of gvr in ns
// ("" for all namespaces), synced or not.
func (d *DynamicResourceCache) HasCoveringInformer(gvr schema.GroupVersionResource, ns string) bool {
	if d == nil {
		return false
	}
	return d.hasCoveringInformer(gvr, ns)
}

// hasCoveringInformer reports whether an existing informer already serves
// reads for (gvr, ns), and must agree with readEntries: a cluster-wide
// informer covers every namespace; a specific ns is also covered by its own
// namespace-scoped informer. ns == "" is covered ONLY by a cluster-wide
// informer — not by incidental namespace-scoped ones, since readEntries(gvr,
// "") won't read those. Treating them as covering here would let
// ensureWatching skip the probe and then have List(gvr, "") find nothing,
// returning a spurious "informer not found" instead of probing cluster-wide
// (or returning a clean forbidden).
func (d *DynamicResourceCache) hasCoveringInformer(gvr schema.GroupVersionResource, ns string) bool {
	d.mu.RLock()
	defer d.mu.RUnlock()
	if _, ok := d.informers[informerKey{gvr: gvr}]; ok {
		return true
	}
	if ns == "" {
		// Covered without a cluster-wide informer only when a previous
		// all-namespaces ensureWatching already fanned out across the
		// fallback namespaces; readEntries(gvr, "") unions those.
		return d.fallbackResolved[gvr]
	}
	_, ok := d.informers[informerKey{gvr: gvr, ns: ns}]
	return ok
}

// startWatching creates and starts an informer for (gvr, scopeNS), where
// scopeNS == "" is cluster-wide. No access probe — the caller has decided the
// scope. Each informer runs under its own context derived from the global
// stop channel, so a single informer can be cancelled independently (the
// idle reaper relies on this) while Stop() still tears them all down.
func (d *DynamicResourceCache) startWatching(gvr schema.GroupVersionResource, scopeNS string) error {
	return d.startWatchingScope(gvr, scopeNS, false)
}

// errNamespaceWatchesKept refuses a cluster-wide watch that would replace
// namespace watches.
var errNamespaceWatchesKept = errors.New("namespace watches kept")

// startWatchingScope is startWatching; keepNamespaceWatches refuses a
// cluster-wide watch that would replace namespace watches, deciding under
// the same lock that would replace them.
func (d *DynamicResourceCache) startWatchingScope(gvr schema.GroupVersionResource, scopeNS string, keepNamespaceWatches bool) error {
	d.mu.Lock()
	defer d.mu.Unlock()

	if d.stopped {
		// A warmup or read probe can race Stop (context switch); informers
		// created now would never be shut down with the rest of the cache.
		return fmt.Errorf("dynamic cache stopped")
	}

	key := informerKey{gvr: gvr, ns: scopeNS}
	if _, exists := d.informers[key]; exists {
		return nil
	}
	// Enforce the "never both" invariant: a GVR has either one cluster-wide
	// informer OR namespace-scoped ones, never both (overlap would duplicate
	// objects in ListWatched and double-fire change callbacks).
	if scopeNS != "" {
		// A cluster-wide informer already covers this namespace — don't add a
		// redundant namespaced watch.
		if _, exists := d.informers[informerKey{gvr: gvr}]; exists {
			return nil
		}
	} else {
		if keepNamespaceWatches {
			for k := range d.informers {
				if k.gvr == gvr && k.ns != "" {
					return errNamespaceWatchesKept
				}
			}
		}
		// Starting cluster-wide supersedes any namespace-scoped informers for
		// this GVR; stop and drop them.
		for k, e := range d.informers {
			if k.gvr == gvr && k.ns != "" {
				e.cancel()
				delete(d.informers, k)
				d.informerGeneration++
			}
		}
	}

	factory := d.factoryForNs(scopeNS)
	informer := factory.ForResource(gvr).Informer()
	// Apply the dynamic-cache transform BEFORE informer.Run so every
	// object entering the store is shrunk in place. SetTransform must
	// be called pre-Run (returns ErrRunning otherwise). If it ever
	// fails we log and continue — the informer still functions, just
	// with fattier cached objects.
	if err := informer.SetTransform(DropUnstructuredManagedFields); err != nil {
		log.Printf("Warning: SetTransform failed for %v: %v (cache will retain managedFields/CRD schemas)", gvr, err)
	}
	// The factory makes one informer per GVR and Radar never drops a
	// cluster-wide one, so this informer is new. Code that stops informers
	// must build a fresh factory: this one would hand back a stopped informer
	// that reports synced. Without an index, reads of it from this GVR fail
	// rather than miss.
	indexers := cache.Indexers{}
	if !isEventResource(gvr) {
		maps.Copy(indexers, ownerUIDIndexers)
	}
	maps.Copy(indexers, gitopsMembersIndexers[gvr.GroupResource()])
	maps.Copy(indexers, kueueQueueIndexers[gvr.GroupResource()])
	for name := range informer.GetIndexer().GetIndexers() {
		delete(indexers, name)
	}
	if len(indexers) > 0 {
		if err := informer.AddIndexers(indexers); err != nil {
			log.Printf("Warning: indexing %v failed: %v", gvr, err)
		}
	}

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		select {
		case <-d.stopCh:
			cancel()
		case <-ctx.Done():
		}
	}()
	d.informerGeneration++
	d.informers[key] = &informerEntry{
		informer:  informer,
		cancel:    cancel,
		startedAt: time.Now().UTC(),
	}
	delete(d.observations, gvr)

	kind := d.gvrToKind(gvr)
	d.addDynamicChangeHandlers(informer, kind, gvr)
	// Re-apply handlers registered via AddGVRChangeHandler so informers created
	// lazily (or re-created after an idle reap) still feed derived caches.
	for _, h := range d.gvrHandlers[gvr] {
		if _, err := informer.AddEventHandler(h); err != nil {
			log.Printf("Warning: re-applying change handler for %v failed: %v", gvr, err)
		}
	}

	go informer.Run(ctx.Done())

	informerCount := len(d.informers)
	scopeDesc := "cluster-wide"
	if scopeNS != "" {
		scopeDesc = "namespace " + scopeNS
	}
	log.Printf("Started watching dynamic resource: %s.%s/%s (%s) (total dynamic informers: %d)", gvr.Resource, gvr.Group, gvr.Version, scopeDesc, informerCount)

	go func() {
		syncCtx, syncCancel := context.WithTimeout(ctx, 30*time.Second)
		defer syncCancel()

		if !cache.WaitForCacheSync(syncCtx.Done(), informer.HasSynced) {
			select {
			case <-ctx.Done():
				return
			default:
				log.Printf("Warning: cache sync timeout for %v (%s)", gvr, scopeDesc)
			}
		} else {
			log.Printf("Dynamic resource synced: %s.%s/%s (%s)", gvr.Resource, gvr.Group, gvr.Version, scopeDesc)
		}

		d.mu.Lock()
		if e := d.informers[key]; e != nil {
			e.synced = true
		}
		d.mu.Unlock()
	}()
	return nil
}

// nsCtxExpired reports whether a probe error is a context deadline/cancel —
// the one non-auth error class that must NOT fail open into a grant during
// the fanout walk.
func nsCtxExpired(err error) bool {
	return errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)
}

// IsClientRateLimited reports an error from client-go's client-side rate
// limiter: the request was never sent because its wait would have run past
// the context deadline. client-go wraps the limiter's own error, which is not
// a context error, in this fixed message.
func IsClientRateLimited(err error) bool {
	return err != nil && strings.Contains(err.Error(), "client rate limiter Wait returned an error")
}

// errScopeProbeIncomplete marks a namespace walk that ended before every
// candidate answered and granted none: not a denial, so not retained as one.
var errScopeProbeIncomplete = errors.New("namespace access probe incomplete")

// probeUnanswered reports a probe the API server never answered: it ran out
// of time or the client-side rate limiter refused to send it. Either says
// nothing about access, so it is neither a grant nor a denial.
func probeUnanswered(err error) bool {
	return nsCtxExpired(err) || IsClientRateLimited(err)
}

// fallbackNamespaces returns the configured namespace-fallback candidates,
// preferring the multi-namespace form.
// fanoutSettled reports whether the all-namespaces scope for gvr is fully
// resolved. A multi-candidate walk settles via the fallbackResolved marker;
// a SINGLE-candidate configuration is trivially settled the moment its one
// informer exists — a legacy --namespace user reading through explicit
// namespace requests must not have counts withheld waiting for an
// all-namespaces read that their UI never issues. Caller must hold d.mu.
func (d *DynamicResourceCache) fanoutSettledLocked(gvr schema.GroupVersionResource) bool {
	if r := d.probeRetries[gvr]; r != nil && !r.settled() {
		return false
	}
	if d.fallbackResolved[gvr] {
		return true
	}
	fallbacks := d.fallbackNamespaces()
	if len(fallbacks) != 1 {
		return false
	}
	_, ok := d.informers[informerKey{gvr: gvr, ns: fallbacks[0]}]
	return ok
}

// FallbackNamespaces returns the namespaces the cache walks when its identity
// cannot list a namespaced resource cluster-wide.
func (d *DynamicResourceCache) FallbackNamespaces() []string {
	if d == nil {
		return nil
	}
	return append([]string(nil), d.fallbackNamespaces()...)
}

func (d *DynamicResourceCache) fallbackNamespaces() []string {
	if len(d.config.NamespaceFallbacks) > 0 {
		return d.config.NamespaceFallbacks
	}
	if d.config.NamespaceFallback != "" {
		return []string{d.config.NamespaceFallback}
	}
	return nil
}

// probeScopes decides which scopes to watch for gvr via limit=1 list probes.
// It returns the scope namespaces to start informers for — [""] means one
// cluster-wide informer — or a forbidden error when the identity can list
// the resource neither cluster-wide nor in any candidate namespace.
// preferredNS is the namespace the caller actually wants; when cluster-wide
// is denied and the caller named none, every configured fallback namespace
// is probed and each granted one becomes a scope.
func (d *DynamicResourceCache) probeScopes(gvr schema.GroupVersionResource, preferredNS string) (scopes []string, complete bool, err error) {
	o := d.walkScopes(gvr, preferredNS)
	return o.scopes, o.complete, o.err
}

// probeOutcome is one access walk's result.
type probeOutcome struct {
	startedAt time.Time
	scopes    []string
	complete  bool
	err       error
	// unanswered are namespaces whose check got no answer; whole
	// means the cluster-wide check itself got none, so no candidate was tried.
	unanswered      []string
	whole           bool
	fullWalk        bool
	clusterAnswered bool
	answered        []string
	// failedOpen marks a cluster-wide scope chosen because the cluster-wide
	// check failed for a reason other than a refusal, not because it passed.
	failedOpen bool
}

func (d *DynamicResourceCache) walkScopes(gvr schema.GroupVersionResource, preferredNS string) probeOutcome {
	return d.walkScopesContext(d.probeCtx, gvr, preferredNS)
}

func (d *DynamicResourceCache) walkScopesContext(parent context.Context, gvr schema.GroupVersionResource, preferredNS string) (outcome probeOutcome) {
	select {
	case d.probeSlots <- struct{}{}:
		defer func() { <-d.probeSlots }()
	case <-parent.Done():
		return probeOutcome{startedAt: time.Now(), whole: true, err: parent.Err()}
	}
	startedAt := time.Now()
	clusterAnswered := false
	defer func() {
		outcome.startedAt = startedAt
		outcome.clusterAnswered = clusterAnswered
		outcome.fullWalk = outcome.fullWalk || preferredNS == "" || d.config.NamespaceScoped
	}()
	// The budget covers the cluster-wide probe plus the whole candidate walk;
	// scale it with the candidate count so a 20-namespace fanout isn't judged
	// by a budget sized for the single-fallback case, while staying bounded
	// for the synchronous read paths that call ensureWatching.
	fallbacks := slices.Clone(d.fallbackNamespaces())
	if preferredNS == "" {
		d.mu.RLock()
		if r := d.probeRetries[gvr]; r != nil {
			for _, ns := range r.unanswered {
				if !slices.Contains(fallbacks, ns) {
					fallbacks = append(fallbacks, ns)
				}
			}
		}
		d.mu.RUnlock()
	}
	budget := scopeProbeBaseBudget
	if n := len(fallbacks); n > 1 {
		budget = min(scopeProbeBaseBudget+time.Duration(n)*500*time.Millisecond, 15*time.Second)
	}
	ctx, cancel := context.WithTimeout(parent, budget)
	defer cancel()

	// Forced namespace mode (--namespace-scope): pin NAMESPACED resources to the
	// configured namespace. Cluster-scoped resources have no namespace dimension,
	// so they fall through to the cluster-wide path below — pinning them to a
	// namespace would list nothing.
	if d.config.NamespaceScoped && d.config.Namespace != "" && d.gvrIsNamespaced(gvr) {
		ns, err := d.classifyScope(gvr, d.config.Namespace, d.listProbe(ctx, gvr, d.config.Namespace))
		if probeUnanswered(err) {
			return probeOutcome{unanswered: []string{d.config.Namespace}, err: fmt.Errorf("%w: %w", errScopeProbeIncomplete, err)}
		}
		if err != nil {
			return probeOutcome{complete: true, answered: []string{d.config.Namespace}, err: err}
		}
		return probeOutcome{scopes: []string{ns}, answered: []string{ns}, complete: true}
	}

	// Cluster-wide first — one informer then serves every namespace.
	err := d.listProbe(ctx, gvr, "")
	if probeUnanswered(err) {
		var unanswered []string
		if preferredNS != "" {
			unanswered = []string{preferredNS}
		}
		return probeOutcome{whole: true, unanswered: unanswered, err: fmt.Errorf("%w: %w", errScopeProbeIncomplete, err)}
	}
	clusterAnswered = true
	if err == nil {
		return probeOutcome{scopes: []string{""}, complete: true, fullWalk: true, answered: []string{""}}
	}
	if apierrors.IsUnauthorized(err) {
		return probeOutcome{complete: true, fullWalk: true, err: err}
	}
	if !isAuthProbeError(err) {
		// Transient/NotFound on proxy-fronted clusters — fail open to a
		// cluster-wide informer rather than disabling the kind; real
		// problems surface when the informer lists.
		log.Printf("[dynamic cache] Cluster-wide probe for %s.%s/%s returned non-auth error (allowing): %v", gvr.Resource, gvr.Group, gvr.Version, err)
		return probeOutcome{scopes: []string{""}, complete: true, fullWalk: true, answered: []string{""}, failedOpen: true}
	}
	if !d.gvrIsNamespaced(gvr) {
		return probeOutcome{complete: true, fullWalk: true, err: err} // cluster-scoped resource, no namespace to fall back to
	}

	if preferredNS != "" {
		ns, err := d.classifyScope(gvr, preferredNS, d.listProbe(ctx, gvr, preferredNS))
		if probeUnanswered(err) {
			return probeOutcome{unanswered: []string{preferredNS}, err: err}
		}
		if err != nil {
			return probeOutcome{complete: true, answered: []string{preferredNS}, err: err}
		}
		return probeOutcome{scopes: []string{ns}, answered: []string{ns}, complete: true}
	}

	if len(fallbacks) == 0 {
		return probeOutcome{complete: true, err: err}
	}
	granted := make([]string, 0, len(fallbacks))
	answered := make(map[string]bool, len(fallbacks))
	complete := true
	var unanswered error
	var unauthorized error
	// mu guards the walk's results while candidates are checked at once.
	var mu sync.Mutex
	// probe lists one candidate under its own deadline and records the
	// answer. It reports whether the walk can go on, and the probe's error.
	probe := func(ns string, deadline time.Duration) (bool, error) {
		// Per-candidate sub-deadline: without it a single consistently slow
		// namespace eats the whole walk budget on every retry, and since
		// retries restart from the first candidate, later namespaces would
		// never be reached at all.
		nsCtx, nsCancel := context.WithTimeout(ctx, deadline)
		probeErr := d.listProbe(nsCtx, gvr, ns)
		nsCancel()
		mu.Lock()
		defer mu.Unlock()
		switch {
		case apierrors.IsUnauthorized(probeErr):
			unauthorized = probeErr
			return false, probeErr
		case ctx.Err() != nil:
			// The probe ran into the shared deadline, so whatever it returned
			// is no answer. The cache will retry it within its own budget.
			complete, unanswered = false, ctx.Err()
			return false, probeErr
		case IsClientRateLimited(probeErr):
		case probeUnanswered(probeErr):
			complete, unanswered = false, probeErr
		default:
			answered[ns] = true
			if scoped, nsErr := d.classifyScope(gvr, ns, probeErr); nsErr == nil {
				granted = append(granted, scoped)
			}
		}
		return true, probeErr
	}
	// The client-side rate limiter refuses a request whose wait would run past
	// its deadline, which says nothing about the namespace. Candidates it
	// refused are retried after the others, each with an equal share of what
	// is left of the budget, so none can starve the rest.
	var throttled []string
	var stopped atomic.Bool
	check := func(ns string) {
		ok, probeErr := probe(ns, 2*time.Second)
		if !ok {
			stopped.Store(true)
		} else if IsClientRateLimited(probeErr) {
			mu.Lock()
			throttled = append(throttled, ns)
			mu.Unlock()
		}
	}
	// One at a time, a kind refused everywhere costs a round trip per
	// candidate, which adds up to minutes on a page reading many such kinds.
	// Candidates share only the slots no other walk holds, so the checks
	// running at once stay capped and a busy startup walks as it always did.
	var candidates sync.WaitGroup
	for _, ns := range fallbacks {
		if ctx.Err() != nil {
			mu.Lock()
			complete = false
			mu.Unlock()
			break
		}
		if stopped.Load() {
			break
		}
		select {
		case d.probeSlots <- struct{}{}:
			candidates.Go(func() {
				defer func() { <-d.probeSlots }()
				check(ns)
			})
		default:
			check(ns)
		}
	}
	candidates.Wait()
	byCandidateOrder := func(a, b string) int {
		return slices.Index(fallbacks, a) - slices.Index(fallbacks, b)
	}
	slices.SortFunc(granted, byCandidateOrder)
	slices.SortFunc(throttled, byCandidateOrder)
	deadline, _ := ctx.Deadline()
	for i, ns := range throttled {
		if ctx.Err() != nil {
			complete = false
			break
		}
		ok, probeErr := probe(ns, time.Until(deadline)/time.Duration(len(throttled)-i))
		if !ok {
			break
		}
		if IsClientRateLimited(probeErr) {
			complete, unanswered = false, probeErr
		}
	}
	if unauthorized != nil {
		return probeOutcome{complete: true, fullWalk: true, err: unauthorized}
	}
	var silent, answeredNamespaces []string
	for _, ns := range fallbacks {
		if !answered[ns] {
			silent = append(silent, ns)
		} else {
			answeredNamespaces = append(answeredNamespaces, ns)
		}
	}
	if len(granted) == 0 {
		if !complete {
			// Not every candidate answered, so this is not a denial.
			return probeOutcome{unanswered: silent, answered: answeredNamespaces, err: fmt.Errorf("%w: %w", errScopeProbeIncomplete, cmp.Or(unanswered, ctx.Err(), err))}
		}
		return probeOutcome{complete: true, answered: answeredNamespaces, err: err} // original cluster-wide forbidden — no candidate granted
	}
	return probeOutcome{scopes: granted, complete: complete, unanswered: silent, answered: answeredNamespaces}
}

type probeRetry struct {
	unanswered []string
	whole      bool
	fullWalk   bool
	// running: the fast retries within the budget are under way.
	running bool
	// owned: a goroutine is re-checking this record, fast or slowly.
	owned bool
	// rejected: Kubernetes rejected Radar's credentials during a re-check,
	// so nothing re-checks the record again.
	rejected bool
	answered map[string]time.Time
	deadline time.Time
	// ctx is the owner's lifetime: cancel ends it when the record settles,
	// is replaced, or the cache stops.
	ctx    context.Context
	cancel context.CancelFunc
	// done is closed when the fast retries end.
	done chan struct{}
}

func (r *probeRetry) settled() bool {
	return r.fullWalk && !r.whole && len(r.unanswered) == 0
}

const probeRetryBudget = 30 * time.Second

var defaultProbeRetryBackoff = []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 4 * time.Second}

func (d *DynamicResourceCache) noteWalk(gvr schema.GroupVersionResource, o probeOutcome) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.stopped {
		return
	}
	r := d.probeRetries[gvr]
	if r == nil && len(o.unanswered) == 0 && !o.whole {
		if o.fullWalk && len(o.scopes) > 0 {
			d.fallbackResolved[gvr] = true
			d.fanoutIncomplete[gvr] = false
		}
		return
	}
	// A record whose owner ended, is ending, or met rejected credentials needs
	// a fresh owner for a new gap, and the fresh owner takes over every gap
	// still outstanding; a live owner picks the gap up itself. Owners decide
	// to end under d.mu, so no gap lands between that decision and the
	// record's release.
	if r == nil || ((!r.owned || r.rejected || r.ctx == nil || r.ctx.Err() != nil) && (len(o.unanswered) > 0 || o.whole)) {
		prev := r
		r = &probeRetry{running: true, owned: true, fullWalk: d.fallbackResolved[gvr], answered: map[string]time.Time{}, deadline: o.startedAt.Add(probeRetryBudget), done: make(chan struct{})}
		if prev != nil {
			if prev.cancel != nil {
				prev.cancel()
			}
			r.fullWalk, r.whole = prev.fullWalk, prev.whole
			r.unanswered = slices.Clone(prev.unanswered)
			maps.Copy(r.answered, prev.answered)
		}
		r.ctx, r.cancel = context.WithCancel(d.probeCtx)
		d.probeRetries[gvr] = r
		go d.retryUnanswered(gvr, r)
	}
	for _, ns := range o.answered {
		r.answered[ns] = time.Now()
		r.unanswered = slices.DeleteFunc(r.unanswered, func(n string) bool { return n == ns })
	}
	for _, ns := range o.unanswered {
		// An answer that arrived during this walk supersedes its stale gap.
		if !r.answered[ns].After(o.startedAt) && !slices.Contains(r.unanswered, ns) {
			r.unanswered = append(r.unanswered, ns)
		}
	}
	if o.clusterAnswered {
		r.answered[""] = time.Now()
		r.whole = false
	}
	if o.fullWalk && !o.whole {
		r.fullWalk, r.whole = true, false
	} else if o.whole && !r.answered[""].After(o.startedAt) {
		r.whole = true
	}
	for key := range d.informers {
		if key.gvr == gvr {
			if key.ns == "" {
				r.unanswered, r.whole, r.fullWalk = nil, false, true
			} else {
				r.unanswered = slices.DeleteFunc(r.unanswered, func(ns string) bool { return ns == key.ns })
			}
		}
	}
	if r.settled() {
		r.cancel()
		d.settleProbeLocked(gvr)
	} else {
		d.fallbackResolved[gvr] = false
		d.fanoutIncomplete[gvr] = true
	}
}

func (d *DynamicResourceCache) noteAnswered(gvr schema.GroupVersionResource, ns string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if r := d.probeRetries[gvr]; r != nil {
		r.answered[ns] = time.Now()
		if ns == "" {
			r.unanswered, r.whole, r.fullWalk = nil, false, true
		} else {
			r.unanswered = slices.DeleteFunc(r.unanswered, func(n string) bool { return n == ns })
		}
		if r.settled() {
			r.cancel()
			d.settleProbeLocked(gvr)
		}
	}
}

// retryUnanswered owns r: it re-checks r's unanswered access checks with
// backoff within the budget, then, if they still haven't answered, slowly
// until they do, credentials are rejected, or the cache stops.
func (d *DynamicResourceCache) retryUnanswered(gvr schema.GroupVersionResource, r *probeRetry) {
	fast, cancel := context.WithDeadline(r.ctx, r.deadline)
	backoff := d.config.ProbeRetryBackoff
	if len(backoff) == 0 {
		backoff = defaultProbeRetryBackoff
	}
	for _, delay := range backoff {
		if !waitFor(fast, delay) || d.retryOnce(fast, gvr, r) {
			break
		}
	}
	cancel()
	d.mu.Lock()
	r.running = false
	settled := r.settled()
	if settled {
		d.settleProbeLocked(gvr)
		r.owned = false
		r.cancel()
	}
	close(r.done)
	d.mu.Unlock()
	if !settled {
		d.recoverProbes(gvr, r)
	}
}

// defaultProbeRecoveryIntervals space the re-checks of access checks still
// unanswered when their retry budget ran out; the last repeats.
var defaultProbeRecoveryIntervals = []time.Duration{time.Minute, 2 * time.Minute, 5 * time.Minute}

// recoverProbes keeps re-checking, slowly, access checks that were still
// unanswered when their retry budget ran out. Readers of the cache alone,
// such as GitOps detection, never probe a kind themselves, so without this a
// kind whose checks timed out or were rate-limited at startup would stay
// unwatched until something read it by name. Each attempt checks as a first
// walk would, so an identity limited to some namespaces still falls back to
// them, where watching cluster-wide on a guess would never sync.
func (d *DynamicResourceCache) recoverProbes(gvr schema.GroupVersionResource, r *probeRetry) {
	intervals := d.config.ProbeRecoveryIntervals
	if len(intervals) == 0 {
		intervals = defaultProbeRecoveryIntervals
	}
	release := func() bool {
		d.mu.Lock()
		defer d.mu.Unlock()
		if r.rejected || r.settled() || r.ctx.Err() != nil {
			r.owned = false
			r.cancel()
			return true
		}
		return false
	}
	for attempt := 0; !release(); attempt++ {
		if !waitFor(r.ctx, intervals[min(attempt, len(intervals)-1)]) {
			continue
		}
		ctx, cancel := context.WithTimeout(r.ctx, probeRetryBudget)
		d.retryOnce(ctx, gvr, r)
		cancel()
	}
}

func waitFor(ctx context.Context, delay time.Duration) bool {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return false
	case <-timer.C:
		return true
	}
}

// retryOnce re-checks r's unanswered access checks once: the whole walk when
// the cluster-wide check never answered, otherwise each unanswered namespace.
// It reports whether retrying should stop: the checks settled, ctx ended, a
// watch couldn't start, or Kubernetes rejected Radar's credentials.
func (d *DynamicResourceCache) retryOnce(ctx context.Context, gvr schema.GroupVersionResource, r *probeRetry) bool {
	d.mu.RLock()
	pending, whole, fullWalk := slices.Clone(r.unanswered), r.whole, r.fullWalk
	settled := r.settled()
	d.mu.RUnlock()
	if settled {
		return true
	}
	if whole || !fullWalk {
		o := d.walkScopesContext(ctx, gvr, "")
		if ctx.Err() != nil {
			return true
		}
		for _, scope := range o.scopes {
			// A cluster-wide check that failed, rather than passed, is no
			// reason to replace namespace watches that work: try again.
			if err := d.startWatchingScope(gvr, scope, o.failedOpen); errors.Is(err, errNamespaceWatchesKept) {
				return false
			} else if err != nil {
				return true
			}
		}
		if o.complete {
			d.retainAuthProbeFailure(gvr, "", true, o.err)
		}
		d.noteWalk(gvr, o)
		if apierrors.IsUnauthorized(o.err) {
			d.mu.Lock()
			r.rejected = true
			d.mu.Unlock()
			return true
		}
	} else {
		deadline, _ := ctx.Deadline()
		for i, ns := range pending {
			if ctx.Err() != nil {
				return true
			}
			d.mu.RLock()
			stillPending := slices.Contains(r.unanswered, ns)
			d.mu.RUnlock()
			if !stillPending {
				continue
			}
			select {
			case d.probeSlots <- struct{}{}:
			case <-ctx.Done():
				return true
			}
			probeCtx, cancel := context.WithTimeout(ctx, min(3*time.Second, time.Until(deadline)/time.Duration(len(pending)-i)))
			probeErr := d.listProbe(probeCtx, gvr, ns)
			unanswered := probeUnanswered(probeErr) || probeCtx.Err() != nil
			cancel()
			<-d.probeSlots
			if apierrors.IsUnauthorized(probeErr) {
				// A 401 rejects Radar's credentials, not this namespace:
				// stop for good, as the first walk does.
				d.retainAuthProbeFailure(gvr, "", true, probeErr)
				d.mu.Lock()
				r.rejected = true
				d.mu.Unlock()
				return true
			}
			if unanswered {
				continue
			}
			if scoped, err := d.classifyScope(gvr, ns, probeErr); err == nil {
				if err := d.startWatching(gvr, scoped); err != nil {
					return true
				}
			}
			d.noteAnswered(gvr, ns)
		}
	}
	d.mu.RLock()
	done := r.settled()
	d.mu.RUnlock()
	return done
}

func (d *DynamicResourceCache) settleProbeLocked(gvr schema.GroupVersionResource) {
	if r := d.probeRetries[gvr]; r != nil && !r.settled() {
		return
	}
	d.fanoutIncomplete[gvr] = false
	if retained, ok := d.observations[gvr]; ok && retained.reasonCode == "scope_probe_incomplete" {
		delete(d.observations, gvr)
	}
	for key := range d.informers {
		if key.gvr == gvr {
			d.fallbackResolved[gvr] = true
			return
		}
	}
	if retained, ok := d.observations[gvr]; ok && retained.reasonCode == "credentials_rejected" {
		return
	}
	truncated := d.namespaceProbeTruncated(gvr, "", true)
	reasonCode := "access_denied"
	if truncated {
		reasonCode = "access_denied_partial_probe"
	}
	d.observations[gvr] = retainedObservation{observedAt: time.Now().UTC(), state: DynamicObservationDenied, reasonCode: reasonCode, truncated: truncated}
}

func (d *DynamicResourceCache) namespaceProbeTruncated(gvr schema.GroupVersionResource, preferredNS string, complete bool) bool {
	if preferredNS != "" || d.config.NamespaceScoped {
		return false
	}
	return !complete || (d.config.NamespaceFallbacksTruncated && d.gvrIsNamespaced(gvr))
}

func (d *DynamicResourceCache) listProbe(ctx context.Context, gvr schema.GroupVersionResource, namespace string) error {
	if namespace != "" {
		_, err := d.config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{Limit: 1})
		return err
	}
	_, err := d.config.DynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{Limit: 1})
	return err
}

// classifyScope maps a namespace probe result to (scope, err): success → watch
// ns; auth error, or a probe that was never sent or ran out of time → no
// scope; any other error → fail open and watch ns anyway, matching the
// cluster-wide fail-open path.
func (d *DynamicResourceCache) classifyScope(gvr schema.GroupVersionResource, ns string, err error) (string, error) {
	if err == nil {
		return ns, nil
	}
	if isAuthProbeError(err) || probeUnanswered(err) {
		return "", err
	}
	log.Printf("[dynamic cache] Probe for %s.%s/%s in namespace %q returned non-auth error (allowing): %v", gvr.Resource, gvr.Group, gvr.Version, ns, err)
	return ns, nil
}

// isAuthProbeError classifies an error as an auth (403/401) failure as
// opposed to a transient or NotFound error. Uses the typed K8s helpers only —
// substring matching on "forbidden"/"unauthorized" misclassifies admission-
// webhook denials and optimistic-concurrency conflicts ("Operation cannot be
// fulfilled ... forbidden") on proxy-fronted clusters, permanently disabling
// CRDs for the session.
func isAuthProbeError(err error) bool {
	if err == nil {
		return false
	}
	return apierrors.IsForbidden(err) || apierrors.IsUnauthorized(err)
}

// gvrIsNamespaced reads the scope of the exact version asked for: discovery
// lists every served version, and an aggregated API can serve one resource
// with a different scope per version, so another version's scope can't stand
// in. Stale discovery is refreshed first, since an API replaced under the same
// name can change scope. A version discovery doesn't list reads as namespaced,
// which keeps namespace filters and the --namespace-scope pin in place.
func (d *DynamicResourceCache) gvrIsNamespaced(gvr schema.GroupVersionResource) bool {
	if d.config.Discovery == nil {
		return true
	}
	if err := d.config.Discovery.RefreshIfStale(); err != nil {
		log.Printf("Warning: failed to refresh API resources: %v", err)
	}
	if namespaced, known := d.config.Discovery.NamespacedGVR(gvr); known {
		return namespaced
	}
	return true
}

// ProbeCount does a quick list with limit=1 and returns the approximate resource count.
// Returns -1 if access is denied, -2 if the probe failed for non-auth reasons (caller
// should defer), or the count (items + remainingItemCount) on success.
//
// Exported so callers outside this package (e.g. internal/k8s when deciding
// whether to eager-warm high-cardinality CRDs like PolicyReports) can gate
// informer creation on cluster size before paying the watch-layer cost.
func (d *DynamicResourceCache) ProbeCount(gvr schema.GroupVersionResource) int {
	count, _ := d.probeCount(gvr)
	return count
}

// probeCount is ProbeCount that also returns the error behind -1 and -2.
func (d *DynamicResourceCache) probeCount(gvr schema.GroupVersionResource) (int, error) {
	select {
	case d.probeSlots <- struct{}{}:
		defer func() { <-d.probeSlots }()
	case <-d.probeCtx.Done():
		return -2, d.probeCtx.Err()
	}
	ctx, cancel := context.WithTimeout(d.probeCtx, 5*time.Second)
	defer cancel()

	var list *unstructured.UnstructuredList
	var err error
	list, err = d.probeCountList(ctx, gvr)

	if err != nil {
		if ctx.Err() != nil {
			return -2, err
		}
		if isAuthProbeError(err) {
			return -1, err
		}
		log.Printf("[dynamic cache] probeCount for %s.%s/%s returned non-auth error (deferring): %v",
			gvr.Resource, gvr.Group, gvr.Version, err)
		return -2, err
	}

	count := len(list.Items)
	if remaining := list.GetRemainingItemCount(); remaining != nil {
		count += int(*remaining)
		return count, nil
	}
	if list.GetContinue() != "" {
		return -2, nil
	}
	return count, nil
}

func (d *DynamicResourceCache) probeCountList(ctx context.Context, gvr schema.GroupVersionResource) (*unstructured.UnstructuredList, error) {
	// Match probeScope: under --namespace-scope only NAMESPACED resources are
	// pinned to the configured namespace. Counting a cluster-scoped resource
	// inside a namespace lists nothing (or errors), which would misgate the
	// size-based eager-warm decision, so it stays cluster-wide.
	if d.config.NamespaceScoped && d.config.Namespace != "" && d.gvrIsNamespaced(gvr) {
		return d.config.DynamicClient.Resource(gvr).Namespace(d.config.Namespace).List(ctx, metav1.ListOptions{Limit: 1})
	}

	list, err := d.config.DynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{Limit: 1})
	if err == nil {
		return list, nil
	}
	if apierrors.IsUnauthorized(err) {
		return nil, err
	}
	if isAuthProbeError(err) && d.gvrIsNamespaced(gvr) {
		// Size heuristic only — the first granted fallback namespace is a
		// good-enough sample for the eager-warm decision.
		var fallbackErr error
		for _, ns := range d.fallbackNamespaces() {
			nsList, nsErr := d.config.DynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{Limit: 1})
			if nsErr == nil {
				return nsList, nil
			}
			if apierrors.IsUnauthorized(nsErr) {
				return nil, nsErr
			}
			if !isAuthProbeError(nsErr) && fallbackErr == nil {
				fallbackErr = nsErr
			}
		}
		if fallbackErr != nil {
			return nil, fallbackErr
		}
	}
	return list, err
}

// gvrToKind converts a GVR to a Kind name using resource discovery.
func (d *DynamicResourceCache) gvrToKind(gvr schema.GroupVersionResource) string {
	if d.config.Discovery != nil {
		if kind := d.config.Discovery.GetKindForGVR(gvr); kind != "" {
			return kind
		}
	}
	// Fallback: capitalize and singularize
	name := gvr.Resource
	if len(name) > 1 && name[len(name)-1] == 's' {
		name = name[:len(name)-1]
	}
	if len(name) > 0 {
		return strings.ToUpper(name[:1]) + name[1:]
	}
	return name
}

// ---------------------------------------------------------------------------
// Change handlers
// ---------------------------------------------------------------------------

// safeCallback invokes fn with panic recovery to protect informer goroutines.
func (d *DynamicResourceCache) safeCallback(name string, fn func()) {
	defer func() {
		if r := recover(); r != nil {
			log.Printf("ERROR: k8score dynamic cache %s callback panicked: %v", name, r)
		}
	}()
	fn()
}

func (d *DynamicResourceCache) addDynamicChangeHandlers(inf cache.SharedIndexInformer, kind string, gvr schema.GroupVersionResource) {
	_, _ = inf.AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc: func(obj any) {
			d.enqueueDynamicChange(kind, gvr, obj, nil, OpAdd)
		},
		UpdateFunc: func(oldObj, newObj any) {
			d.enqueueDynamicChange(kind, gvr, newObj, oldObj, OpUpdate)
		},
		DeleteFunc: func(obj any) {
			d.enqueueDynamicChange(kind, gvr, obj, nil, OpDelete)
		},
	})
}

func (d *DynamicResourceCache) enqueueDynamicChange(kind string, gvr schema.GroupVersionResource, obj any, oldObj any, op string) {
	u, ok := obj.(*unstructured.Unstructured)
	if !ok {
		if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
			u, ok = tombstone.Obj.(*unstructured.Unstructured)
			if !ok {
				return
			}
		} else {
			return
		}
	}
	// Callbacks consume the resource object, not client-go's delivery
	// wrapper. Keeping the wrapper here silently drops dynamic deletes in
	// Radar's timeline callback even though identity was resolved above.
	obj = u

	namespace := u.GetNamespace()
	name := u.GetName()
	uid := string(u.GetUID())

	if d.config.OnReceived != nil {
		d.safeCallback("OnReceived", func() { d.config.OnReceived(kind) })
	}

	// During initial sync, still fire OnChange (for historical recording)
	// but skip the channel send (no SSE flood).
	isSyncAdd := false
	if op == OpAdd {
		d.mu.RLock()
		synced := d.gvrSyncedLocked(gvr, namespace)
		d.mu.RUnlock()

		if !synced {
			isSyncAdd = true
			if d.config.DebugEvents {
				log.Printf("[DEBUG] Dynamic initial sync add event: %s/%s/%s (recording historical only)", kind, namespace, name)
			}
		}
	}

	var diff *DiffInfo
	if op == OpUpdate && oldObj != nil && obj != nil && d.config.ComputeDiff != nil {
		d.safeCallback("ComputeDiff", func() { diff = d.config.ComputeDiff(kind, oldObj, obj) })
	}

	change := ResourceChange{
		Kind:      kind,
		Namespace: namespace,
		Name:      name,
		UID:       uid,
		Operation: op,
		Diff:      diff,
		Group:     gvr.Group,
		Resource:  gvr.Resource,
	}

	// Always fire OnChange (even during sync adds — Radar uses this for timeline)
	if d.config.OnChange != nil {
		d.safeCallback("OnChange", func() { d.config.OnChange(change, obj, oldObj) })
	}

	// Skip channel send during initial sync
	if isSyncAdd {
		return
	}

	// Send to change channel
	if d.config.Changes != nil {
		select {
		case d.config.Changes <- change:
		default:
			if d.config.OnDrop != nil {
				d.safeCallback("OnDrop", func() { d.config.OnDrop(kind, namespace, name, "channel_full", op) })
			}
			if d.config.DebugEvents {
				log.Printf("[DEBUG] Dynamic change channel full, dropped: %s/%s/%s op=%s", kind, namespace, name, op)
			}
		}
	}

	if d.config.OnRecorded != nil {
		d.safeCallback("OnRecorded", func() { d.config.OnRecorded(kind) })
	}
}

// ---------------------------------------------------------------------------
// Read methods
// ---------------------------------------------------------------------------

// WatchedGVRs returns the GVRs that already have a started informer. Callers
// that want to enumerate "what dynamic resources is the cache currently
// observing?" should use this — never iterate every CRD from API discovery
// and call List() on each, which spins up a new persistent informer per GVR
// and grows unbounded on clusters with many CRDs (Upbound AWS alone ships
// ~1000 kinds).
//
// Result is a snapshot; callers must not assume the set is stable across
// calls. Synced and unsynced informers are both included — callers that
// need only ready data should additionally call WaitForSync.
func (d *DynamicResourceCache) WatchedGVRs() []schema.GroupVersionResource {
	if d == nil {
		return nil
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.distinctGVRsLocked()
}

// distinctGVRsLocked returns the unique GVRs that have at least one informer,
// collapsing the per-namespace keys. Caller must hold d.mu.
func (d *DynamicResourceCache) distinctGVRsLocked() []schema.GroupVersionResource {
	seen := make(map[schema.GroupVersionResource]struct{}, len(d.informers))
	out := make([]schema.GroupVersionResource, 0, len(d.informers))
	for k := range d.informers {
		if _, ok := seen[k.gvr]; ok {
			continue
		}
		seen[k.gvr] = struct{}{}
		out = append(out, k.gvr)
	}
	return out
}

// readEntries returns the informer(s) that serve reads for (gvr, ns). A
// cluster-wide informer alone covers every namespace; otherwise reads come
// from namespace-scoped informers — the one matching a specific ns, or all of
// the matching namespace-scoped informer for a specific ns. e.informer is
// immutable after creation, so the returned entries are safe to read outside
// the lock (e.synced is not touched here). Returns nil when nothing covers
// (gvr, ns).
//
// ns == "" means cluster-wide and is served ONLY by a cluster-wide informer —
// it deliberately does NOT union whatever per-namespace informers happen to
// exist, which would make results depend on incidental cache state (and, in a
// shared cache, on namespaces another request warmed). Callers wanting a union
// over a known namespace set must name it via ListNamespaces.
func (d *DynamicResourceCache) readEntries(gvr schema.GroupVersionResource, ns string) []*informerEntry {
	d.mu.RLock()
	defer d.mu.RUnlock()
	if e, ok := d.informers[informerKey{gvr: gvr}]; ok {
		return []*informerEntry{e}
	}
	if ns != "" {
		if e, ok := d.informers[informerKey{gvr: gvr, ns: ns}]; ok {
			return []*informerEntry{e}
		}
		return nil
	}
	// ns == "" ("all namespaces") with no cluster-wide informer. When Radar
	// connects with a namespace-restricted identity (fallbacks configured),
	// cluster-wide list was denied so the only informers for this GVR are
	// per-namespace — union them, otherwise an all-namespaces read returns
	// nothing even though the per-namespace informers are synced.
	//
	// Deliberately NOT gated on fallbackResolved: list surfaces are
	// best-effort and self-healing (every read re-runs ensureWatching until
	// the candidate walk settles), matching the typed probe's keep-partial
	// philosophy. Cardinality/absence assertions are the strict surfaces —
	// Count(gvr, nil), CountWatched, and IsClusterWideSynced all refuse an
	// unsettled fanout, because a partial number masquerades as a smaller
	// truth while a partial list is just an incomplete view. A cluster-wide
	// cache (no fallbacks configured) stays empty here, preserving the
	// deterministic cluster-wide-only contract that keeps incidental
	// per-namespace informers from leaking across users.
	if len(d.fallbackNamespaces()) == 0 {
		return nil
	}
	var out []*informerEntry
	for k, e := range d.informers {
		if k.gvr == gvr {
			out = append(out, e)
		}
	}
	return out
}

// indexerItems gathers raw store objects from the given entries, filtered to
// namespace when non-empty. Namespace-scoped informers hold disjoint
// namespaces, so unioning across entries cannot duplicate an object.
func indexerItems(entries []*informerEntry, namespace string) ([]any, error) {
	var items []any
	for _, e := range entries {
		idx := e.informer.GetIndexer()
		if namespace != "" {
			got, err := idx.ByIndex(cache.NamespaceIndex, namespace)
			if err != nil {
				return nil, err
			}
			items = append(items, got...)
		} else {
			items = append(items, idx.List()...)
		}
	}
	return items, nil
}

// gvrSyncedLocked reports whether the informer holding objects of gvr in
// namespace has finished its initial sync (cluster-wide informer first, else
// the namespace-scoped one). Caller must hold d.mu.
func (d *DynamicResourceCache) gvrSyncedLocked(gvr schema.GroupVersionResource, namespace string) bool {
	if e, ok := d.informers[informerKey{gvr: gvr}]; ok {
		return e.synced
	}
	if e, ok := d.informers[informerKey{gvr: gvr, ns: namespace}]; ok {
		return e.synced
	}
	return false
}

// getByKeyFromEntries returns the first store object matching key across the
// entries. Namespace-scoped informers hold disjoint namespaces, so at most one
// can hold a given namespaced key.
func getByKeyFromEntries(entries []*informerEntry, key string) (any, bool, error) {
	for _, e := range entries {
		item, ok, err := e.informer.GetIndexer().GetByKey(key)
		if err != nil {
			return nil, false, err
		}
		if ok {
			return item, true, nil
		}
	}
	return nil, false, nil
}

// entriesSynced reports whether every entry's informer has completed its
// initial sync (informer.HasSynced is the thread-safe source of truth).
func entriesSynced(entries []*informerEntry) bool {
	for _, e := range entries {
		if !e.informer.HasSynced() {
			return false
		}
	}
	return len(entries) > 0
}

// servedSynced is entriesSynced for an answer the cache vouches for. Stop
// keeps the informers, and they go on reporting synced while their stores no
// longer change, so a stopped cache vouches for nothing. stopped never resets,
// so checking it after the informers keeps a true answer from describing a
// cache that had already stopped.
func (d *DynamicResourceCache) servedSynced(entries []*informerEntry) bool {
	if !entriesSynced(entries) {
		return false
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return !d.stopped
}

// Count returns the number of resources for a given GVR, optionally filtered by namespaces.
// Unlike List(), this avoids allocating a result slice and skips StripUnstructuredFields.
// Count reads only what is already watched and synced; it does not start informers.
//
// A cluster-wide informer serves any namespace filter. Without one, Count can
// answer for explicitly-named namespaces when each has its own synced informer.
// In namespace-fallback mode, Count(nil) unions the namespace-scoped informers
// so it agrees with all-namespace reads.
func (d *DynamicResourceCache) Count(gvr schema.GroupVersionResource, namespaces []string) (int, error) {
	if d == nil {
		return 0, fmt.Errorf("dynamic resource cache not initialized")
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	if d.stopped {
		return 0, fmt.Errorf("dynamic cache stopped")
	}
	if e, ok := d.informers[informerKey{gvr: gvr}]; ok {
		if !e.informer.HasSynced() {
			return 0, fmt.Errorf("informer not found or not synced for %v", gvr)
		}
		if len(namespaces) == 0 {
			return len(e.informer.GetIndexer().List()), nil
		}
		return countByNamespaces(e, namespaces)
	}

	if len(namespaces) == 0 {
		// No cluster-wide informer. In namespace-fallback mode union the
		// per-namespace informers so Count agrees with
		// readEntries on an all-namespaces read; a cluster-wide cache keeps the
		// strict cluster-wide-only contract and errors.
		if len(d.fallbackNamespaces()) == 0 {
			return 0, fmt.Errorf("informer not found or not synced for %v", gvr)
		}
		// A truncated candidate walk leaves a partial namespace set whose sum
		// would silently under-count; only a settled fanout is authoritative.
		if !d.fanoutSettledLocked(gvr) {
			return 0, fmt.Errorf("informer not found or not synced for %v", gvr)
		}
		total := 0
		found := false
		for k, e := range d.informers {
			if k.gvr != gvr {
				continue
			}
			if !e.informer.HasSynced() {
				return 0, fmt.Errorf("informer not found or not synced for %v", gvr)
			}
			found = true
			total += len(e.informer.GetIndexer().List())
		}
		if !found {
			return 0, fmt.Errorf("informer not found or not synced for %v", gvr)
		}
		return total, nil
	}

	total := 0
	for _, ns := range namespaces {
		e, ok := d.informers[informerKey{gvr: gvr, ns: ns}]
		if !ok || !e.informer.HasSynced() {
			return 0, fmt.Errorf("informer not found or not synced for %v in namespace %s", gvr, ns)
		}
		n, err := countByNamespaces(e, []string{ns})
		if err != nil {
			return 0, err
		}
		total += n
	}
	return total, nil
}

// CountWatched returns cached object counts for every currently watched and
// synced GVR. It never starts informers and never probes the apiserver.
func (d *DynamicResourceCache) CountWatched(namespaces []string) map[schema.GroupVersionResource]int {
	if d == nil {
		return nil
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	if d.stopped {
		return nil
	}
	type watchedSet struct {
		clusterWide *informerEntry
		byNamespace map[string]*informerEntry
	}
	byGVR := make(map[schema.GroupVersionResource]*watchedSet)
	for k, e := range d.informers {
		if !e.informer.HasSynced() {
			continue
		}
		set := byGVR[k.gvr]
		if set == nil {
			set = &watchedSet{byNamespace: make(map[string]*informerEntry)}
			byGVR[k.gvr] = set
		}
		if k.ns == "" {
			set.clusterWide = e
		} else {
			set.byNamespace[k.ns] = e
		}
	}

	// Unsynced entries were dropped above, so re-scan for GVRs whose fanout
	// still has an unsynced sibling — summing the synced subset would report
	// a silently smaller count instead of "not ready yet".
	partiallySynced := make(map[schema.GroupVersionResource]bool)
	for k, e := range d.informers {
		if !e.informer.HasSynced() {
			partiallySynced[k.gvr] = true
		}
	}

	counts := make(map[schema.GroupVersionResource]int)
	for gvr, set := range byGVR {
		if len(namespaces) == 0 {
			if set.clusterWide != nil {
				counts[gvr] = len(set.clusterWide.informer.GetIndexer().List())
				continue
			}
			if len(d.fallbackNamespaces()) == 0 {
				continue
			}
			// All-namespaces sums over a fanout are only authoritative once
			// the candidate walk settled and every informer synced.
			if !d.fanoutSettledLocked(gvr) || partiallySynced[gvr] {
				continue
			}
			total := 0
			for _, e := range set.byNamespace {
				total += len(e.informer.GetIndexer().List())
			}
			counts[gvr] = total
			continue
		}
		if set.clusterWide != nil {
			n, err := countByNamespaces(set.clusterWide, namespaces)
			if err == nil {
				counts[gvr] = n
			}
			continue
		}
		total := 0
		complete := true
		for _, ns := range namespaces {
			e, ok := set.byNamespace[ns]
			if !ok {
				complete = false
				break
			}
			n, err := countByNamespaces(e, []string{ns})
			if err != nil {
				complete = false
				break
			}
			total += n
		}
		if complete {
			counts[gvr] = total
		}
	}
	return counts
}

func countByNamespaces(e *informerEntry, namespaces []string) (int, error) {
	total := 0
	for _, ns := range namespaces {
		items, err := e.informer.GetIndexer().ByIndex(cache.NamespaceIndex, ns)
		if err != nil {
			return 0, fmt.Errorf("failed to count resources in namespace %s: %w", ns, err)
		}
		total += len(items)
	}
	return total, nil
}

func (d *DynamicResourceCache) CountDirectProbe(ctx context.Context, gvr schema.GroupVersionResource, namespaces []string, maxNamespaces, concurrency int) (int, error) {
	if d == nil {
		return 0, fmt.Errorf("dynamic resource cache not initialized")
	}
	if d.config.DynamicClient == nil {
		return 0, fmt.Errorf("dynamic client not initialized")
	}
	if len(namespaces) == 0 {
		return d.countDirectProbeOne(ctx, gvr, "")
	}
	if maxNamespaces > 0 && len(namespaces) > maxNamespaces {
		return 0, ErrResourceCountUnavailable
	}
	if concurrency < 1 {
		concurrency = 1
	}
	if concurrency > len(namespaces) {
		concurrency = len(namespaces)
	}

	type result struct {
		count int
		err   error
	}
	jobs := make(chan string)
	results := make(chan result, len(namespaces))
	var wg sync.WaitGroup
	for i := 0; i < concurrency; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for ns := range jobs {
				n, err := d.countDirectProbeOne(ctx, gvr, ns)
				results <- result{count: n, err: err}
			}
		}()
	}

	go func() {
		defer close(jobs)
		for _, ns := range namespaces {
			select {
			case <-ctx.Done():
				return
			case jobs <- ns:
			}
		}
	}()
	wg.Wait()
	close(results)

	total := 0
	for r := range results {
		if r.err != nil {
			return 0, r.err
		}
		total += r.count
	}
	if err := ctx.Err(); err != nil {
		return 0, err
	}
	return total, nil
}

func (d *DynamicResourceCache) countDirectProbeOne(ctx context.Context, gvr schema.GroupVersionResource, namespace string) (int, error) {
	var list *unstructured.UnstructuredList
	var err error
	opts := metav1.ListOptions{Limit: directCountProbeLimit}
	if namespace != "" {
		list, err = d.config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, opts)
	} else {
		list, err = d.config.DynamicClient.Resource(gvr).List(ctx, opts)
	}
	if err != nil {
		if isAuthProbeError(err) {
			return 0, ErrResourceCountUnavailable
		}
		return 0, fmt.Errorf("failed to probe resource count: %w", err)
	}
	if list == nil {
		return 0, ErrResourceCountUnavailable
	}
	if remaining := list.GetRemainingItemCount(); remaining != nil {
		return len(list.Items) + int(*remaining), nil
	}
	if list.GetContinue() == "" {
		return len(list.Items), nil
	}
	return 0, ErrResourceCountUnavailable
}

// List returns all resources of a given GVR, optionally filtered by namespace.
// This is non-blocking — returns whatever data is available immediately.
func (d *DynamicResourceCache) List(gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) {
	return d.ListContext(context.Background(), gvr, namespace)
}

// ListContext is List with ctx bounding how long the caller waits for an
// on-demand watch to start.
func (d *DynamicResourceCache) ListContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}

	if err := d.EnsureWatchingContext(ctx, gvr, namespace); err != nil {
		return nil, err
	}

	entries := d.readEntries(gvr, namespace)
	if len(entries) == 0 {
		return nil, fmt.Errorf("informer not found for %v", gvr)
	}

	items, err := indexerItems(entries, namespace)
	if err != nil {
		return nil, fmt.Errorf("failed to list resources: %w", err)
	}

	result := make([]*unstructured.Unstructured, 0, len(items))
	for _, item := range items {
		if u, ok := item.(*unstructured.Unstructured); ok {
			result = append(result, StripUnstructuredFields(u))
		}
	}

	return result, nil
}

// ListReadOnly is List without its per-object copy: it returns the cached
// objects themselves. Informer transforms run before objects enter the store,
// and updates replace stored objects rather than mutating them in place.
// Callers must not mutate the returned objects or retain them beyond the
// synchronous computation that requested them.
func (d *DynamicResourceCache) ListReadOnly(gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}
	if err := d.ensureWatching(gvr, namespace); err != nil {
		return nil, err
	}
	return d.readOnlyItems(gvr, namespace)
}

// ListReadOnlyContext is ListReadOnly with ctx bounding the wait to start
// watching gvr, as ListContext is to List.
func (d *DynamicResourceCache) ListReadOnlyContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}
	if err := d.EnsureWatchingContext(ctx, gvr, namespace); err != nil {
		return nil, err
	}
	return d.readOnlyItems(gvr, namespace)
}

func (d *DynamicResourceCache) readOnlyItems(gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) {
	entries := d.readEntries(gvr, namespace)
	if len(entries) == 0 {
		return nil, fmt.Errorf("informer not found for %v", gvr)
	}
	items, err := indexerItems(entries, namespace)
	if err != nil {
		return nil, fmt.Errorf("failed to list resources: %w", err)
	}
	result := make([]*unstructured.Unstructured, 0, len(items))
	for _, item := range items {
		if u, ok := item.(*unstructured.Unstructured); ok {
			result = append(result, u)
		}
	}
	return result, nil
}

// ListWatched returns every cached object for gvr across all currently-watched
// scopes (cluster-wide and/or per-namespace), unioned. It does NOT start or
// re-probe informers — it reads whatever is already watched. This is for
// internal "scan what's already cached" callers (Crossplane audit, Kyverno
// PolicyReport indexing) that iterate WatchedGVRs(): unlike List(gvr, ""),
// which is cluster-wide-only, it surfaces namespace-scoped contents so those
// scanners don't silently drop them in a namespace-restricted install.
// Request-facing reads must stay on List / ListNamespaces with explicit
// namespaces.
func (d *DynamicResourceCache) ListWatched(gvr schema.GroupVersionResource) ([]*unstructured.Unstructured, error) {
	items, err := d.ListWatchedReadOnly(gvr)
	if err != nil {
		return nil, err
	}
	result := make([]*unstructured.Unstructured, 0, len(items))
	for _, item := range items {
		result = append(result, StripUnstructuredFields(item))
	}
	return result, nil
}

// ListWatchedReadOnly returns the cached objects covered by ListWatched
// without copying them. Informer transforms run before objects enter the store,
// and updates replace stored objects rather than mutating them in place. Callers
// must not mutate the returned objects or retain them beyond the synchronous
// computation that requested them.
func (d *DynamicResourceCache) ListWatchedReadOnly(gvr schema.GroupVersionResource) ([]*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}
	entries := d.entriesForGVR(gvr)
	items, err := indexerItems(entries, "")
	if err != nil {
		return nil, fmt.Errorf("failed to list resources: %w", err)
	}
	result := make([]*unstructured.Unstructured, 0, len(items))
	for _, item := range items {
		if u, ok := item.(*unstructured.Unstructured); ok {
			result = append(result, u)
		}
	}
	return result, nil
}

// ListWatchedNamespaceReadOnly reads an already-watched namespace when it fits
// within the candidate budget. Larger namespaces return no rows and truncated
// coverage rather than an unstable sample. The client-go namespace index allocates its namespace-sized
// result first; this avoids materializing other namespaces and bounds subsequent
// object inspection, not the index lookup allocation. Returned objects must not
// be mutated or retained beyond the synchronous read.
func (d *DynamicResourceCache) ListWatchedNamespaceReadOnly(gvr schema.GroupVersionResource, namespace string, limit int) ([]*unstructured.Unstructured, bool, error) {
	if d == nil {
		return nil, false, fmt.Errorf("dynamic resource cache not initialized")
	}
	if namespace == "" || limit <= 0 {
		return nil, false, fmt.Errorf("namespace and positive candidate limit are required")
	}
	entries, readable := d.servedEntries(gvr, namespace)
	if !readable {
		return nil, false, fmt.Errorf("namespace cache is not synchronized")
	}
	items, err := indexerItems(entries, namespace)
	if err != nil {
		return nil, false, err
	}
	if len(items) > limit {
		return nil, true, nil
	}
	result := make([]*unstructured.Unstructured, 0, len(items))
	for _, item := range items {
		if u, ok := item.(*unstructured.Unstructured); ok {
			result = append(result, u)
		}
	}
	return result, false, nil
}

// GetWatched reads one object only from already-watched informer stores. It
// never probes RBAC, starts an informer, or waits for a cache sync.
func (d *DynamicResourceCache) GetWatched(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}
	key := name
	if namespace != "" {
		key = namespace + "/" + name
	}
	item, found, err := getByKeyFromEntries(d.entriesForGVR(gvr), key)
	if err != nil {
		return nil, fmt.Errorf("failed to get resource: %w", err)
	}
	if !found {
		return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, key)
	}
	u, ok := item.(*unstructured.Unstructured)
	if !ok {
		return nil, fmt.Errorf("unexpected type in cache")
	}
	return StripUnstructuredFields(u), nil
}

// ListNamespaces returns resources of gvr unioned across an explicit set of
// namespaces. This is the sanctioned multi-namespace path — callers with a
// known, RBAC-filtered namespace set use it instead of List(gvr, ""), so the
// union is driven by the caller's explicit list rather than by whatever
// per-namespace informers incidentally exist. Namespace-scoped informers hold
// disjoint namespaces, so the union cannot duplicate an object.
//
// Two cases short-circuit to a cluster-wide read: an empty/nil set ("all"),
// and a cluster-scoped resource (no namespace dimension — a per-namespace
// filter would match nothing, so always read it cluster-wide regardless of the
// requested namespaces). The cluster-scoped check makes this safe to call
// uniformly over a mix of namespaced and cluster-scoped GVRs.
func (d *DynamicResourceCache) ListNamespaces(gvr schema.GroupVersionResource, namespaces []string) ([]*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}
	if len(namespaces) == 0 || !d.gvrIsNamespaced(gvr) {
		return d.List(gvr, "")
	}
	result := make([]*unstructured.Unstructured, 0)
	for _, ns := range namespaces {
		items, err := d.List(gvr, ns)
		if err != nil {
			return nil, err
		}
		result = append(result, items...)
	}
	return result, nil
}

// ListBlocking returns all resources, waiting for cache sync first. timeout
// bounds the sync wait as a whole, however many informers serve the read. It
// doesn't cover starting a watch that isn't running yet, which can wait for
// CRD discovery and scope probes.
func (d *DynamicResourceCache) ListBlocking(gvr schema.GroupVersionResource, namespace string, timeout time.Duration) ([]*unstructured.Unstructured, error) {
	return d.ListBlockingContext(context.Background(), gvr, namespace, timeout)
}

// ListBlockingContext is ListBlocking with ctx also bounding this caller's
// wait. Cancelling ctx ends the wait, not the shared watch. As with
// ListBlocking, a wait that ends before the watch syncs returns whatever the
// watch holds so far; callers that need a complete read check
// IsNamespaceSynced afterwards.
func (d *DynamicResourceCache) ListBlockingContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string, timeout time.Duration) ([]*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}

	if err := d.EnsureWatchingContext(ctx, gvr, namespace); err != nil {
		return nil, err
	}

	entries := d.readEntries(gvr, namespace)
	if len(entries) == 0 {
		return nil, fmt.Errorf("informer not found for %v", gvr)
	}

	var pending []cache.InformerSynced
	for _, e := range entries {
		if !e.informer.HasSynced() {
			pending = append(pending, e.informer.HasSynced)
		}
	}
	if len(pending) > 0 {
		ctx, cancel := context.WithTimeout(ctx, timeout)
		cache.WaitForCacheSync(ctx.Done(), pending...)
		cancel()
	}

	items, err := indexerItems(entries, namespace)
	if err != nil {
		return nil, fmt.Errorf("failed to list resources: %w", err)
	}

	result := make([]*unstructured.Unstructured, 0, len(items))
	for _, item := range items {
		if u, ok := item.(*unstructured.Unstructured); ok {
			result = append(result, StripUnstructuredFields(u))
		}
	}

	return result, nil
}

// Get returns a single resource by namespace and name.
func (d *DynamicResourceCache) Get(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
	return d.get(gvr, namespace, name, false)
}

// GetPreserveLastApplied returns a single cached resource while preserving the
// kubectl last-applied annotation for internal drift computation.
func (d *DynamicResourceCache) GetPreserveLastApplied(gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
	return d.get(gvr, namespace, name, true)
}

func (d *DynamicResourceCache) get(gvr schema.GroupVersionResource, namespace, name string, preserveLastApplied bool) (*unstructured.Unstructured, error) {
	if d == nil {
		return nil, fmt.Errorf("dynamic resource cache not initialized")
	}

	if err := d.ensureWatching(gvr, namespace); err != nil {
		return nil, err
	}

	entries := d.readEntries(gvr, namespace)
	if len(entries) == 0 {
		return nil, fmt.Errorf("informer not found for %v", gvr)
	}

	var key string
	if namespace != "" {
		key = namespace + "/" + name
	} else {
		key = name
	}

	item, found, err := getByKeyFromEntries(entries, key)
	if err != nil {
		return nil, fmt.Errorf("failed to get resource: %w", err)
	}

	if !found && !entriesSynced(entries) {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		for _, e := range entries {
			cache.WaitForCacheSync(ctx.Done(), e.informer.HasSynced)
		}
		cancel()

		item, found, err = getByKeyFromEntries(entries, key)
		if err != nil {
			return nil, fmt.Errorf("failed to get resource: %w", err)
		}
	}

	if !found {
		return nil, fmt.Errorf("%w: %s", ErrResourceNotFound, key)
	}

	u, ok := item.(*unstructured.Unstructured)
	if !ok {
		return nil, fmt.Errorf("unexpected type in cache")
	}

	if preserveLastApplied {
		return StripUnstructuredFieldsPreserveLastApplied(u), nil
	}
	return StripUnstructuredFields(u), nil
}

// ListWithSelector returns resources matching a label selector.
func (d *DynamicResourceCache) ListWithSelector(gvr schema.GroupVersionResource, namespace string, selector labels.Selector) ([]*unstructured.Unstructured, error) {
	items, err := d.List(gvr, namespace)
	if err != nil {
		return nil, err
	}

	if selector == nil || selector.Empty() {
		return items, nil
	}

	result := make([]*unstructured.Unstructured, 0)
	for _, item := range items {
		if selector.Matches(labels.Set(item.GetLabels())) {
			result = append(result, item)
		}
	}

	return result, nil
}

// ListDirect fetches resources directly from the API (bypasses cache).
func (d *DynamicResourceCache) ListDirect(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]*unstructured.Unstructured, error) {
	items, _, err := d.ListDirectSelected(ctx, gvr, namespace, "", 0)
	return items, err
}

// ListDirectSelected fetches one page of resources matching labelSelector
// directly from the API (bypasses cache). A limit of 0 means no limit.
// truncated reports that the API server returned a continue token, i.e. more
// matching objects exist beyond the returned page.
func (d *DynamicResourceCache) ListDirectSelected(ctx context.Context, gvr schema.GroupVersionResource, namespace, labelSelector string, limit int64) (items []*unstructured.Unstructured, truncated bool, err error) {
	opts := metav1.ListOptions{LabelSelector: labelSelector, Limit: limit}
	var list *unstructured.UnstructuredList

	if namespace != "" {
		list, err = d.config.DynamicClient.Resource(gvr).Namespace(namespace).List(ctx, opts)
	} else {
		list, err = d.config.DynamicClient.Resource(gvr).List(ctx, opts)
	}

	if err != nil {
		return nil, false, fmt.Errorf("failed to list resources: %w", err)
	}

	result := make([]*unstructured.Unstructured, len(list.Items))
	for i := range list.Items {
		result[i] = StripUnstructuredFields(&list.Items[i])
	}

	return result, list.GetContinue() != "", nil
}

// GetDirect fetches a single resource directly from the API (bypasses cache).
func (d *DynamicResourceCache) GetDirect(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
	return d.getDirect(ctx, gvr, namespace, name, false)
}

// GetDirectPreserveLastApplied fetches a single resource directly from the
// API while preserving kubectl.kubernetes.io/last-applied-configuration. Used
// exclusively by GitOps drift detection, which needs the annotation to diff
// declared vs live state. Bypasses the dynamic informer entirely on purpose:
// caching this code path would otherwise force-start an informer for the
// resource's GVR (often core kinds like apps/Deployment, /v1/Service that
// Argo's status.resources references) and retain last-applied across every
// object cluster-wide — a meaningful memory regression to power a per-page
// diagnostic. Direct GET pays one API round-trip per managed resource per
// insight build (memoized 5s upstream).
func (d *DynamicResourceCache) GetDirectPreserveLastApplied(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) {
	return d.getDirect(ctx, gvr, namespace, name, true)
}

func (d *DynamicResourceCache) getDirect(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string, preserveLastApplied bool) (*unstructured.Unstructured, error) {
	var u *unstructured.Unstructured
	var err error

	if namespace != "" {
		u, err = d.config.DynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
	} else {
		u, err = d.config.DynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{})
	}

	if err != nil {
		return nil, err
	}

	if preserveLastApplied {
		return StripUnstructuredFieldsPreserveLastApplied(u), nil
	}
	return StripUnstructuredFields(u), nil
}

// ---------------------------------------------------------------------------
// Batch / warmup
// ---------------------------------------------------------------------------

// WarmupParallel starts watching multiple resources in parallel and waits for all to sync.
func (d *DynamicResourceCache) WarmupParallel(gvrs []schema.GroupVersionResource, timeout time.Duration) {
	if d == nil || len(gvrs) == 0 {
		return
	}

	type probeResult struct {
		gvr      schema.GroupVersionResource
		scopes   []string
		complete bool
		err      error
		outcome  probeOutcome
	}
	results := make(chan probeResult, len(gvrs))
	for _, gvr := range gvrs {
		go func(g schema.GroupVersionResource) {
			o := d.walkScopes(g, "")
			results <- probeResult{gvr: g, scopes: o.scopes, complete: o.complete, err: o.err, outcome: o}
		}(gvr)
	}

	var started []informerKey
	for range gvrs {
		r := <-results
		if r.err != nil {
			switch {
			case d.retainAuthProbeFailure(r.gvr, "", r.complete, r.err):
			case errors.Is(r.err, errScopeProbeIncomplete):
				d.retainObservation(r.gvr, DynamicObservationDeferred, "scope_probe_incomplete", true)
			default:
				d.retainObservation(r.gvr, DynamicObservationDeferred, "scope_probe_failed", false)
			}
			d.noteWalk(r.gvr, r.outcome)
			continue
		}
		allStarted := true
		for _, scope := range r.scopes {
			if err := d.startWatching(r.gvr, scope); err == nil {
				started = append(started, informerKey{gvr: r.gvr, ns: scope})
			} else {
				allStarted = false
				truncated := len(r.scopes) > 0 && r.scopes[0] != "" && (!r.complete || d.config.NamespaceFallbacksTruncated)
				d.retainObservation(r.gvr, DynamicObservationDeferred, "watch_start_failed", truncated)
			}
		}
		if !allStarted {
			r.outcome.fullWalk = false
		}
		d.noteWalk(r.gvr, r.outcome)
	}

	if len(started) == 0 {
		return
	}

	d.mu.RLock()
	syncFuncs := make([]cache.InformerSynced, 0, len(started))
	for _, key := range started {
		if e, ok := d.informers[key]; ok {
			syncFuncs = append(syncFuncs, e.informer.HasSynced)
		}
	}
	d.mu.RUnlock()

	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	if !cache.WaitForCacheSync(ctx.Done(), syncFuncs...) {
		log.Printf("Warning: not all dynamic caches synced within timeout")
	} else {
		log.Printf("All %d dynamic resources synced", len(syncFuncs))
	}
}

// DiscoverAllCRDs discovers all CRDs that support list/watch and decides which
// to watch eagerly vs on-demand. Known integrations (cert-manager, KEDA, etc.)
// are already watching from WarmupCommonCRDs. For the rest, CRDs with ≤100
// resources are watched eagerly (cheap, full timeline coverage). CRDs with >100
// resources (calico, cilium, etc.) are deferred to on-demand via EnsureWatching()
// when the user browses them, avoiding expensive watch connections.
//
// Once the startup pass is done, CRDs installed later get the same decision:
// see watchLateCRDs.
func (d *DynamicResourceCache) DiscoverAllCRDs() {
	if d == nil {
		log.Println("[CRD Discovery] Cache is nil, skipping")
		return
	}

	d.discoveryMu.Lock()
	if d.discoveryStatus != CRDDiscoveryIdle {
		log.Printf("[CRD Discovery] Already in status: %s, skipping", d.discoveryStatus)
		d.discoveryMu.Unlock()
		return
	}
	d.discoveryStatus = CRDDiscoveryInProgress
	d.discoveryMu.Unlock()
	log.Println("[CRD Discovery] Starting CRD discovery...")

	go func() {
		defer func() {
			panicked := false
			if r := recover(); r != nil {
				panicked = true
				buf := make([]byte, 4096)
				n := runtime.Stack(buf, false)
				log.Printf("PANIC in CRD discovery goroutine: %v\n%s", r, buf[:n])
			}
			d.discoveryMu.Lock()
			if d.discoveryStatus != CRDDiscoveryComplete {
				d.discoveryStatus = CRDDiscoveryComplete
				close(d.discoveryDone)
			}
			d.discoveryMu.Unlock()
			if panicked {
				log.Println("[CRD Discovery] CRD discovery terminated due to panic (marked complete to unblock waiters)")
			} else {
				log.Println("[CRD Discovery] CRD discovery complete")
				d.watchLateCRDs()
			}

			d.notifyCRDDiscoveryComplete()
		}()

		if d.config.Discovery == nil {
			log.Println("Resource discovery not available for CRD discovery")
			return
		}

		gvrs, keys, err := d.crdWatchCandidates()
		if err != nil {
			log.Printf("Failed to get API resources for CRD discovery: %v", err)
			return
		}
		d.markCRDsSeen(keys)

		if len(gvrs) == 0 {
			log.Println("No watchable CRDs found")
			return
		}

		stats := d.watchSmallCRDs(gvrs)
		if stats.eager+stats.deferred+stats.noAccess == 0 {
			log.Printf("Discovered %d watchable CRDs (all %d already watching from warmup)", len(gvrs), stats.alreadyWatching)
			return
		}
		log.Printf("Discovered %d watchable CRDs (%d already watching, %d small → eager, %d large → on-demand, %d no access)",
			len(gvrs), stats.alreadyWatching, stats.eager, stats.deferred, stats.noAccess)
	}()
}

// crdWatchCandidates returns one GVR per CRD (its most stable served version)
// for every CRD that supports list and watch, and the group/resource key of
// every CRD discovery reports, watchable or not.
func (d *DynamicResourceCache) crdWatchCandidates() ([]schema.GroupVersionResource, []string, error) {
	resources, err := d.config.Discovery.GetAPIResources()
	if err != nil {
		return nil, nil, err
	}

	best := make(map[string]schema.GroupVersionResource)
	allKeys := make(map[string]struct{})
	for _, res := range resources {
		if !res.IsCRD {
			continue
		}
		allKeys[res.Group+"/"+res.Name] = struct{}{}
		gvr := schema.GroupVersionResource{
			Group:    res.Group,
			Version:  res.Version,
			Resource: res.Name,
		}
		// Crossplane serves this legacy API with a deprecation warning on every
		// informer watch renewal. Keep it available on demand, but don't eagerly watch it.
		if res.Group == "apiextensions.crossplane.io" && res.Name == "usages" {
			d.retainObservation(gvr, DynamicObservationDeferred, "legacy_api_deferred", false)
			continue
		}
		hasList := false
		hasWatch := false
		for _, verb := range res.Verbs {
			if verb == "list" {
				hasList = true
			}
			if verb == "watch" {
				hasWatch = true
			}
		}
		if !hasList || !hasWatch {
			continue
		}
		key := res.Group + "/" + res.Name
		if existing, ok := best[key]; ok {
			if !IsMoreStableVersion(res.Version, existing.Version) {
				continue
			}
		}
		best[key] = gvr
	}

	gvrs := make([]schema.GroupVersionResource, 0, len(best))
	for _, gvr := range best {
		gvrs = append(gvrs, gvr)
	}
	keys := make([]string, 0, len(allKeys))
	for key := range allKeys {
		keys = append(keys, key)
	}
	return gvrs, keys, nil
}

type crdWatchStats struct {
	alreadyWatching, eager, deferred, noAccess int
	retry                                      []schema.GroupVersionResource
}

// maxEagerResources is the object count up to which a CRD is watched eagerly.
// CRDs with few resources are cheap to watch and give full timeline coverage;
// CRDs with many (calico policies, cilium endpoints) are deferred to on-demand.
const maxEagerResources = 100

// watchSmallCRDs probes each GVR that isn't watched yet and starts watching
// the ones with at most maxEagerResources objects.
func (d *DynamicResourceCache) watchSmallCRDs(gvrs []schema.GroupVersionResource) crdWatchStats {
	var stats crdWatchStats

	d.mu.RLock()
	watched := make(map[schema.GroupVersionResource]struct{}, len(d.informers))
	for k := range d.informers {
		watched[k.gvr] = struct{}{}
	}
	stats.alreadyWatching = len(watched)
	var remaining []schema.GroupVersionResource
	for _, gvr := range gvrs {
		if _, exists := watched[gvr]; !exists {
			remaining = append(remaining, gvr)
		}
	}
	d.mu.RUnlock()

	if len(remaining) == 0 {
		return stats
	}

	type probeResult struct {
		gvr   schema.GroupVersionResource
		count int // -1 = no access
		err   error
	}
	results := make(chan probeResult, len(remaining))
	for _, gvr := range remaining {
		go func(g schema.GroupVersionResource) {
			defer func() {
				if r := recover(); r != nil {
					log.Printf("[CRD Discovery] Panic probing %s.%s/%s: %v", g.Resource, g.Group, g.Version, r)
					results <- probeResult{gvr: g, count: -2}
				}
			}()
			count, err := d.probeCount(g)
			results <- probeResult{gvr: g, count: count, err: err}
		}(gvr)
	}

	var eager []schema.GroupVersionResource
	for range remaining {
		r := <-results
		if r.count == -1 {
			stats.noAccess++
			d.retainAuthProbeFailure(r.gvr, "", true, r.err)
			continue
		}
		if r.count == -2 {
			// Probe failed (timeout, network error) — defer to be safe
			stats.deferred++
			stats.retry = append(stats.retry, r.gvr)
			d.retainObservation(r.gvr, DynamicObservationDeferred, "resource_count_probe_failed", false)
			continue
		}
		if r.count <= maxEagerResources {
			eager = append(eager, r.gvr)
			d.retainObservation(r.gvr, DynamicObservationDeferred, "eager_watch_pending", false)
		} else {
			stats.deferred++
			d.retainObservation(r.gvr, DynamicObservationDeferred, "resource_count_exceeds_eager_limit", false)
			if d.config.DebugEvents {
				kind := d.gvrToKind(r.gvr)
				log.Printf("[CRD Discovery] Deferring %s (%d resources > %d threshold)", kind, r.count, maxEagerResources)
			}
		}
	}
	stats.eager = len(eager)

	if len(eager) > 0 {
		d.WarmupParallel(eager, 30*time.Second)
	}
	return stats
}

// ---------------------------------------------------------------------------
// CRDs installed after startup discovery
// ---------------------------------------------------------------------------

var crdGVR = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"}

// lateCRDScanDelay batches the CRD events of one install (an operator chart
// usually creates many CRDs at once, and each becomes Established in a later
// update) into a single discovery refresh. A scan that leaves a new CRD
// unresolved (discovery failed or doesn't serve it yet) is retried with
// doubling delays, up to lateCRDMaxRetries times.
var lateCRDScanDelay = 3 * time.Second

const lateCRDMaxRetries = 5

// markCRDsSeen records CRDs (group/resource keys) that already went through a
// watch decision and returns the ones not seen before. Seen CRDs are no longer
// pending a scan.
func (d *DynamicResourceCache) markCRDsSeen(keys []string) map[string]struct{} {
	d.lateCRDMu.Lock()
	defer d.lateCRDMu.Unlock()
	fresh := make(map[string]struct{})
	for _, key := range keys {
		delete(d.pendingCRDs, key)
		if _, ok := d.seenCRDs[key]; ok {
			continue
		}
		d.seenCRDs[key] = struct{}{}
		fresh[key] = struct{}{}
	}
	return fresh
}

// watchLateCRDs subscribes to CRD events so CRDs installed after startup get
// the same watch decision as DiscoverAllCRDs gave the ones already present,
// and their resources' changes reach the timeline from install on.
func (d *DynamicResourceCache) watchLateCRDs() {
	handler := cache.ResourceEventHandlerFuncs{
		AddFunc:    func(obj any) { d.onCRDEvent(obj) },
		UpdateFunc: func(_, obj any) { d.onCRDEvent(obj) },
		DeleteFunc: func(obj any) { d.forgetCRD(obj) },
	}
	if err := d.AddGVRChangeHandler(crdGVR, handler); err == nil {
		return
	}
	if err := d.EnsureWatching(crdGVR); err != nil {
		log.Printf("[CRD Discovery] Not watching CRDs, so CRDs installed later are watched only when listed: %v", err)
		return
	}
	if err := d.AddGVRChangeHandler(crdGVR, handler); err != nil {
		log.Printf("[CRD Discovery] Not watching CRDs, so CRDs installed later are watched only when listed: %v", err)
	}
}

// crdKey returns the group/resource key of a CRD object, as used in seenCRDs.
func crdKey(obj any) (string, bool) {
	if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
		obj = tombstone.Obj
	}
	u, ok := obj.(*unstructured.Unstructured)
	if !ok {
		return "", false
	}
	group, _, _ := unstructured.NestedString(u.Object, "spec", "group")
	plural, _, _ := unstructured.NestedString(u.Object, "spec", "names", "plural")
	if group == "" || plural == "" {
		return "", false
	}
	return group + "/" + plural, true
}

// onCRDEvent schedules a scan for a CRD that hasn't had a watch decision yet.
// Updates to known CRDs (cert-manager's cainjector rewrites CRD CA bundles
// regularly) don't trigger one: a scan refreshes discovery, which is costly.
func (d *DynamicResourceCache) onCRDEvent(obj any) {
	key, ok := crdKey(obj)
	if !ok {
		return
	}
	d.lateCRDMu.Lock()
	defer d.lateCRDMu.Unlock()
	if _, seen := d.seenCRDs[key]; seen {
		return
	}
	if _, pending := d.pendingCRDs[key]; !pending {
		// A newly pending CRD gets the full retry budget, even if it arrives
		// while earlier CRDs are on their last retry.
		d.pendingCRDs[key] = struct{}{}
		d.lateCRDRetries = 0
	}
	d.scheduleLateCRDScanLocked(lateCRDScanDelay)
}

// scheduleLateCRDScanLocked starts a scan after delay unless one is already
// scheduled or running; a running scan reschedules itself while CRDs remain
// pending. Caller holds lateCRDMu.
func (d *DynamicResourceCache) scheduleLateCRDScanLocked(delay time.Duration) {
	if d.lateCRDTimer != nil || d.lateCRDScanning {
		return
	}
	d.lateCRDTimer = time.AfterFunc(delay, d.runLateCRDScan)
}

func (d *DynamicResourceCache) runLateCRDScan() {
	d.lateCRDMu.Lock()
	d.lateCRDTimer = nil
	d.lateCRDScanning = true
	d.lateCRDMu.Unlock()

	d.scanLateCRDs()

	d.lateCRDMu.Lock()
	defer d.lateCRDMu.Unlock()
	d.lateCRDScanning = false
	if d.isStopped() || len(d.pendingCRDs) == 0 {
		d.lateCRDRetries = 0
		return
	}
	if d.lateCRDRetries >= lateCRDMaxRetries {
		log.Printf("[CRD Discovery] %d new CRDs still not served after %d retries; they'll be watched when listed", len(d.pendingCRDs), d.lateCRDRetries)
		clear(d.pendingCRDs)
		d.lateCRDRetries = 0
		return
	}
	d.lateCRDRetries++
	d.scheduleLateCRDScanLocked(lateCRDScanDelay << d.lateCRDRetries)
}

func (d *DynamicResourceCache) isStopped() bool {
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.stopped
}

// forgetCRD lets a deleted CRD be handled again if it's reinstalled.
func (d *DynamicResourceCache) forgetCRD(obj any) {
	key, ok := crdKey(obj)
	if !ok {
		return
	}
	d.lateCRDMu.Lock()
	delete(d.seenCRDs, key)
	delete(d.pendingCRDs, key)
	d.lateCRDMu.Unlock()
}

func (d *DynamicResourceCache) scanLateCRDs() {
	if d.isStopped() || d.config.Discovery == nil {
		return
	}
	// A new CRD isn't in the cached API resource list until discovery refreshes.
	if err := d.config.Discovery.Refresh(); err != nil {
		log.Printf("[CRD Discovery] Refresh after CRD change failed: %v", err)
		return
	}
	if d.isStopped() {
		return
	}
	gvrs, keys, err := d.crdWatchCandidates()
	if err != nil {
		log.Printf("[CRD Discovery] Listing CRDs after CRD change failed: %v", err)
		return
	}
	newKeys := d.markCRDsSeen(keys)
	var fresh []schema.GroupVersionResource
	for _, gvr := range gvrs {
		if _, ok := newKeys[gvr.Group+"/"+gvr.Resource]; ok {
			fresh = append(fresh, gvr)
		}
	}
	if len(fresh) == 0 {
		return
	}
	stats := d.watchSmallCRDs(fresh)
	d.lateCRDMu.Lock()
	for _, gvr := range stats.retry {
		key := gvr.Group + "/" + gvr.Resource
		if _, seen := d.seenCRDs[key]; seen {
			delete(d.seenCRDs, key)
			d.pendingCRDs[key] = struct{}{}
		}
	}
	d.lateCRDMu.Unlock()
	// A reinstalled CRD can still have the informer from before its deletion.
	alreadyWatched := len(fresh) - stats.eager - stats.deferred - stats.noAccess
	log.Printf("[CRD Discovery] %d CRDs installed after startup (%d small → eager, %d large → on-demand, %d no access, %d already watched)",
		len(fresh), stats.eager, stats.deferred, stats.noAccess, alreadyWatched)
}

// ---------------------------------------------------------------------------
// Discovery status / sync
// ---------------------------------------------------------------------------

// GetDiscoveryStatus returns the current CRD discovery status.
func (d *DynamicResourceCache) GetDiscoveryStatus() CRDDiscoveryStatus {
	if d == nil {
		return CRDDiscoveryIdle
	}

	d.discoveryMu.RLock()
	defer d.discoveryMu.RUnlock()

	return d.discoveryStatus
}

// entriesForGVR returns every informer entry watching gvr across all scopes —
// the cluster-wide entry and/or per-namespace entries. GVR-level status and
// handler-registration APIs ask "is this kind watched?" rather than returning
// resource data, so they span all of a GVR's namespace-scoped informers —
// unlike readEntries, whose ns == "" path is cluster-wide-only to keep data
// reads deterministic. Using readEntries(gvr, "") here would wrongly report a
// namespace-restricted (no cluster-wide informer) GVR as unwatched/unsynced.
func (d *DynamicResourceCache) entriesForGVR(gvr schema.GroupVersionResource) []*informerEntry {
	d.mu.RLock()
	defer d.mu.RUnlock()
	var out []*informerEntry
	for k, e := range d.informers {
		if k.gvr == gvr {
			out = append(out, e)
		}
	}
	return out
}

// WaitForSync waits for a resource's cache(s) to be synced (with timeout).
// A GVR may have several namespace-scoped informers; all must sync.
func (d *DynamicResourceCache) WaitForSync(gvr schema.GroupVersionResource, timeout time.Duration) bool {
	entries := d.entriesForGVR(gvr)
	if len(entries) == 0 {
		return false
	}

	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	syncFuncs := make([]cache.InformerSynced, 0, len(entries))
	for _, e := range entries {
		syncFuncs = append(syncFuncs, e.informer.HasSynced)
	}
	return cache.WaitForCacheSync(ctx.Done(), syncFuncs...)
}

// WaitForSyncContext waits, at most until ctx ends, for the watches covering
// namespace to sync, and reports whether they did.
func (d *DynamicResourceCache) WaitForSyncContext(ctx context.Context, gvr schema.GroupVersionResource, namespace string) bool {
	entries := d.readEntries(gvr, namespace)
	if len(entries) == 0 {
		return false
	}
	syncFuncs := make([]cache.InformerSynced, 0, len(entries))
	for _, e := range entries {
		syncFuncs = append(syncFuncs, e.informer.HasSynced)
	}
	return cache.WaitForCacheSync(ctx.Done(), syncFuncs...)
}

// IsSynced checks if a resource's cache(s) are synced (non-blocking). It
// reports on the informers that EXIST — an unsettled fallback fanout can be
// synced-but-partial. Callers asserting completeness or absence must use
// IsClusterWideSynced (or Count, which refuses unsettled fanouts) instead.
func (d *DynamicResourceCache) IsSynced(gvr schema.GroupVersionResource) bool {
	return d.servedSynced(d.entriesForGVR(gvr))
}

// WatchedSince returns when the cache started watching gvr: the latest start
// among its informers, so every change after it was delivered. ok is false
// when gvr isn't watched or an informer hasn't synced yet.
func (d *DynamicResourceCache) WatchedSince(gvr schema.GroupVersionResource) (since time.Time, ok bool) {
	return d.watchedSince(d.entriesForGVR(gvr))
}

// NamespaceWatchedSince is WatchedSince for only the informers serving namespace.
// An empty namespace checks every informer. An unwatched or unsynced scope
// returns ok=false; that is unknown coverage, never a zero-time boundary.
func (d *DynamicResourceCache) NamespaceWatchedSince(gvr schema.GroupVersionResource, namespace string) (time.Time, bool) {
	if namespace == "" {
		return d.WatchedSince(gvr)
	}
	return d.watchedSince(d.readEntries(gvr, namespace))
}

func (d *DynamicResourceCache) watchedSince(entries []*informerEntry) (since time.Time, ok bool) {
	if !d.servedSynced(entries) {
		return time.Time{}, false
	}
	for _, e := range entries {
		if e.startedAt.After(since) {
			since = e.startedAt
		}
	}
	return since, true
}

// AllNamespacesFanoutIncomplete reports that the last all-namespaces walk for
// gvr across the fallback namespaces ended early (its deadline hit mid-walk),
// so a read across every namespace can miss some of them even once the
// informers that did start have synced. The next all-namespaces read resumes
// the walk.
func (d *DynamicResourceCache) AllNamespacesFanoutIncomplete(gvr schema.GroupVersionResource) bool {
	if d == nil || d.config.NamespaceScoped {
		return false
	}
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.fanoutIncomplete[gvr]
}

// IsClusterWideSynced reports whether gvr is served by a single cluster-wide
// informer that has finished syncing. When true, ListWatched(gvr) is
// authoritative for EVERY namespace — the caller can safely conclude an object
// is absent cluster-wide. When false (namespace-scoped fallback, unsynced, or
// unwatched), the cache may only know a subset of namespaces, so "not found"
// must NOT be treated as "doesn't exist". The "never both" informer invariant
// (a GVR has either one cluster-wide informer or namespaced ones, never both)
// makes the cluster-wide check authoritative.
func (d *DynamicResourceCache) IsClusterWideSynced(gvr schema.GroupVersionResource) bool {
	if d == nil {
		return false
	}
	// Explicitly require the cluster-wide informer — hasCoveringInformer
	// also reports true for a resolved namespace-fallback fanout, whose
	// coverage is NOT cluster-wide (callers use this to assert absence).
	d.mu.RLock()
	_, clusterWide := d.informers[informerKey{gvr: gvr}]
	d.mu.RUnlock()
	return clusterWide && d.IsSynced(gvr)
}

// IsNamespaceSynced reports whether the cache can answer authoritatively for
// this GVR *in this namespace*.
//
// IsSynced spans every informer for the GVR, so it answers "is some informer
// synced" — true as soon as any one namespace has been watched. A caller reading
// a different namespace would pass that gate, start a fresh informer on the
// first List, and read an empty indexer with no error: an absence the cache
// never established. This asks the question the read actually depends on — the
// cluster-wide informer if there is one, otherwise this namespace's.
//
// An empty namespace means a cluster-wide read; use IsClusterWideSynced for
// that, which this defers to.
func (d *DynamicResourceCache) IsNamespaceSynced(gvr schema.GroupVersionResource, namespace string) bool {
	if d == nil {
		return false
	}
	if namespace == "" {
		return d.IsClusterWideSynced(gvr)
	}
	// readEntries selects exactly the informers that would serve this read — the
	// cluster-wide one if it exists, otherwise this namespace's, and nothing when
	// neither does. entriesSynced then asks each informer directly rather than
	// reading the bookkeeping flag, which the watch goroutine sets a moment later
	// and which therefore reports a synced informer as unsynced.
	return d.servedSynced(d.readEntries(gvr, namespace))
}

// servedEntries returns the informers serving reads of gvr in namespace now and
// whether they answer authoritatively, by IsNamespaceSynced's rules. Both come
// from the same informers, so a replacement between them cannot mix them.
func (d *DynamicResourceCache) servedEntries(gvr schema.GroupVersionResource, namespace string) ([]*informerEntry, bool) {
	if d == nil {
		return nil, false
	}
	d.mu.RLock()
	clusterWide, ok := d.informers[informerKey{gvr: gvr}]
	d.mu.RUnlock()
	if ok {
		entries := []*informerEntry{clusterWide}
		return entries, d.servedSynced(entries)
	}
	entries := d.readEntries(gvr, namespace)
	if namespace == "" {
		// Only the cluster-wide informer answers a cluster-wide read.
		return entries, false
	}
	return entries, d.servedSynced(entries)
}

// ReadableObject gets one object from the informers serving gvr in namespace
// now ("" for a cluster-scoped resource), without probing, starting a watch or
// waiting for one. found reports whether they hold it. ok is true only if they
// had completed their initial list before the read; when ok is false, a miss
// proves nothing.
//
// The object is the cached one, not a copy: callers must not modify it or keep
// it beyond the computation that read it.
func (d *DynamicResourceCache) ReadableObject(gvr schema.GroupVersionResource, namespace, name string) (obj *unstructured.Unstructured, found, ok bool) {
	entries, ok := d.servedEntries(gvr, namespace)
	if !ok {
		return nil, false, false
	}
	key := name
	if namespace != "" {
		key = namespace + "/" + name
	}
	item, found, err := getByKeyFromEntries(entries, key)
	if err != nil {
		return nil, false, false
	}
	if !found {
		return nil, false, true
	}
	u, isUnstructured := item.(*unstructured.Unstructured)
	return u, isUnstructured, isUnstructured
}

// ---------------------------------------------------------------------------
// Introspection
// ---------------------------------------------------------------------------

// GetWatchedResources returns a list of GVRs currently being watched.
func (d *DynamicResourceCache) GetWatchedResources() []schema.GroupVersionResource {
	if d == nil {
		return nil
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	return d.distinctGVRsLocked()
}

// GetInformerCount returns the number of active dynamic informers.
func (d *DynamicResourceCache) GetInformerCount() int {
	if d == nil {
		return 0
	}

	d.mu.RLock()
	defer d.mu.RUnlock()

	return len(d.informers)
}

// AddGVRChangeHandler registers a change handler on the informer for the
// given GVR. The handler fires for add/update/delete events (including the
// initial sync). Returns an error if no informer exists yet for the GVR —
// callers should warm up or EnsureWatching the resource before registering.
//
// Used by derived caches (PolicyReport index, etc.) that need to react to
// changes on a single resource kind without subscribing to the global
// OnChange callback (which would fire for every dynamic resource).
//
// The handler runs on the informer's event-processing goroutine; it must
// be non-blocking. A panic in the handler is contained by the upstream
// informer machinery (no impact on other handlers).
func (d *DynamicResourceCache) AddGVRChangeHandler(gvr schema.GroupVersionResource, handler cache.ResourceEventHandler) error {
	if d == nil {
		return fmt.Errorf("dynamic resource cache not initialized")
	}

	d.mu.Lock()
	defer d.mu.Unlock()

	var entries []*informerEntry
	for k, e := range d.informers {
		if k.gvr == gvr {
			entries = append(entries, e)
		}
	}
	if len(entries) == 0 {
		return fmt.Errorf("no informer for %s.%s/%s; warm up the resource before registering a handler", gvr.Resource, gvr.Group, gvr.Version)
	}

	// Remember the handler so informers started later for this GVR (lazy
	// per-namespace watches, or re-creations after an idle reap) get it too —
	// otherwise derived caches silently miss those namespaces' events.
	d.gvrHandlers[gvr] = append(d.gvrHandlers[gvr], handler)

	for _, e := range entries {
		if _, err := e.informer.AddEventHandler(handler); err != nil {
			return fmt.Errorf("add event handler for %v: %w", gvr, err)
		}
	}
	return nil
}

// ---------------------------------------------------------------------------
// CRD discovery callbacks
// ---------------------------------------------------------------------------

// OnCRDDiscoveryComplete registers a callback to be called when CRD discovery completes.
func (d *DynamicResourceCache) OnCRDDiscoveryComplete(callback func()) {
	d.crdCallbacksMu.Lock()
	defer d.crdCallbacksMu.Unlock()
	d.crdCallbacks = append(d.crdCallbacks, callback)
}

func (d *DynamicResourceCache) notifyCRDDiscoveryComplete() {
	d.crdCallbacksMu.RLock()
	defer d.crdCallbacksMu.RUnlock()
	for _, cb := range d.crdCallbacks {
		go cb()
	}
}

// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------

// IsStopped reports whether Stop has been called. A stopped cache's answers
// belong to the connection it was built for, so a reader that held it across
// a context switch must not report them as current.
func (d *DynamicResourceCache) IsStopped() bool {
	d.mu.RLock()
	defer d.mu.RUnlock()
	return d.stopped
}

// Stop initiates a non-blocking shutdown of the dynamic cache.
func (d *DynamicResourceCache) Stop() {
	if d == nil {
		return
	}

	d.stopOnce.Do(func() {
		log.Println("Stopping dynamic resource cache")

		d.mu.Lock()
		d.stopped = true
		d.stopProbes()
		d.mu.Unlock()

		d.discoveryMu.Lock()
		if d.discoveryStatus != CRDDiscoveryComplete {
			d.discoveryStatus = CRDDiscoveryComplete
			close(d.discoveryDone)
		}
		d.discoveryMu.Unlock()

		d.lateCRDMu.Lock()
		if d.lateCRDTimer != nil {
			d.lateCRDTimer.Stop()
			d.lateCRDTimer = nil
		}
		d.lateCRDMu.Unlock()

		// Closing stopCh cancels every per-informer context (each informer's
		// watchdog goroutine selects on it), stopping all watches.
		close(d.stopCh)

		d.mu.RLock()
		nsFactories := make([]dynamicinformer.DynamicSharedInformerFactory, 0, len(d.nsFactories))
		for _, f := range d.nsFactories {
			nsFactories = append(nsFactories, f)
		}
		d.mu.RUnlock()

		go func() {
			done := make(chan struct{})
			go func() {
				d.factory.Shutdown()
				for _, f := range nsFactories {
					f.Shutdown()
				}
				close(done)
			}()
			select {
			case <-done:
				log.Println("Dynamic resource cache factory shutdown complete")
			case <-time.After(5 * time.Second):
				log.Println("Dynamic resource cache factory shutdown taking >5s, abandoning (goroutine will finish on its own)")
			}
		}()
	})
}