0044. Tracking detection moves to pkg/gitopstracking; pkg/prmode.Decide turns it into a write-vs-PR gate

Status: Superseded by ADR 0101 · Date: 2026-10-26 · Design section: GitOps and Argo CD ("PR mode") Decided: moved isTracked from internal/cli to a new pkg/gitopstracking package with Flux detection corrected to an inventory scan (the field manager is only a first signal; flux-client-side-apply was never Flux's), and added a pure pkg/prmode.Decide(trackedBy, hasRepoConnection) Mode so "tracked → don't write, open a PR instead" has one place to live rather than zero

Context

architecture.md's "GitOps and Argo CD" section requires: "If a CR is tracked by Argo CD and a repository connection exists, the UI and CLI don't write it ... open a PR ... Without a repository connection the CR is read-only with an export button." Nothing made that call. isTracked (internal/cli/gitops_tracking.go) already detected Argo CD/Flux tracking correctly enough for its one existing use -- a warning in gitops export -- but was private to internal/cli, so neither pkg/prmode (ADR 0038, which opens the PR) nor internal/uiapi could call it, and pkg/prmode.SubmitPR had no tracking check of its own: called, it always opens a PR, with nothing upstream deciding that it should.

This is the same shape of problem ADR 0033 solved for strongIdentity and loadBundle: a rule two packages need was a copy away from drifting, except here there was no second copy yet -- the second caller didn't exist. The job was to make the rule reachable before a second copy could appear, not to un-drift two already-diverging ones.

Question 1: is a field-manager lookup enough for Flux when it gates a write?

isTracked's old doc comment justified checking one field manager name, flux-client-side-apply, over listing Kustomizations, because the function only fed a warning. Used as a write gate, a miss means Ichor writes directly to a CR Flux owns, the fight PR mode exists to prevent. Two things were checked against primary sources (fluxcd.io docs and kustomize-controller source, 2026-10-26).

flux-client-side-apply is not a Flux signal. fluxcd.io's FAQ and Kustomization page document it as the field manager name a user must give their own kubectl edits for Flux to preserve them. It is the opposite of "Flux applied this"; the old code and architecture.md had it wrong.

kustomize-controller is a guess too. kustomize-controller builds its resource manager with ssa.Owner{Field: r.ControllerName, ...}, and the controller name is configurable, so kustomize-controller is only the default. A deployment that changes --field-manager never shows that name, and Ichor would write directly to Flux-managed CRs. That is the reason architecture.md has Ichor read argocd-cm instead of guessing an Argo CD marker, and it applies equally here. (An earlier draft of this ADR concluded the lookup was enough because the name is fixed; that was wrong.)

Flux also puts no label or annotation on what it applies; ownership is the Kustomization's .status.inventory. So the authoritative check is the scan.

Decided: scan inventories, keep the field manager as a first signal only. IsTracked order: managed fields naming kustomize-controller or helm-controller (a hit is enough; a stale hit errs toward a PR, the safe direction), then Argo CD via argocd-cm, then the Flux inventory scan, which runs whenever the first two find nothing. A miss on the first signal never means "not tracked".

Inventory shape, from the kustomize-controller api/v1 ResourceRef doc comment: .status.inventory.entries[].id is <namespace>_<name>_<group>_<kind>, with the version in a separate v field. fluxcd.io's prose says ..._<kind>_<version>, but its own example ids (default_podinfo__Service, default_podinfo_apps_Deployment, with v alone carrying the version) match the source, so the source wins. The code reads Kustomizations as unstructured; Ichor takes no Flux Go dependency.

Flux not installed (NoKindMatch or NotFound on the list) means "not tracked", not an error and not "unknown". Any other list error (for example forbidden) is returned as an error: for a gate, "cannot tell" must not become "go ahead and write".

Warning and decision stay one function. The scan is one List, reached only when nothing cheaper matched. gitops export already turns an IsTracked error into a printed warning and continues, so a failed scan degrades it gracefully, and a second, weaker function would be a second rule that can drift (the problem ADR 0033 fixed). internal/cli's existing tests are unchanged in behavior.

Not covered: HelmRelease ownership. Only the first-signal manager name sees it; whether a HelmRelease exposes an inventory to scan was not verified. Deferred.

Question 2: where does the shared rule live?

pkg/gitopstracking, a new package -- not pkg/gitops (the existing write-side package), because pkg/gitops/doc.go states its own invariant in so many words: "It makes no Argo CD, Git or Kubernetes API calls: everything here is pure construction and YAML encoding" -- a deliberate choice (ADR behind it argued against even the dependency shape of a live client). IsTracked does a live client.Get by design; adding it to pkg/gitops would either quietly break that package's own documented invariant or require rewriting the comment that explains why the dependency-light package exists at all, for a function that has nothing to do with building Application/skeleton manifests. A new package costs one file more than reusing pkg/gitops, and leaves both packages' own stated scope true.

Question 3: what happens with no repository connection?

architecture.md already answers this -- "Without a repository connection the CR is read-only with an export button" -- so this is not a new design decision, only this unit's confirmation that Decide implements that sentence exactly rather than guessing at a nearby behavior (warn-but-write, or refuse with an error): ModeReadOnly, the same outcome Decide also returns for a Flux-tracked CR (see below), carries no write and no PR.

Decision

pkg/gitopstracking.IsTracked (tracking.go) is internal/cli's old isTracked, moved rather than copied, with the Flux fix from Question 1. internal/cli/gitops_tracking.go keeps isTracked and its six package-local constants as one-line aliases/delegation to the new package -- exactly ADR 0033's bundleSecretKey pattern -- so gitops_tracking_test.go and gitops_export_test.go needed no edits at all: both reference the lowercase symbols by name, not by literal value, so they are unaffected (TestIsTracked_FluxManagedFieldsEntry sets Manager: fluxFieldManager, which aliases kustomize-controller).

pkg/prmode.Decide (decide.go) is new:

type Mode int

const (
	ModeDirectWrite Mode = iota
	ModeOpenPR
	ModeReadOnly
)

func Decide(trackedBy string, hasRepoConnection bool) Mode

It takes IsTracked's own by return value directly ("", "Argo CD", or "Flux") and a caller-supplied connection flag, and does no I/O of its own -- the caller already ran IsTracked (which does) and already knows its own repository-connection state, so pkg/prmode gains this function with zero new imports, consistent with every other file in the package. ModeOpenPR is returned only for "Argo CD" with a connection, matching architecture.md's sentence literally; every other case -- not tracked is the one exception, mapped to ModeDirectWrite -- is ModeReadOnly, including "Flux" regardless of connection state: ADR 0037 already scoped PR mode to Argo CD (ClassifySource takes an Argo CD Application directly; "wiring ... the Flux equivalent are both left to the caller/a later unit"), so a Flux-tracked CR has no SubmitPR path to route to yet, and ModeReadOnly is what is left once ModeDirectWrite and ModeOpenPR are ruled out -- not a new special case, the fallthrough of the two the design already named.

Consequences

  • internal/cli's behavior and tests are unchanged byte-for-byte in gitops_tracking_test.go/gitops_export_test.go; goconst's package-scoped counting meant two new string literals ("kustomize-controller", "FieldsV1") needed pulling into named constants inside pkg/gitopstracking itself once its own test file repeated them three times -- a pkg/gitopstracking-local concern, not a cross-package one.

  • Not built here, same as ADR 0038/0037 left them: strict mode, override, the UI GitOps page, computing an impact preview, and wiring "CR → owning Application" for the Flux case. pkg/prmode.Decide and pkg/gitopstracking.IsTracked are both now importable from internal/uiapi, but nothing in internal/uiapi calls either yet -- that caller is a later unit's job.

  • pkg/gitopstracking has no Argo CD or Flux Go module dependency: the Flux inventory scan uses unstructured, the Argo CD branch a corev1.ConfigMap read.

  • ADR 0051 wires the first caller: internal/uiapi now runs IsTracked and Decide before every write to an existing CR.

Search the docs

Search pages and headings