Skip to content

Ownership: answer what manages an object from its status lists and claims - #2319

Merged
nadaverell merged 13 commits into
mainfrom
ownership/manager-resolution
Oct 26, 2026
Merged

nadaverell merged 13 commits into
mainfrom
ownership/manager-resolution

Conversation

@nadaverell

@nadaverell nadaverell commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Radar names an object's GitOps manager in several places that disagree. Two of them sit in the same drawer:

  • the header chip reads relationships.managedBy: the graph's topmost owner, else the first tracking label it finds, plus a one-hop inheritance the browser does itself;
  • Related Resources → Managed By reads relationships.managers: the Argo CD Applications and Flux objects whose status lists name the object itself, never its controller.

They use different evidence and disagree in ordinary cases. A Pod carries its Application's instance label but only its Deployment is listed, so the chip names a manager and the group shows none (73 objects on one test cluster). A relabelled object can claim one manager while another's list names it. Neither the MCP resource context nor the GitOps write guard sees status lists at all.

Change

This PR adds one answer, computed server-side from the same per-request ownership view the drawer already uses. It changes no existing output: nothing reads the new answer yet. The header chip and the write guard move to it in a follow-up PR, together.

ownership.View.Manager(ref) gathers two kinds of evidence:

  • recorders: the local managers whose status lists name the object;
  • claims: what the object's own metadata names, checked against each named manager. A check is one of: recorded, omitted (the list was read and doesn't name it), unreadable, remote, missing, not-recording (a native Helm release), unresolved (no namespace), withheld from the caller, or unchecked (Radar couldn't establish what the list says).

It sums them up as one state, first match wins:

state meaning
shared several lists name it
disagree a list names it and a claim names a different manager
agree one list names it and every claim names that manager
listed one list names it, no claim
claimed only claims name a manager
unknown no evidence, and Radar couldn't look everywhere
none no evidence in what Radar collects and the caller may read; never "unmanaged"

It names no winner the evidence doesn't support: a disagreement stays one, with every candidate kept.

Controller chain. When the object's own evidence doesn't settle the question, the answer walks Pod → ReplicaSet → Deployment and reports the nearest ancestor that does as via. Some claims are weak and don't stop the walk:

  • an Argo CD tracking ID copied from another object;
  • metadata a controller-owned object commonly inherits: an Argo CD instance label from its template, or Helm annotations its Deployment copied onto it.

When nothing on the chain settles it and the object makes no claim of its own, the nearest ancestor that claims a manager answers as via, with claimed; claims farther up stay in between. A walk that can't follow an owner says which reference and why (stop). A manager this request read differently earlier (still loading, or another incarnation) leaves the answer incomplete rather than confirming membership.

Claim parsing is new (pkg/gitops/claims.go). It keeps every candidate rather than the first hit, and parses Argo CD's tracking ID in full: the ns_app form, the core group, and cluster-scoped objects, whose namespace Argo CD writes but ignores. With annotation+label tracking, an instance label Argo CD cut to 63 characters names the tracking ID's Application. A native Helm release's claim counts only without GitOps evidence, since Flux's helm-controller writes the same annotations.

Wire (additive).

  • New relationships.ownership.manager holds {state, lead?, direct, via?, between?, chainEnd?, stop?, narrowed?}, with matching TypeScript types. Each evidence object carries object, recorders and claims. Which manager lists Radar couldn't read or search stays internal: the state says whether that left the answer unknown, and ownership.managers still says what the object's own search missed. Readers should show an unfamiliar state, check, source or chain end generically: newer Radars may add values.
  • relationships.managedBy and relationships.managers are unchanged. managedBy's topmost-owner meaning is still read by workload selection and summary grouping.
  • View.RecordingManagers now searches each object once per view, so the existing projection and the new answer share one search.

Verification

  • Tests. 28 table cases cover every state, plus each hole the design reviews named:
    • a readable list omitting the object;
    • a recorder beside an unreadable claimed manager;
    • an Argo CD recorder beside Helm annotations;
    • a Pod claim against its Deployment's recorder;
    • a claim outside two recorders;
    • copied-only claims;
    • several claims with no recorder;
    • weak claims on successive hops;
    • withheld and gone controllers;
    • undecided access;
    • app-of-apps.
  • Mutation and conformance checks. The weak-claim rule was mutation-checked. Cache-source conformance compares the manager answer with the brute-force oracle on every generated world and on a claims world, for an unrestricted and a namespace-restricted caller.
  • Drawer golden. The only changes are added manager fields; every existing answer is byte-identical.
  • Benchmark. A Pod whose Deployment is one of 10,000 an Application lists answers in about 0.75 ms.
  • Live on the EKS test cluster (1,965 objects, no auth, a warm server):
    • none 1,655, claimed 242, agree 44, shared 9, listed 6, disagree 5, unknown 4.
    • disagree is exactly the relabelled Flux fixture; shared is the two-Application fixture and a second tracking ID.
    • unknown is the broken-chain fixtures Radar can't follow (an unserved owner kind, a loop). An owner that is gone or was replaced ends the chain with an answer.
    • 41 ReplicaSets carrying their Deployment's Helm annotations answer through the Deployment.
    • The first ~110 requests after a restart get no relationships at all: the startup race Relationships: wait for the graph a reader is already building #2317 fixes, which this branch doesn't include. A second pass answers every object.
  • Live on a GKE nonprod cluster (941 objects, read-only): none 642, claimed 236, agree 63. Most claimed answers name an Argo CD Application by an instance label without a namespace, for an Application that isn't in this cluster.
  • Live with proxy auth. A namespace-scoped user sees only recorders it may read (checked against SubjectAccessReviews: no leaks). Claims on managers it can't read come back withheld or unresolved, never omitted, and every answer is narrowed.

Not in this PR

  • The header chip and the GitOps write guard reading this answer: a follow-up PR, together, so the warning and the chip never disagree.
  • MCP and REST resource context: a follow-up PR.
  • Other places that name a manager from metadata (rightsizing, upgrade readiness, CNPG, audit coverage) keep their own rules.
  • Node controllers (mirror Pods) are not walked.

…lse winner

Radar names an object's GitOps manager from two kinds of evidence that can
disagree: managers' status lists, and the object's own tracking labels and
annotations. View.Manager weighs both for an object and, when its own
evidence doesn't settle it, for its nearest controller ancestor, and sums
them up as shared, disagree, agree, listed, claimed, unknown or none. A
disagreement stays one; none means no evidence in what Radar reads, not
unmanaged.

Claims are parsed in full, every candidate kept, a copied tracking ID
marked. The answer reaches the wire as relationships.ownership.manager;
existing fields are unchanged.
…anager answer

A Deployment copies a Helm release's annotations onto its ReplicaSets, so
on an object a controller owns that claim is inherited like an instance
label: it no longer stops the walk short of a list naming the Deployment.
A cluster-scoped object's tracking ID is compared without the namespace
Argo CD writes into it. An access check Radar couldn't make keeps an answer
from being none. The answer keeps the claims of the objects it walked past
and says which controller reference it couldn't follow.
@nadaverell
nadaverell requested a review from hisco as a code owner October 26, 2026 01:56
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Resolve GitOps managers from status lists, claims, and controller ancestry

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds an access-aware manager answer that preserves conflicting and incomplete ownership evidence.
• Checks metadata claims against status lists and follows controller ancestry when direct evidence
 is weak.
• Exposes the answer additively, leaving existing manager fields and their consumers unchanged.
Diagram

graph TD
  A["Resource metadata"] --> B["Claim parser"] --> D["View Manager"] --> F["Ownership wire"] --> G["UI types"]
  C["Status lists"] --> D
  E["Controller chain"] --> D
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Resolve ownership in existing drawer consumers
  • ➕ Could reuse current graph and label presentation paths without adding a wire field.
  • ➖ Would preserve divergent evidence rules and duplicate access-aware resolution across future consumers.
2. Use recorded membership alone
  • ➕ Would yield a simpler answer directly from manager status lists.
  • ➖ Would miss unconfirmed claims and fail to explain relabeling conflicts or inherited evidence.

Recommendation: Keep the server-side, per-view answer. It gives future consumers one access-aware evidence model while preserving the existing fields and deferring their migration together; neither client-side resolution nor lists alone addresses the documented disagreements.

Files changed (17) +2105 / -20

Enhancement (7) +652 / -13
core.tsType the additive manager answer +55/-0

Type the additive manager answer

• Adds TypeScript contracts for manager state, direct and ancestor evidence, claim checks, chain stops, and restricted-access answers. The new field is optional for compatibility with older Radars.

packages/k8s-ui/src/types/core.ts

claims.goParse all GitOps and Helm metadata claims +125/-0

Parse all GitOps and Helm metadata claims

• Extracts Flux labels, Argo CD tracking IDs and instance labels, and native Helm annotations as separate candidates. Identifies copied tracking IDs and handles namespaced and cluster-scoped identities.

pkg/gitops/claims.go

manager.goResolve manager states from lists, claims, and ancestors +301/-0

Resolve manager states from lists, claims, and ancestors

• Adds an access-aware View.Manager answer with checked claims, conflict-preserving states, and controller-chain fallback for weak evidence. Retains intermediate evidence and reports incomplete searches or unfollowable owners.

pkg/ownership/manager.go

read.goCapture claims alongside object ownership metadata +5/-0

Capture claims alongside object ownership metadata

• Stores parsed metadata claims in each ownership observation when the object is captured.

pkg/ownership/read.go

view.goReuse recorder searches within each view +33/-13

Reuse recorder searches within each view

• Caches RecordingManagers answers per object so the existing projection and new resolver share a search. Tracks undecided access separately to avoid treating incomplete evidence as complete.

pkg/ownership/view.go

ownership.goProject the manager answer into relationships +39/-0

Project the manager answer into relationships

• Adds the resolved answer to ownership coverage and converts its evidence, lead, chain stop, and access flags into wire types. Existing managedBy and managers projections remain in place.

pkg/topology/ownership.go

types.goDefine JSON contracts for manager evidence +94/-0

Define JSON contracts for manager evidence

• Introduces wire structures for the state, candidate claims and checks, recorder coverage, ancestor evidence, and controller-chain stops. Adds manager as an optional ownership-coverage field.

pkg/topology/types.go

Tests (9) +1433 / -6
claims_test.goTest claim parsing and copied tracking IDs +87/-0

Test claim parsing and copied tracking IDs

• Covers malformed and multiple claims, Argo CD application namespaces, and tracking IDs on cluster-scoped objects.

pkg/gitops/claims_test.go

conformance_test.goCompare cached manager answers with the oracle +23/-0

Compare cached manager answers with the oracle

• Adds the manager answer to source conformance comparisons and introduces a world with recorded, inherited, disagreeing, copied, and Helm claims.

pkg/ownership/cachesource/conformance_test.go

membership_bench_test.goBenchmark manager resolution through a controller chain +29/-0

Benchmark manager resolution through a controller chain

• Measures a fresh view answering for a Pod whose Deployment appears in an Application list of 10,000 members.

pkg/ownership/cachesource/membership_bench_test.go

render_test.goRender claim metadata in cache-source fixtures +2/-0

Render claim metadata in cache-source fixtures

• Copies fixture labels and annotations onto rendered Kubernetes objects so cache-source tests exercise the same claims as the oracle.

pkg/ownership/cachesource/render_test.go

manager_test.goExercise manager states and traversal edge cases +397/-0

Exercise manager states and traversal edge cases

• Tests list and claim combinations, weak inherited metadata, ancestor resolution, and withheld or undecided access. Also checks that conflicting claims do not produce an unsupported lead.

pkg/ownership/manager_test.go

generate.goAdd metadata setup to ownership test builders +11/-0

Add metadata setup to ownership test builders

• Adds a builder method for assigning labels and annotations to an already-added fixture object.

pkg/ownership/ownershiptest/generate.go

scan.goParse fixture claims into oracle observations +2/-0

Parse fixture claims into oracle observations

• Populates ownership observations with claims derived from fixture metadata.

pkg/ownership/ownershiptest/scan.go

world.goStore claim metadata in fixture objects +9/-6

Store claim metadata in fixture objects

• Extends ownership test-world objects with labels and annotations.

pkg/ownership/ownershiptest/world.go

drawer-relationships.jsonRecord additive manager answers in drawer snapshots +873/-0

Record additive manager answers in drawer snapshots

• Adds manager answers across characterized resources, including claims, ancestor evidence, unknown searches, and chain stops.

pkg/topology/testdata/characterization/drawer-relationships.json

Documentation (1) +20 / -1
ownership.mdDocument evidence-based manager resolution +20/-1

Document evidence-based manager resolution

• Explains claim sources, manager states, weak inherited claims, controller traversal, and the additive wire field. Clarifies that absent evidence does not prove an object is unmanaged.

docs/ownership.md

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

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

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Inherited labels can falsely name a manager ✓ Resolved
Description
derive excludes copied claims but retains other claims marked Weak, returning claimed before
it checks whether the controller walk settled. When a controller-owned object has only an inherited
instance label or Helm annotation and its ancestors provide no strong evidence, that weak metadata
becomes the answer even if the walk stopped at an unfollowed controller.
Code

pkg/ownership/manager.go[R261-265]

+	var active []gitops.Claim
+	for _, c := range e.Claims {
+		if !c.Copied {
+			active = append(active, c.Claim)
+		}
Evidence
managerEvidence marks inherited claims weak, and strong deliberately refuses to stop on them.
After the walk, however, derive filters only Copied and reaches its claimed branch before its
unsettled-state check; controller walks can end without reaching an ancestor.

pkg/ownership/manager.go[98-102]
pkg/ownership/manager.go[159-185]
pkg/ownership/manager.go[197-209]
pkg/ownership/manager.go[260-299]

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

## Issue description
Weak inherited claims can become a final `claimed` answer when a controller walk finds no stronger evidence.
## Fix Focus Areas
- pkg/ownership/manager.go[159-185]
- pkg/ownership/manager.go[260-299]
## Recommended Fix
Keep weak claims in the reported evidence, but do not use them to produce a final manager state or lead when the walk has found no strong evidence. Add cases for a controller-owned object with only an inherited claim, both with a complete chain and an unfollowed owner.

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



Remediation recommended

2. Matching claims lose their manager lead ✓ Resolved
Description
The no-recorder branch of derive compares claim references literally, despite names treating a
namespace-less claim as matching a namespaced manager of the same kind and name. An object with both
a namespaced Argo tracking ID and an unqualified instance label for that Application therefore
returns claimed without a lead, even though the two claims are compatible.
Code

pkg/ownership/manager.go[R291-295]

+	case len(active) > 0:
+		lead := active[0].Manager
+		if slices.ContainsFunc(active, func(c gitops.Claim) bool { return c.Manager != lead }) {
+			return ManagerClaimed, nil
+		}
Evidence
The parser emits separate tracking-ID and instance-label claims. names defines how an unqualified
claim matches a qualified manager, but this branch instead uses raw reference inequality.

pkg/gitops/claims.go[67-81]
pkg/ownership/manager.go[248-254]
pkg/ownership/manager.go[291-295]

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

## Issue description
Equivalent qualified and unqualified Application claims are treated as different managers when no status list records the object.
## Fix Focus Areas
- pkg/ownership/manager.go[248-255]
- pkg/ownership/manager.go[291-295]
## Recommended Fix
Use the existing namespace-aware matching rule when grouping claims without recorders. Prefer an explicitly namespaced reference for the lead when one is available, and test an Argo tracking ID alongside an unqualified instance label.

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


3. Malformed identities become manager claims ✓ Resolved
Description
argoApplication splits a namespaced Application value at the first underscore but does not reject
underscores in the resulting name. A value such as team_app_extra is consequently emitted as a
claim for the invalid Application name app_extra, including when an otherwise matching tracking ID
puts it on the object itself.
Code

pkg/gitops/claims.go[R119-124]

+	if ns, name, ok := strings.Cut(v, "_"); ok {
+		ref.Namespace, ref.Name = ns, name
+	} else {
+		ref.Name = v
+	}
+	return ref, ref.Name != "" && (ref.Namespace != "" || !strings.Contains(v, "_"))
Evidence
Both Argo claim sources use argoApplication; it validates nonempty components but not the
additional underscore, despite documenting that Kubernetes names cannot contain one. Parsed claims
are captured into ownership observations and can then participate in manager resolution.

pkg/gitops/claims.go[57-80]
pkg/gitops/claims.go[92-124]
pkg/ownership/read.go[66-80]

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

## Issue description
Tracking IDs and instance labels containing additional underscores produce claims for invalid Application names instead of being rejected as malformed.
## Fix Focus Areas
- pkg/gitops/claims.go[92-124]
- pkg/gitops/claims_test.go[10-69]
## Recommended Fix
Validate both components of a qualified Application identity, rejecting an additional underscore in the name, and add malformed tracking-ID and instance-label cases that yield no claim.

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



Informational

4. Checking one unlisted claim reads every member ✓ Resolved
Description
checkClaim calls v.Members(claimed) to distinguish an omitted claim from an unchecked one,
although it uses only the returned Subject and Membership; Members access-checks and reads
every entry in the claimed manager’s inventory. When a namespaced claim is not confirmed by a list,
each drawer request can traverse a large inventory for that claim at each step of the controller
chain, and multiple such claims repeat the work.
Code

pkg/ownership/manager.go[R226-234]

+	members := v.Members(claimed)
+	switch {
+	case members.Subject.Access == Denied:
+		return ClaimWithheld
+	case members.Subject.Read.State == Absent:
+		return ClaimMissing
+	case !members.Subject.Present():
+		return ClaimUnchecked
+	}
Evidence
Members loops over inventory.Members and calls v.read(m) for each entry, performing an access
decision and, when permitted, a cache object read. checkClaim waits for that result but uses only
members.Subject and members.Membership. The repository has a benchmark inventory with 10,000
members, but its Pod claim has no namespace and returns unresolved before reaching this path.

pkg/ownership/view.go[389-431]
pkg/ownership/manager.go[212-246]
pkg/ownership/manager.go[212-245]
pkg/ownership/view.go[378-410]
pkg/ownership/view.go[51-60]
pkg/ownership/cachesource/membership_bench_test.go[40-74]

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

## Issue description
`checkClaim` traverses and authorization-checks the claimed manager’s entire inventory to classify a claim, despite needing only the manager’s subject and membership state.
## Fix Focus Areas
- pkg/ownership/manager.go[212-245]
- pkg/ownership/view.go[354-431]
## Recommended Fix
Add a lightweight helper, such as `v.membershipState(manager)`, that checks access to and reads the claimed manager, then performs the `session.Members` lookup and the existing unavailable-state and UID-mismatch checks without iterating over inventory members. Use it in `checkClaim` instead of `Members`, preserving the remote, unreadable, missing, and gap distinctions. Benchmark classification of an omitted claim against a large inventory.

ⓘ 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 type 'qodo, fix this' on a finding and the fix lands right on your PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread pkg/ownership/manager.go
Comment thread pkg/ownership/manager.go
Comment thread pkg/gitops/claims.go Outdated
Comment thread pkg/ownership/manager.go Outdated
A controller of a kind discovery can't map left the answer's stop without
the owner it names. The reference on the object the walk reached names it,
as the primary owner's does.
@cursor

cursor Bot commented Oct 26, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

Checking a stale claim read every object the claimed manager's list names,
just to report that the list omits the claimant; a large Application made
that thousands of reads per answer.
A tracking ID naming argocd/shop and an instance label naming shop made a
claimed answer with no lead, as if they named two managers; the one that
says its namespace now leads. An Application identity whose name holds an
underscore, which Kubernetes names can't, makes no claim.
A controller that is gone, was replaced or is an Event ends the chain: no
manager above it was missed, so an object nothing names reads none, not
unknown.
… by list state

A Pod whose ReplicaSet's instance label names an Application, under a
Deployment with no evidence, answered none: the walk passed the only
claim it found. The nearest ancestor claim now answers, as claimed via
that ancestor, when the object's own metadata claims nothing.

Argo CD's annotation+label tracking cuts the instance label to 63
characters; such a label now names the tracking ID's Application instead
of a second one that doesn't exist.

Checking a claim against a manager whose list omits the object read and
sorted the whole list. ReadSession.Membership reads only the list's
state.
nadaverell added a commit that referenced this pull request Oct 26, 2026
…2316)

## Problem

Radar adds an advisory warning when the target's metadata names another
tool that may revert direct edits. This warning reaches MCP write tools,
Diagnose and mutation verification through `pkg/k8score`'s object
warnings.

`detectExternalManager` looked up `argocd.argoproj.io/instance`,
`kustomize.toolkit.fluxcd.io/{name,namespace}` and
`helm.toolkit.fluxcd.io/{name,namespace}` in the object's
**annotations**. Argo CD and Flux write these as **labels**, so the
warning never fired for objects tracked by them; only Argo's
`tracking-id` (a real annotation) and Helm were detected. The unit tests
had placed the keys in annotations too, so they passed.

## Change

Read those five keys from labels. The Argo `tracking-id` annotation and
the Helm checks are unchanged. The test cases now carry the keys as
labels, and a new case pins that the same keys as annotations are not
tracking evidence.

Once it fires, the warning has to say what the metadata says rather than
state a verdict. Tracking metadata can be copied from another object or
name a manager whose list no longer has the object, and native Helm acts
only on an upgrade or rollback. So the sentence changes:

- before: "Resource is reconciled by Argo CD (instance: shop). Direct
edits may be reverted by the next reconciliation; …"
- after: "Its tracking metadata names Argo CD (instance: shop). If that
manager still tracks it, its next sync may revert direct edits; …"
- Helm: "Its metadata says Helm installed it (instance: web). The next
helm upgrade or rollback of that release may overwrite direct edits;
prefer changing the chart values."

Where a weighed manager answer is available (MCP `get_resource` and
`diagnose`, #2320), that answer replaces the Argo CD, Flux and Helm
warning, and #2320 also stops a tracking ID copied from another object
from raising it (that check needs #2319's claim parser).

## Verification

`go test ./k8score/` (pkg module) and `go test ./internal/mcp/` pass;
with the old lookup, 7 of the detection cases fail. A wording test pins
that neither sentence says the manager reconciles the object.
Which claims a state counts (never a copied one; native Helm only
without other evidence), when a lead exists and that its namespace can
be empty, the via fallback to a weak ancestor claim, between without
via, when chainEnd is absent, what unchecked and missing mean, that a
manager answer is absent rather than none when there is no answer, and
the claim parser's real validation. gitops.HelmReleaseKind becomes
unexported: nothing outside the parser reads it.
…he request

The recorder search reused the view's earlier read of each manager it
found: an earlier unavailable read silently dropped the manager (an
answer could say none), and an earlier read of another incarnation
confirmed membership from the replacement's list (agree, as Members
already refused). Both now leave a gap, so the answer is unknown and the
claim unchecked.

A fallback to the nearest ancestor's claim kept only the claims before
it; claims farther up the chain now stay in Between. Argo CD's truncated
instance label also drops trailing non-alphanumerics; match that.
The manager answer's evidence carried unreadable, remote, notChecked and
undecided. Nothing reads them; for the resource itself three repeat
ownership.managers, and counts can't say which manager. They stay
internal, where they still decide unknown from none; a reader that needs
to explain an incomplete answer can add a field for it later without
breaking anyone.

An ancestor kept in Between only because its own search had gaps is no
longer sent: without its gaps it would carry nothing. The drawer golden
loses exactly the 97 removed keys.
@nadaverell
nadaverell merged commit 326f836 into main Oct 26, 2026
11 checks passed
nadaverell added a commit that referenced this pull request Oct 26, 2026
Stacked on #2319 (the manager answer).

## Problem

An AI agent asking "which GitOps manager should I change for this
resource?" gets `resourceContext.managedBy`: the topmost owner the
relationship graph links, or the first tracking label it finds. It never
sees the managers' status lists, so it can't tell a confirmed answer
from a stale label, and gets one answer where there are two. On a test
cluster, a relabelled Flux Pod's `managedBy` named the Kustomization
whose inventory lists it, and said nothing of the one its label claims.

## Change

`get_resource` and `diagnose` (MCP) and the REST AI resource context now
carry `resourceContext.manager`, compacted from #2319's answer:

- `listedBy`: the managers whose status lists name the resource;
- `claims`: the managers its metadata names, with each `check`
(recorded, omitted, unreadable, remote, missing, not-recording,
unresolved, withheld). Copied tracking IDs are dropped.
- `state`, plus `lead` when the state has one;
- `via`: the controller ancestor whose evidence answers when the
resource's own doesn't, in which case `listedBy` and `claims` are that
ancestor's;
- `alsoClaimed`: claims on objects the walk passed (and, with `via`, on
the resource itself), each `on` its object;
- `narrowed`.

With a manager answer in the context, `get_resource` and `diagnose`
leave out the metadata warning for an Argo CD, Flux or Helm claim the
answer already weighs ("Its tracking metadata names …", #2316); the
warning beside it would contradict the answer when they disagree.
Metadata the answer doesn't weigh keeps its warning: another
controller's `app.kubernetes.io/managed-by` label (cert-manager, an
operator), or Helm named by labels without its release annotation. A
tracking ID copied from another object raises no warning on any path,
and no longer hides an independent controller label behind it. Write
tools and mutation verification, which carry no manager answer, keep
every warning.

`managedBy` is unchanged. The owner-only ownership answer resource
contexts already used (`topology.ApplyOwner`) now also answers the
manager. That costs one recorder-index lookup per object on the
controller chain and a claimed manager's list read; there is still no
children search. The test that guards this cost now allows exactly that
and checks the answer equals the drawer's.

`docs/mcp.md` gains a section, written for agents: `none` is not
"unmanaged", and `disagree` means look at both.

## Verification

- `pkg/resourcecontext` tests cover the compaction: via evidence, a
copied claim dropped, narrowed, and walked claims kept without `via`.
`pkg/k8score` tests cover copied IDs, an own Argo claim beside a
cert-manager label, and Helm labels alone, on both the plain and the
answered path; an `internal/mcp` test checks the answered path is used
exactly when a manager answer is present.
- `internal/mcp` and `internal/server` tests pass.
- Live MCP `get_resource` on a test cluster:
- the relabelled Flux Pod reads `disagree` via its Deployment, listed by
`wg-ks-susp`, with its label's `wg-ks` claim `omitted`;
- a Pod of a Deployment two Applications list reads `shared`, led by the
Application its label claims;
  - an ordinary Argo CD Deployment reads `agree`.
nadaverell added a commit that referenced this pull request Oct 26, 2026
Stacked on #2320, which is on #2319.

## Problem

The header chip and the GitOps write guard decide "which manager owns
this" in the browser:

- the chip uses the first tracking label it finds, inherits a parent's
manager one hop up, and guesses an Argo CD Application's namespace by
name;
- the write guard trusts whatever owner the browser sends and never
checks the managers' status lists. With no owner it says nothing.

So the chip and the warning could name a manager whose list doesn't
include the object, or miss a second manager whose list does.

## Change

**Chip.** When the server sends `relationships.ownership.manager`
(#2319), the chip reads it: no browser inheritance, no guess by name.
With an older Radar backend the chip behaves exactly as before; Radar
Hub runs the newest UI against older Radars.

| state | chip | tooltip |
|---|---|---|
| agree | Managed by X | the list naming it, and that its tracking
metadata agrees |
| listed | Listed by X | the list naming it; a list alone can name an
object its manager doesn't sync (a copy carrying another object's
tracking ID, a CRD a HelmRelease skips) |
| via an ancestor | same wording, plus "via <Kind>" | "Through
Deployment …", plus the object's own weak claims |
| claimed | Tracked by X | why the claim couldn't be confirmed: omitted,
unreadable, remote, missing, withheld, … |
| disagree | Tracked by A, with a warning | A's list doesn't name it;
B's does |
| shared | Listed by A and B | no single manager's sync status shown |
| unknown / none | no chip | — |

With several candidates (disagree, shared, several claims) the chip
opens a menu naming each and what names it (its status, its metadata or
both); with one it opens that manager. A chip without a link is
focusable, so keyboard users reach its tooltip.

Related Resources' Managed By group is hidden only when the chip says
exactly what it would.

**Write guard.**

- **The server decides.** `POST /api/gitops/write-evidence` computes the
manager answer itself, as the caller, reading the live target's
metadata, and returns it as `manager`. Its lead decides which manager's
policy applies. The client's owner is only a hint when the server has no
ownership answer for the kind. When the server computed one and couldn't
finish (the object was replaced since the cache saw it, the cache isn't
ready), `managerUnavailable` says why, and neither the hint nor the
cached answer is used: the dialog warns and asks for an acknowledgement.
- **Every cached answer is checked live,** including `none` and
`unknown`: an object adopted since the cache saw it has a manager the
cache doesn't. One request per guarded dialog. A failed request leaves a
cached answer that names a manager unconfirmed (acknowledgement); a
cached `none` stays quiet.
- **The classifier composes.** The existing operation rules are
unchanged: status writes say nothing, creating a child is informational,
an operator-owned delete is informational. On top of them:
- **disagree, shared, or several claims:** field writes get "may
revert", naming every candidate, and no single candidate's ignore rules
or sync policy exempt the change;
- **an unconfirmed claim** (omitted, unreadable, remote, missing,
withheld): can't prove "will revert" or waive the acknowledgement;
- **"will revert" needs the list and the object's metadata to agree**
(`agree`). A list alone (`listed`) gives "may revert", saying Radar
can't confirm a sync applies the object, then the field evidence under
"If one does:";
- **a CronJob's Job** (scheduled, or made with `kubectl create job
--from`) runs once: deleting it doesn't say its controller recreates it;
- **values from a newer Radar** (a state, check, source or chain end
this UI doesn't know) render generically and read as unconfirmed, so
they never prove a revert, waive a warning or link a manager;
- **an adopted Helm install:** a native Helm release's annotations
beside an Argo CD Application or Flux Kustomization keep "may revert"
even when that manager ignores the field, since the release's next
upgrade or rollback can still overwrite it;
- **via an ancestor, with no claim of the object's own:** informational
("generated from <parent>, which X manages", or "which X lists", or
"whose metadata names X", as the evidence allows), no acknowledgement;
  - **via, with the object's own claim:** keeps "may revert";
- **a chain Radar couldn't read** (owner withheld, unknown or
unresolvable, a loop, too long): keeps the conservative "couldn't tell"
warning. An owner that is gone or replaced ends the chain with an answer
and adds no warning;
  - **no `manager` in the evidence** (older Radar): verdicts as before.

## Verification

- **Tests.** Classifier tests cover each rule above, including:
  - an ignore rule on one of two managers still warning;
  - a status write on a shared object staying silent;
  - gone and replaced owners not warning.

Chip tests cover every state's wording, plural names, and the
group-hiding rule. Hook tests check the evidence query fires for
ambiguous and inherited answers and that the server's answer wins over
the client's. Server tests check `manager` in the response and that the
server's lead overrides a conflicting client owner. A live-overlay test
covers live metadata, a replaced UID, a cache switch and restricted
access.
- **Checks.** `make tsc` is clean. Frontend: 5,290+ tests across the UI
package, 64 hook tests in the app. Go: `pkg/...`, `internal/k8s`,
`internal/server`, `internal/mcp`.
- **Live on a test cluster:**
- a relabelled Flux Pod reads "Tracked by flux-system/wg-ks" with a
warning; the tooltip says wg-ks's status doesn't list it, wg-ks-susp's
does, through its Deployment;
- a Pod of a Deployment two Applications list reads "Listed by …-a and
…-b";
- an ordinary Argo CD Deployment reads "Managed by … · Synced", and its
Managed By group is hidden;
- the disagreeing Deployment's Set image dialog warns that both
Kustomizations may overwrite the fields and requires the
acknowledgement;
- the write-evidence endpoint answers a Pod through its Deployment's
Application even when the client sends a different owner.
nadaverell added a commit that referenced this pull request Oct 26, 2026
This is #2325's change, reopened against `main`. #2325 merged into its
stacked base branch after #2320 had already merged, so none of it
reached `main`. Same code as #2325's final head, with current `main`
merged in, plus shorter docs: `docs/ownership.md` holds the
manager-answer rules (states as a table), the `docs/mcp.md` section
keeps what an agent needs, and the `docs/gitops.md` write-warning rules
are one table of answer by operation.


## Problem

The header chip and the GitOps write guard decide "which manager owns
this" in the browser:

- the chip uses the first tracking label it finds, inherits a parent's
manager one hop up, and guesses an Argo CD Application's namespace by
name;
- the write guard trusts whatever owner the browser sends and never
checks the managers' status lists. With no owner it says nothing.

So the chip and the warning could name a manager whose list doesn't
include the object, or miss a second manager whose list does.

## Change

**Chip.** When the server sends `relationships.ownership.manager`
(#2319), the chip reads it: no browser inheritance, no guess by name.
With an older Radar backend the chip behaves exactly as before; Radar
Hub runs the newest UI against older Radars.

| state | chip | tooltip |
|---|---|---|
| agree | Managed by X | the list naming it, and that its tracking
metadata agrees |
| listed | Listed by X | the list naming it; a list alone can name an
object its manager doesn't sync (a copy carrying another object's
tracking ID, a CRD a HelmRelease skips) |
| via an ancestor | same wording, plus "via <Kind>" | "Through
Deployment …", plus the object's own weak claims |
| claimed | Tracked by X | why the claim couldn't be confirmed: omitted,
unreadable, remote, missing, withheld, … |
| disagree | Tracked by A, with a warning | A's list doesn't name it;
B's does |
| shared | Listed by A and B | no single manager's sync status shown |
| unknown / none | no chip | — |

With several candidates (disagree, shared, several claims) the chip
opens a menu naming each and what names it (its status, its metadata or
both); with one it opens that manager. A chip without a link is
focusable, so keyboard users reach its tooltip.

Related Resources' Managed By group is hidden only when the chip says
exactly what it would.

**Write guard.**

- **The server decides.** `POST /api/gitops/write-evidence` computes the
manager answer itself, as the caller, reading the live target's
metadata, and returns it as `manager`. Its lead decides which manager's
policy applies. The client's owner is only a hint when the server has no
ownership answer for the kind. When the server computed one and couldn't
finish (the object was replaced since the cache saw it, the cache isn't
ready), `managerUnavailable` says why, and neither the hint nor the
cached answer is used: the dialog warns and asks for an acknowledgement.
- **Every cached answer is checked live,** including `none` and
`unknown`: an object adopted since the cache saw it has a manager the
cache doesn't. One request per guarded dialog. A failed request leaves a
cached answer that names a manager unconfirmed (acknowledgement); a
cached `none` stays quiet.
- **The classifier composes.** The existing operation rules are
unchanged: status writes say nothing, creating a child is informational,
an operator-owned delete is informational. On top of them:
- **disagree, shared, or several claims:** field writes get "may
revert", naming every candidate, and no single candidate's ignore rules
or sync policy exempt the change;
- **an unconfirmed claim** (omitted, unreadable, remote, missing,
withheld): can't prove "will revert" or waive the acknowledgement;
- **"will revert" needs the list and the object's metadata to agree**
(`agree`). A list alone (`listed`) gives "may revert", saying Radar
can't confirm a sync applies the object, then the field evidence under
"If one does:";
- **a CronJob's Job** (scheduled, or made with `kubectl create job
--from`) runs once: deleting it doesn't say its controller recreates it;
- **values from a newer Radar** (a state, check, source or chain end
this UI doesn't know) render generically and read as unconfirmed, so
they never prove a revert, waive a warning or link a manager;
- **an adopted Helm install:** a native Helm release's annotations
beside an Argo CD Application or Flux Kustomization keep "may revert"
even when that manager ignores the field, since the release's next
upgrade or rollback can still overwrite it;
- **via an ancestor, with no claim of the object's own:** informational
("generated from <parent>, which X manages", or "which X lists", or
"whose metadata names X", as the evidence allows), no acknowledgement;
  - **via, with the object's own claim:** keeps "may revert";
- **a chain Radar couldn't read** (owner withheld, unknown or
unresolvable, a loop, too long): keeps the conservative "couldn't tell"
warning. An owner that is gone or replaced ends the chain with an answer
and adds no warning;
  - **no `manager` in the evidence** (older Radar): verdicts as before.

## Verification

- **Tests.** Classifier tests cover each rule above, including:
  - an ignore rule on one of two managers still warning;
  - a status write on a shared object staying silent;
  - gone and replaced owners not warning.

Chip tests cover every state's wording, plural names, and the
group-hiding rule. Hook tests check the evidence query fires for
ambiguous and inherited answers and that the server's answer wins over
the client's. Server tests check `manager` in the response and that the
server's lead overrides a conflicting client owner. A live-overlay test
covers live metadata, a replaced UID, a cache switch and restricted
access.
- **Checks.** `make tsc` is clean. Frontend: 5,290+ tests across the UI
package, 64 hook tests in the app. Go: `pkg/...`, `internal/k8s`,
`internal/server`, `internal/mcp`.
- **Live on a test cluster:**
- a relabelled Flux Pod reads "Tracked by flux-system/wg-ks" with a
warning; the tooltip says wg-ks's status doesn't list it, wg-ks-susp's
does, through its Deployment;
- a Pod of a Deployment two Applications list reads "Listed by …-a and
…-b";
- an ordinary Argo CD Deployment reads "Managed by … · Synced", and its
Managed By group is hidden;
- the disagreeing Deployment's Set image dialog warns that both
Kustomizations may overwrite the fields and requires the
acknowledgement;
- the write-evidence endpoint answers a Pod through its Deployment's
Application even when the client sends a different owner.
@nadaverell
nadaverell deleted the ownership/manager-resolution branch October 26, 2026 16:55
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