Skip to content

Say when get_changes can't see a custom kind because Radar isn't collecting it - #2369

Merged
nadaverell merged 6 commits into
mainfrom
fix/get-changes-kind-coverage
Oct 27, 2026
Merged

nadaverell merged 6 commits into
mainfrom
fix/get-changes-kind-coverage

Conversation

@nadaverell

@nadaverell nadaverell commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

get_changes kind=X for a custom kind Radar wasn't collecting returned {"changes":[]} with no qualification. That reads exactly like "nothing changed". It happens for large CRDs Radar defers at startup (more than 100 objects), for kinds Radar's identity can't list, and for kinds it collects only in some namespaces. #2360 made get_changes steer agents to kind= for custom kinds, so this path is now common.

Now the answer says what Radar collects for that kind, using the same coverage list_resources reports, and when collection started if that was inside the window. On a live EKS test cluster, Radar defers CiliumEndpoint at startup:

get_changes kind=CiliumEndpoint since=10s
→ coverage: {status: complete, scope: all_namespaces, â€Ķ}
  narrowHint: "Radar has watched ciliumendpoints only since 2026-10-27T17:12:48Z; earlier changes in the window are unknown."

What changed

Reuses the coverage foundation; no new vocabulary. The new coverage field on get_changes is the existing resourceapi.Coverage (readreason codes, narrowedBy, gaps), computed by list_resources' own dynamic read path:

  • listDynamicResources is split into readDynamicList, which reads and returns what the read covered, plus rendering. list_resources output is unchanged.
  • get_changes calls the read without rows. It reads the cached objects in place (ListReadOnly), so no objects are copied or minified.
  • Like listing the kind, asking may start collecting it.

Same access rules as the change rows. Change rows require list on the kind itself, so coverage applies that exact check (narrowByExactAccess). A caller with namespace access but no access to the kind reads user_forbidden, never "complete".

Coverage vs history. coverage describes what Radar collects now. It says nothing about how much history Radar holds:

  • historyCoverage stays store-wide.
  • narrowHint leads with any part Radar doesn't collect, then the collection start when it falls inside the window. That sentence is dropped only when the same response's historyCoverage already states a boundary within a minute of it; a namespace created after Radar started, or a caller shown no boundary time, keeps it.
  • When Radar watches the kind per namespace (fallback installs), the hint says the start can't be established.
  • Radar's Diagnose agent reads narrowHint, so an answer that is complete but narrowed still says other namespaces are unknown.

Names that aren't one custom kind:

  • Built-in kinds get no coverage.
  • Kinds resolve through the history query's own normalization (meaningfulchanges.CanonicalKind), so cm or deploy is never "unknown".
  • A custom kind sharing its name with a built-in kind or native Helm history (Knative Service, Calico NetworkPolicy, Flux HelmRelease) is checked by itself and named by group in narrowHint, without a coverage field. The rows can come from both sources.
  • Custom kinds in several groups, unknown kinds, and a cluster-scoped kind with namespace= get one sentence.
  • A caller limited to some namespaces gets no coverage for a cluster-scoped custom kind, but a sentence instead: get_changes reads their history only in those namespaces, so that kind's changes never come back to them, and complete coverage would vouch for that empty answer.
  • A name= query without kind= that finds nothing says to pass kind=.
  • Hints only suggest steps get_changes can take (it has no group argument).

Connection switches. If Radar's connection changes during the request, the answer is connection_changed, on the early-return path too.

