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 ingitops_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 insidepkg/gitopstrackingitself once its own test file repeated them three times -- apkg/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.Decideandpkg/gitopstracking.IsTrackedare both now importable frominternal/uiapi, but nothing ininternal/uiapicalls either yet -- that caller is a later unit's job. -
pkg/gitopstrackinghas no Argo CD or Flux Go module dependency: the Flux inventory scan usesunstructured, the Argo CD branch acorev1.ConfigMapread. -
ADR 0051 wires the first caller:
internal/uiapinow runsIsTrackedandDecidebefore every write to an existing CR.