diff --git a/docs/design/support-boundary/README.md b/docs/design/support-boundary/README.md
index 3a8d948c..435ef49b 100644
--- a/docs/design/support-boundary/README.md
+++ b/docs/design/support-boundary/README.md
@@ -62,6 +62,7 @@ overlay owns (a pure passthrough overlay is adopted yet `editable: 0`).
| Render attribution and proof | [Render attribution](render-attribution.md), [render fidelity](render-fidelity.md) |
| Out-of-folder Flux/Argo render context | [Render fidelity](render-fidelity.md) |
| Controller-expanded resources and provenance | [Expansion boundary](expansion-boundary-and-corpus-organisation.md), [orchestrator knowledge](orchestrator-knowledge-boundary.md) |
+| Git-authored Helm values and ConfigMap-backed content | [Values-content architecture](values-content-architecture.md), [values-file projection](values-file-projection.md) |
| Kpt packages and KRM functions | [Kpt and KRM functions](kpt-and-krm-functions.md) |
| Secrets and other document capabilities | [Resource capability model](resource-capability-model.md), [write-only encrypted secrets](write-only-encrypted-secrets.md) |
@@ -78,6 +79,9 @@ distinguishes those planned gates from the permanent construct boundary.
- [Renderer abstraction](renderer-abstraction-idea.md) and
[Kustomize token writeback](kustomize-token-writeback-explained.md) — exploratory and
teaching records.
+- [Values-content architecture](values-content-architecture.md) — why a values file is a
+ Git content surface rather than a field to copy into an `Application` or `HelmRelease`, and
+ the staged design for safely editing it.
- [Admission consent](admission-consent.md), [unreflectable edits](unreflectable-edits-and-write-gating.md),
and [reconcile trigger](orchestrator-reconcile-trigger.md) — write-gating decisions.
- Historical implementation records: [image/replica edit-through](finished/images-and-replicas-edit-through.md)
diff --git a/docs/design/support-boundary/acceptance-precision.md b/docs/design/support-boundary/acceptance-precision.md
index 3a0c4401..bc9688f5 100644
--- a/docs/design/support-boundary/acceptance-precision.md
+++ b/docs/design/support-boundary/acceptance-precision.md
@@ -87,8 +87,11 @@ read. It fits the closed claim vocabulary in
path, read from a document we already parse, needing no orchestrator code. Reading it turns
`ci-metadata.yaml` from a fatal stray into a declared non-target.
-**1c. A referenced values file is context.** Handled in
-[values-file-projection.md](values-file-projection.md) §2.
+**1c. A referenced values file is context.** **Shipped (2026-07-16, Argo CD `Application`s and
+Flux `HelmRelease`s).** A `values.yaml` a release names through `helm.valueFiles` or
+`spec.chart.spec.valuesFiles` is now read-only context, so `platform/cert-manager` and its
+`ClusterIssuer` are accepted instead of refused as `non-krm-yaml`. Designed and recorded in
+[values-file-projection.md](values-file-projection.md) §2 (Move 1).
**1d. Consider partial acceptance.** The three fixes above shrink the blast radius but do
not remove it — the next unknown file still stops the target. Whether an unmanaged file
@@ -187,6 +190,7 @@ Ranked by value per hour, and all five are independent:
one fixture, and every real repo has a `.gitignore`.
2. **Per-feature messages** (§4) — the data is already computed; pass it through.
3. **Referenced values files as context** (§1c) — rescues a folder that holds real config.
+ **Shipped** for Argo CD `Application`s and Flux `HelmRelease`s.
4. **`Generated{path}`** (§3) — stops us writing into files a script will overwrite.
5. **The transformer leak** (§2) — the live correctness bug, but last, because it needs the
subtract-or-refuse decision made first and neither answer is one line.
diff --git a/docs/design/support-boundary/values-content-architecture.md b/docs/design/support-boundary/values-content-architecture.md
new file mode 100644
index 00000000..c3ecd627
--- /dev/null
+++ b/docs/design/support-boundary/values-content-architecture.md
@@ -0,0 +1,391 @@
+# Editing values content: architecture and delivery plan
+
+> **Status: direction-setting.** This records the architecture needed to expose Git-authored
+> values as an editing surface. It does not make Helm rendering, free-standing values-file
+> editing, or Kustomize generators supported today.
+>
+> **Related:** [support contract](support-contract.md),
+> [values-file projection](values-file-projection.md),
+> [orchestrator knowledge boundary](orchestrator-knowledge-boundary.md),
+> [resource capability model](resource-capability-model.md), and the
+> [layout corpus](../../../test/fixtures/gitops-layouts/).
+
+## The short answer
+
+A Helm `Application` or `HelmRelease` should **not** be modified to contain the contents of a
+`values.yaml` it references. The Kubernetes object contains a *reference* to a source input, not a
+copy of that input. Argo CD and Flux read that input while they render the chart; the resulting
+objects are expansion output, not a place from which the values file can be reconstructed.
+
+The neat way to edit a values file is therefore:
+
+1. prove that a Git-authored content artifact is an input to exactly one release;
+2. expose that artifact as a virtual editing surface, with clear provenance; and
+3. write the requested YAML-node change back to its one real Git location.
+
+The virtual surface may eventually be served through an aggregated API, but it must stay a
+projection of Git. It is not an additional copy of the content, is not a watched cluster object,
+and must never be inserted into the normal KRM mirror/sweep model.
+
+It is also an explicit **Git authoring** boundary. It must create a Git change first and let the
+normal GitOps reconciler apply it later; it must not mutate the chart output or an in-cluster
+release as a shortcut.
+
+```mermaid
+flowchart LR
+ R[Application or HelmRelease
ordinary KRM] -->|InputBinding| A[ContentArtifact
Git file or ConfigMap data key]
+ A -->|read projection| V[Virtual values editing surface]
+ V -->|ContentPatch + expected revision| B[one BranchWorker file buffer]
+ B --> G[Git]
+ G -->|normal reconciliation| R
+ R -->|renders| O[chart output
never mirrored as source]
+
+ classDef source fill:#dfd,stroke:#3a3,color:#111
+ classDef output fill:#fdd,stroke:#c33,color:#111
+ class R,A,V,B,G source
+ class O output
+```
+
+## The mental-model correction
+
+The operator already treats an `Application` or `HelmRelease` as ordinary, editable intent-layer
+KRM. A watch observes a changed object and the normal writer updates its Git document. That works
+for fields actually held by the object, including inline values such as `HelmRelease.spec.values`
+or Argo `helm.valuesObject`.
+
+`valueFiles`, `valuesFiles`, and `valuesFrom` are different. They are indirections:
+
+| Release field | What is stored in the release object | Actual value location |
+|---|---|---|
+| Argo `helm.valuesObject`; Flux `spec.values` | YAML values | a field of the release document |
+| Argo `helm.values` | YAML encoded as one string | a field of the release document; today it is an opaque blob |
+| Argo `helm.valueFiles`; Flux `spec.chart.spec.valuesFiles` | a path | a file in the selected source repository |
+| Flux `spec.valuesFrom` | object name, optional key, and optional target path | `ConfigMap`/`Secret` data in another KRM object |
+
+Copying file contents into `Application.spec` or `HelmRelease.spec` would produce two competing
+sources of configuration and would also change the release's semantics. Editing a values file must
+instead target the source named by the reference. Once Git is committed, the existing Argo/Flux
+reconciliation detects the source revision and applies it in its normal direction.
+
+This also keeps the expansion boundary intact. A chart-rendered `Deployment` does not reliably say
+which values key created a field, and one values key can affect many rendered objects. The operator
+must not infer a values-file edit from a rendered object change.
+
+## The authority boundary: Git authoring is a product decision
+
+The values-content proposal crosses a boundary that normal GitOps reconcilers deliberately do not
+cross. Their direction is:
+
+```text
+Git desired state --> Argo CD / Flux --> cluster state
+```
+
+The current GitOps Reverser has a different, reverse-mirroring capability: it can observe selected
+KRM changes in the cluster and write their source documents to Git. That is useful for a deliberate
+capture workflow, but it is not the same as ordinary Git-authoritative reconciliation. A values
+editing surface must not quietly blend the two directions.
+
+There are three coherent product modes:
+
+| Mode | Initiator | Write path | Cluster change | Values-file fit |
+|---|---|---|---|---|
+| **Strict GitOps** | developer edits Git or raises a PR | Git only | Argo/Flux applies the merged revision | The operator may inspect/prove inputs, but does not edit them |
+| **Git authoring gateway** | authenticated user sends an explicit content-edit request | operator makes a Git commit or PR | Argo/Flux later applies that Git revision | **Recommended future values experience** |
+| **Reverse capture** | a selected live KRM object changes | watcher mirrors the object back to Git | the change already happened | Existing product direction; not a way to infer or edit a free values file |
+
+The second mode is still GitOps in the important sense: Git remains the sole desired-state authority
+after the request. The service is an authoring client for Git, much like a UI that opens a pull
+request. It does *not* call the Kubernetes API to change a `Deployment`, and it does not make an
+`Application` or `HelmRelease` contain copied values. The user asks to change a Git artifact; the
+reconciler notices the new Git revision afterwards.
+
+This is a meaningful expansion of the product, so it needs explicit consent and a separate API
+contract. It should not arrive accidentally through a `WatchRule` or as a side effect of observing
+a rendered resource.
+
+### Requirements for a Git authoring gateway
+
+An eventual `ContentPatch` request must carry the same accountability expected of a direct Git
+author:
+
+- **Authorisation and identity:** Kubernetes authentication identifies the requester; the Git
+ commit/PR records that identity and the exact artifact, YAML path, old revision, and new value.
+- **Git-first acknowledgement:** success means the expected Git revision was changed (or a PR was
+ opened), not that the cluster was mutated. A later status may report that Argo/Flux observed or
+ applied the revision, but that is a separate asynchronous fact.
+- **Proven deployment source:** before authoring, the binding must prove that the target Git
+ repository, revision, and path are the source the `Application` or `HelmRelease` consumes.
+ Otherwise the operator could commit a valid file that no deployed release reads.
+- **No direct object shortcut:** the request may only change its resolved `ContentArtifact` in Git.
+ It may not rewrite the release object, a generated ConfigMap, or a chart-rendered workload to
+ simulate the outcome.
+- **Loop discipline:** a later Argo/Flux apply will produce normal Kubernetes events. The capture
+ pipeline must recognise the already-committed source form as a no-op and must not create a second
+ reverse-mirror commit.
+
+The open policy choice is whether this gateway pushes directly to a protected branch or always
+creates a branch/PR. A PR is the safer initial shape: it makes the new authoring role visible and
+preserves the normal review gate. Direct pushes should require an explicit repository policy, not a
+default hidden behind a values editor.
+
+## User-facing use cases
+
+The corpus already contains the important shapes. They are related, but they need distinct backing
+locations and safety rules.
+
+| User goal | Source of truth | Proposed experience | Initial verdict |
+|---|---|---|---|
+| Change inline Helm values | `Application`/`HelmRelease` document | normal KRM editing | Supported today where the field is structured |
+| Change a whole values document in a hand-written ConfigMap | `ConfigMap.data[valuesKey]` | normal ConfigMap edit; later expose the nested YAML view | Supported as ordinary KRM today |
+| Change one scalar supplied by `valuesFrom.targetPath` | `ConfigMap.data[valuesKey]` | edit the ConfigMap key, with the target-path provenance shown | Normal KRM edit today; nested-value UX is future work |
+| Change a private, single-release `values.yaml` | free-standing Git file | virtual values editing surface writes the file | Planned |
+| Inspect a private values file without editing it | free-standing Git file | virtual read-only surface with provenance | Planned before writing |
+| Change a shared `common.yaml` | one file consumed by several releases | show the consumers and refuse the edit | Refuse initially |
+| Change a generated ConfigMap's source `values.yaml` | file read by `configMapGenerator` | edit the file, then prove the Kustomize render | Deferred: generators are currently refused |
+| Change chart templates or rendered output | chart package / controller output | no surface | Permanent refusal |
+
+The useful product promise is not "edit whatever Helm rendered." It is: **edit an authored
+configuration input when the system can prove its one writable home and its effects are not
+ambiguous.**
+
+## Where the current branch is
+
+The branch delivers a deliberately small Move 1: a recognised values file can be **read-only
+context**, rather than a stray non-KRM YAML document that refuses its whole folder.
+
+The current implementation is:
+
+1. `FolderScan` retains YAML bytes, while `ManifestStore.FilesByPath` materialises only KRM-bearing
+ files.
+2. `helmValueFileRefs` in
+ [`internal/manifestanalyzer/valuefiles.go`](../../../internal/manifestanalyzer/valuefiles.go)
+ minimally decodes Argo `Application` and Flux `HelmRelease` documents and gathers path-shaped
+ values entries.
+3. It records matching non-KRM YAML paths in `ManifestStore.ValueFileRefs`.
+4. The acceptance gate suppresses `ReasonNotKRM` for those paths, and `scan-repo` stops counting
+ them as non-KRM noise.
+5. The file remains outside `FilesByPath`; the writer has no route into it, so it is not patched,
+ swept, or deleted.
+
+That separation is the correct first principle: context must not accidentally become managed KRM.
+It is not yet an editable-file architecture.
+
+There is no current virtual content API and no current direct-cluster values write. The branch only
+changes acceptance classification; it neither commits values content nor changes an `Application`,
+`HelmRelease`, ConfigMap, or chart output.
+
+### A path-shaped reference is not yet a proven deployment input
+
+`ValueFileRefs` is only a `map[path]struct{}`. It remembers neither the consumer nor why the path
+was selected. The current matcher tries relative, scan-root, and basename-co-located candidates
+without proving that the release's source is this GitTarget repository.
+
+This is acceptable **only** because Move 1 has the narrow classification claim: a matching YAML
+file is retained *read-only context* and is never materialised, patched, swept, or deleted. The
+product may choose to accept such a file as a benign, named passenger even when it cannot prove the
+deployer consumes it. That choice keeps the door open to a permanent read-only policy for external
+values files.
+
+It must not be described as source awareness or as proof that the release consumes the local file.
+The branch currently proves a path-shaped match, not the Git/source relationship. Comments,
+fixtures, status text, and future API labels should say **"named read-only context"**, not
+**"deployment input"**. A proven binding is compulsory for a read projection that claims
+provenance and for every write feature; it is not a prerequisite for this deliberately harmless
+Move 1 acceptance policy.
+
+### How Argo CD and Flux resolve values-file paths
+
+The tool semantics matter most once the product makes a claim beyond benign context. The following
+describes the current upstream resolution models; it is not behaviour implemented by this branch.
+
+| Tool and spelling | Where the deployer resolves it | Is a co-located GitTarget file necessarily consumed? | Consequence for a future editor |
+|---|---|---|---|
+| Argo `helm.valueFiles: [$values/platform/app/values.yaml]`, with a `sources[]` item `ref: values` | The `$values` prefix selects that ref source; the remainder is relative to the **root of the referenced Git repository**. | No; the analyzer must still prove the ref source is the GitTarget repository and revision it may write. | Potentially editable after exact `ref` and repository/revision proof. This is the standard external-chart + Git-hosted-values pattern. |
+| Argo `helm.valueFiles: [values.yaml]` on a Git application source | The path is resolved in that source's application path. | No; it is local only when that source is proven to be this Git target and path. | Potentially editable only after source proof. |
+| Argo `helm.valueFiles: [values.yaml]` on a Helm/OCI chart source (`repoURL` plus `chart`) | The path is resolved in the fetched/extracted chart. | **No.** A same-named YAML file next to the `Application` in the operator's Git checkout is unrelated. | Never offer that co-located file as an editable release input. |
+| Flux `HelmChart.spec.valuesFiles` (including the `HelmRelease.spec.chart.spec` template form) with `sourceRef.kind: GitRepository` | The path is relative to the referenced Git source artifact. | No; the referenced `GitRepository` must be proven to identify this writable Git target and revision. | Potentially editable after source-object and repository proof. |
+| The same Flux field with `sourceRef.kind: HelmRepository` | The path is read from the fetched chart package. | **No.** A local file beside the `HelmRelease` is not what Flux reads. | Never offer that co-located file as an editable release input. |
+| Flux `valuesFrom` | A named in-cluster `ConfigMap` or `Secret` data key, merged in declared order. | Not a free-standing file question. | Model it as a KRM-field artifact; ConfigMaps and Secrets have distinct capability rules. |
+
+Argo documents the `$ref` form specifically for an external/public chart with Git-hosted custom
+values: the ref maps to a Git source and `$ref/...` is rooted at that source's repository
+root. It also documents ordinary value-file ordering, glob expansion, and environment substitution.
+Flux documents `valuesFiles` as paths relative to the `sourceRef`, with `HelmRepository`,
+`GitRepository`, and `Bucket` being distinct source kinds and later files overriding earlier
+ones. See the upstream [Argo Helm guide](https://argo-cd.readthedocs.io/en/latest/user-guide/helm/),
+[Argo multiple-sources guide](https://argo-cd.readthedocs.io/en/latest/user-guide/multiple_sources/),
+and [Flux HelmChart guide](https://fluxcd.io/flux/components/source/helmcharts/).
+
+Globbed, environment-expanded, remote-URL, missing/optional, and dynamically generated entries
+are not one statically identifiable file. A future binding interpreter must represent them as such,
+not use basename matching to turn them into a writable local path.
+
+The three branch fixtures illustrate why this distinction is useful:
+
+- The Argo `$values/...` fixture represents the right **shape** for externally hosted charts with
+ Git values. The current test proves only that the matcher recognizes the spelling; it does not
+ validate the declared `ref` or repository identity.
+- The bare-path external-Argo fixture and the Flux `HelmRepository` fixture are useful Move 1
+ regression cases: they show a harmless local values-shaped YAML can be accepted as context. They
+ must not be used as evidence that changing the local file changes a deployment.
+
+### What must be added before a provenance or editability claim
+
+The matcher is insufficient for any stronger safety claim:
+
+- it does not resolve an Argo `$ref` to a declared source or validate that source's canonical Git
+ identity and revision against the target the operator may write;
+- it does not follow a Flux `sourceRef` to its `GitRepository`/`HelmRepository` object and source
+ identity;
+- it cannot tell whether a path is a glob/environment expansion, one consumer or many consumers,
+ nor whether a later values layer overrides the change.
+
+Those omissions are intentionally outside a read-only acceptance classification. They are blockers
+for source-aware read APIs and writes.
+
+## The missing model: an input graph
+
+The analyser needs to retain the relationship it has discovered, rather than collapse it to a path
+set. The terms below are deliberately tool-neutral.
+
+```go
+// Sketch only: this is a design vocabulary, not an API proposal.
+type ContentArtifact struct {
+ // File names free-standing Git bytes. KRMString names a string-valued field
+ // inside one KRM document, for example ConfigMap.data["values.yaml"].
+ Kind ArtifactKind
+ FilePath string
+ Document RecordRef
+ Field []string
+}
+
+type InputBinding struct {
+ Consumer RecordRef
+ Artifact ContentArtifact
+ Semantics BindingSemantics // ArgoValueFile, FluxChartValueFile, FluxValuesFrom, ...
+ Order int // precedence order, never guessed from a map
+ Resolution Resolution // ProvenLocal, External, Missing, Ambiguous, Unsupported
+ Reason string
+}
+```
+
+The graph is built during analysis from the already-parsed KRM documents, but source resolution
+also needs immutable GitTarget context: canonical repository identity, checked-out revision, scan
+root, and write scope. A structural `fs.FS` scan does not know the repository URL, so it may report
+an association as *unverifiable*; the live writer can supply the actual GitProvider origin.
+
+The graph supplies three different answers that a path set cannot:
+
+1. **Acceptance:** is this non-KRM path a proven input to content the target owns?
+2. **Readability:** what release and field consume this artifact, and in what order?
+3. **Editability:** is there one writable artifact, one consumer, no disqualifying layer or other
+ writer, and a precise source-aware edit route?
+
+The existing Kustomize write fan-in covers only Kustomize `resources:` reachability. Values-file
+fan-in must be calculated from `InputBinding`s; it is a separate invariant, even though it has the
+same user-facing rule: do not write through into shared context.
+
+## A single backing buffer, including ConfigMaps
+
+There must be exactly one mutable copy of each Git file per commit. The current `writeBatch` already
+has the right lower-level shape: it hydrates a file once into a commit-scoped buffer and flushes only
+that buffer if it changed.
+
+Content editing should add a `ContentPatch` operation to that batch rather than create another cache
+or another writer:
+
+1. The read side derives a `ContentArtifact` and a content revision/hash from the current store
+ snapshot.
+2. A caller requests a YAML-path edit with that expected revision.
+3. The writer rechecks eligibility and revision, edits a `yaml.Node` in the artifact, and writes
+ the result into the existing buffer for the containing Git file.
+4. The normal Git path, ignore, scope, fan-in, and applicable render preconditions run before the
+ branch worker commits.
+
+For a free-standing file, the buffer target is simply `values.yaml`. For a hand-written ConfigMap,
+the artifact is a nested view:
+
+```text
+ConfigMap document in values-configmap.yaml
+ data["values.yaml"] -- a YAML string --> parsed nested YAML node
+```
+
+The nested node is transient. A successful patch is encoded back into that one `data` scalar and
+then into the same `values-configmap.yaml` file buffer. A normal watch-driven ConfigMap update and a
+content-surface update therefore serialize in the same branch worker and never compete through two
+in-memory copies.
+
+Secrets need a separate capability decision: their data representation and encryption policy may
+make a nested editable view unsafe. They must not inherit ConfigMap behaviour by accident.
+
+## Layering and generated inputs
+
+Values are ordered inputs, not a single map. Argo has `parameters`, `valuesObject`, `values`, and
+multiple `valueFiles`; Flux has inline values and ordered `valuesFrom` entries, including
+`targetPath` splices. A file can be a legitimate input while a particular key in it has no effect.
+
+The first editable release should use a deliberately strict document-level rule:
+
+- exactly one proven local content artifact;
+- exactly one consuming release;
+- no glob, environment expansion, remote source, or unresolved reference;
+- no other supported layer that can override it; and
+- no Kustomize generator, external writer, or source-form ambiguity.
+
+Later work may offer per-key eligibility by evaluating a complete precedence graph. It should not
+pretend that generic YAML values can be safely merged without preserving the source tool's ordering
+and semantics.
+
+`configMapGenerator` is a separate multi-hop graph, not a ConfigMap special case:
+
+```text
+values.yaml --generator input--> generated ConfigMap.data["values.yaml"]
+ --valuesFrom--> HelmRelease
+```
+
+The writable home is the file, never the generated ConfigMap. Supporting it requires Kustomize
+generator support and a render proof that the proposed source edit changes only the intended derived
+content. The current support boundary correctly refuses this shape; it should remain deferred.
+
+## Recommended delivery sequence
+
+1. **Choose the authority mode.** Treat values editing as an explicit Git authoring gateway or keep
+ the product strict-GitOps/read-only. Define authentication, commit/PR policy, and the distinction
+ between Git commit success and later reconciliation success before exposing a write endpoint.
+2. **Make Move 1's policy explicit.** Keep the current path match only as named, never-written
+ context, or narrow acceptance to proved inputs if that stronger product policy is desired. In
+ either case, correct comments and fixture descriptions so they do not claim the matcher proved
+ runtime consumption. Add a multi-document decode regression: a malformed/unrelated document
+ must not prevent a later release from contributing its Move 1 context classification.
+3. **Add the source-aware read model only if it is valuable.** Replace `ValueFileRefs` with
+ `InputBinding`s that resolve source identity, field, precedence, and fan-in. Its output should
+ distinguish `named-context`, `proven-local-input`, `external-input`, and `unresolved` rather than
+ treating all matches as writable candidates. This step is useful for inspection even if the
+ product permanently declines free-standing file writes.
+4. **Write one free-standing file through Git.** Support an exact, single-release, unlayered local
+ values file through an authenticated `ContentPatch` plus an optimistic content revision. Prove
+ byte-stable no-op behaviour, branch-worker conflict handling, and an Argo/Flux apply that follows
+ the Git change rather than a direct Kubernetes mutation.
+5. **Add ConfigMap data-key views.** Reuse the same artifact/editor contract, but bind a nested YAML
+ view to the existing ConfigMap document buffer. Test a simultaneous ordinary ConfigMap update and
+ virtual-content edit.
+6. **Consider generators last.** Only after Kustomize generator modelling and before/after render
+ verification can establish source form and blast radius.
+
+Every source-aware or write step should add corpus fixtures and an integration test that uses a real
+source relationship: an Argo `$ref` whose repo is the test GitTarget, or a Flux Git source that
+demonstrably contains the values file. A Move 1 test that only proves the GitTarget accepts a folder
+is sufficient for its narrow no-write classification—but it does not prove the release consumes the
+file.
+
+## Non-goals
+
+- Rendering a chart to reverse-map a live `Deployment` field to a Helm value.
+- Treating chart output, Helm templates, or generated ConfigMaps as editable source.
+- Duplicating file contents inside `Application` or `HelmRelease` objects.
+- Adding synthetic values objects to the ordinary Kubernetes watch/resync/sweep lifecycle.
+- Using a values edit to mutate a release or workload directly in the cluster.
+- Making a Secret editable as if it were a plain ConfigMap.
+
+These limits are what let a future editing experience be powerful without turning GitOps Reverser
+into a Helm renderer or a second configuration authority.
diff --git a/docs/design/support-boundary/values-file-projection.md b/docs/design/support-boundary/values-file-projection.md
index e382b692..50f4e0cc 100644
--- a/docs/design/support-boundary/values-file-projection.md
+++ b/docs/design/support-boundary/values-file-projection.md
@@ -1,13 +1,15 @@
# The values file: refused for where it sits, not for what it is
-> **design** — direction-setting; ships no code. Nothing it describes is supported today.
-> Captured: 2026-07-14
+> **design + implementation record** — Move 1 (a referenced values file is read-only context)
+> has shipped; Move 2 (projecting the file as an editable object) is still design.
+> Captured: 2026-07-14. Move 1 shipped: 2026-07-16.
> Related:
> [README.md](README.md),
> [support-contract.md](support-contract.md),
> [expansion-boundary-and-corpus-organisation.md](expansion-boundary-and-corpus-organisation.md),
> [write-only-encrypted-secrets.md](write-only-encrypted-secrets.md),
> [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md),
+> [values-content-architecture.md](values-content-architecture.md),
> [acceptance-precision.md](acceptance-precision.md),
> [finished/higher-level-krm-documents.md](finished/higher-level-krm-documents.md)
@@ -16,6 +18,10 @@ names the free-standing Helm values file as *"the single highest-leverage thing
for the Helm story"* and says it *"deserves a design of its own rather than a decision
here."* This is that design.
+The wider architecture — including the distinction between an editable Git content surface and a
+release object's path/reference field, ConfigMap-backed YAML, and the delivery sequence — lives in
+[values-content-architecture.md](values-content-architecture.md).
+
The boundary it must not move: **we edit the intent layer, never the expansion layer.** We
never render `templates/`. We never learn what a value *means*. Everything below treats a
values file as **bytes with a home in Git**, and nothing more.
@@ -50,18 +56,32 @@ The worst case is worth spelling out. `platform/cert-manager/values.yaml` is:
- refused as `non-krm-yaml: "YAML is not a Kubernetes manifest"`, which **also takes down a
perfectly valid `ClusterIssuer`** sitting beside it, because acceptance is all-or-nothing.
-We refuse the folder on the grounds that we do not know what the file is. The repository is
-telling us what it is, in a field we already parse.
+We used to refuse the folder on the grounds that we did not know what the file was. The release
+field now gives Move 1 a deliberately limited reason to retain it as named context. That is not yet
+proof that this checkout is the source the deployer consumes; the formal Argo/Flux distinction is
+in [values-content-architecture.md](values-content-architecture.md).
---
## 2. Two moves, in order
-### Move 1 (cheap, immediate): a referenced values file is context, not junk
+### Move 1 (cheap, immediate): named values context, not junk
+
+> **Shipped (2026-07-16).** A values file named by an `Application`'s `helm.valueFiles` is
+> read-only context in the acceptance gate: understood, never written, and never a refusal for
+> the folder it sits in. The claim is read in
+> [`internal/manifestanalyzer/valuefiles.go`](../../../internal/manifestanalyzer/valuefiles.go)
+> and suppresses the `non-krm-yaml` refusal in `acceptance.go`; `platform/cert-manager` flips
+> refused → accepted in [support-today.md](../../../test/fixtures/gitops-layouts/support-today.md).
+> Both path-valued spellings ship: an Argo CD `Application`'s `helm.valueFiles` and a Flux
+> `HelmRelease`'s `spec.chart.spec.valuesFiles`, recognized by one scan-local candidate matcher
+> (repo-root `$values/…`, whole-repo, and co-located subtree). Move 1 does **not** prove that the
+> matching local file is the deployer's source; see [values-content-architecture.md](values-content-architecture.md).
-A values file named by an `Application`'s **`helm.valueFiles`**, or by a `HelmRelease`'s
-**`spec.chart.spec.valuesFiles`**, is **Read-only context** — a file we understand, never
-write, and never refuse the folder over.
+A values-shaped YAML file that this matcher associates with an `Application`'s **`helm.valueFiles`**,
+or with a `HelmRelease`'s **`spec.chart.spec.valuesFiles`**, is **Read-only context** — a file we
+retain, never write, and never refuse the folder over. Move 1 deliberately calls this named context,
+not proof that the deployer consumes this particular local file.
**Only the path-valued fields, and this distinction is load-bearing**, because the three
spellings are three different surfaces and only one of them is a file at all (both verified
@@ -69,7 +89,7 @@ against upstream, not inferred from the names):
| Field | What it holds | Surface |
|---|---|---|
-| Argo `helm.valueFiles`, Flux `spec.chart.spec.valuesFiles` | a **path** — `$values/platform/cert-manager/values.yaml` | **a file in the repo.** This document's subject. |
+| Argo `helm.valueFiles`, Flux `spec.chart.spec.valuesFiles` | a **path** — `$values/platform/cert-manager/values.yaml` | **a file in a selected source.** Move 1 recognizes a local candidate; source identity is needed before any read-provenance or editability claim. |
| Argo `helm.valuesObject` (a `runtime.RawExtension`), Flux `spec.values` | **inline YAML**, embedded in the Application/HelmRelease itself | not a file. It is a *field of a KRM document we already parse*, so it is editable exactly as any other field of that document — no projection, no new claim. |
| Flux `spec.valuesFrom` | a **KRM object reference** — `Secret/my-secret-values`, with a `valuesKey` | not a file either. It names a ConfigMap or Secret, which is *already* a document the store handles (and a Secret drags in [write-only-encrypted-secrets.md](write-only-encrypted-secrets.md), a different problem with a different answer). |
@@ -106,11 +126,10 @@ Why this is not chart inflation, and not a widening of the boundary:
already performs on every manifest — the pipeline is kind-agnostic, which is precisely
what [finished/higher-level-krm-documents.md](finished/higher-level-krm-documents.md)
proved when a `HelmRelease` needed no new code.
-- **Fan-in = 1 gates it for free.** The analyzer can already see which
- `Application`/`HelmRelease` references which file. A values file referenced by exactly one
- is editable. `values/ingress-nginx/common.yaml` — referenced by several — has fan-in > 1
- and **is refused by the existing rule with no new machinery**. The corpus contains both
- cases, plus two orphan values files referenced by nobody.
+- **Fan-in must be modelled separately.** The current analyzer records only a path set; it cannot
+ report consumers or calculate values-file fan-in. A future `InputBinding` graph can refuse a
+ shared `values/ingress-nginx/common.yaml` and accept only a proven, single-consumer input. The
+ existing Kustomize resource fan-in rule does not provide this automatically.
- **We still never learn what a value means.** `replicaCount: 4` is a scalar at a path. The
operator edits the field a human pointed it at. It does not know that the chart turns it
into a `Deployment`.
@@ -142,8 +161,8 @@ and that is probably where it belongs.
**Layered values.** `helm-environment-values` layers `chart/values.yaml` →
`values/common.yaml` → `values/.yaml` → the Application's `parameters` → and then a
hidden `chart/.argocd-source.yaml` that **overrides all of them**. Editing "the value" is
-ambiguous when five files can supply it. Fan-in = 1 refuses the shared layers; the hidden
-dotfile is an `OverriddenBy` claim and must refuse the folder or the edit, loudly. **This
+ambiguous when five files can supply it. A future source-aware fan-in rule must refuse shared
+layers; the hidden dotfile is an `OverriddenBy` claim and must refuse the edit, loudly. **This
document should not try to resolve the layering. It should refuse it and say so.**
**The `helm.values` string blob.** `Application.spec.source.helm.values` is a YAML document
@@ -154,11 +173,11 @@ to **name `valuesObject` as the supported surface** — structured, ordinary YAM
with no special case — and to document the string form as a blob we do not reach into.
`valuesObject` is un-adjudicated in the docs today and this is the moment to adjudicate it.
-**What makes a chart a chart?** Move 1 needs to tell a values file apart from a chart's own
-`values.yaml`. `Chart.yaml` + `templates/` is the signal, and the whole chart folder is
-skipped as a unit. `mixed-and-hostile` plants a `templates/` directory with no `Chart.yaml`,
-so the detector must not be fooled by either half alone. This residual is already logged in
-[expansion-boundary-and-corpus-organisation.md](expansion-boundary-and-corpus-organisation.md).
+**What makes a source local?** Move 1 does not need this answer because it only retains context.
+Move 2 does: an Argo `$ref` must resolve to the writable Git target or a Flux `GitRepository`
+source must prove that identity. A bare path on an external chart or a Flux `HelmRepository` is
+inside the fetched chart, not the co-located checkout. Chart-folder recognition (`Chart.yaml` plus
+`templates/`) may be an additional refusal rule, but it is not a substitute for source resolution.
---
@@ -170,12 +189,13 @@ so the detector must not be fooled by either half alone. This residual is alread
| Change a value on a `HelmRelease` (`spec.values`) | **Editable** today |
| Change a value via `Application` `parameters` / `valuesObject` | **Editable** today |
| Change a value held in a hand-written `ConfigMap` (`valuesFrom`) | **Editable** today |
-| Change a value in a values file **referenced by exactly one release** | **Planned: Editable** — §2, Move 2 |
-| Keep a values file in a folder without killing the folder | **Planned: Read-only context** — §2, Move 1 |
-| Change a value in a **shared** values file (`common.yaml`) | **Refused** — fan-in > 1, and correctly so |
+| Change a value in a values file **proven to be referenced by exactly one release** | **Planned: Editable** — §2, Move 2 |
+| Keep a values file in a folder without killing the folder | **Read-only context** — §2, Move 1 (shipped) |
+| Change a value in a **shared** values file (`common.yaml`) | **Refused** once a source-aware input graph proves fan-in > 1 |
| Change a value that a hidden `.argocd-source.yaml` overrides | **Refused** — say why, loudly |
| Edit the chart's `templates/` | **Refused**, permanently |
| Edit a chart-rendered object | **Not mirrored** — expansion layer |
-The first four rows already work. What the product is missing is rows five and six, and row
-six is a week's work in the acceptance gate.
+The first four rows already work, and row six (Move 1) now ships — a referenced values file is
+read-only context, so the folder is no longer refused for holding it. What the product is still
+missing is row five: projecting the file as an editable object (§2, Move 2).
diff --git a/internal/manifestanalyzer/acceptance.go b/internal/manifestanalyzer/acceptance.go
index 41bbf0ef..8b1c1bce 100644
--- a/internal/manifestanalyzer/acceptance.go
+++ b/internal/manifestanalyzer/acceptance.go
@@ -351,6 +351,15 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta
if managed {
return impureIssue(d, "a non-KRM document"), true
}
+ if store.isReferencedValuesFile(d.Path) {
+ // A values file a release names by a locally matching path — an Argo CD Application's
+ // helm.valueFiles or a Flux HelmRelease's spec.chart.spec.valuesFiles — is NAMED
+ // read-only context, not a stray: the repository points at it in a field we already
+ // parse, so it is retained (never written) and must not refuse the folder it sits in.
+ // This is a name match, not proof the deployer consumes the file. See
+ // docs/design/support-boundary/values-file-projection.md §2 (Move 1).
+ return AcceptanceIssue{}, false
+ }
return AcceptanceIssue{
Kind: IssueNonKRM, Path: d.Path, DocumentIndex: d.DocumentIndex,
Message: "YAML is not a Kubernetes manifest",
diff --git a/internal/manifestanalyzer/acceptance_test.go b/internal/manifestanalyzer/acceptance_test.go
index 7689b6e1..a280d6d4 100644
--- a/internal/manifestanalyzer/acceptance_test.go
+++ b/internal/manifestanalyzer/acceptance_test.go
@@ -119,6 +119,56 @@ func TestAccept_StandaloneNonKRMRefuses(t *testing.T) {
onlyIssue(t, acc, IssueNonKRM, "values.yaml", 0)
}
+func TestAccept_ReferencedValuesFileIsContext(t *testing.T) {
+ // The hybrid folder the design rescues: an Argo Application, the values.yaml it names
+ // through helm.valueFiles, and a co-located ClusterIssuer. The values file is retained as
+ // named read-only context, so the folder is accepted and the ClusterIssuer rides along —
+ // instead of one stray non-krm-yaml refusing all three.
+ fsys := fstest.MapFS{
+ "application.yaml": {Data: []byte(argoAppMultiSourceValues)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ "clusterissuer.yaml": {Data: []byte(clusterIssuerYAML)},
+ }
+ store, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ if !acc.Accepted || len(acc.Issues) != 0 {
+ t.Fatalf("a referenced values file must not refuse its folder, got %+v", acc.Issues)
+ }
+ if !store.isReferencedValuesFile("values.yaml") {
+ t.Errorf("values.yaml should be recognised as read-only context")
+ }
+ if store.FilesByPath["values.yaml"] != nil {
+ t.Errorf("a read-only values file must never become managed (it is never written)")
+ }
+}
+
+func TestAccept_FluxHelmReleaseValuesFileIsContext(t *testing.T) {
+ // The Flux counterpart to the Argo case: a HelmRelease names the values.yaml through
+ // spec.chart.spec.valuesFiles, so the values file is read-only context and the folder is
+ // accepted rather than refused as non-krm-yaml.
+ fsys := fstest.MapFS{
+ "helmrelease.yaml": {Data: []byte(fluxHelmReleaseValues)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ store, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ if !acc.Accepted || len(acc.Issues) != 0 {
+ t.Fatalf("a HelmRelease-referenced values file must not refuse its folder, got %+v", acc.Issues)
+ }
+ if !store.isReferencedValuesFile("values.yaml") {
+ t.Errorf("values.yaml should be recognised as read-only context")
+ }
+ if store.FilesByPath["values.yaml"] != nil {
+ t.Errorf("a read-only values file must never become managed (it is never written)")
+ }
+}
+
+func TestAccept_UnreferencedValuesFileStillRefuses(t *testing.T) {
+ // Nothing in the folder names this values file, so it stays the bucket-2 dangerous
+ // unknown — the suppression is reference-driven, never a blanket "tolerate any values.yaml".
+ fsys := fstest.MapFS{"values.yaml": {Data: []byte(helmValuesFile)}}
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ onlyIssue(t, acc, IssueNonKRM, "values.yaml", 0)
+}
+
func TestAccept_StandaloneInvalidRefuses(t *testing.T) {
fsys := fstest.MapFS{"broken.yaml": {Data: []byte(brokenYAML)}}
_, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go
index be76485d..b844fd67 100644
--- a/internal/manifestanalyzer/scan_repo.go
+++ b/internal/manifestanalyzer/scan_repo.go
@@ -630,12 +630,13 @@ func reachedResourceFilesFrom(rootDir string, kusts map[string]*kustomizationDoc
}
// nonKRMUnder counts non-KRM YAML documents and foreign entries under dir. Retained
-// build directives, operator artifacts, and accepted benign passengers are excluded
-// (they are neither KRM nor noise).
+// build directives, operator artifacts, accepted benign passengers, and values files a
+// release names as read-only context are excluded (they are neither KRM nor noise).
func nonKRMUnder(store *ManifestStore, dir string) int {
n := 0
for _, d := range store.Diagnostics {
- if d.Reason == manifestedit.ReasonNotKRM && pathWithin(d.Path, dir) {
+ if d.Reason == manifestedit.ReasonNotKRM && pathWithin(d.Path, dir) &&
+ !store.isReferencedValuesFile(d.Path) {
n++
}
}
diff --git a/internal/manifestanalyzer/scan_repo_test.go b/internal/manifestanalyzer/scan_repo_test.go
index 840cf654..f63a11b4 100644
--- a/internal/manifestanalyzer/scan_repo_test.go
+++ b/internal/manifestanalyzer/scan_repo_test.go
@@ -210,6 +210,49 @@ func TestScanRepo_RefusedPlainSurfacesGateReasons(t *testing.T) {
}
}
+// TestScanRepo_ReferencedValuesFileAccepted is the positive counterpart to
+// plain-nonkrm: the same shape (a plain folder holding a non-KRM values.yaml) is ADOPTED
+// once an Argo CD Application names the values file through helm.valueFiles. The values
+// file is read-only context, so it is not counted as non-KRM noise (nonKrm=0) and the
+// co-located ClusterIssuer rides along instead of being refused with the folder.
+func TestScanRepo_ReferencedValuesFileAccepted(t *testing.T) {
+ c := scanFixtureCandidates(t, "supported/argocd-external-helm-values")
+ if len(c) != 1 {
+ t.Fatalf("want 1 candidate, got %d: %+v", len(c), c)
+ }
+ got := c[0]
+ if !got.AcceptedByOperator {
+ t.Fatalf("a folder whose values.yaml is named by a release should be adopted; reasons=%+v",
+ got.RefusalReasons)
+ }
+ if got.Resources.NonKRM != 0 {
+ t.Errorf("the referenced values file is context, not noise: nonKrm = %d, want 0", got.Resources.NonKRM)
+ }
+ if got.Resources.Rendered != 2 || got.Resources.Editable != 2 {
+ t.Errorf("Application + ClusterIssuer should both be managed: rendered=%d editable=%d, want 2/2",
+ got.Resources.Rendered, got.Resources.Editable)
+ }
+}
+
+// TestScanRepo_FluxHelmReleaseValuesFileAccepted is the Flux counterpart to
+// TestScanRepo_ReferencedValuesFileAccepted: a folder holding a non-KRM values.yaml is adopted
+// once a Flux HelmRelease names it through spec.chart.spec.valuesFiles, and the values file is
+// not counted as non-KRM noise.
+func TestScanRepo_FluxHelmReleaseValuesFileAccepted(t *testing.T) {
+ c := scanFixtureCandidates(t, "supported/flux-helmrelease-values")
+ if len(c) != 1 {
+ t.Fatalf("want 1 candidate, got %d: %+v", len(c), c)
+ }
+ got := c[0]
+ if !got.AcceptedByOperator {
+ t.Fatalf("a folder whose values.yaml is named by a HelmRelease should be adopted; reasons=%+v",
+ got.RefusalReasons)
+ }
+ if got.Resources.NonKRM != 0 {
+ t.Errorf("the referenced values file is context, not noise: nonKrm = %d, want 0", got.Resources.NonKRM)
+ }
+}
+
// TestScanRepo_SopsBootstrapAcceptedLikeWriter guards that scan-repo's acceptance
// matches the live writer's allowlist: a folder holding the operator's .sops.yaml
// bootstrap config is accepted (the writer retains it), not refused as non-KRM, and the
diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go
index caf6d3b8..c0fef1bb 100644
--- a/internal/manifestanalyzer/store.go
+++ b/internal/manifestanalyzer/store.go
@@ -93,6 +93,16 @@ type ManifestStore struct {
// same surface as any other refusal.
IgnoreIssues []AcceptanceIssue
+ // ValueFileRefs is the set of scanned non-KRM files (slash paths) a release document — an Argo
+ // CD Application's helm.valueFiles or a Flux HelmRelease's spec.chart.spec.valuesFiles — names
+ // by a path that matches locally: NAMED read-only context, not junk, and not proof the deployer
+ // consumes this file. The acceptance gate consults it so a values file the repository points at
+ // no longer refuses its folder as non-krm-yaml, and the discovery scan does not count it as
+ // noise. It is never materialised, so the operator retains the file yet never writes it. See
+ // docs/design/support-boundary/values-file-projection.md §2 (Move 1) and
+ // values-content-architecture.md.
+ ValueFileRefs map[string]struct{}
+
// reachedByMultipleRoots is the set of resource-file paths (slash) that more than one
// render root reaches through the resources graph — the generalised write-fan-in
// signal, exposed via ReachedByMultipleRenderRoots. Computed once at build time from
@@ -404,6 +414,11 @@ func buildStore(
Ignore: scan.Ignore,
IgnoreIssues: scan.IgnoreIssues,
Kustomizations: kustomizationInfos(kusts),
+ // The named read-only-context set: non-KRM values files a release (Argo Application or
+ // Flux HelmRelease) names by a locally matching path. It travels with the store so the
+ // acceptance gate stops refusing them as non-krm-yaml and the discovery scan stops counting
+ // them as noise.
+ ValueFileRefs: helmValueFileRefs(yamlFiles),
// The generalised write-fan-in set: files more than one render root reaches. It
// is derived from the same kustomization graph renderRoots reads, so a base
// shared by two overlays is flagged whether or not an override entry is at stake.
@@ -496,6 +511,19 @@ func (s *ManifestStore) DocumentLocations() map[*DocumentModel]RecordRef {
return documentLocations(s)
}
+// isReferencedValuesFile reports whether a scanned path is a non-KRM values file a release (an
+// Argo CD Application's helm.valueFiles or a Flux HelmRelease's spec.chart.spec.valuesFiles) names
+// by a locally matching path — named read-only context the acceptance gate must not refuse as
+// non-krm-yaml and the discovery scan must not count as noise. filePath is matched as a slash path,
+// the form ValueFileRefs holds.
+func (s *ManifestStore) isReferencedValuesFile(filePath string) bool {
+ if s.ValueFileRefs == nil {
+ return false
+ }
+ _, ok := s.ValueFileRefs[filepathToSlash(filePath)]
+ return ok
+}
+
// materializeInputs are the scan-wide facts every record is judged against.
type materializeInputs struct {
lookup typeset.Lookup
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json
new file mode 100644
index 00000000..f87a1411
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json
@@ -0,0 +1,23 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "argocd",
+ "resources": {
+ "rendered": 2,
+ "editable": 2,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 1
+ },
+ "accepted": 1,
+ "refused": 0
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/application.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/application.yaml
new file mode 100644
index 00000000..2b0d67ed
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/application.yaml
@@ -0,0 +1,22 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: cert-manager
+ namespace: argocd
+spec:
+ project: default
+ # Multi-source: the chart is an external Helm repo; the value file is the values.yaml
+ # sitting next to this Application, exposed through the $values ref.
+ sources:
+ - repoURL: https://charts.jetstack.io
+ chart: cert-manager
+ targetRevision: v1.16.2
+ helm:
+ valueFiles:
+ - $values/app/values.yaml
+ - repoURL: https://github.com/example-org/gitops.git
+ targetRevision: main
+ ref: values
+ destination:
+ server: https://kubernetes.default.svc
+ namespace: cert-manager
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/clusterissuer.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/clusterissuer.yaml
new file mode 100644
index 00000000..e7d8b4e5
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/clusterissuer.yaml
@@ -0,0 +1,10 @@
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: letsencrypt-production
+spec:
+ acme:
+ server: https://acme-v02.api.letsencrypt.org/directory
+ email: platform-team@example-org.com
+ privateKeySecretRef:
+ name: letsencrypt-production-account-key
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/values.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/values.yaml
new file mode 100644
index 00000000..e68a380f
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values/app/values.yaml
@@ -0,0 +1,7 @@
+# Helm values for the cert-manager chart -- NOT a Kubernetes object.
+# Named by the co-located Application through helm.valueFiles ($values ref), so it is
+# read-only context: understood, never written, and never a refusal for its folder.
+installCRDs: true
+replicaCount: 2
+prometheus:
+ enabled: true
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json
new file mode 100644
index 00000000..34e11e89
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json
@@ -0,0 +1,23 @@
+{
+ "candidates": [
+ {
+ "path": "app",
+ "layout": "plain",
+ "acceptedByOperator": true,
+ "renderRoot": false,
+ "inferredNamespace": "flux-system",
+ "resources": {
+ "rendered": 1,
+ "editable": 1,
+ "nonKrm": 0
+ }
+ }
+ ],
+ "summary": {
+ "candidatesByLayout": {
+ "plain": 1
+ },
+ "accepted": 1,
+ "refused": 0
+ }
+}
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values/app/helmrelease.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values/app/helmrelease.yaml
new file mode 100644
index 00000000..e18ced5f
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values/app/helmrelease.yaml
@@ -0,0 +1,21 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: ingress-nginx
+ namespace: flux-system
+spec:
+ interval: 30m
+ releaseName: ingress-nginx
+ targetNamespace: ingress-nginx
+ chart:
+ spec:
+ chart: ingress-nginx
+ version: 4.11.3
+ sourceRef:
+ kind: HelmRepository
+ name: ingress-nginx
+ namespace: flux-system
+ # The values file sits beside this HelmRelease and is named here, so it is
+ # read-only context — understood, never written, never a refusal for the folder.
+ valuesFiles:
+ - values.yaml
diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values/app/values.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values/app/values.yaml
new file mode 100644
index 00000000..08647ebf
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values/app/values.yaml
@@ -0,0 +1,7 @@
+# Helm values for the ingress-nginx chart -- NOT a Kubernetes object.
+# Named by the co-located HelmRelease through spec.chart.spec.valuesFiles, so it is
+# read-only context: understood, never written, and never a refusal for its folder.
+controller:
+ replicaCount: 2
+ service:
+ type: LoadBalancer
diff --git a/internal/manifestanalyzer/valuefiles.go b/internal/manifestanalyzer/valuefiles.go
new file mode 100644
index 00000000..ebedc568
--- /dev/null
+++ b/internal/manifestanalyzer/valuefiles.go
@@ -0,0 +1,244 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package manifestanalyzer
+
+import (
+ "bytes"
+ "path"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+)
+
+// This file reads ONE path-valued field of a document we already parse — an Argo CD
+// Application's helm.valueFiles or a Flux HelmRelease's spec.chart.spec.valuesFiles — and turns a
+// values-shaped file that field names into NAMED read-only context, so the analyzer stops refusing
+// a folder for holding a values file the repository points at. It is
+// docs/design/support-boundary/values-file-projection.md §2 "Move 1" and
+// docs/design/support-boundary/acceptance-precision.md §1c: a values file named by a release is
+// retained, never written, and never a refusal that takes the folder down with it.
+//
+// This is a scan-LOCAL name match, NOT proof that the deployer consumes this file, and the
+// distinction is deliberate. An Argo bare path on an external Helm/OCI chart, or a Flux
+// HelmRepository source, resolves INSIDE the fetched chart — a same-named file sitting here in the
+// checkout is unrelated to what the release actually reads. Move 1 accepts such a file as a benign,
+// named passenger and NEVER presents it as a proven or editable deployment input. Source identity
+// (Argo $ref → its Git source, Flux sourceRef → a GitRepository, both matched to THIS GitTarget) is
+// a Move 2 prerequisite, not something this classifier claims. The Argo/Flux resolution table and
+// this policy live in docs/design/support-boundary/values-content-architecture.md.
+//
+// The boundary this must not move: we read a PATH-valued field and nothing else. We never
+// render a chart, never learn what a value means, and never reach into inline values
+// (helm.valuesObject / helm.values / HelmRelease spec.values) or an object reference
+// (Flux valuesFrom) — those are not files, and only the path-valued spelling is this document's
+// subject. The tolerated shape mirrors the kustomize patch precedent exactly: a file named by a
+// directive is retained as build context, the render is mirrored, and nothing is routed INTO it.
+
+// The closed vocabulary of release kinds that name a values FILE in a path-valued field. Nothing
+// else is honoured — the claim comes from a group + kind we recognise, never from a filename.
+const (
+ // argoAppGroup owns Application.spec.source(s).helm.valueFiles.
+ argoAppGroup = "argoproj.io"
+ // fluxHelmGroup owns HelmRelease.spec.chart.spec.valuesFiles.
+ fluxHelmGroup = "helm.toolkit.fluxcd.io"
+)
+
+// helmSource is the one part of an Argo source this file cares about: its helm.valueFiles. A
+// source carries much more, but a values FILE is named only here, so nothing else is decoded.
+type helmSource struct {
+ Helm *struct {
+ ValueFiles []string `yaml:"valueFiles"`
+ } `yaml:"helm"`
+}
+
+// releaseDoc is the minimal decode of the two release kinds that name a values file by path: an
+// Argo CD Application (spec.source and spec.sources[].helm.valueFiles) and a Flux HelmRelease
+// (spec.chart.spec.valuesFiles). Both spec shapes live on one struct; the fields the doc's kind
+// does not use simply stay nil. Every other field is ignored on purpose.
+type releaseDoc struct {
+ APIVersion string `yaml:"apiVersion"`
+ Kind string `yaml:"kind"`
+ Spec struct {
+ // Argo CD Application: single source and multi-source spellings.
+ Source *helmSource `yaml:"source"`
+ Sources []helmSource `yaml:"sources"`
+ // Flux HelmRelease: the chart's own valuesFiles list.
+ Chart *struct {
+ Spec struct {
+ ValuesFiles []string `yaml:"valuesFiles"`
+ } `yaml:"spec"`
+ } `yaml:"chart"`
+ } `yaml:"spec"`
+}
+
+// valueFileEntries returns every values-file path the document names, dispatched by its kind: an
+// Argo Application's helm.valueFiles (across its single and multiple sources) or a Flux
+// HelmRelease's spec.chart.spec.valuesFiles. A document that is neither names none.
+func (d releaseDoc) valueFileEntries() []string {
+ group := groupOf(d.APIVersion)
+ switch {
+ case group == argoAppGroup && d.Kind == "Application":
+ var out []string
+ if d.Spec.Source != nil && d.Spec.Source.Helm != nil {
+ out = append(out, d.Spec.Source.Helm.ValueFiles...)
+ }
+ for _, s := range d.Spec.Sources {
+ if s.Helm != nil {
+ out = append(out, s.Helm.ValueFiles...)
+ }
+ }
+ return out
+ case group == fluxHelmGroup && d.Kind == "HelmRelease":
+ if d.Spec.Chart != nil {
+ return d.Spec.Chart.Spec.ValuesFiles
+ }
+ }
+ return nil
+}
+
+// groupOf returns the API group of an apiVersion ("group/version" → "group"; a core "v1" → "").
+func groupOf(apiVersion string) string {
+ if i := strings.IndexByte(apiVersion, '/'); i >= 0 {
+ return apiVersion[:i]
+ }
+ return apiVersion
+}
+
+// helmValueFileRefs is the set of scanned files (slash paths) whose path a release document's
+// path-valued values-file field matches by name against the scan AND that are not themselves
+// Kubernetes manifests — the NAMED read-only-context set (a local name match, not proven
+// consumption). A referenced file that IS valid KRM is left to the normal rules (it is a manifest,
+// mirrored as one); only a non-KRM values file is rescued here, which is exactly the file that
+// would otherwise be refused as non-krm-yaml.
+//
+// It mirrors patchFilesOf: parse the directive, resolve the path it names against the scan, and
+// hand the store a set it retains outside the managed model. Fan-in is not gated here — a values
+// file shared by several releases is still read-only context (never refused for being shared);
+// the fan-in = 1 gate belongs to the editable-projection step (Move 2), not to making a file we
+// understand stop refusing its folder.
+func helmValueFileRefs(files []manifestedit.FileContent) map[string]struct{} {
+ tree := contentByPath(files)
+ refs := map[string]struct{}{}
+ for _, f := range files {
+ dir := slashDir(f.Path)
+ for _, doc := range decodeReleases(f.Content) {
+ for _, entry := range doc.valueFileEntries() {
+ if p := resolveValueFilePath(entry, dir, tree); p != "" {
+ refs[p] = struct{}{}
+ }
+ }
+ }
+ }
+ return refs
+}
+
+// decodeReleases returns every release document (Argo Application or Flux HelmRelease) in one
+// file's bytes. A file may hold several documents (or none that are releases); each is decoded
+// independently. Documents are first split as generic yaml.Node values, then converted one at a
+// time: a single document this minimal struct cannot read — e.g. an unrelated kind whose spec is
+// type-incompatible, sitting BEFORE a release in the same file — is skipped without aborting the
+// stream, so it can never hide a release that follows it. Only EOF (or an unrecoverable syntax
+// error, which corrupts the stream position anyway) ends the scan.
+func decodeReleases(content []byte) []releaseDoc {
+ var out []releaseDoc
+ dec := yaml.NewDecoder(bytes.NewReader(content))
+ for {
+ var node yaml.Node
+ if err := dec.Decode(&node); err != nil {
+ break // EOF or an unrecoverable stream error: no more documents to read.
+ }
+ var doc releaseDoc
+ if err := node.Decode(&doc); err != nil {
+ continue // this one document is not a shape we can read; a later one still might be.
+ }
+ out = append(out, doc)
+ }
+ return out
+}
+
+// resolveValueFilePath matches one values-file entry to a scanned non-KRM file by name, or returns
+// "" when nothing we hold matches. dir is the referencing release's own directory (slash, relative
+// to the scanned root). This is a NAME match against the local scan; it does NOT resolve the
+// release's actual source, so a match is "named context", not proof the deployer reads this file
+// (see the file header and values-content-architecture.md's resolution table).
+//
+// The entry can arrive in several spellings; the ordered candidates below try each against both a
+// whole-repo scan and a subtree (the live operator's GitTarget path) scan:
+//
+// - a $ref-prefixed Argo entry ($values/platform/cert-manager/values.yaml): upstream, the $values
+// ref selects a Git source and the remainder is rooted at that source's repo. We strip the
+// leading $ref/ token and match the remainder locally WITHOUT proving that source is this repo.
+// - a Flux valuesFiles entry is relative to its sourceRef; only a GitRepository sourceRef is a
+// repo, and we do not read sourceRef.kind, so we match by path without proving the source local.
+// - a plain relative entry (values.yaml, ../shared/values.yaml) is tried against the release's own
+// directory — even though a bare path on an external chart is read from the chart, not here.
+//
+// Candidates, first non-KRM match wins: relative to the release's directory (a co-located relative
+// path), the path as a scan-root-relative path (a whole-repo scan of a repo-root-relative
+// reference), and finally co-located by basename (a subtree scan that has lost the repo-root prefix
+// but holds the file beside the release — the "referenced values file in the same folder" case).
+// Only a path that exists AND is non-KRM is returned, so a genuine manifest a release happens to
+// reference is never silently un-managed.
+func resolveValueFilePath(entry, dir string, tree map[string][]byte) string {
+ entry = strings.TrimSpace(entry)
+ if entry == "" || isRemoteResource(entry) {
+ return ""
+ }
+ if strings.HasPrefix(entry, "$") {
+ entry = stripValuesRef(entry)
+ if entry == "" {
+ return ""
+ }
+ }
+ for _, cand := range []string{
+ cleanJoin(dir, entry),
+ path.Clean(entry),
+ cleanJoin(dir, path.Base(entry)),
+ } {
+ if p := existingNonKRM(cand, tree); p != "" {
+ return p
+ }
+ }
+ return ""
+}
+
+// stripValuesRef drops the leading "$ref/" token from a $-prefixed Argo valueFiles entry, leaving
+// the path the ref names. "$values/platform/cert-manager/values.yaml" -> that path;
+// a bare "$values" (naming the ref root, no file) -> "".
+func stripValuesRef(entry string) string {
+ if i := strings.IndexByte(entry, '/'); i >= 0 {
+ return entry[i+1:]
+ }
+ return ""
+}
+
+// existingNonKRM returns p when the scan holds a file at p whose content is not a Kubernetes
+// manifest (a values file), and "" otherwise. The non-KRM test is the same apiVersion+kind
+// presence check the indexer uses to decide a document is not KRM, so the two agree on what a
+// values file is.
+func existingNonKRM(p string, tree map[string][]byte) string {
+ if p == "" {
+ return ""
+ }
+ content, ok := tree[p]
+ if !ok || looksLikeKRM(content) {
+ return ""
+ }
+ return p
+}
+
+// looksLikeKRM reports whether the first document of content carries both an apiVersion and a
+// kind — the minimum that makes a YAML document a Kubernetes manifest rather than a values
+// file. It reads the reason code (the two fields), never the payload.
+func looksLikeKRM(content []byte) bool {
+ var head struct {
+ APIVersion string `yaml:"apiVersion"`
+ Kind string `yaml:"kind"`
+ }
+ if err := yaml.Unmarshal(content, &head); err != nil {
+ return false
+ }
+ return head.APIVersion != "" && head.Kind != ""
+}
diff --git a/internal/manifestanalyzer/valuefiles_test.go b/internal/manifestanalyzer/valuefiles_test.go
new file mode 100644
index 00000000..904d644d
--- /dev/null
+++ b/internal/manifestanalyzer/valuefiles_test.go
@@ -0,0 +1,243 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package manifestanalyzer
+
+import (
+ "context"
+ "io/fs"
+ "testing"
+ "testing/fstest"
+)
+
+// argoAppMultiSourceValues is the right SHAPE for an external chart with Git-hosted values: a
+// multi-source Application whose first source renders an external chart and whose second source
+// (ref: values) is a Git source, so helm.valueFiles names a values file through a $values ref.
+// Move 1 only recognizes the spelling and matches the path locally; it does NOT validate the ref
+// or prove the referenced repo is this GitTarget.
+const argoAppMultiSourceValues = `apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: cert-manager
+ namespace: argocd
+spec:
+ sources:
+ - repoURL: https://charts.jetstack.io
+ chart: cert-manager
+ targetRevision: v1.16.2
+ helm:
+ valueFiles:
+ - $values/platform/cert-manager/values.yaml
+ - repoURL: https://github.com/example-org/gitops.git
+ targetRevision: main
+ ref: values
+`
+
+// argoAppRelativeValues is a bare single-source spelling against an EXTERNAL Helm chart
+// (repoURL charts.example.com + chart foo). Upstream, Argo resolves such a bare valueFiles path
+// INSIDE the fetched chart, so the co-located values.yaml here is NOT what Argo reads. Move 1
+// still accepts it as a benign, named passenger — never as a proven or editable deployment input.
+const argoAppRelativeValues = `apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: app
+ namespace: argocd
+spec:
+ source:
+ repoURL: https://charts.example.com
+ chart: foo
+ helm:
+ valueFiles:
+ - values.yaml
+`
+
+// fluxHelmReleaseValues is a Flux HelmRelease naming a values file through
+// spec.chart.spec.valuesFiles with a HelmRepository sourceRef — the Flux counterpart to an Argo
+// Application's helm.valueFiles. Upstream, a HelmRepository source reads valuesFiles from the
+// fetched chart package, so the co-located values.yaml here is not what Flux reads; Move 1 accepts
+// it as named context, never as a proven or editable input.
+const fluxHelmReleaseValues = `apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: ingress-nginx
+ namespace: flux-system
+spec:
+ interval: 30m
+ chart:
+ spec:
+ chart: ingress-nginx
+ version: 4.11.3
+ sourceRef:
+ kind: HelmRepository
+ name: ingress-nginx
+ valuesFiles:
+ - values.yaml
+`
+
+// helmValuesFile is a Helm values document: plain YAML, no apiVersion/kind, so it is non-KRM.
+const helmValuesFile = "# helm values for the chart -- NOT a Kubernetes object\n" +
+ "replicaCount: 2\ninstallCRDs: true\n"
+
+// clusterIssuerYAML is the co-located plain KRM the folder-wide refusal used to take down.
+const clusterIssuerYAML = `apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: letsencrypt
+spec:
+ acme:
+ email: platform@example.com
+`
+
+// valueRefsOf builds a structure-only store and returns the read-only-context set.
+func valueRefsOf(t *testing.T, fsys fs.FS) map[string]struct{} {
+ t.Helper()
+ store := buildStoreFS(context.Background(), fsys, nil, WriterAllowlist())
+ return store.ValueFileRefs
+}
+
+func hasRef(refs map[string]struct{}, p string) bool {
+ _, ok := refs[p]
+ return ok
+}
+
+func TestHelmValueFileRefs_WholeRepoAbsoluteRef(t *testing.T) {
+ // The whole-repo scan sees the $values path at exactly its root-relative location.
+ fsys := fstest.MapFS{
+ "platform/cert-manager/application.yaml": {Data: []byte(argoAppMultiSourceValues)},
+ "platform/cert-manager/values.yaml": {Data: []byte(helmValuesFile)},
+ "platform/cert-manager/clusterissuer.yaml": {Data: []byte(clusterIssuerYAML)},
+ }
+ refs := valueRefsOf(t, fsys)
+ if !hasRef(refs, "platform/cert-manager/values.yaml") {
+ t.Fatalf("want platform/cert-manager/values.yaml as read-only context, got %v", refs)
+ }
+ if len(refs) != 1 {
+ t.Fatalf("only the referenced values file is context, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_SubtreeCoLocatedRef(t *testing.T) {
+ // The live operator's GitTarget subtree has lost the repo-root prefix, so the same
+ // $values/platform/cert-manager/values.yaml ref resolves by co-location beside the app.
+ fsys := fstest.MapFS{
+ "application.yaml": {Data: []byte(argoAppMultiSourceValues)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ "clusterissuer.yaml": {Data: []byte(clusterIssuerYAML)},
+ }
+ refs := valueRefsOf(t, fsys)
+ if !hasRef(refs, "values.yaml") {
+ t.Fatalf("want values.yaml recognised by co-location in a subtree scan, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_RelativeSingleSource(t *testing.T) {
+ fsys := fstest.MapFS{
+ "application.yaml": {Data: []byte(argoAppRelativeValues)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ refs := valueRefsOf(t, fsys)
+ if !hasRef(refs, "values.yaml") {
+ t.Fatalf("want values.yaml resolved relative to the Application, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_FluxHelmReleaseCoLocated(t *testing.T) {
+ // The Flux counterpart: a HelmRelease names values.yaml through spec.chart.spec.valuesFiles,
+ // resolved relative to the HelmRelease's own directory.
+ fsys := fstest.MapFS{
+ "helmrelease.yaml": {Data: []byte(fluxHelmReleaseValues)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ refs := valueRefsOf(t, fsys)
+ if !hasRef(refs, "values.yaml") {
+ t.Fatalf("want values.yaml named by a HelmRelease as read-only context, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_FluxHelmReleaseRepoRootPath(t *testing.T) {
+ // Flux valuesFiles are relative to the SourceRef (the repo), so a whole-repo scan resolves
+ // the path at its root-relative location, and a subtree scan resolves it by co-location.
+ release := "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: r\n" +
+ "spec:\n chart:\n spec:\n chart: c\n valuesFiles:\n - apps/ingress/values.yaml\n"
+
+ wholeRepo := fstest.MapFS{
+ "apps/ingress/helmrelease.yaml": {Data: []byte(release)},
+ "apps/ingress/values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ if refs := valueRefsOf(t, wholeRepo); !hasRef(refs, "apps/ingress/values.yaml") {
+ t.Fatalf("whole-repo scan should resolve the root-relative valuesFiles path, got %v", refs)
+ }
+
+ subtree := fstest.MapFS{
+ "helmrelease.yaml": {Data: []byte(release)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ if refs := valueRefsOf(t, subtree); !hasRef(refs, "values.yaml") {
+ t.Fatalf("subtree scan should resolve the co-located values file by basename, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_FluxKRMTargetIsNotContext(t *testing.T) {
+ // As with Argo, a HelmRelease that references a real manifest must not un-manage it.
+ release := "apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: r\n" +
+ "spec:\n chart:\n spec:\n chart: c\n valuesFiles:\n - deploy.yaml\n"
+ fsys := fstest.MapFS{
+ "helmrelease.yaml": {Data: []byte(release)},
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ }
+ if refs := valueRefsOf(t, fsys); len(refs) != 0 {
+ t.Fatalf("a KRM target is not read-only context, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_KRMTargetIsNotContext(t *testing.T) {
+ // A release that references a real manifest must not silently un-manage it: only a
+ // non-KRM values file is rescued as context.
+ app := "apiVersion: argoproj.io/v1alpha1\nkind: Application\nmetadata:\n name: app\n" +
+ "spec:\n source:\n helm:\n valueFiles:\n - deploy.yaml\n"
+ fsys := fstest.MapFS{
+ "application.yaml": {Data: []byte(app)},
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ }
+ refs := valueRefsOf(t, fsys)
+ if len(refs) != 0 {
+ t.Fatalf("a KRM target is not read-only context, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_MissingTargetIsNotContext(t *testing.T) {
+ fsys := fstest.MapFS{"application.yaml": {Data: []byte(argoAppRelativeValues)}}
+ if refs := valueRefsOf(t, fsys); len(refs) != 0 {
+ t.Fatalf("a values file that is not in the scan is not context, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_NonApplicationIsIgnored(t *testing.T) {
+ // A helm.valueFiles-looking field on a document that is not an Argo Application is not a
+ // claim we honour — the vocabulary is closed to argoproj.io/Application.
+ notApp := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n" +
+ "spec:\n source:\n helm:\n valueFiles:\n - values.yaml\n"
+ fsys := fstest.MapFS{
+ "cm.yaml": {Data: []byte(notApp)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ if refs := valueRefsOf(t, fsys); len(refs) != 0 {
+ t.Fatalf("only an Argo Application names a values file, got %v", refs)
+ }
+}
+
+func TestHelmValueFileRefs_EarlierIncompatibleDocDoesNotHideRelease(t *testing.T) {
+ // Regression: a multi-document file whose FIRST document is an unrelated kind with a
+ // type-incompatible spec (spec is a scalar, not a mapping) must not abort the decode and
+ // hide the release that follows it. decodeReleases splits documents as generic nodes and
+ // skips only the one it cannot read, so the Application still contributes its values file.
+ multiDoc := "apiVersion: example.com/v1\nkind: Weird\nspec: just-a-string\n---\n" +
+ argoAppRelativeValues
+ fsys := fstest.MapFS{
+ "bundle.yaml": {Data: []byte(multiDoc)},
+ "values.yaml": {Data: []byte(helmValuesFile)},
+ }
+ refs := valueRefsOf(t, fsys)
+ if !hasRef(refs, "values.yaml") {
+ t.Fatalf("an incompatible earlier document must not hide a later release's values file, got %v", refs)
+ }
+}
diff --git a/pkg/manifestanalyzer/folder_test.go b/pkg/manifestanalyzer/folder_test.go
index fb343095..6714acab 100644
--- a/pkg/manifestanalyzer/folder_test.go
+++ b/pkg/manifestanalyzer/folder_test.go
@@ -69,6 +69,80 @@ func TestScanFolder_RefusalIsNotAnError(t *testing.T) {
"Helm inflation is the permanent support boundary and must be named as such")
}
+// A folder holding a values file that an Argo CD Application names through helm.valueFiles
+// is adopted, not refused: the values file is read-only context. This is the published
+// contract for the live operator adopting a GitTarget subtree with a co-located release.
+func TestScanFolder_ReferencedValuesFileAccepted(t *testing.T) {
+ t.Parallel()
+
+ const application = `apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: cert-manager
+ namespace: argocd
+spec:
+ sources:
+ - repoURL: https://charts.jetstack.io
+ chart: cert-manager
+ helm:
+ valueFiles:
+ - $values/platform/cert-manager/values.yaml
+ - repoURL: https://github.com/example-org/gitops.git
+ ref: values
+`
+ const valuesFile = "# helm values -- not a Kubernetes object\nreplicaCount: 2\ninstallCRDs: true\n"
+ const clusterIssuer = "apiVersion: cert-manager.io/v1\nkind: ClusterIssuer\nmetadata:\n name: le\n"
+
+ fsys := fstest.MapFS{
+ "application.yaml": {Data: []byte(application)},
+ "values.yaml": {Data: []byte(valuesFile)},
+ "clusterissuer.yaml": {Data: []byte(clusterIssuer)},
+ }
+ report := manifestanalyzer.ScanFolderFS(context.Background(), fsys)
+
+ require.True(t, report.Accepted, "a referenced values file must not refuse its folder: %+v", report.Issues)
+ for _, issue := range report.Issues {
+ require.NotEqual(t, manifestanalyzer.IssueNonKRM, issue.Kind,
+ "the values file the Application names is context, not a non-krm-yaml refusal")
+ }
+}
+
+// The Flux counterpart to TestScanFolder_ReferencedValuesFileAccepted: a folder holding a
+// values file a HelmRelease names through spec.chart.spec.valuesFiles is adopted, not refused.
+func TestScanFolder_FluxHelmReleaseValuesFileAccepted(t *testing.T) {
+ t.Parallel()
+
+ const helmRelease = `apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: ingress-nginx
+ namespace: flux-system
+spec:
+ chart:
+ spec:
+ chart: ingress-nginx
+ sourceRef:
+ kind: HelmRepository
+ name: ingress-nginx
+ valuesFiles:
+ - values.yaml
+`
+ const valuesFile = "# helm values -- not a Kubernetes object\ncontroller:\n replicaCount: 2\n"
+
+ fsys := fstest.MapFS{
+ "helmrelease.yaml": {Data: []byte(helmRelease)},
+ "values.yaml": {Data: []byte(valuesFile)},
+ }
+ report := manifestanalyzer.ScanFolderFS(context.Background(), fsys)
+
+ require.True(t, report.Accepted, "a HelmRelease-referenced values file must not refuse its folder: %+v",
+ report.Issues)
+ for _, issue := range report.Issues {
+ require.NotEqual(t, manifestanalyzer.IssueNonKRM, issue.Kind,
+ "the values file the HelmRelease names is context, not a non-krm-yaml refusal")
+ }
+}
+
func TestScanFolder_MissingDirIsAnError(t *testing.T) {
t.Parallel()
_, err := manifestanalyzer.ScanFolder(context.Background(), filepath.Join(t.TempDir(), "absent"))
diff --git a/test/e2e/fixtures/values-file-folder-flux/helmrelease.yaml b/test/e2e/fixtures/values-file-folder-flux/helmrelease.yaml
new file mode 100644
index 00000000..0a1709c0
--- /dev/null
+++ b/test/e2e/fixtures/values-file-folder-flux/helmrelease.yaml
@@ -0,0 +1,21 @@
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: ingress-nginx
+ namespace: flux-system
+spec:
+ interval: 30m
+ releaseName: ingress-nginx
+ targetNamespace: ingress-nginx
+ chart:
+ spec:
+ chart: ingress-nginx
+ version: 4.11.3
+ sourceRef:
+ kind: HelmRepository
+ name: ingress-nginx
+ namespace: flux-system
+ # The values file sits beside this HelmRelease and is named here, so it is
+ # read-only context: understood, never written, never a refusal for the folder.
+ valuesFiles:
+ - values.yaml
diff --git a/test/e2e/fixtures/values-file-folder-flux/values.yaml b/test/e2e/fixtures/values-file-folder-flux/values.yaml
new file mode 100644
index 00000000..0b187484
--- /dev/null
+++ b/test/e2e/fixtures/values-file-folder-flux/values.yaml
@@ -0,0 +1,8 @@
+# e2e-readonly-context-marker: this Helm values file is NOT a Kubernetes object.
+# It is named by the co-located HelmRelease through spec.chart.spec.valuesFiles, so the
+# operator understands it as read-only context: it must never write or delete this file,
+# and it must never refuse the folder for holding it.
+controller:
+ replicaCount: 2
+ service:
+ type: LoadBalancer
diff --git a/test/e2e/fixtures/values-file-folder/application.yaml b/test/e2e/fixtures/values-file-folder/application.yaml
new file mode 100644
index 00000000..3eedece7
--- /dev/null
+++ b/test/e2e/fixtures/values-file-folder/application.yaml
@@ -0,0 +1,23 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: cert-manager
+ namespace: argocd
+spec:
+ project: default
+ # Multi-source: the chart is an external Helm repo; the value file is the co-located
+ # values.yaml, named through the $values ref. The path is repo-root-relative in Argo's
+ # model, and this folder is seeded at platform/cert-manager so the reference is correct.
+ sources:
+ - repoURL: https://charts.jetstack.io
+ chart: cert-manager
+ targetRevision: v1.16.2
+ helm:
+ valueFiles:
+ - $values/platform/cert-manager/values.yaml
+ - repoURL: https://github.com/example-org/gitops.git
+ targetRevision: main
+ ref: values
+ destination:
+ server: https://kubernetes.default.svc
+ namespace: cert-manager
diff --git a/test/e2e/fixtures/values-file-folder/clusterissuer.yaml b/test/e2e/fixtures/values-file-folder/clusterissuer.yaml
new file mode 100644
index 00000000..e7d8b4e5
--- /dev/null
+++ b/test/e2e/fixtures/values-file-folder/clusterissuer.yaml
@@ -0,0 +1,10 @@
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: letsencrypt-production
+spec:
+ acme:
+ server: https://acme-v02.api.letsencrypt.org/directory
+ email: platform-team@example-org.com
+ privateKeySecretRef:
+ name: letsencrypt-production-account-key
diff --git a/test/e2e/fixtures/values-file-folder/values.yaml b/test/e2e/fixtures/values-file-folder/values.yaml
new file mode 100644
index 00000000..f490022b
--- /dev/null
+++ b/test/e2e/fixtures/values-file-folder/values.yaml
@@ -0,0 +1,8 @@
+# e2e-readonly-context-marker: this Helm values file is NOT a Kubernetes object.
+# It is named by the co-located Application through helm.valueFiles, so the operator
+# understands it as read-only context: it must never write or delete this file, and it
+# must never refuse the folder for holding it.
+installCRDs: true
+replicaCount: 2
+prometheus:
+ enabled: true
diff --git a/test/e2e/values_file_acceptance_e2e_test.go b/test/e2e/values_file_acceptance_e2e_test.go
new file mode 100644
index 00000000..a7644ec7
--- /dev/null
+++ b/test/e2e/values_file_acceptance_e2e_test.go
@@ -0,0 +1,191 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package e2e
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// These two specs are the positive counterpart to the unsupported-folder refusal: when a
+// GitTarget's path holds a Helm values.yaml that a release names as a path — an Argo CD
+// Application through helm.valueFiles, or a Flux HelmRelease through spec.chart.spec.valuesFiles —
+// the values file is read-only context: understood, never written, and never a refusal for the
+// folder it sits in. The whole folder is ADOPTED, the operator mirrors a live object into it
+// (which a refused folder never does), and every seeded file is left byte-for-byte untouched.
+//
+// They prove docs/design/support-boundary/values-file-projection.md §2 (Move 1) end to end, for
+// both the Argo and the Flux spelling. Each folder is seeded so the reference is correct both for
+// the tool (repo-root-relative) and for the operator's subtree scan (co-located beside the
+// release). Each spec owns its repo/provider/namespace (one repo per Describe, the suite
+// convention) so the initial seed push never races the operator's mirror pushes on a shared branch.
+
+var _ = Describe("Manager Values File Acceptance (Argo)", Label("manager", "values-file"), Ordered, func() {
+ var env valuesFileEnv
+
+ BeforeAll(func() { env = provisionValuesFileEnv("argo") })
+
+ It("adopts a folder whose values.yaml an Argo CD Application names, leaving it untouched", func() {
+ assertReferencedValuesFileAdopted(env, referencedValuesCase{
+ caseName: "argo",
+ fixtureRoot: "test/e2e/fixtures/values-file-folder",
+ gitPath: "platform/cert-manager",
+ // The co-located ClusterIssuer used to be taken down with the values file; both must
+ // now survive adoption (an unwatched type is never swept by the configmaps resync).
+ survivors: map[string]string{
+ "platform/cert-manager/values.yaml": "e2e-readonly-context-marker",
+ "platform/cert-manager/clusterissuer.yaml": "letsencrypt-production",
+ "platform/cert-manager/application.yaml": "kind: Application",
+ },
+ })
+ })
+})
+
+var _ = Describe("Manager Values File Acceptance (Flux)", Label("manager", "values-file"), Ordered, func() {
+ var env valuesFileEnv
+
+ BeforeAll(func() { env = provisionValuesFileEnv("flux") })
+
+ It("adopts a folder whose values.yaml a Flux HelmRelease names, leaving it untouched", func() {
+ assertReferencedValuesFileAdopted(env, referencedValuesCase{
+ caseName: "flux",
+ fixtureRoot: "test/e2e/fixtures/values-file-folder-flux",
+ gitPath: "apps/ingress-nginx",
+ survivors: map[string]string{
+ "apps/ingress-nginx/values.yaml": "e2e-readonly-context-marker",
+ "apps/ingress-nginx/helmrelease.yaml": "kind: HelmRelease",
+ },
+ })
+ })
+})
+
+// valuesFileEnv is one spec's isolated Git + operator wiring: its own namespace, Gitea repo, and
+// Ready GitProvider. Keeping it per-Describe means each spec pushes its seed to its own branch, so
+// the seed never contends with the operator's mirror pushes.
+type valuesFileEnv struct {
+ testNs string
+ providerName string
+ repo *RepoArtifacts
+}
+
+// provisionValuesFileEnv creates the namespace, repo, credentials, SOPS key, and Ready GitProvider
+// for one values-file spec, and registers their teardown via DeferCleanup (so it runs after the
+// Describe's specs). caseName keeps the namespace/provider/repo names distinct between the two specs.
+func provisionValuesFileEnv(caseName string) valuesFileEnv {
+ GinkgoHelper()
+
+ By("creating the values-file test namespace")
+ testNs := testNamespaceFor("manager-values-file-" + caseName)
+ _, _ = kubectlRun("create", "namespace", testNs)
+
+ By("setting up Gitea repo and credentials")
+ repo := SetupRepo(resolveE2EContext(), testNs, fmt.Sprintf("e2e-values-file-%s-%d", caseName, GinkgoRandomSeed()))
+
+ _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace")
+
+ // createGitTarget enables SOPS encryption referencing the shared sops-age-key secret, so it
+ // must exist or the GitTarget's EncryptionConfigured gate (and thus Ready) never goes True —
+ // independent of the acceptance under test.
+ applySOPSAgeKeyToNamespace(testNs)
+
+ providerName := caseName + "-values-provider"
+ By("creating the GitProvider")
+ createGitProviderWithURLInNamespace(providerName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP)
+ verifyResourceStatus("gitprovider", providerName, testNs, "True", "Ready", "")
+
+ DeferCleanup(func() {
+ _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", providerName, "--ignore-not-found=true")
+ cleanupNamespace(testNs)
+ })
+
+ return valuesFileEnv{testNs: testNs, providerName: providerName, repo: repo}
+}
+
+// referencedValuesCase parameterises the shared adoption proof over the Argo and Flux spellings.
+type referencedValuesCase struct {
+ caseName string
+ fixtureRoot string
+ gitPath string
+ // survivors maps a repo-relative file path to a substring that must persist unchanged after
+ // adoption — the read-only values file and every co-located file the folder-wide refusal used
+ // to take down with it.
+ survivors map[string]string
+}
+
+// assertReferencedValuesFileAdopted seeds a release folder, points a GitTarget at it, and proves
+// the folder is adopted (not refused as non-krm-yaml): the operator mirrors a live ConfigMap into
+// it — which a refused folder never does — while leaving the values file and its co-located
+// siblings byte-for-byte untouched. Per-spec resources are torn down via DeferCleanup.
+func assertReferencedValuesFileAdopted(env valuesFileEnv, tc referencedValuesCase) {
+ GinkgoHelper()
+
+ destName := tc.caseName + "-values-dest"
+ ruleName := tc.caseName + "-values-rule"
+ cmName := tc.caseName + "-values-demo"
+
+ By("seeding the Git repository with the release, its values.yaml, and any co-located manifest")
+ seedRenderedFolderIntoRepo(env.repo, env.testNs, tc.fixtureRoot, tc.gitPath)
+
+ By("the GitTarget accepts the path instead of refusing it as non-krm-yaml")
+ createGitTarget(destName, env.testNs, env.providerName, tc.gitPath, "main")
+ DeferCleanup(func() { cleanupGitTarget(destName, env.testNs) })
+ err := applyFromTemplate("test/e2e/templates/manager/watchrule-configmap.tmpl", struct {
+ Name string
+ Namespace string
+ DestinationName string
+ }{Name: ruleName, Namespace: env.testNs, DestinationName: destName}, env.testNs)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply ConfigMap WatchRule")
+ DeferCleanup(func() { cleanupWatchRule(ruleName, env.testNs) })
+
+ waitForGitTargetGitPathAccepted(destName, env.testNs)
+ verifyResourceCondition("gittarget", destName, env.testNs, "Validated", "True", "OK", "")
+ verifyResourceStatus("watchrule", ruleName, env.testNs, "True", "Ready", "")
+ waitForStreamsRunning(destName, env.testNs)
+
+ By("mirroring a live ConfigMap into the adopted folder — a refused folder writes nothing")
+ _, err = kubectlRunInNamespace(env.testNs, "create", "configmap", cmName, "--from-literal=color=blue")
+ Expect(err).NotTo(HaveOccurred(), "ConfigMap creation should succeed")
+ DeferCleanup(func() {
+ _, _ = kubectlRunInNamespace(env.testNs, "delete", "configmap", cmName, "--ignore-not-found=true")
+ })
+
+ mirrored := filepath.Join(
+ env.repo.CheckoutDir,
+ tc.gitPath,
+ fmt.Sprintf("%s/configmaps/%s.yaml", env.testNs, cmName),
+ )
+ Eventually(func(g Gomega) {
+ pullLatestRepoState(g, env.repo.CheckoutDir)
+ content, readErr := os.ReadFile(mirrored)
+ g.Expect(readErr).NotTo(HaveOccurred(), "the operator must adopt the folder and mirror %s", mirrored)
+ g.Expect(string(content)).To(ContainSubstring("color: blue"))
+ }, 90*time.Second, 2*time.Second).Should(Succeed())
+
+ By("leaving the read-only values file and every co-located manifest byte-for-byte untouched")
+ Consistently(func(g Gomega) {
+ pullLatestRepoState(g, env.repo.CheckoutDir)
+ for rel, want := range tc.survivors {
+ content, readErr := os.ReadFile(filepath.Join(env.repo.CheckoutDir, rel))
+ g.Expect(readErr).NotTo(HaveOccurred(), "%s must remain in the repo, never swept", rel)
+ g.Expect(string(content)).To(ContainSubstring(want),
+ "a read-only context file must never be edited or replaced by the operator: %s", rel)
+ }
+ }, 15*time.Second, 3*time.Second).Should(Succeed())
+}
+
+// waitForGitTargetGitPathAccepted is the positive counterpart to waitForGitTargetGitPathRefused:
+// the GitTarget reports the path accepted (GitPathAccepted=True) and is healthy (Ready, not
+// stalled), so the acceptance gate passed on the seeded folder rather than refusing it.
+func waitForGitTargetGitPathAccepted(name, namespace string) {
+ GinkgoHelper()
+
+ verifyResourceCondition("gittarget", name, namespace, "GitPathAccepted", "True", "", "", "150s")
+ verifyResourceCondition("gittarget", name, namespace, "Ready", "True", "", "", "150s")
+ verifyResourceCondition("gittarget", name, namespace, "Stalled", "False", "", "", "150s")
+}
diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md
index 7c66e46e..6169622f 100644
--- a/test/fixtures/gitops-layouts/support-today.md
+++ b/test/fixtures/gitops-layouts/support-today.md
@@ -28,7 +28,7 @@ Reading rules:
| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml: ci-metadata.yaml: YAML is not a Kubernetes manifest |
| 1-desired-state/flux-monorepo | 0 | All reported candidates accepted | 6 | 0 | kustomize-overlay=2, kustomize-single=4 | - | None |
| 1-desired-state/repo-per-environment | 0 | All reported candidates accepted | 9 | 0 | plain=9 | - | None |
-| 2-rendered/argocd-external-helm | 0 | Partial | 2 | 1 | plain=3 | - | non-krm-yaml: values.yaml: YAML is not a Kubernetes manifest |
+| 2-rendered/argocd-external-helm | 0 | All reported candidates accepted | 3 | 0 | plain=3 | - | None |
| 2-rendered/helm-chart | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None |
| 2-rendered/helm-environment-values | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None |
| 2-rendered/kustomize-overlay-minimal | 0 | All reported candidates accepted | 2 | 0 | kustomize-overlay=2 | - | None |
@@ -99,14 +99,14 @@ Unsupported constructs: `none`. Fleet root: `false`.
## 2-rendered/argocd-external-helm
-Reported rc `0`. Accepted `2`, refused `1`.
+Reported rc `0`. Accepted `3`, refused `0`.
Unsupported constructs: `none`. Fleet root: `false`.
| Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons |
|---|---|---|---|---|---|
| `applications` | `plain` | true | `argocd` | 3/3/0 | none |
| `extras/ingress-nginx` | `plain` | true | `ingress-nginx` | 2/2/0 | none |
-| `platform/cert-manager` | `plain` | false | `argocd` | 2/2/1 | non-krm-yaml: values.yaml: YAML is not a Kubernetes manifest |
+| `platform/cert-manager` | `plain` | true | `argocd` | 2/2/0 | none |
## 2-rendered/helm-chart