Testing

  • New internal/mcp/changes_collection_test.go:
    • an unwatched kind starts collection and states its start;
    • watched from before the window → no hint;
    • the startup boundary, with the window after and before Radar's start, and for a namespace created after Radar started;
    • fallback namespaces → narrowed coverage plus the per-namespace caveat;
    • namespace access without access to the kind → user_forbidden;
    • unknown kind;
    • custom kinds in two groups;
    • Knative Service beside core;
    • Calico NetworkPolicy with the built-in missing from discovery;
    • Helm without Flux;
    • the cm short name;
    • a cluster-scoped kind with namespace;
    • name= without kind=;
    • a built-in kind;
    • a namespace-limited caller asking about a cluster-scoped kind;
    • no hint suggests list_resources' own arguments.
  • go test ./internal/mcp/ ./internal/meaningfulchanges/ ./internal/server/, make lint, cd pkg && go build ./... pass. Existing list_resources tests pass unchanged.
  • Live on an EKS test cluster (local binary, read-only): the deferred CiliumEndpoint and CiliumIdentity, VirtualService watched since startup (no extra sentence), Deployment (no coverage), the cm short name, native HelmRelease, and an unknown kind. Results matched the cases above.
  • Visual-test skipped: no UI change.

Notes

  • Coverage still walks the kind's cached object pointers (no copies). On a kind's first read, the readiness wait list_resources shares lists the kind once. A planning-only path would mean changing that shared wait, which this PR leaves alone.
  • A collection start up to a minute after a boundary historyCoverage already states in the same response is not repeated.
  • Pre-existing, unchanged: a namespace-limited caller never gets cluster-scoped changes from get_changes, even for a kind they may list. Fixing that changes what restricted users can see, so it is left for a separate decision.
  • Not in scope:
    • a machine-readable per-kind collection start (text only for now);
    • per-group coverage when a kind name has several sources;
    • the count of custom-resource changes the unfiltered feed leaves out;
    • the owner on owned custom-resource rows.

â€Ķecting it

get_changes kind=X for a custom kind now carries the coverage list_resources
reports for that kind, read from the same code without listing rows, and
narrowHint leads with what Radar doesn't collect and when collection started
inside the window. An empty answer for a kind Radar wasn't watching no longer
reads the same as no changes.
â€Ķccurate

- Kinds resolve through the history query's own normalization, so a short
  name such as cm is never an unknown kind; a known built-in never is.
- A custom kind sharing its name with a built-in kind or native Helm history
  is checked by itself, named by group, without a coverage field.
- The per-namespace caveat follows how Radar watches the kind, not whether
  every namespace's watch has synced.
- No collection-start sentence within a minute of Radar's own start, which
  historyCoverage already reports.
- Hints keep only next steps get_changes can take.
- The denied-namespace early return checks for a changed connection.
The startup slack applies only when the window also starts before Radar did,
where historyCoverage states the boundary. A known built-in kind counts as a
shared name even when discovery omits it, and hints for a shared name's
custom kind name its group on failure too.
â€Ķ boundary

Whether a nearby store-wide boundary already covers the gap is decided from
the response's own historyCoverage, which is absent for a namespace created
after Radar started and withheld from callers who can't read every
namespace.
@nadaverell
nadaverell requested a review from hisco as a code owner October 27, 2026 17:19
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Report custom-kind collection coverage in get_changes

🐞 Bug fix âœĻ Enhancement 🧊 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

â€Ē Distinguishes missing custom-kind collection from an empty change history.
â€Ē Reuses list_resources coverage and explains collection gaps or recent watch starts in narrowHint.
â€Ē Handles ambiguous kinds and access restrictions without implying all change sources are covered.
Diagram

graph TD
  A["get_changes"] --> B["Kind normalization"] --> C["Resource discovery"] --> D["Dynamic coverage read"] --> E["Dynamic cache"]
  A --> F["Change timeline"] --> G["Changes response"]
  D --> G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a planning-only coverage check
  • ➕ Could avoid listing cached objects or starting a watch solely to answer a history query.
  • ➖ Would require a separate coverage path and could disagree with what list_resources actually reads.

Recommendation: Keep the shared dynamic-read approach: consistent, access-aware coverage is more valuable than avoiding its first-read cost. Consider a planning-only path only if that cost becomes material and it can preserve the same coverage semantics.

Files changed (6) +559 / -33

Enhancement (1) +4 / -0
meaningfulchanges.goExpose history kind normalization +4/-0

Expose history kind normalization

â€Ē Exports the existing canonical-kind resolver so collection checks interpret short names and plurals as the change query does.

internal/meaningfulchanges/meaningfulchanges.go

Bug fix (2) +281 / -2
changes_collection.goAssess collection for queried custom kinds +246/-0

Assess collection for queried custom kinds

â€Ē Resolves kinds against history normalization and discovery, checks kind-level list access, and derives coverage and hints from dynamic reads. Handles watch starts, narrowed namespace collection, ambiguous names, and connection changes.

internal/mcp/changes_collection.go

tools.goInclude collection coverage in get_changes responses +35/-2

Include collection coverage in get_changes responses

â€Ē Adds optional coverage and collection-first narrow hints alongside timeline results, including the denied-namespace return. Rejects responses if the connection changes during a kind-filtered request.

internal/mcp/tools.go

Refactor (1) +66 / -30
resource_dynamic_reads.goSeparate dynamic coverage reads from row rendering +66/-30

Separate dynamic coverage reads from row rendering

â€Ē Extracts the shared dynamic read and coverage result from list_resources rendering. Coverage-only calls use cached objects without copying or minifying rows; existing list rendering remains on the same read path.

internal/mcp/resource_dynamic_reads.go

Tests (1) +207 / -0
changes_collection_test.goTest custom-kind coverage and hint cases +207/-0

Test custom-kind coverage and hint cases

â€Ē Adds fixtures and tests for watch starts, fallback namespaces, access denial, ambiguous and unknown kinds, built-in aliases, and history-boundary hints.

internal/mcp/changes_collection_test.go

Documentation (1) +1 / -1
mcp.mdDocument get_changes collection coverage +1/-1

Document get_changes collection coverage

â€Ē Explains when custom-kind coverage appears, how it differs from store-wide history coverage, and how hints qualify incomplete or ambiguous results.

docs/mcp.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) ðŸŽĻ UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. New custom services can appear covered 🐞 Bug ≡ Correctness
Description
changesKindCollection refreshes discovery only when no identity matches, so an existing built-in
Service entry prevents it from looking for a newly installed custom Service. Until discovery
updates, a query can return an empty answer without a collection caveat even though the custom kind
may not be watched.
Code

internal/mcp/changes_collection.go[R57-59]

+	if len(ids) == 0 {
+		refreshDiscoveryForKind(strings.ToLower(kind), "")
+		ids = customKindIdentities(kind)
Evidence
The new check skips refresh whenever discovery has any matching identity, then returns without a
hint if all matches are built-in. Discovery retains its resource entries between refreshes, while
the subsequent history query filters by kind independently of the collection identity check.

internal/mcp/changes_collection.go[54-75]
internal/mcp/changes_collection.go[136-151]
pkg/k8score/discovery.go[199-269]
internal/meaningfulchanges/meaningfulchanges.go[625-688]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
An already-discovered built-in kind prevents the collection check from discovering a newly installed custom kind with the same name, leaving its absence from collection unqualified.
## Fix Focus Areas
- internal/mcp/changes_collection.go[54-75]
- internal/mcp/resource_reads.go[856-865]
## Recommended Fix
When the query names a built-in kind but no custom identity is found, perform a discovery refresh that can detect a newly installed custom namesake rather than relying on the existing built-in match. Recheck identities after the refresh and add a regression test for this collision.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
ðŸ’Ą Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread internal/mcp/changes_collection.go
It has no start time to compare, so no reported boundary can stand in for it.
get_changes reads history only in a namespace-limited caller's namespaces,
so a cluster-scoped kind's changes never come back to them. Its coverage is
dropped there and the limitation stated instead.
@nadaverell
nadaverell merged commit 72430ae into main Oct 27, 2026
11 checks passed
@nadaverell
nadaverell deleted the fix/get-changes-kind-coverage branch October 27, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant