diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index ceff31e0..b7868318 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -10,7 +10,9 @@
"Bash(go:*)",
"Bash(ls:*)",
"Bash(cat:*)",
- "Bash(task test-e2e:*)"
+ "Bash(task test-e2e:*)",
+ "Bash(k3d cluster *)",
+ "Bash(kubectl config *)"
]
}
}
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 1057b3ac..dde92b5f 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -50,6 +50,7 @@ RUN apt-get update \
# TASK_VERSION -> https://github.com/go-task/task/releases
# TILT_VERSION -> https://github.com/tilt-dev/tilt/releases
# ACTIONLINT_VERSION -> https://github.com/rhysd/actionlint/releases
+# VALKEY_VERSION -> https://github.com/valkey-io/valkey/releases
ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \
KUBECTL_VERSION=v1.36.1 \
@@ -57,12 +58,13 @@ ENV PATH="/go/bin:/usr/local/go/bin:${PATH}" \
KUBEBUILDER_VERSION=4.14.1 \
GOLANGCI_LINT_VERSION=v2.12.2 \
HELM_VERSION=v4.2.0 \
- K3D_VERSION=v5.8.3 \
+ K3D_VERSION=v5.9.0 \
FLUX_VERSION=2.8.8 \
FLUX_OPERATOR_VERSION=0.50.0 \
TASK_VERSION=v3.51.1 \
TILT_VERSION=v0.37.3 \
- ACTIONLINT_VERSION=1.7.12
+ ACTIONLINT_VERSION=1.7.12 \
+ VALKEY_VERSION=9.1.0
# https://github.com/fluxcd/flux2/releases
# https://fluxoperator.dev/
@@ -133,6 +135,14 @@ RUN asset="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
&& install -m 0755 actionlint /usr/local/bin/actionlint \
&& rm -rf "${tmpdir}"
+# Install valkey-cli
+# Valkey only ships prebuilt binaries for Ubuntu jammy/noble; the jammy build
+# (glibc 2.35) is compatible with this bookworm image (glibc 2.36). Extract only
+# the CLI binary from the release tarball.
+RUN curl -fsSL "https://download.valkey.io/releases/valkey-${VALKEY_VERSION}-jammy-x86_64.tar.gz" \
+ | tar -xzO "valkey-${VALKEY_VERSION}-jammy-x86_64/bin/valkey-cli" > /usr/local/bin/valkey-cli \
+ && chmod +x /usr/local/bin/valkey-cli
+
# Set working directory
WORKDIR /workspaces
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 08b75515..4b2b9d99 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -276,7 +276,7 @@ jobs:
e2e_ginkgo_procs: "2"
k3d_agent_count: "0"
- name: full
- script: "task test-e2e-full"
+ script: "task test-e2e"
needs_artifact: false
# Bumped 1 -> 4: the full suite was serialized to avoid the
# cross-target rule-change snapshot coupling, now fixed (per-target
diff --git a/README.md b/README.md
index febd3f53..328e96e1 100644
--- a/README.md
+++ b/README.md
@@ -180,6 +180,10 @@ helm upgrade gitops-reverser \
The default quickstart namespace is `default`, so the `git-creds` Secret above should exist there
unless you explicitly set `quickstart.namespace` to something else.
+The starter `GitTarget` writes under `live-cluster` by default. That keeps the first run away from
+the repository root. To deliberately target the root instead, add
+`--set quickstart.gitTarget.path=.` to the Helm command.
+
Check that the starter resources become ready:
```bash
diff --git a/Tiltfile b/Tiltfile
index 3a876666..f9812c50 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -88,9 +88,6 @@ k8s_resource(
test_targets = [
'test-e2e',
- 'test-e2e-full',
- 'test-e2e-signing',
- 'test-e2e-manager',
'test-image-refresh',
]
for name in test_targets:
diff --git a/api/v1alpha1/gitprovider_types.go b/api/v1alpha1/gitprovider_types.go
index 753d0a6c..48c1d183 100644
--- a/api/v1alpha1/gitprovider_types.go
+++ b/api/v1alpha1/gitprovider_types.go
@@ -23,8 +23,20 @@ import (
)
// GitProviderSpec defines the desired state of GitProvider.
+//
+// Only the repository URL is immutable. The URL is the destination identity that every
+// referencing GitTarget materializes into; changing it would silently point those
+// targets at a different repository and orphan their existing materialization (the same
+// reason a GitTarget's destination is immutable). To repoint, delete and recreate the
+// GitProvider. Everything else here is operational and deliberately stays mutable —
+// notably allowedBranches (widening or narrowing the writable set is a normal change
+// that must not require tearing down every GitTarget), plus auth, push tuning, and
+// commit identity/signing.
+//
+// +kubebuilder:validation:XValidation:rule="self.url == oldSelf.url",message="spec.url is immutable; delete and recreate the GitProvider to point at a different repository"
type GitProviderSpec struct {
- // URL of the repository (HTTP/SSH)
+ // URL of the repository (HTTP/SSH).
+ // Immutable: delete and recreate the GitProvider to point at a different repository.
URL string `json:"url"`
// SecretRef for authentication credentials (may be nil for public repos)
diff --git a/api/v1alpha1/gittarget_types.go b/api/v1alpha1/gittarget_types.go
index 9f11e632..98450d4f 100644
--- a/api/v1alpha1/gittarget_types.go
+++ b/api/v1alpha1/gittarget_types.go
@@ -42,19 +42,42 @@ type GitProviderReference struct {
}
// GitTargetSpec defines the desired state of GitTarget.
+//
+// The destination fields — providerRef, branch, and path — are immutable. A
+// GitTarget materializes the watched resources at exactly one (provider, branch,
+// folder); changing where it writes would orphan the old materialization and require
+// migrating manifests between repositories/branches/folders. Instead of reconciling
+// that move, the destination is fixed: to relocate a GitTarget, delete it and create a
+// new one. This keeps the one-owner-per-folder invariant and the initial-snapshot gate
+// simple — a successful snapshot can never be silently invalidated by a destination
+// change.
+//
+// +kubebuilder:validation:XValidation:rule="self.providerRef == oldSelf.providerRef",message="spec.providerRef is immutable; delete and recreate the GitTarget to change its destination"
+// +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitTarget to change its destination"
+// +kubebuilder:validation:XValidation:rule="self.path == oldSelf.path",message="spec.path is immutable; delete and recreate the GitTarget to change its destination"
type GitTargetSpec struct {
// ProviderRef references the GitProvider or Flux GitRepository.
+ // Immutable: delete and recreate the GitTarget to change its destination.
// +required
ProviderRef GitProviderReference `json:"providerRef"`
// Branch to use for this target.
// Must be one of the allowed branches in the provider.
+ // Immutable: delete and recreate the GitTarget to change its destination.
// +required
Branch string `json:"branch"`
- // Path within the repository to write resources to.
- // +optional
- Path string `json:"path,omitempty"`
+ // Path within the repository to write resources to, relative to the repository
+ // root. Required and must be non-empty — there is no default, so a GitTarget can
+ // never silently write to the repository root. To deliberately target the
+ // repository root, set it to "." (the ArgoCD/Flux convention); an empty string is
+ // rejected because it is too easy to leave blank by accident to be a deliberate
+ // root choice. Any leading slash (absolute path) and ".." are rejected, and a
+ // trailing slash is normalized away.
+ // Immutable: delete and recreate the GitTarget to change its destination.
+ // +required
+ // +kubebuilder:validation:MinLength=1
+ Path string `json:"path"`
// Encryption defines encryption settings for Secret resource writes.
// +optional
diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md
index dcc32b6d..f0c02bc3 100644
--- a/charts/gitops-reverser/README.md
+++ b/charts/gitops-reverser/README.md
@@ -137,7 +137,7 @@ resources:
memory: 128Mi
limits:
cpu: 1000m
- memory: 512Mi
+ memory: 1Gi
monitoring:
serviceMonitor:
@@ -169,7 +169,7 @@ nodeSelector:
| `quickstart.namespace` | Namespace for the starter quickstart resources | `default` |
| `quickstart.gitProvider.url` | Repository URL used by the starter `GitProvider` | `""` |
| `quickstart.gitProvider.secretRef.name` | Existing Secret name used by the starter `GitProvider` | `git-creds` |
-| `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget` | `live-cluster` |
+| `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget`; set `.` only to deliberately target the repo root | `live-cluster` |
| `quickstart.watchRule.rules` | Rules used by the starter `WatchRule` | `configmaps create/update/delete` |
| `queue.redis.addr` | Redis endpoint (`host:port`) for required durable audit queueing | `valkey:6379` |
| `queue.redis.auth.existingSecret` | Name of a pre-created Secret holding the Redis password | `valkey-auth` |
diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml
index 19f4128d..4b25cee7 100644
--- a/charts/gitops-reverser/values.yaml
+++ b/charts/gitops-reverser/values.yaml
@@ -177,8 +177,8 @@ rbac:
# Resource limits and requests
resources:
limits:
- cpu: 500m
- memory: 512Mi
+ cpu: 1000m
+ memory: 1Gi
requests:
cpu: 10m
memory: 256Mi
@@ -267,6 +267,8 @@ quickstart:
gitTarget:
name: example-target
branch: main
+ # Required relative repository path for the starter GitTarget. The chart
+ # default avoids repo-root writes; set "." only to deliberately target root.
path: live-cluster
encryption:
provider: sops
diff --git a/cmd/main.go b/cmd/main.go
index 8f2cfa2d..832478da 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -52,7 +52,6 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/controller"
"github.com/ConfigButler/gitops-reverser/internal/git"
"github.com/ConfigButler/gitops-reverser/internal/queue"
- "github.com/ConfigButler/gitops-reverser/internal/reconcile"
"github.com/ConfigButler/gitops-reverser/internal/rulestore"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
"github.com/ConfigButler/gitops-reverser/internal/types"
@@ -149,12 +148,6 @@ func main() {
)
fatalIfErr(mgr.Add(workerManager), "unable to add worker manager to manager")
- // Create ReconcilerManager (will be set up as ControlEventEmitter)
- reconcilerManager := reconcile.NewReconcilerManager(
- nil, // eventRouter will be set after EventRouter is created
- ctrl.Log.WithName("reconciler-manager"),
- )
-
// Watch ingestion manager (placeholder, will get EventRouter set later)
watchMgr := &watch.Manager{
Client: mgr.GetClient(),
@@ -162,22 +155,27 @@ func main() {
RuleStore: ruleStore,
EventRouter: nil, // Will be set below
AuditLiveEventsEnabled: true,
+ SensitiveResources: cfg.sensitiveResources,
}
- // Initialize EventRouter with all dependencies
+ // Initialize EventRouter with all dependencies. The streaming-snapshot resync
+ // (M8) is driven directly through the worker, so there is no longer a separate
+ // reconciler manager / two-snapshot handshake.
eventRouter := watch.NewEventRouter(
workerManager,
- reconcilerManager,
watchMgr,
mgr.GetClient(),
ctrl.Log.WithName("event-router"),
)
- reconcilerManager.SetEventRouter(eventRouter)
- reconcilerManager.SetOnReconcilerCreated(watchMgr.MaybeReplaySnapshot)
// Set EventRouter reference in WatchManager
watchMgr.EventRouter = eventRouter
+ // Inject the live followability registry into the writer, so a GVR-only DELETE
+ // event resolves to a manifest moved off its canonical path (M6 in the writer).
+ // The registry is a stable pointer the watch manager refreshes in place.
+ workerManager.SetMapper(watchMgr.TypeRegistry())
+
// WatchRule controller (with WatchManager reference for dynamic reconciliation)
fatalIfErr((&controller.WatchRuleReconciler{
Client: mgr.GetClient(),
diff --git a/cmd/manifest-analyzer/main.go b/cmd/manifest-analyzer/main.go
new file mode 100644
index 00000000..a5db4e54
--- /dev/null
+++ b/cmd/manifest-analyzer/main.go
@@ -0,0 +1,258 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// manifest-analyzer is a standalone, read-only CLI that analyzes a folder of
+// Kubernetes manifests. It is the proof-of-concept consumer of the
+// internal/manifestanalyzer library described in
+// docs/design/manifest/current-manifest-support-review.md. It writes nothing; it
+// only reports what it finds.
+//
+// Usage:
+//
+// manifest-analyzer [flags]
+// manifest-analyzer --mode discovery [flags]
+//
+// --mode analyze|scan|discovery what to produce (default analyze)
+// analyze: the structural report (files, GVK inventory)
+// scan: the adoption dry-run (acceptance + plan), the
+// shared scan-mode pipeline with no flush
+// discovery: raw Kubernetes API discovery dump
+// --format text|json output format (default text)
+// --policy report|refuse
+// report: always exit 0 (analysis only)
+// refuse: exit 1 when the folder would be refused
+// (analyze: any acceptance issue; scan: not accepted)
+//
+// The tool is structure-only and needs no cluster: it reports duplicate
+// identities, KRM vs. non-KRM classification, multi-document files, and the
+// inventory of every GVK found. Scan mode additionally applies the non-API KRM
+// allowlist (kustomization.yaml is retained, not flagged), runs the full adoption
+// acceptance gate, and renders the plan — which is empty here because no cluster
+// state is available to compare against.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/discovery"
+ "k8s.io/client-go/tools/clientcmd"
+
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
+)
+
+// Process exit codes.
+const (
+ exitOK = 0 // success
+ exitRefused = 1 // acceptance issues found under --policy refuse
+ exitUsage = 2 // usage or I/O error
+)
+
+type discoveryClient interface {
+ ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error)
+}
+
+type discoveryDump struct {
+ Groups []*metav1.APIGroup `json:"groups"`
+ Resources []*metav1.APIResourceList `json:"resources"`
+ FailedGroupVersions map[string]string `json:"failedGroupVersions,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type discoveryClientFactory func(kubeconfig, contextName string) (discoveryClient, error)
+
+func main() {
+ os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}
+
+// run is the testable entry point. It returns one of the exit* codes.
+func run(args []string, stdout, stderr io.Writer) int {
+ return runWithDiscoveryClientFactory(args, stdout, stderr, newKubeDiscoveryClient)
+}
+
+func runWithDiscoveryClientFactory(
+ args []string,
+ stdout, stderr io.Writer,
+ newClient discoveryClientFactory,
+) int {
+ fs := flag.NewFlagSet("manifest-analyzer", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ mode := fs.String("mode", "analyze", "what to produce: analyze|scan|discovery")
+ format := fs.String("format", "text", "output format: text|json")
+ policy := fs.String("policy", "report", "adoption policy: report|refuse")
+ kubeconfig := fs.String(
+ "kubeconfig",
+ "",
+ "kubeconfig path for --mode discovery (default: standard loading rules)",
+ )
+ contextName := fs.String("context", "", "kubeconfig context for --mode discovery")
+ fs.Usage = func() {
+ fmt.Fprintln(stderr, "usage: manifest-analyzer [flags] ")
+ fmt.Fprintln(stderr, " manifest-analyzer --mode discovery [flags]")
+ fs.PrintDefaults()
+ }
+
+ if err := fs.Parse(args); err != nil {
+ return exitUsage
+ }
+ if *mode != "analyze" && *mode != "scan" && *mode != "discovery" {
+ fmt.Fprintf(stderr, "error: unknown mode %q (want analyze|scan|discovery)\n", *mode)
+ return exitUsage
+ }
+ if *format != "text" && *format != "json" {
+ fmt.Fprintf(stderr, "error: unknown format %q (want text|json)\n", *format)
+ return exitUsage
+ }
+ if *policy != "report" && *policy != "refuse" {
+ fmt.Fprintf(stderr, "error: unknown policy %q (want report|refuse)\n", *policy)
+ return exitUsage
+ }
+ if *mode == "discovery" {
+ if fs.NArg() != 0 {
+ fmt.Fprintln(stderr, "error: discovery mode does not accept a directory argument")
+ fs.Usage()
+ return exitUsage
+ }
+ return runDiscovery(*kubeconfig, *contextName, stdout, stderr, newClient)
+ }
+ if fs.NArg() != 1 {
+ fmt.Fprintln(stderr, "error: exactly one directory argument is required")
+ fs.Usage()
+ return exitUsage
+ }
+
+ if *mode == "scan" {
+ return runScan(fs.Arg(0), *format, *policy, stdout, stderr)
+ }
+ return runAnalyze(fs.Arg(0), *format, *policy, stdout, stderr)
+}
+
+// runAnalyze renders the structural report and applies the refuse policy over its
+// acceptance issues.
+func runAnalyze(dir, format, policy string, stdout, stderr io.Writer) int {
+ rep, err := manifestanalyzer.AnalyzeDir(dir)
+ if err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+
+ if format == "json" {
+ if err := manifestanalyzer.RenderJSON(stdout, rep); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+ } else {
+ manifestanalyzer.RenderText(stdout, rep)
+ }
+
+ if policy == "refuse" && len(rep.Issues) > 0 {
+ return exitRefused
+ }
+ return exitOK
+}
+
+func runDiscovery(
+ kubeconfig, contextName string,
+ stdout, stderr io.Writer,
+ newClient discoveryClientFactory,
+) int {
+ client, err := newClient(kubeconfig, contextName)
+ if err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+ groups, resources, err := client.ServerGroupsAndResources()
+ dump := discoveryDump{
+ Groups: groups,
+ Resources: resources,
+ }
+ if err != nil {
+ failed, ok := discovery.GroupDiscoveryFailedErrorGroups(err)
+ if !ok {
+ fmt.Fprintf(stderr, "error: discover API resources: %v\n", err)
+ return exitUsage
+ }
+ dump.FailedGroupVersions = failedGroupVersions(failed)
+ dump.Error = err.Error()
+ }
+ if err := json.NewEncoder(stdout).Encode(dump); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+ return exitOK
+}
+
+func newKubeDiscoveryClient(kubeconfig, contextName string) (discoveryClient, error) {
+ rules := clientcmd.NewDefaultClientConfigLoadingRules()
+ if kubeconfig != "" {
+ rules.ExplicitPath = kubeconfig
+ }
+ overrides := &clientcmd.ConfigOverrides{CurrentContext: contextName}
+ restConfig, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides).ClientConfig()
+ if err != nil {
+ return nil, fmt.Errorf("load kubeconfig: %w", err)
+ }
+ client, err := discovery.NewDiscoveryClientForConfig(restConfig)
+ if err != nil {
+ return nil, fmt.Errorf("create discovery client: %w", err)
+ }
+ return client, nil
+}
+
+func failedGroupVersions(failed map[schema.GroupVersion]error) map[string]string {
+ out := make(map[string]string, len(failed))
+ for gv, err := range failed {
+ out[gv.String()] = err.Error()
+ }
+ return out
+}
+
+// runScan runs the adoption dry-run (the shared scan-mode pipeline) and applies the
+// refuse policy over the acceptance decision. It is structure-only: no cluster
+// state, so the plan is empty, but the acceptance gate is the full one — it applies
+// the non-API KRM allowlist and the impure-managed-file / mixed-file refusals.
+func runScan(dir, format, policy string, stdout, stderr io.Writer) int {
+ scanPolicy := manifestanalyzer.ScanPolicy{
+ Acceptance: manifestanalyzer.AcceptancePolicy{Allowlist: manifestanalyzer.DefaultAllowlist()},
+ }
+ result, err := manifestanalyzer.ScanDir(context.Background(), dir, nil, nil, scanPolicy)
+ if err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+
+ if format == "json" {
+ if err := manifestanalyzer.RenderScanJSON(stdout, result); err != nil {
+ fmt.Fprintf(stderr, "error: %v\n", err)
+ return exitUsage
+ }
+ } else {
+ manifestanalyzer.RenderScanText(stdout, result)
+ }
+
+ if policy == "refuse" && !result.Acceptance.Accepted {
+ return exitRefused
+ }
+ return exitOK
+}
diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go
new file mode 100644
index 00000000..e4138256
--- /dev/null
+++ b/cmd/manifest-analyzer/main_test.go
@@ -0,0 +1,265 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/discovery"
+)
+
+const deployYAML = `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: default
+`
+
+// fixtureDir creates a temp dir containing a watched-clean manifest plus a
+// non-KRM YAML file (which is an acceptance issue).
+func fixtureDir(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ write(t, dir, "deploy.yaml", deployYAML)
+ write(t, dir, "values.yaml", "just: data\n")
+ return dir
+}
+
+func TestRun_TextReport(t *testing.T) {
+ var out, errBuf bytes.Buffer
+ code := run([]string{fixtureDir(t)}, &out, &errBuf)
+ if code != 0 {
+ t.Fatalf("exit = %d, stderr=%s", code, errBuf.String())
+ }
+ if !strings.Contains(out.String(), "Manifest analysis:") {
+ t.Errorf("missing header: %s", out.String())
+ }
+}
+
+func TestRun_JSONReport(t *testing.T) {
+ var out, errBuf bytes.Buffer
+ code := run([]string{"--format", "json", fixtureDir(t)}, &out, &errBuf)
+ if code != 0 {
+ t.Fatalf("exit = %d, stderr=%s", code, errBuf.String())
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ t.Fatalf("output is not valid JSON: %v\n%s", err, out.String())
+ }
+ if _, ok := parsed["summary"]; !ok {
+ t.Errorf("json output missing summary key")
+ }
+}
+
+func TestRun_RefusePolicy(t *testing.T) {
+ dir := fixtureDir(t) // contains a non-KRM YAML, so there is an issue
+
+ var out, errBuf bytes.Buffer
+ if code := run([]string{"--policy", "refuse", dir}, &out, &errBuf); code != 1 {
+ t.Errorf("refuse with issues: exit = %d, want 1", code)
+ }
+
+ // A clean tree under refuse should pass.
+ clean := t.TempDir()
+ write(t, clean, "deploy.yaml", deployYAML)
+ out.Reset()
+ errBuf.Reset()
+ if code := run([]string{"--policy", "refuse", clean}, &out, &errBuf); code != 0 {
+ t.Errorf("refuse on clean tree: exit = %d, want 0\nstderr=%s", code, errBuf.String())
+ }
+}
+
+func TestRun_GVKInventory(t *testing.T) {
+ dir := t.TempDir()
+ write(t, dir, "deploy.yaml", deployYAML)
+ write(t, dir, "cm.yaml", "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n namespace: default\n")
+
+ var out, errBuf bytes.Buffer
+ if code := run([]string{dir}, &out, &errBuf); code != 0 {
+ t.Fatalf("exit = %d, want 0\nstderr=%s", code, errBuf.String())
+ }
+ // Every GVK found is reported in the inventory, with no cluster involved.
+ for _, want := range []string{"apps/v1/Deployment", "v1/ConfigMap"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("expected %q in GVK inventory: %s", want, out.String())
+ }
+ }
+}
+
+func TestRun_ScanTextRefuses(t *testing.T) {
+ // fixtureDir contains a non-KRM values.yaml, so scan mode refuses under --policy
+ // refuse and prints the acceptance verdict and the (empty, structure-only) plan.
+ var out, errBuf bytes.Buffer
+ if code := run([]string{"--mode", "scan", "--policy", "refuse", fixtureDir(t)}, &out, &errBuf); code != 1 {
+ t.Fatalf("scan refuse with issues: exit = %d, want 1 (stderr=%s)", code, errBuf.String())
+ }
+ for _, want := range []string{"Acceptance: REFUSED", "Plan: no changes"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("scan output missing %q:\n%s", want, out.String())
+ }
+ }
+}
+
+func TestRun_ScanCleanPasses(t *testing.T) {
+ clean := t.TempDir()
+ write(t, clean, "deploy.yaml", deployYAML)
+
+ var out, errBuf bytes.Buffer
+ if code := run([]string{"--mode", "scan", "--policy", "refuse", clean}, &out, &errBuf); code != 0 {
+ t.Fatalf("scan refuse on clean tree: exit = %d, want 0 (stderr=%s)", code, errBuf.String())
+ }
+ if !strings.Contains(out.String(), "Acceptance: accepted") {
+ t.Errorf("expected accepted verdict: %s", out.String())
+ }
+}
+
+func TestRun_ScanJSON(t *testing.T) {
+ var out, errBuf bytes.Buffer
+ code := run([]string{"--mode", "scan", "--format", "json", fixtureDir(t)}, &out, &errBuf)
+ if code != 0 {
+ t.Fatalf("exit = %d, stderr=%s", code, errBuf.String())
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ t.Fatalf("scan JSON invalid: %v\n%s", err, out.String())
+ }
+ if _, ok := parsed["accepted"]; !ok {
+ t.Errorf("scan JSON missing accepted key: %s", out.String())
+ }
+}
+
+func TestRun_DiscoveryJSON(t *testing.T) {
+ client := fakeDiscovery{
+ groups: []*metav1.APIGroup{
+ {
+ Name: "apps",
+ Versions: []metav1.GroupVersionForDiscovery{
+ {GroupVersion: "apps/v1", Version: "v1"},
+ },
+ PreferredVersion: metav1.GroupVersionForDiscovery{GroupVersion: "apps/v1", Version: "v1"},
+ },
+ },
+ resources: []*metav1.APIResourceList{
+ {
+ GroupVersion: "apps/v1",
+ APIResources: []metav1.APIResource{
+ {Name: "deployments", SingularName: "deployment", Namespaced: true, Kind: "Deployment"},
+ },
+ },
+ },
+ }
+
+ var out, errBuf bytes.Buffer
+ if code := runWithDiscoveryClient([]string{"--mode", "discovery"}, &out, &errBuf, client); code != 0 {
+ t.Fatalf("exit = %d, want 0\nstderr=%s", code, errBuf.String())
+ }
+
+ var parsed discoveryDump
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ t.Fatalf("discovery JSON invalid: %v\n%s", err, out.String())
+ }
+ if got := parsed.Resources[0].APIResources[0].Name; got != "deployments" {
+ t.Fatalf("resource name = %q, want deployments", got)
+ }
+}
+
+func TestRun_DiscoveryPartialFailureStillDumps(t *testing.T) {
+ failedGV := schema.GroupVersion{Group: "wardle.example.com", Version: "v1alpha1"}
+ client := fakeDiscovery{
+ resources: []*metav1.APIResourceList{{GroupVersion: "v1"}},
+ err: &discovery.ErrGroupDiscoveryFailed{
+ Groups: map[schema.GroupVersion]error{failedGV: errors.New("aggregated API unavailable")},
+ },
+ }
+
+ var out, errBuf bytes.Buffer
+ if code := runWithDiscoveryClient([]string{"--mode", "discovery"}, &out, &errBuf, client); code != 0 {
+ t.Fatalf("exit = %d, want 0\nstderr=%s", code, errBuf.String())
+ }
+
+ var parsed discoveryDump
+ if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
+ t.Fatalf("discovery JSON invalid: %v\n%s", err, out.String())
+ }
+ got := parsed.FailedGroupVersions[failedGV.String()]
+ if got != "aggregated API unavailable" {
+ t.Fatalf("failed group/version = %q, want aggregated API unavailable", got)
+ }
+ if parsed.Error == "" {
+ t.Fatal("expected partial discovery error to be included")
+ }
+}
+
+func TestRun_Errors(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ want int
+ }{
+ {"no args", nil, 2},
+ {"too many args", []string{"a", "b"}, 2},
+ {"bad flag", []string{"--nope", "x"}, 2},
+ {"bad mode", []string{"--mode", "delete", "x"}, 2},
+ {"bad format", []string{"--format", "xml", "x"}, 2},
+ {"bad policy", []string{"--policy", "delete", "x"}, 2},
+ {"discovery rejects dir", []string{"--mode", "discovery", "x"}, 2},
+ {"missing dir", []string{filepath.Join("definitely", "missing", "dir")}, 2},
+ {"scan missing dir", []string{"--mode", "scan", filepath.Join("definitely", "missing")}, 2},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ var out, errBuf bytes.Buffer
+ if code := run(c.args, &out, &errBuf); code != c.want {
+ t.Errorf("exit = %d, want %d (stderr=%s)", code, c.want, errBuf.String())
+ }
+ })
+ }
+}
+
+type fakeDiscovery struct {
+ groups []*metav1.APIGroup
+ resources []*metav1.APIResourceList
+ err error
+}
+
+func (f fakeDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) {
+ return f.groups, f.resources, f.err
+}
+
+func runWithDiscoveryClient(args []string, stdout, stderr io.Writer, client discoveryClient) int {
+ return runWithDiscoveryClientFactory(args, stdout, stderr, func(_, _ string) (discoveryClient, error) {
+ return client, nil
+ })
+}
+
+func write(t *testing.T, dir, name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+}
diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml
index ea08a851..660109aa 100644
--- a/config/crd/bases/configbutler.ai_gitproviders.yaml
+++ b/config/crd/bases/configbutler.ai_gitproviders.yaml
@@ -173,12 +173,18 @@ spec:
- name
type: object
url:
- description: URL of the repository (HTTP/SSH)
+ description: |-
+ URL of the repository (HTTP/SSH).
+ Immutable: delete and recreate the GitProvider to point at a different repository.
type: string
required:
- allowedBranches
- url
type: object
+ x-kubernetes-validations:
+ - message: spec.url is immutable; delete and recreate the GitProvider
+ to point at a different repository
+ rule: self.url == oldSelf.url
status:
description: status defines the observed state of GitProvider
properties:
diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml
index dd5d8c8e..f4e7d0c4 100644
--- a/config/crd/bases/configbutler.ai_gittargets.yaml
+++ b/config/crd/bases/configbutler.ai_gittargets.yaml
@@ -62,6 +62,7 @@ spec:
description: |-
Branch to use for this target.
Must be one of the allowed branches in the provider.
+ Immutable: delete and recreate the GitTarget to change its destination.
type: string
encryption:
description: Encryption defines encryption settings for Secret resource
@@ -129,10 +130,21 @@ spec:
- provider
type: object
path:
- description: Path within the repository to write resources to.
+ description: |-
+ Path within the repository to write resources to, relative to the repository
+ root. Required and must be non-empty — there is no default, so a GitTarget can
+ never silently write to the repository root. To deliberately target the
+ repository root, set it to "." (the ArgoCD/Flux convention); an empty string is
+ rejected because it is too easy to leave blank by accident to be a deliberate
+ root choice. Any leading slash (absolute path) and ".." are rejected, and a
+ trailing slash is normalized away.
+ Immutable: delete and recreate the GitTarget to change its destination.
+ minLength: 1
type: string
providerRef:
- description: ProviderRef references the GitProvider or Flux GitRepository.
+ description: |-
+ ProviderRef references the GitProvider or Flux GitRepository.
+ Immutable: delete and recreate the GitTarget to change its destination.
properties:
group:
default: configbutler.ai
@@ -152,8 +164,19 @@ spec:
type: object
required:
- branch
+ - path
- providerRef
type: object
+ x-kubernetes-validations:
+ - message: spec.providerRef is immutable; delete and recreate the GitTarget
+ to change its destination
+ rule: self.providerRef == oldSelf.providerRef
+ - message: spec.branch is immutable; delete and recreate the GitTarget
+ to change its destination
+ rule: self.branch == oldSelf.branch
+ - message: spec.path is immutable; delete and recreate the GitTarget to
+ change its destination
+ rule: self.path == oldSelf.path
status:
description: status defines the observed state of GitTarget
properties:
diff --git a/config/deployment.yaml b/config/deployment.yaml
index 3c7346aa..eb992aa7 100644
--- a/config/deployment.yaml
+++ b/config/deployment.yaml
@@ -70,8 +70,8 @@ spec:
periodSeconds: 10
resources:
limits:
- cpu: 500m
- memory: 512Mi
+ cpu: 1000m
+ memory: 1Gi
requests:
cpu: 10m
memory: 256Mi
diff --git a/config/samples/README.md b/config/samples/README.md
index 2743cbf1..eeb10823 100644
--- a/config/samples/README.md
+++ b/config/samples/README.md
@@ -3,7 +3,7 @@
These samples are quick starting points for common GitOps Reverser setups.
- `quickstart-gitprovider.yaml`: Minimal `GitProvider` with credentials.
-- `quickstart-gittarget.yaml`: Minimal `GitTarget` using `spec.path` and SOPS encryption auto-generation.
+- `quickstart-gittarget.yaml`: Minimal `GitTarget` using a non-root `spec.path` and SOPS encryption auto-generation.
- `quickstart-watchrule.yaml`: Minimal `WatchRule` for ConfigMaps.
- `clusterwatchrule.yaml`: Minimal `ClusterWatchRule` for cluster-scoped resources.
- `commitrequest.yaml`: Minimal `CommitRequest` — an on-demand "save" signal that finalizes a `GitTarget`'s open commit window.
diff --git a/config/samples/quickstart-gittarget.yaml b/config/samples/quickstart-gittarget.yaml
index 43429795..3ad980c5 100644
--- a/config/samples/quickstart-gittarget.yaml
+++ b/config/samples/quickstart-gittarget.yaml
@@ -7,6 +7,7 @@ spec:
providerRef:
name: example-provider
branch: main
+ # Required relative repository path. Use "." only to deliberately write at repo root.
path: live-cluster
encryption:
provider: sops
diff --git a/docs/TODO.md b/docs/TODO.md
index 52374d43..39cdbfdf 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -56,6 +56,12 @@ This file is meant to track the smaller current backlog, not historical notes.
Comments, ordering, and other low-noise formatting details are still easy to lose when rewriting
manifests.
+- [ ] Handle resources whose GVK cannot be resolved against the live cluster.
+ A manifest may reference a `apiVersion`/`kind` whose CRD is not installed, so the RESTMapper
+ cannot map it to a GVR. This is already a problem today and also blocks the manifest-inventory
+ work in [docs/design/manifest/manifest-inventory-file-agnostic-placement.md](design/manifest/manifest-inventory-file-agnostic-placement.md):
+ indexing must record the manifest identity and defer rather than fail the whole scan.
+
- [ ] Resolve the unused `GitTarget.status.lastCommit` field.
It is documented as "the SHA of the last commit processed" but is never populated — the only
writer blanks it in [gittarget_controller.go](../internal/controller/gittarget_controller.go),
@@ -85,3 +91,8 @@ This file is meant to track the smaller current backlog, not historical notes.
Prototype audit-carried options such as `user.extra` enrichment and transient metadata stripped by
admission before committing to an aggregated API or CRD. Notes in
[docs/future/idea-end-user-commit-messages.md](future/idea-end-user-commit-messages.md).
+
+Research work:
+
+* Replace metrics mechanism with https://docs.victoriametrics.com/helm/victoria-metrics-operator/ (so that it's also helm and so that we can have proper deps)
+* Read more on how resource versions work (and can work in the HA rebruild): https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions
diff --git a/docs/architecture.md b/docs/architecture.md
index 5a6c76f3..91c56de4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,891 +1,707 @@
# GitOps Reverser Architecture
-> Last updated: May 2026
+> Last updated: June 2026
-GitOps Reverser is a Kubernetes operator that observes cluster mutations and writes their resulting
-object state to Git. It reverses the traditional GitOps flow: instead of Git driving the cluster,
-the cluster drives Git.
+GitOps Reverser is a Kubernetes operator that observes cluster mutations and writes the resulting
+desired object state to Git. It reverses the traditional GitOps direction: instead of Git driving
+the cluster, the Kubernetes API drives Git.
-This document is intended as the starting point for any new contributor.
+This document is the starting point for new contributors. It describes the current code, not only
+the original design intent.
---
## Core Philosophy
-**The Kubernetes API is the source of truth.** If a conflict occurs during a Git push, the operator
-checks out the latest remote commit and replays the stale events from scratch. A full reconcile may
-also be forced so that newly appeared resources are captured. The API always wins; Git is the
-derived artifact.
+**The Kubernetes API is the source of truth.** Git is a materialized mirror. When a push conflicts
+with a newer remote commit, the operator fetches the new remote state, resets the local clone, and
+replays retained writes. Snapshot and resync paths always derive their desired state from the live
+API, never from Git.
-This design is chosen for speed. The operator keeps the Git folder and the live API state equal as
-fast as possible, without needing distributed locking or multi-step conflict resolution.
+**Writes are serialized per Git branch.** One [BranchWorker](../internal/git/branch_worker.go) owns
+each `(GitProvider namespace, GitProvider name, branch)` tuple. Multiple `GitTarget`s may share one
+branch, but all writes funnel through that worker.
-**Single-threaded writes per branch.** A Git branch can only have one writer at a time. The
-[BranchWorker](internal/git/branch_worker.go) abstraction enforces this: one goroutine per
-`(GitProvider, Branch)` pair processes all write events sequentially. Multiple `GitTarget`s may
-share a branch (writing to different paths), but they all funnel through the same worker.
+**Audit is the authoritative live event source.** Audit events carry user identity, request intent,
+and response bodies. Dynamic watch/informer infrastructure is still essential, but it is used for
+API discovery, type followability, initial snapshots, rule-change resync, and cache/dedup support.
-**Redis queues for HA preparation.** All event ingestion flows through Redis streams. Today this
-runs single-instance, but the architecture is designed so that multiple pods can ingest events
-(audit webhook receivers) while a single leader-elected pod processes them. This is the foundation
-for future high-availability.
+**Manifest content is treated as structured YAML, not just generated files.** Existing manifests are
+scanned by identity. Updates patch an existing document in place when it is safe, moved manifests
+stay where they are, and resync uses mark-and-sweep only after a complete cluster snapshot.
-**One event source today, extensible tomorrow.** The audit webhook is the authoritative live event
-source. Watch/informer infrastructure exists and is fully functional, but is currently used only for
-snapshot/reconcile and GVR discovery. The architecture preserves watch as a future valid source of
-state information (it just lacks author attribution).
+**Redis/Valkey is the ingestion buffer.** Audit webhook receivers enqueue canonical audit events
+into Redis streams. The consumer group model is HA-ready, but deployments run one replica by
+default while full HA support remains unfinished.
---
-## Custom Resource Definitions
+## User-Facing API
-Four CRDs define the user-facing API.
+Five CRDs define the public API.
```mermaid
graph LR
- GP[GitProvider] -->|referenced by| GT[GitTarget]
- GT -->|referenced by| WR[WatchRule]
- GT -->|referenced by| CWR[ClusterWatchRule]
+ GP[GitProvider] -->|providerRef| GT[GitTarget]
+ GT -->|targetRef| WR[WatchRule]
+ GT -->|targetRef| CWR[ClusterWatchRule]
+ GT -->|gitTargetRef| CR[CommitRequest]
style GP fill:#e8f4fd,stroke:#2196f3
style GT fill:#e8f4fd,stroke:#2196f3
style WR fill:#fff3e0,stroke:#ff9800
style CWR fill:#fff3e0,stroke:#ff9800
+ style CR fill:#f3e5f5,stroke:#8e24aa
```
-### GitProvider (namespaced)
+### GitProvider
-Represents a Git remote and its credentials.
+- **Scope**: namespaced
+- **Source**: [api/v1alpha1/gitprovider_types.go](../api/v1alpha1/gitprovider_types.go)
+- **Controller**: [internal/controller/gitprovider_controller.go](../internal/controller/gitprovider_controller.go)
-- **Source**: [api/v1alpha1/gitprovider_types.go](api/v1alpha1/gitprovider_types.go)
-- **Controller**: [internal/controller/gitprovider_controller.go](internal/controller/gitprovider_controller.go)
+`GitProvider` represents a Git repository and the credentials/configuration used to write it.
Key fields:
-- `spec.url` — repository URL (HTTP or SSH)
-- `spec.secretRef` — Kubernetes Secret with authentication credentials
-- `spec.allowedBranches` — glob patterns controlling which branches may be written to
-- `spec.push.commitWindow` — rolling silence window for coalescing events into one commit per (author, gitTarget); default `5s`. The push cooldown (5s) is fixed in code, and the per-pod buffer cap is operator-configured via `--branch-buffer-max-bytes` (default `8Mi`)
-- `spec.commit.committer` / `spec.commit.message` / `spec.commit.signing` — committer identity, Go template for messages, SSH signing config
-- `status.signingPublicKey` — populated when commit signing is active
-The controller verifies remote connectivity and manages SSH signing key lifecycle (including
-auto-generation and Gitea key registration via [internal/giteaclient/](internal/giteaclient/)).
+- `spec.url`: repository URL. It is immutable.
+- `spec.secretRef`: optional namespace-local Secret for HTTP/SSH authentication.
+- `spec.allowedBranches`: glob patterns that gate writable branches.
+- `spec.push.commitWindow`: rolling silence window for grouped commits, defaulting to `5s`.
+- `spec.commit.committer`: committer identity.
+- `spec.commit.message`: templates for event, snapshot, and grouped commit messages.
+- `spec.commit.signing`: SSH signing key reference and optional key generation.
+- `status.signingPublicKey`: populated when signing is configured and key material is available.
-### GitTarget (namespaced)
+The controller verifies repository reachability and manages signing key lifecycle. For Gitea, the
+helper client in [internal/giteaclient/](../internal/giteaclient/) can register generated signing
+keys.
-One `(GitProvider, Branch, Path)` triple = one Git write destination.
+### GitTarget
-- **Source**: [api/v1alpha1/gittarget_types.go](api/v1alpha1/gittarget_types.go)
-- **Controller**: [internal/controller/gittarget_controller.go](internal/controller/gittarget_controller.go)
+- **Scope**: namespaced
+- **Source**: [api/v1alpha1/gittarget_types.go](../api/v1alpha1/gittarget_types.go)
+- **Controller**: [internal/controller/gittarget_controller.go](../internal/controller/gittarget_controller.go)
+
+`GitTarget` is one materialization destination: `(provider, branch, path)`.
Key fields:
-- `spec.providerRef` — reference to a GitProvider in the same namespace
-- `spec.branch` — must match an allowed branch pattern in the provider
-- `spec.path` — subfolder inside the repo for all writes from this target
-- `spec.encryption` — optional SOPS/age encryption for Secrets
-The controller exposes a **four-gate lifecycle** via `status.conditions`:
+- `spec.providerRef`: currently implemented for namespace-local `GitProvider`.
+- `spec.branch`: immutable branch name, validated against `GitProvider.spec.allowedBranches`.
+- `spec.path`: immutable required path under the repository; `.` deliberately means repo root.
+- `spec.encryption`: optional SOPS/age encryption settings for sensitive resources.
+
+The destination fields `providerRef`, `branch`, and `path` are immutable so a target cannot silently
+orphan an old materialization. The controller also rejects path overlaps between GitTargets that
+share the same provider and branch.
+
+`GitTarget` status exposes readiness gates:
+
+- `Validated`: provider exists, branch is allowed, and the target path is valid/non-overlapping.
+- `EncryptionConfigured`: required encryption material exists or has been generated.
+- `SnapshotSynced`: initial cluster-to-Git resync completed.
+- `EventStreamLive`: the target is ready for live audit-driven writes.
+- `Ready`: aggregate readiness.
-1. **Validated** — GitProvider exists, branch is allowed, no path collision with other GitTargets
-2. **EncryptionConfigured** — age key Secret generated/validated (when encryption is requested)
-3. **SnapshotSynced** — initial cluster-to-git reconcile completed as a single atomic commit
-4. **EventStreamLive** — live event stream is processing; `Ready=True`
+Snapshot stats are stored in `status.snapshot.stats`.
-Note: the `providerRef` API schema allows referencing a Flux `GitRepository` as an alternative to
-`GitProvider` ([api/v1alpha1/gittarget_types.go:33](api/v1alpha1/gittarget_types.go#L33)). This is
-not yet supported end-to-end — the controller and rule wiring only handle `GitProvider` today.
+The `providerRef` schema still mentions Flux `GitRepository`, but the controller path only supports
+`GitProvider` today.
-### WatchRule (namespaced)
+### WatchRule
-Defines which resources to watch **within the rule's own namespace**.
+- **Scope**: namespaced
+- **Source**: [api/v1alpha1/watchrule_types.go](../api/v1alpha1/watchrule_types.go)
+- **Controller**: [internal/controller/watchrule_controller.go](../internal/controller/watchrule_controller.go)
-- **Source**: [api/v1alpha1/watchrule_types.go](api/v1alpha1/watchrule_types.go)
-- **Controller**: [internal/controller/watchrule_controller.go](internal/controller/watchrule_controller.go)
+`WatchRule` selects resources in its own namespace and routes matching events to a namespace-local
+`GitTarget`.
Key fields:
-- `spec.targetRef` — references a GitTarget in the same namespace
-- `spec.rules[]` — logical OR of resource rules, each with `operations`, `apiGroups`, `apiVersions`, `resources`
-Within a rule, omitted `apiGroups` / `apiVersions` mean *all* groups / versions: a bare resource
-name is resolved against the cluster's served API surface, not assumed to be core `/v1`. The
-controller reports resolution outcomes on a `ResourcesResolved` status condition — see
-[Watch / Informer System](#watch--informer-system).
+- `spec.targetRef`: same-namespace `GitTarget`.
+- `spec.rules[]`: OR-ed resource rules.
+- `rules[].operations`: `CREATE`, `UPDATE`, `DELETE`, or `*`; omitted means all operations.
+- `rules[].apiGroups`: omitted means resolve the named resource across served API groups.
+- `rules[].apiVersions`: omitted means preferred served version.
+- `rules[].resources`: plural resource names or `*`.
-### ClusterWatchRule (cluster-scoped)
+Subresources are intentionally rejected in rule resources. Snapshot and steady-state mirroring
+operate on top-level resources; selected subresource effects are translated separately when they can
+be safely mapped back to parent desired state.
-Watches resources across namespaces or cluster-wide.
+### ClusterWatchRule
-- **Source**: [api/v1alpha1/clusterwatchrule_types.go](api/v1alpha1/clusterwatchrule_types.go)
-- **Controller**: [internal/controller/clusterwatchrule_controller.go](internal/controller/clusterwatchrule_controller.go)
+- **Scope**: cluster
+- **Source**: [api/v1alpha1/clusterwatchrule_types.go](../api/v1alpha1/clusterwatchrule_types.go)
+- **Controller**: [internal/controller/clusterwatchrule_controller.go](../internal/controller/clusterwatchrule_controller.go)
+
+`ClusterWatchRule` selects cluster-scoped resources or namespaced resources across the cluster.
Key fields:
-- `spec.targetRef` — references a GitTarget with explicit namespace
-- `spec.rules[].scope` — `Cluster` (cluster-scoped resources) or `Namespaced` (namespaced resources across all namespaces)
-### Controller dependency watches
+- `spec.targetRef`: `GitTarget` reference with explicit namespace.
+- `spec.rules[].scope`: `Cluster` for cluster-scoped resources, `Namespaced` for namespaced
+ resources across all namespaces.
+- `spec.rules[]`: same operation/group/version/resource model as `WatchRule`.
-The reference chain above is also a *watch* chain. Each controller `Watches` the
-kind it references, so a freshly-applied or spec-changed dependency re-enqueues
-its dependents within milliseconds instead of waiting on the periodic requeue
-(~2 min):
+### CommitRequest
-- `GitTargetReconciler` watches `GitProvider`
-- `WatchRuleReconciler` / `ClusterWatchRuleReconciler` watch `GitTarget` and `GitProvider`
+- **Scope**: namespaced
+- **Source**: [api/v1alpha1/commitrequest_types.go](../api/v1alpha1/commitrequest_types.go)
+- **Controller**: [internal/controller/commitrequest_controller.go](../internal/controller/commitrequest_controller.go)
+- **Audit handling**: [internal/queue/commit_request.go](../internal/queue/commit_request.go)
-These watches use a `GenerationChangedPredicate`: they fire on create and spec
-changes but ignore the status-only updates the controllers write to their own
-dependencies. Without the predicate, every dependency heartbeat would re-list
-and re-enqueue all dependents. The trade-off: a dependency that becomes *usable*
-via a status-only transition (e.g. a `GitProvider` whose credentials start
-working with no spec edit) re-enqueues dependents only on the next periodic
-requeue, not instantly. See
-[idea-cross-kind-dependency-watches.md](future/idea-cross-kind-dependency-watches.md).
+`CommitRequest` is a one-shot "save now" signal. Creating it finalizes the open commit window for a
+same-namespace `GitTarget` instead of waiting for the silence timer.
----
+Key fields:
+
+- `spec.gitTargetRef.name`: target whose open window should be finalized.
+- `spec.message`: optional verbatim commit message, bounded by CRD validation.
+- `status.phase`: `WaitingForAuditEvent`, `Committed`, `NoOpenWindow`, or `Failed`.
+- `status.branch` / `status.sha`: set when a commit was produced.
-## Kubernetes API Concepts That Matter Here
+The controller only stamps `WaitingForAuditEvent`. The actual finalize happens when the audit
+consumer processes the CommitRequest's own create audit event. That ordering is deliberate: by the
+time the CommitRequest audit event is consumed, earlier user mutations have already entered the open
+window.
-A few Kubernetes API server concepts are central to how this operator works. This section is for
-contributors who may not be deeply familiar with these mechanisms.
+---
+
+## Kubernetes Concepts That Matter
-### Audit webhook
+### Audit Webhook
-The Kubernetes API server can be configured to send **audit events** to an external HTTP endpoint
-for every API request it processes. This is configured via two flags on the API server itself:
+The Kubernetes API server can POST audit `EventList` payloads to an external HTTP endpoint. The
+operator cannot configure that API server policy itself; it only receives what the cluster sends.
+Audit events are valuable because they include the original user, verb, object identity, response
+status, and often request/response bodies.
-- `--audit-policy-file` — defines which API requests are logged and at what detail level
-- `--audit-webhook-config-file` — points to a kubeconfig-style file that tells the API server where
- to POST audit events
+GitOps Reverser exposes:
-This is external infrastructure: the operator cannot influence the API server configuration. It can
-only receive what the API server sends. Each audit event includes the full request and/or response
-object, the verb (create/update/delete), the user who performed the action, and a `resourceVersion`
-that identifies the exact version of the object in etcd.
+```text
+POST /audit-webhook
+POST /audit-webhook-additional
+```
-### Informers (list/watch)
+`/audit-webhook` is the canonical kube-apiserver source. `/audit-webhook-additional` is for
+supplementary body providers, especially aggregated API paths where the kube-apiserver audit events are shallow. Meaning that not the full body is available.
-Kubernetes informers use the **list/watch** protocol to track resources. An informer first lists all
-existing objects of a given GVR (Group/Version/Resource), then opens a long-lived watch connection
-to receive incremental updates. Informers operate at GVR granularity — you subscribe to "all
-Deployments" or "all ConfigMaps", not to individual objects.
+### Discovery and Informers
-Watch events also carry `resourceVersion`, which means audit events and watch events for the same
-mutation reference the same etcd version. In principle this could be used to correlate and
-deduplicate across sources. Today the operator uses content-hash deduplication instead, which is
-simpler but does not leverage this ordering guarantee.
+Discovery reports the served API surface. Dynamic informers and streaming-list watches observe
+resources by GVR `(group, version, resource)`. GitOps Reverser uses this to resolve user rules,
+decide which types are followable, start needed informers, and gather complete snapshots.
### resourceVersion
-Every Kubernetes object has a `metadata.resourceVersion` that is incremented on every write to
-etcd. Both audit events and watch events carry this version. The operator strips `resourceVersion`
-during [sanitization](internal/sanitize/sanitize.go) before writing to Git, since it is
-cluster-internal state that would cause spurious diffs.
+Kubernetes objects carry `metadata.resourceVersion`, but it is cluster-internal runtime state.
+[internal/sanitize/](../internal/sanitize/) strips it before writing to Git.
---
-## High-Level Event Flow
+## High-Level Flow
```mermaid
flowchart TD
- subgraph "Kubernetes API Server (external, pre-configured)"
- ETCD[(etcd)] --> KAS[kube-apiserver]
- KAS --- AUDIT_CFG["--audit-webhook-config-file\n--audit-policy-file"]
+ subgraph "Kubernetes API Server"
+ KAS[kube-apiserver]
+ DISC[Discovery + list/watch]
end
- subgraph "Watch / Informers (snapshot + discovery)"
- WM[Watch Manager]
- CAT[APIResourceCatalog]
- WM -->|resolve rules| CAT
- WM -->|GVR planning| RULE
- WM -->|snapshot events| ER
+ subgraph "Audit Ingress"
+ OFF["/audit-webhook"]
+ ADD["/audit-webhook-additional"]
+ JOIN[Audit joiner + canonical gate]
+ OFF --> JOIN
+ ADD --> JOIN
end
- KAS -->|audit webhook POST| AH
- KAS <-->|list/watch + discovery| WM
-
- subgraph Ingestion
- AH[AuditHandler] -->|XADD| RS[(Redis Stream)]
+ subgraph Redis
+ STREAM[(gitopsreverser.audit.events.v1)]
+ DEBUG[(optional debug stream)]
end
- subgraph Processing ["Processing (leader-elected)"]
- RS -->|XREADGROUP| AC[AuditConsumer]
- AC -->|filter + match| RULE[RuleStore]
- AC -->|sanitize + route| ER[EventRouter]
+ subgraph "Processing"
+ CONS[AuditConsumer]
+ RULES[RuleStore]
+ ROUTER[EventRouter]
+ WATCH[Watch Manager]
+ TYPES[TypeRegistry + WatchedTypeTables]
end
- subgraph Routing
- ER --> GTES[GitTargetEventStream]
- GTES -->|dedup + enqueue| BW[BranchWorker]
+ subgraph "Git Writes"
+ GTES[GitTargetEventStream]
+ BW[BranchWorker]
+ PLAN[Manifest-aware plan/flush]
+ PUSH[Atomic push]
end
- subgraph "Git Write (single-threaded per branch)"
- BW -->|coalesce by commitWindow| COMMIT[Generate Commits]
- COMMIT --> PUSH[PushAtomic]
- PUSH -->|conflict?| RETRY[Fetch Fresh + Replay]
- RETRY --> COMMIT
- end
+ KAS -->|official audit EventList| OFF
+ JOIN --> STREAM
+ JOIN -. raw decoded tap .-> DEBUG
+ STREAM -->|XREADGROUP| CONS
+ CONS --> RULES
+ CONS --> ROUTER
+ ROUTER --> GTES
+ GTES --> BW
+ BW --> PLAN
+ PLAN --> PUSH
+
+ DISC --> WATCH
+ WATCH --> TYPES
+ TYPES --> RULES
+ WATCH -->|snapshot/resync| ROUTER
```
-### Step-by-step: audit event to Git commit
-
-Audit events are inherently cluster-wide: the API server sends every matching mutation regardless
-of namespace. All namespace-level filtering must happen inside the operator.
-
-1. **Kubernetes API Server** sends audit events via webhook POST to `/audit-webhook`
-2. Supplementary audit sources can send matching `EventList` payloads to `/audit-webhook-additional`
-3. [AuditHandler](internal/webhook/audit_handler.go) deserializes each `EventList`, optionally appends every decoded event to a separate early debug Redis stream, lets the audit joiner park or merge body contributions by `auditID`, and calls `RedisAuditQueue.Enqueue()` for canonical events
-4. [RedisAuditQueue](internal/queue/redis_audit_queue.go) writes to Redis stream `gitopsreverser.audit.events.v1` via `XADD`
-5. [AuditConsumer](internal/queue/redis_audit_consumer.go) reads batches of 50 via `XREADGROUP` (consumer group for HA-readiness)
-6. For each message: filter to `ResponseComplete` stage + mutating verbs only, then call [RuleStore](internal/rulestore/store.go) to find matching rules
-7. Extract the response object (or request object for DELETE), run through [sanitize.Sanitize()](internal/sanitize/) to strip runtime fields
-8. For each matched rule: call [EventRouter.RouteToGitTargetEventStream()](internal/watch/event_router.go)
-9. [GitTargetEventStream](internal/reconcile/git_target_event_stream.go) deduplicates by content hash and enqueues to the BranchWorker
-10. [BranchWorker](internal/git/branch_worker.go) buffers events and flushes when the commit window expires, on shutdown, or when the buffer hits the operator's byte cap
-11. [BranchWorker](internal/git/branch_worker.go) converts retained writes into commit plans, executes local commits, and publishes them with [PushAtomic()](internal/git/git_atomic_push.go)
-12. If the remote has diverged: fetch fresh remote state, hard-reset, rebuild from retained pending writes, and retry
+### Audit Event to Commit
+
+1. The API server posts an audit `EventList` to `/audit-webhook`.
+2. Optional supplementary body providers post matching `EventList` payloads to
+ `/audit-webhook-additional`.
+3. [AuditHandler](../internal/webhook/audit_handler.go) decodes, classifies, joins, deduplicates,
+ and enqueues one canonical event per `auditID`.
+4. [RedisAuditQueue](../internal/queue/redis_audit_queue.go) writes to
+ `gitopsreverser.audit.events.v1`.
+5. [AuditConsumer](../internal/queue/redis_audit_consumer.go) reads batches via Redis consumer
+ groups.
+6. The consumer keeps `ResponseComplete` mutating events and drops unsupported shallow or
+ subresource-only shapes.
+7. [RuleStore](../internal/rulestore/store.go) finds matching `WatchRule` and `ClusterWatchRule`
+ entries.
+8. The event is sanitized, sensitive/subresource handling is applied, and
+ [EventRouter](../internal/watch/event_router.go) routes it to the target stream.
+9. [GitTargetEventStream](../internal/reconcile/git_target_event_stream.go) buffers during target
+ reconciliation and deduplicates live content.
+10. [BranchWorker](../internal/git/branch_worker.go) groups events by author and GitTarget inside
+ the commit window.
+11. The manifest-aware writer scans the target subtree, applies structured edits, commits, and
+ eventually pushes through [PushAtomic](../internal/git/git_atomic_push.go).
+12. If the remote moved, the worker fetches, resets, rebuilds retained pending writes, and retries.
---
-## Key Abstractions
+## Rule and Type Resolution
-### BranchWorker
+### RuleStore
-- **Source**: [internal/git/branch_worker.go](internal/git/branch_worker.go)
-- **Managed by**: [WorkerManager](internal/git/worker_manager.go)
+- **Source**: [internal/rulestore/store.go](../internal/rulestore/store.go)
-One BranchWorker per `(GitProvider namespace, GitProvider name, Branch)`. Ensures all Git writes
-to a branch are serialized into a single goroutine.
+The RuleStore is an in-memory cache populated by the WatchRule and ClusterWatchRule controllers.
+Compiled rules include the full chain from rule to `GitTarget`, `GitProvider`, branch, and path.
-```
-BranchKey = {ProviderNamespace, ProviderName, Branch}
-```
+It is read by:
-The worker runs a single `processEvents()` goroutine that reads from a buffered channel
-(capacity 100). Events are flushed as commits when either:
-- the rolling commit-window timer expires after `spec.push.commitWindow` of silence (default `5s`)
-- the per-worker buffer hits `--branch-buffer-max-bytes` (default `8Mi`, operator-tuned)
-- the worker shuts down
+- the audit consumer for live event routing;
+- the watch manager for target watch planning;
+- rule-change reconciliation for deciding which targets need resync.
-A fixed 5s push cooldown bounds how often successful pushes hit the remote.
+### APIResourceCatalog
-Atomic requests (e.g., initial snapshot reconcile) bypass the buffer and commit everything in one
-commit.
+- **Source**: [internal/watch/api_resource_catalog.go](../internal/watch/api_resource_catalog.go)
+- **Observation projection**: [internal/watch/catalog_observe.go](../internal/watch/catalog_observe.go)
-Local clones live under `/tmp/gitops-reverser-workers/{namespace}/{provider}/{branch}/repos/{hash}`.
-Branch metadata (exists, HEAD SHA, last fetch time) is cached for 30 seconds to avoid redundant
-fetches when multiple GitTargets share the same branch.
+`APIResourceCatalog` is the discovery-backed view of served resources. Trust is tracked per
+group/version. If one aggregated API group/version is degraded, the catalog keeps the last trusted
+entries for that group/version instead of treating it as an empty API surface and causing accidental
+Git deletions.
-### GitTargetEventStream
+The catalog refreshes on startup, periodically, and when CRD/APIService trigger informers observe
+API-surface changes.
-- **Source**: [internal/reconcile/git_target_event_stream.go](internal/reconcile/git_target_event_stream.go)
+### TypeRegistry and Followability
-A two-state machine per GitTarget:
+- **Source**: [internal/typeset/](../internal/typeset/)
+- **Design**: [docs/design/manifest/version2/type-followability.md](design/manifest/version2/type-followability.md)
-| State | Behavior |
-|---|---|
-| `RECONCILING` | Buffer live events while an initial snapshot or rule-change reconcile is in flight |
-| `LIVE_PROCESSING` | Deduplicate by content hash (SHA-256 of sanitized YAML), then enqueue to BranchWorker |
+`internal/typeset` is the single decision surface for "can this type be followed?" The watch manager
+projects catalog entries into `typeset.Observation`s, then publishes one `TypeRecord` per known
+type in a `TypeRegistry`.
-The transition from `RECONCILING` to `LIVE_PROCESSING` flushes all buffered events.
+Each record carries:
-### EventRouter
+- GVK and GVR identity;
+- scope and preferred version facts;
+- origin classification;
+- subresource facts, including usable `/scale` bindings;
+- sensitivity policy;
+- one `Followability` verdict and reason-code summary.
-- **Source**: [internal/watch/event_router.go](internal/watch/event_router.go)
+Followability replaces older scattered checks. Snapshot planning, informer planning, manifest
+analysis, and GVR-only delete/scale resolution all read the same registry.
-Central dispatch hub. Holds references to `WorkerManager`, `ReconcilerManager`, and `WatchManager`.
-Maintains a registry of `GitTargetEventStream`s keyed by `ResourceReference.Key()`.
+### WatchedTypeTable
-Responsibilities:
-- Route live events to the correct GitTargetEventStream
-- Dispatch control events (`RequestClusterState`, `RequestRepoState`, `ReconcileResource`)
-- Coordinate reconciliation state transitions
+- **Source**: [internal/watch/watched_type_table.go](../internal/watch/watched_type_table.go)
-### RuleStore
+A `WatchedTypeTable` is a per-GitTarget projection of the type registry filtered by that target's
+WatchRules and ClusterWatchRules. It records the resolved GVK/GVR/scope plus namespace and
+operation coverage.
-- **Source**: [internal/rulestore/store.go](internal/rulestore/store.go)
+The table is the resident answer to "what does this GitTarget watch?" and feeds:
-Thread-safe in-memory cache of compiled rules. Populated by WatchRule and ClusterWatchRule
-controllers at reconcile time. Each compiled rule stores the full reference chain:
-`WatchRule → GitTarget → GitProvider → branch + path`, including the source namespace of the rule.
+- informer start/stop decisions;
+- streaming snapshot tasks;
+- effective rule-set hashing;
+- resync trigger decisions.
-`GetMatchingRules()` matches by `{resource, operation, apiGroup, apiVersion, scope}`. Supports
-wildcards (`*`) and core API group matching (`""`).
+---
-This is an important architectural detail: **both the audit path and the watch/informer path depend
-on the same `GetMatchingRules` contract.** The RuleStore is also read by the Watch Manager's GVR
-planner (`ComputeRequestedGVRs`), which resolves the compiled rules against the API resource
-catalog (see [Watch / Informer System](#watch--informer-system)) to decide which informers to
-start. That means the RuleStore serves two different jobs through the same interface:
+## Watch, Snapshot, and Resync
-- **GVR planning** — which resource types need informers (works at GVR granularity)
-- **Event routing** — which concrete object event should route to which target (needs full context
- including namespace)
+- **Manager**: [internal/watch/manager.go](../internal/watch/manager.go)
+- **Streaming snapshot**: [internal/watch/snapshot_stream.go](../internal/watch/snapshot_stream.go)
+- **Router resync path**: [internal/watch/event_router.go](../internal/watch/event_router.go)
+- **Worker resync apply**: [internal/git/resync_flush.go](../internal/git/resync_flush.go)
-The compiled rule carries the rule's source namespace, but the matching contract does not accept or
-check namespace. See
-[watch-audit-rule-matching-improvement.md](design/watch-audit-rule-matching-improvement.md) for the
-design to address this.
+The watch manager is a controller-runtime `Runnable`. Live audit events are authoritative, but the
+watch manager owns discovery, dynamic informers, and resync.
-### FolderReconciler
+On startup it bootstraps the RuleStore from existing rules before its first rule-change reconcile.
+Then it refreshes the API catalog, updates the TypeRegistry, builds watched-type tables, and starts
+the required informers.
-- **Source**: [internal/reconcile/folder_reconciler.go](internal/reconcile/folder_reconciler.go)
+### Rule-Change Reconcile
-Diffs cluster state against Git repository state during initial snapshot sync. Receives both
-a `ClusterStateEvent` (what exists in the cluster) and a `RepoStateEvent` (what exists in Git),
-then emits a single atomic `WriteRequest` that brings Git in line with the cluster.
+When a WatchRule, ClusterWatchRule, GitTarget, GitProvider, CRD, or APIService change requires a new
+watch plan, the manager:
----
+1. Refreshes discovery and the TypeRegistry.
+2. Rebuilds affected watched-type tables.
+3. Starts or stops dynamic informers for added/removed watched GVRs.
+4. Computes per-target rule-set hashes.
+5. Triggers a resync for targets whose effective watched set changed.
-## Redis Queue Architecture
+Rule-change resync is fire-and-forget after the snapshot is gathered and enqueued. Initial
+GitTarget snapshot sync waits for the worker result so the GitTarget status can report stats.
-```mermaid
-flowchart LR
- subgraph "Pod A (webhook receiver)"
- AH1[AuditHandler] -->|XADD| STREAM
- end
+### Streaming Snapshot
- subgraph "Pod B (webhook receiver)"
- AH2[AuditHandler] -->|XADD| STREAM
- end
+`StreamClusterSnapshotForGitDest` gathers a complete desired set for one GitTarget using
+Kubernetes streaming-list watch:
- STREAM[(Redis Stream\ngitopsreverser.audit.events.v1)]
+- `sendInitialEvents=true`
+- `resourceVersionMatch=NotOlderThan`
+- `allowWatchBookmarks=true`
- subgraph "Leader Pod (consumer)"
- AC[AuditConsumer] -->|XREADGROUP| STREAM
- AC -->|XAUTOCLAIM idle > 60s| STREAM
- AC -->|XACK| STREAM
- end
-```
+Each watched type/namespace stream emits synthetic initial `ADDED` events and then an
+initial-events-end bookmark. The snapshot is accepted only after every stream reaches its bookmark.
+If any stream errors or closes early, the whole gather aborts and no sweep is enqueued.
-- **Producer**: [RedisAuditQueue](internal/queue/redis_audit_queue.go) — `XADD` with `MAXLEN ~` for bounded retention
-- **Consumer**: [AuditConsumer](internal/queue/redis_audit_consumer.go) — uses Redis consumer groups (`XREADGROUP`) so multiple replicas don't duplicate work
-- **Consumer ID**: set to the Pod name
-- **Batch size**: 50 messages per read, 2s block timeout
-- **Reclaim**: `XAUTOCLAIM` every 30s for messages idle > 60s (crashed consumer recovery)
-- **Poison pill**: messages are ACK'd regardless of processing outcome to prevent queue blockage
+For API servers that cannot stream initial events, the code falls back to a consistent per-type
+LIST. This fallback is narrow; partial watch failures still fail closed.
-### Current state and future direction
+### Mark-and-Sweep Resync
-Today there is a single Redis stream for all audit events. The consumer is leader-elected, meaning
-only one pod processes events at a time.
+The BranchWorker applies resync by scanning the GitTarget subtree and building a manifest plan:
-When `--audit-debug-redis-stream` is set, the handler also appends each successfully decoded
-audit event to that stream immediately after `EventList` decode. This tap runs before per-event
-validation, stage/verb filtering, shallow-body joins, deduplication, and canonical enqueueing.
-It uses the canonical stream fields plus `source` (`official` or `additional`) and retains the raw
-event as `payload_json`. The stream is diagnostic only; no consumer routes it to Git.
+- desired resources are upserted through the same content-derived path as live writes;
+- existing managed documents that are watched but absent from the complete snapshot are deleted;
+- untracked, non-Kubernetes, unresolved, or unsafe YAML is left alone according to analyzer policy;
+- nothing is committed if the apply cannot complete safely.
-**Desired future**: each GitTarget (or at minimum each Git destination) gets its own Redis queue.
-This way a GitHub outage only stalls commits for the affected destination — events keep
-accumulating in Redis and are committed once the remote is reachable again. Other Git destinations
-continue operating normally.
+An empty desired set is authoritative only because the gather completed for every watched type.
---
-## Watch / Informer System
+## Git Write Architecture
-- **Source**: [internal/watch/manager.go](internal/watch/manager.go), [internal/watch/informers.go](internal/watch/informers.go), [internal/watch/gvr.go](internal/watch/gvr.go), [internal/watch/api_resource_catalog.go](internal/watch/api_resource_catalog.go), [internal/watch/rule_gvr_resolver.go](internal/watch/rule_gvr_resolver.go)
+### BranchWorker
-The Watch Manager is a controller-runtime `Runnable` (leader-elected) that manages dynamic
-informers per GVR (Group/Version/Resource).
+- **Source**: [internal/git/branch_worker.go](../internal/git/branch_worker.go)
+- **Worker manager**: [internal/git/worker_manager.go](../internal/git/worker_manager.go)
-### Current role
+`BranchWorker` owns a local clone and a single event loop for its branch. Events are buffered in a
+single open commit window.
-Watch/informers are **not** used as the live event source (audit is). They serve three purposes:
+The open window accepts only one `(author, GitTarget)` pair at a time:
-1. **Snapshot and reconcile** — provide cluster state for initial GitTarget sync and rule-change re-sync
-2. **GVR discovery and planning** — turn compiled rules into concrete GVRs against the API resource catalog
-3. **Content deduplication** — track last-seen content hashes to avoid redundant writes
+- same author + same GitTarget: append to the window;
+- different author or GitTarget: finalize the current window first;
+- repeated writes to the same Git path inside a window are last-write-wins.
-### API resource catalog
+The window finalizes when:
-- **Source**: [internal/watch/api_resource_catalog.go](internal/watch/api_resource_catalog.go), [internal/watch/rule_gvr_resolver.go](internal/watch/rule_gvr_resolver.go), [internal/watch/resource_policy.go](internal/watch/resource_policy.go)
+- `spec.push.commitWindow` passes with no new matching event;
+- the retained buffer reaches `--branch-buffer-max-bytes` (default `8Mi`);
+- a `CommitRequest` finalize signal matches the open author and GitTarget;
+- the worker receives a resync request or shutdown.
-`APIResourceCatalog` is the single, trusted in-memory view of the API surface the cluster
-currently serves. It is built from `ServerGroupsAndResources()` discovery and is the only thing
-rule planning consults — snapshot planning, informer planning, and WatchRule status feedback all
-read the same catalog instead of each path running its own discovery lookup.
+Successful local commits are retained until the push cooldown allows a push. The fixed cooldown
+prevents remote push storms during bursts.
-Trust is tracked **per group/version**. A discovery refresh that fails for one aggregated API
-marks only that group/version `degraded` and keeps its last trusted entries; it never lets a
-flaky aggregated API erase a group and trigger spurious Git deletions. `Generation()` increments
-whenever trusted entries actually change. The catalog refreshes on startup, on the periodic
-reconcile, and on `CustomResourceDefinition` / `APIService` trigger informers
-(`startAPISurfaceTriggerInformers`, started with the Watch Manager runnable) whose events are
-coalesced into a single refresh signal.
+### Local Clones and Conflict Retry
-`RuleGVRResolver` applies WatchRule semantics to the catalog. A rule names resources, not
-necessarily their group or version:
+Local clones live under:
-- Omitted `apiGroups` resolves the resource name across **all** served groups (so a bare
- `resources: ["deployments"]` correctly resolves to `apps/v1`, not core `/v1`).
-- Omitted or `*` `apiVersions` picks the catalog's preferred served version.
-- A resource name served by more than one group is `Ambiguous` — the rule must name an
- explicit `apiGroups` rather than have one guessed.
-- A resource absent from a cleanly discovered catalog is `NotServed`; one whose lookup scope is
- degraded is `DiscoveryDegraded`; one excluded by built-in watch policy is `Disallowed`.
+```text
+/tmp/gitops-reverser-workers/{namespace}/{provider}/{branch}/repos/{hash}
+```
-Unresolved resources are reported on the WatchRule / ClusterWatchRule `ResourcesResolved` status
-condition rather than failing silently. Snapshot planning treats `NotServed` / `Ambiguous` /
-`Disallowed` as a skip (a resource type with no served objects cannot make the snapshot partial)
-but still aborts on `DiscoveryDegraded`, an unexpanded `*`, or a genuine list failure on a served
-GVR — a partial snapshot looks like deletions to the Git mirror.
+`PushAtomic` checks the remote ref before pushing. If the remote diverged:
-### Reconcile cycle
+1. smart-fetch the latest remote state;
+2. hard-reset the local clone to the remote tip;
+3. replay retained pending writes;
+4. retry, up to the configured attempt limit.
-When WatchRule/ClusterWatchRule controllers change rules, they call `WatchManager.ReconcileForRuleChange()`:
+This is valid because pending writes can be rebuilt from sanitized API-derived state.
-1. Refresh the API resource catalog from discovery
-2. Resolve compiled rules to concrete GVRs via `RuleGVRResolver`; unresolved resources are logged and surfaced on rule status
-3. Start/stop informers for added/removed GVRs
-4. Put affected GitTargetEventStreams into `RECONCILING` state
-5. Wait for informer cache sync
-6. Emit snapshot events → FolderReconciler diffs → atomic commit
-7. Transition streams to `LIVE_PROCESSING`, flush buffered events
+### Manifest-Aware Writer
-A newly installed CRD is picked up by the CRD / `APIService` trigger informers, which refresh the
-catalog and re-run the cycle; the periodic reconcile (30s) is the backstop.
+- **Steady state**: [internal/git/plan_flush.go](../internal/git/plan_flush.go)
+- **YAML editor**: [internal/git/manifestedit/](../internal/git/manifestedit/)
+- **Analyzer/planner**: [internal/manifestanalyzer/](../internal/manifestanalyzer/)
+- **Object projection**: [internal/manifestreport/](../internal/manifestreport/)
-### Future role
+The writer no longer blindly regenerates one canonical file per event when a manifest already
+exists. For each commit it:
-The current mode is **audit for live events + watch for reconcile/snapshot**. This is a deliberate
-choice: audit events carry the original author, which we value highly.
+1. scans YAML files under the GitTarget path;
+2. builds a byte-free manifest store keyed by resource identity;
+3. resolves each event to one action;
+4. hydrates only touched files into commit-scoped buffers;
+5. flushes only changed/deleted files.
-Watch should remain useful for:
-- Snapshot and reconcile (current)
-- Discovery and rule planning (current)
-- A future fallback source when audit is unavailable, accepting the loss of author attribution
+Upserts behave this way:
----
+- if a managed document for the resource already exists, patch it in place;
+- if it is sensitive/encrypted, re-encrypt the whole document at its existing path;
+- if no document exists, create a new file at the canonical placement path.
-## Git Operations
+Deletes use the manifest identity index, so a moved manifest can still be deleted even when it is
+not at the canonical path.
-### File path convention
+Field patches, currently used for supported subresource effects such as `/scale`, are intentionally
+narrow. They only patch existing parent manifests and never fabricate a parent object from partial
+subresource data.
-Resources are stored following the Kubernetes REST API structure:
+### Current File Placement
-```
+New resources still use the canonical REST-like path:
+
+```text
{spec.path}/{group}/{version}/{resource}/{namespace}/{name}.yaml
```
-For core API resources (empty group), the group segment is omitted:
+For core resources, the empty group segment is omitted:
-```
+```text
{spec.path}/v1/configmaps/my-namespace/my-config.yaml
```
-Secrets with encryption enabled use `.sops.yaml`:
+Sensitive resources use `.sops.yaml`:
-```
+```text
{spec.path}/v1/secrets/my-namespace/my-secret.sops.yaml
```
-Path generation: [internal/git/git.go:1013](internal/git/git.go#L1013), backed by
-[ResourceIdentifier.ToGitPath()](internal/types/identifier.go).
+Existing resources are match-first: once a document exists in Git, updates and deletes use that
+document's current location instead of recomputing placement.
-### Path bootstrap
+Future placement policy is tracked in
+[docs/design/manifest/version2/gittarget-new-file-placement-rules.md](design/manifest/version2/gittarget-new-file-placement-rules.md).
-When a GitTarget path is first written to, the BranchWorker bootstraps it with operator-managed
-template files to make the target path immediately usable. This currently includes a `README.md`
-and, when encryption is configured, a `.sops.yaml` configuration file that maps age recipients to
-the correct file patterns.
+### Bootstrap Files
-- **Templates**: [internal/git/bootstrapped-repo-template/](internal/git/bootstrapped-repo-template/)
-- **Logic**: [internal/git/bootstrapped_repo_template.go](internal/git/bootstrapped_repo_template.go)
+- **Templates**: [internal/git/bootstrapped-repo-template/](../internal/git/bootstrapped-repo-template/)
+- **Logic**: [internal/git/bootstrapped_repo_template.go](../internal/git/bootstrapped_repo_template.go)
-Bootstrap files are included in the **first commit that writes to the target path**. This is
-usually the initial snapshot sync, but if the snapshot produces no changes (empty cluster state for
-the matched rules), the bootstrap files will appear in the first live event commit instead. They
-are part of the actual repo content and should be expected when inspecting a GitTarget path.
+The first write to a GitTarget path stages operator-managed bootstrap content. That includes a
+`README.md` and, when encryption is configured, a `.sops.yaml` file with age recipient rules.
-### Conflict resolution
+### Sensitive Resources and Encryption
-The strategy is **checkout fresh + replay**:
+- **Encryption model**: [internal/git/encryption.go](../internal/git/encryption.go)
+- **SOPS implementation**: [internal/git/sops_encryptor.go](../internal/git/sops_encryptor.go)
+- **Sensitivity policy**: [internal/types/sensitive_resource.go](../internal/types/sensitive_resource.go)
-```mermaid
-flowchart TD
- A[Generate commits from events] --> B[PushAtomic]
- B -->|success| DONE[Done]
- B -->|remote diverged| C[SmartFetch latest remote]
- C --> D[Hard reset to remote tip]
- D --> A
- A -->|3 attempts exhausted| FAIL[Error]
-```
+Core Secrets are sensitive by default. Operators can mark additional resource types sensitive with
+`--additional-sensitive-resources`.
-1. [PushAtomic()](internal/git/git_atomic_push.go) checks remote refs via `AdvertisedReferences()` before pushing
-2. If the remote has moved, [SmartFetch()](internal/git/git_smart_fetch.go) fetches latest state
-3. Hard reset discards local commits, then the same events are replayed from scratch
-4. Up to 3 retry attempts; each failure is logged as a `PullReport` for observability
+Sensitive resources are never written in plaintext. If encryption is required and unavailable, the
+write fails before the plaintext file is created. The content writer also caches encrypted output by
+resource metadata and plaintext digest to avoid unnecessary SOPS work.
-This works because the API is the source of truth. Any stale commit can be regenerated from the
-current object state.
+### Commit Signing
-### Encryption
+- **Git signing**: [internal/git/signing.go](../internal/git/signing.go)
+- **SSH signatures**: [internal/sshsig/](../internal/sshsig/)
-- **Source**: [internal/git/encryption.go](internal/git/encryption.go), [internal/git/sops_encryptor.go](internal/git/sops_encryptor.go)
+Commit signing uses OpenSSH signatures. The signing key is read from
+`GitProvider.spec.commit.signing.secretRef` or generated when configured.
-**Secrets are never committed in plaintext.** If encryption fails or is not configured, the write
-is rejected and no Secret file is written to the worktree. This is enforced at two layers: the
-[content writer](internal/git/content_writer.go) refuses to produce plaintext Secret content, and
-the [write path](internal/git/git.go) aborts the entire request on encryption failure. Both
-invariants are covered by dedicated tests:
-- [TestBranchWorker_SecretEncryptionFailureDoesNotWritePlaintext](internal/git/secret_write_test.go#L101) — verifies no file appears on disk
-- [TestBuildContentForWrite_SecretRequiresEncryptor](internal/git/content_writer_test.go#L103) — verifies the content writer rejects unencrypted Secrets
+---
-When a GitTarget has `spec.encryption` configured, Secret resources are encrypted with SOPS using
-age keys. The age key is stored in a Kubernetes Secret and can be auto-generated by the GitTarget
-controller.
+## Audit Ingestion
-### Commit signing
+- **Handler**: [internal/webhook/audit_handler.go](../internal/webhook/audit_handler.go)
+- **Joiner**: [internal/webhook/audit_joiner.go](../internal/webhook/audit_joiner.go)
+- **Consumer**: [internal/queue/redis_audit_consumer.go](../internal/queue/redis_audit_consumer.go)
-- **Source**: [internal/git/signing.go](internal/git/signing.go), [internal/sshsig/](internal/sshsig/)
+The audit handler produces a canonical stream with at most one event per `auditID` inside the
+decision TTL.
-SSH commit signing using OpenSSH format. The signing key is read from the Secret referenced by
-`GitProvider.spec.commit.signing.secretRef`.
+### Source Roles
----
+| Endpoint | Role |
+|---|---|
+| `/audit-webhook` | Canonical source, normally kube-apiserver |
+| `/audit-webhook-additional` | Supplementary body source for matching `auditID`s |
-## Startup Sequence
+The endpoint encodes the source role. Cluster-ID path segments are rejected; multi-cluster routing
+is not modeled yet.
-Defined in [cmd/main.go](cmd/main.go):
+### Joiner Behavior
-```mermaid
-flowchart TD
- A[Parse flags + init metrics] --> B[Create controller-runtime Manager]
- B --> C[RuleStore - empty]
- C --> D[WorkerManager - registered as Runnable]
- D --> E[ReconcilerManager]
- E --> F[Watch Manager - constructed]
- F --> G[EventRouter - wires components together]
- G --> H[Register WatchRule + ClusterWatchRule controllers]
- H --> I[Redis audit queue + consumer]
- I --> J[Audit HTTP server]
- J --> K[Watch Manager setup]
- K --> L[Register GitProvider + GitTarget controllers]
- L --> M[mgr.Start - blocks forever]
-```
+The joiner classifies audit shape:
-**Known issue**: the RuleStore is empty at startup until controllers reconcile existing
-WatchRule/ClusterWatchRule CRs. The Watch Manager's initial reconcile reads from this empty store.
-See [watch-audit-rule-matching-improvement.md](design/watch-audit-rule-matching-improvement.md) for
-the design to add explicit cache warm-up before startup reconcile.
+- body-rich official events emit as-is unless a parked body can fill missing fields;
+- bodyless single-resource deletes with complete `objectRef` emit as deletable deletes;
+- `deletecollection` events emit as collection audit facts, but per-item Git fan-out is still a
+ known downstream gap;
+- identity-shallow official events wait up to `--audit-event-body-wait` for a supplementary body;
+- malformed additional events are dropped;
+- late additional bodies are dropped once a decision has already committed.
----
+The official canonical gate is an in-pod mutex. It preserves per-pod official event order while an
+earlier shallow official waits for its body. It is not a global cross-pod ordering guarantee.
-## Audit Ingestion Pipeline
+Redis key families:
-The audit handler is the seam where everything that wants to drive a Git write enters the system.
-It accepts two semantic source roles, deduplicates by `auditID`, classifies event shape, recovers
-bodies for aggregated API requests, preserves official-event ordering, and writes one canonical
-event per `auditID` to the Redis stream.
+| Key | Purpose | Default TTL |
+|---|---|---|
+| `audit:body:v1:` | parked additional body | `5m` |
+| `audit:decision:v1:` | dedupe/decision marker | `1h` |
-### What this pipeline exists for
+### Consumer Handling
-1. **Trustworthy audit identity.** The official kube-apiserver audit event is the authority for
- who did what, when, and with what response status.
-2. **Unique canonical stream.** Within the decision-TTL window, one `auditID` produces at most
- one stream entry.
-3. **No silent shallow writes.** A shallow event is either normalized by a matching additional
- body or dropped — it never produces a stub Git commit.
-4. **Visibility.** Operators see counters for received events, event quality, parked bodies,
- dedupe decisions, shallow drops, and late bodies, plus histograms for official↔additional
- arrival skew and canonical-gate wait time. See [Interpreting metrics](interpreting-metrics.md)
- for the query cookbook. (Orphan additional bodies still expire without a metric — see
- [Known gaps](#known-gaps).)
-5. **Easy setup.** Deployment intent is the only knob; there are no API-group allowlists or
- join-mode flags to maintain.
+The consumer filters to mutating `ResponseComplete` events, matches rules, extracts objects, and
+routes Git events. It also handles special cases:
-### Endpoints
+- CommitRequest create events finalize open windows.
+- Safe `/scale` events become field patches to parent `spec.replicas` when the type registry has a
+ usable scale binding.
+- unsupported subresources are dropped before routing.
+- shallow events that reach the consumer are dropped with explicit warning/metrics instead of
+ creating stub manifests.
-```
-POST /audit-webhook # canonical source: kube-apiserver audit webhook
-POST /audit-webhook-additional # supplementary body source: apiservice-audit-proxy, etc.
-```
+### Redis Queue
-Both accept `audit.k8s.io/v1 EventList`. Cluster-ID path segments and any trailing slash are
-rejected with `400`. The endpoint chosen by the sender is the source role — there is no in-payload
-marker. See [audit_handler.go:384](internal/webhook/audit_handler.go#L384).
+- **Queue producer**: [internal/queue/redis_audit_queue.go](../internal/queue/redis_audit_queue.go)
+- **Metrics reporter**: [internal/queue/queue_metrics.go](../internal/queue/queue_metrics.go)
-Cluster identity is intentionally not modeled in the stream. Multi-cluster support is a separate
-design problem: it needs source registration, rule-match semantics, metrics cardinality rules,
-and file-path semantics together. None of that exists today.
+The canonical stream defaults to `gitopsreverser.audit.events.v1`. Consumers use Redis consumer
+groups with pod-name consumer IDs. Stale pending messages are reclaimed with `XAUTOCLAIM`, and
+messages are ACKed even on processing failure so one poison event cannot block the stream. The
+consumer and watch manager declare `NeedLeaderElection`, but the shipped deployment defaults to one
+replica while full HA behavior is still future work.
-### Deployment modes
+An optional debug stream records every decoded audit event before normal filtering and joining.
-| Mode | Posts to /audit-webhook | Posts to /audit-webhook-additional | Notes |
-| --- | --- | --- | --- |
-| Official only | kube-apiserver | — | Core resources only; aggregated-API events arrive shallow |
-| Official + additional | kube-apiserver | `apiservice-audit-proxy` (or similar) | Recommended — recovers aggregated-API bodies |
-| Proxy as canonical | `apiservice-audit-proxy` | — | Point the source at `/audit-webhook` when it should drive the canonical stream |
+---
-### Pipeline shape
+## Controller Wiring
-```mermaid
-flowchart TD
- KAS[kube-apiserver] -->|official EventList| OFF["/audit-webhook"]
- PROXY[apiservice-audit-proxy] -->|additional EventList| ADD["/audit-webhook-additional"]
-
- OFF --> CLS[Classify event quality]
- ADD --> CLS
-
- CLS -->|official, complete or deletable or collection
or shallow with parked body| CLM[Claim decision → emit]
- CLS -->|official, identity_shallow with no parked body| WAIT_S[Hold official canonical gate
briefly wait for body]
- WAIT_S -->|additional body arrives| MERGE
- WAIT_S -->|wait expires| DROP_S[Drop + shallow_dropped + WARN log]
- CLS -->|additional with body, no committed decision| PARK_A[Park body, wait for official]
- CLS -->|additional, decision committed| DROP_L[Drop + body_late]
- CLS -->|malformed additional| DROP_M[Drop with log]
-
- PARK_A -->|official arrives within TTL| MERGE[Merge → claim → emit]
- PARK_A -.->|TTL expires silently| ORPHAN[(orphan body, silent)]
-
- CLM --> STREAM[(Redis stream
gitopsreverser.audit.events.v1)]
- MERGE --> STREAM
- STREAM --> CONS[AuditConsumer]
- CONS --> RULES[Rule match → EventRouter → BranchWorker]
-```
+Controllers also watch their dependencies so dependent resources reconcile quickly after spec
+changes:
+
+- `GitTargetReconciler` watches `GitProvider`.
+- `WatchRuleReconciler` watches `GitTarget` and `GitProvider`.
+- `ClusterWatchRuleReconciler` watches `GitTarget` and `GitProvider`.
+
+These dependency watches use generation-change predicates to avoid re-enqueuing on status-only
+heartbeat updates.
+
+GitProvider, GitTarget, and CommitRequest specs have immutability constraints where changing the
+spec would invalidate materialized state or delayed audit behavior.
-The official channel is strictly synchronous at the canonical boundary. A bodyless official event
-that needs an additional body holds the official canonical gate for a short grace period; later
-official events wait behind it so canonical Redis stream order cannot overtake the earlier event.
-The additional endpoint is not held by that gate, because it must remain able to park the missing
-body while the official event is waiting.
-
-The canonical gate is an **in-pod mutex** ([audit_handler.go](internal/webhook/audit_handler.go)),
-so this ordering guarantee holds per webhook-receiver pod. Under the future HA topology (multiple
-webhook-receiver pods behind one Service) there is no cross-pod ordering — two pods may interleave
-their canonical writes. This is acceptable because the leader-elected consumer does not depend on
-strict global stream order; the gate exists to keep a single pod's officials from leapfrogging an
-earlier official that is still waiting for its body.
-
-Only `Stage=ResponseComplete` events reach the joiner. kube-apiserver may emit other stages
-(`RequestReceived`, `ResponseStarted`, `Panic`) under the same `auditID`; if those were
-allowed to claim the dedupe key, the later `ResponseComplete` for the same audit ID would be
-silently dropped as a duplicate. The handler filters by stage at the boundary before the
-joiner sees the event.
-
-Audit events with a non-empty `objectRef.subresource` are rejected before the joiner too.
-Current WatchRule planning mirrors top-level resource state only. Subresource requests can
-carry mutating-looking audit verbs without describing a Git-writable object; for example a
-successful `pods/exec` stream is audited as `verb=create` with no request/response resource body.
-
-### Quality classification
-
-The classifier ([audit_joiner.go:507](internal/webhook/audit_joiner.go#L507)) decides what to do
-based on event shape, not API group:
-
-| Quality | Condition | Treatment |
-| --- | --- | --- |
-| `complete` | Request or response body present | Emit |
-| `body_shallow_deletable` | `verb=delete` with `objectRef.Resource` and `Name` set, no body | Emit (delete carve-out) |
-| `collection` | `verb=deletecollection` with body and `objectRef.Resource` | Emit (forwarded raw; per-item routing is a downstream gap) |
-| `identity_shallow` | No body, missing `objectRef` identity | On official: merge if body is parked; otherwise hold the official canonical gate for `--audit-event-body-wait`, then drop + `audit_shallow_dropped_total` + WARN log |
-| `malformed` | Additional event with no body at all | Drop with log |
-
-Two carve-outs are load-bearing:
-
-- **Bodyless delete with a complete `objectRef`** is kube-apiserver's normal shape for "I deleted
- X by name." It must emit. The classifier mirrors
- [allowsBodylessAuditV1Delete](internal/webhook/audit_joiner.go#L527) and the consumer mirrors
- it again as [allowsBodylessSingleDelete](internal/queue/redis_audit_consumer.go#L512).
-- **`deletecollection`** carries a `*List` response body, not a single object, and is a
- high-blast-radius operation that must be auditable. The event is forwarded raw. Per-item
- rule matching from the `*List` body is a known downstream gap, not a reason to drop.
-
-### Joiner state machine
+---
+
+## Startup Sequence
+
+Defined in [cmd/main.go](../cmd/main.go):
```mermaid
-stateDiagram-v2
- [*] --> AdditionalParked: additional with body, no committed decision
- [*] --> EmittedAsIs: official body-rich, no parked body
- [*] --> EmittedMerged: official body-rich, parked body exists
- [*] --> EmittedMerged: official identity_shallow, parked body exists
- [*] --> WaitingForBody: official identity_shallow, no parked body
- WaitingForBody --> EmittedMerged: additional body arrives within grace
- WaitingForBody --> ShallowDropped: grace expires
- [*] --> DuplicateDropped: official matches committed decision
- [*] --> BodyLateDropped: additional with body, decision already committed
-
- AdditionalParked --> EmittedMerged: official arrives within TTL
- AdditionalParked --> [*]: TTL expires silently (orphan body)
-
- EmittedAsIs --> [*]
- EmittedMerged --> [*]
- ShallowDropped --> [*]
- DuplicateDropped --> [*]
- BodyLateDropped --> [*]
+flowchart TD
+ A[Parse flags + init logger/build info] --> B[Init telemetry]
+ B --> C[Create controller-runtime manager]
+ C --> D[Create RuleStore]
+ D --> E[Create WorkerManager and register Runnable]
+ E --> F[Create Watch Manager]
+ F --> G[Create EventRouter]
+ G --> H[Inject TypeRegistry lookup into WorkerManager]
+ H --> I[Register WatchRule + ClusterWatchRule controllers]
+ I --> J[Create Redis audit queue/debug queue/joiner]
+ J --> K[Register AuditConsumer + queue metrics]
+ K --> L[Create audit HTTP server]
+ L --> M[Setup and register Watch Manager]
+ M --> N[Register GitProvider + GitTarget + CommitRequest controllers]
+ N --> O[Add cert watchers + health checks]
+ O --> P[mgr.Start]
```
-### Timing and ordering
-
-The joiner is optimized for the common case where `/audit-webhook-additional` arrives before the
-official kube-apiserver event. When CI or a cluster delivers the official event first by a few
-milliseconds, the handler waits up to `--audit-event-body-wait` before declaring the body missing.
-
-That wait is deliberately attached to the official canonical gate, not only to the individual
-event. This preserves canonical Redis stream order: later official events cannot enqueue while an
-earlier official event is waiting for its additional body. The additional endpoint is allowed to
-continue because blocking it would prevent the missing body from being parked.
-
-If the grace period expires, the cost is still explicit: a shallow drop where a longer wait would
-have merged. The corresponding audit fact is logged at WARN and counted in
-`audit_shallow_dropped_total` — operators see the failure, they don't lose it silently. Sustained
-`audit_join_body_late_total` or `audit_shallow_dropped_total` means the proxy/body path is slower
-than the configured grace period or missing entirely.
-
-The joiner uses two Redis key families:
-
-| Key | Purpose | TTL flag |
-| --- | --- | --- |
-| `audit:body:v1:` | Parked additional-source body contribution only | `--audit-event-body-ttl` (default `5m`) |
-| `audit:decision:v1:` | Dedupe marker; bounds the "at most one canonical entry" window | `--audit-event-decision-ttl` (default `1h`) |
-
-The handler uses a two-phase contract on the joiner:
-
-1. `Decide` claims the decision key via `SET NX` before any stream write.
-2. On enqueue success, `CommitDecision` promotes the claim to `state=emitted`.
-3. On enqueue failure, `ReleaseDecision` deletes the claim so a retry can claim again.
-
-This is what makes "at most one canonical entry per `auditID`" hold across crashes and retries.
-See [audit_joiner.go](internal/webhook/audit_joiner.go) and
-[audit_handler.go](internal/webhook/audit_handler.go).
-
-### Merge rules
-
-When a parked contribution merges into an official event:
-
-- The official remains authoritative for `auditID`, `level`, `stage`, `requestURI`, `verb`,
- `user`, `impersonatedUser`, `sourceIPs`, `userAgent`, timestamps, and `responseStatus`.
-- The contribution can fill in `requestObject`, `responseObject`, missing `objectRef` fields
- (`name`, `namespace`, `uid`, `resourceVersion`), and proxy truncation annotations.
-- For `delete` and `deletecollection`, request/response bodies are not merged — the proxy-side
- body for deletes is `DeleteOptions`, not the deleted object.
-- The official body wins: parked bodies only fill in when the official body is empty. See
- [mergeParkedObjects](internal/webhook/audit_joiner.go#L433).
-
-### Consumer-side drop is explicit
-
-The consumer used to emit a stub `apiVersion+kind+namespace+name` object straight into the Git
-pipeline when no body was present. That silent fallback is gone — `extractObject` now returns
-`errAuditEventObjectMissing` and emits a structured warning with copy-pasteable remediation
-([redis_audit_consumer.go:351](internal/queue/redis_audit_consumer.go#L351)). The classifier
-should have already kept these out, but the consumer enforces it as defense-in-depth.
-
-### Settings
-
-| Flag | Helm value | Default | Meaning |
-| --- | --- | --- | --- |
-| `--audit-event-body-ttl` | `auditEventJoin.bodyTTL` | `5m` | TTL for parked additional bodies waiting for the matching official |
-| `--audit-event-decision-ttl` | `auditEventJoin.decisionTTL` | `1h` | Bounds the dedupe window |
-| `--audit-event-body-wait` | `auditEventJoin.bodyWait` | `500ms` | Grace period for a bodyless official event to wait for a matching additional body while holding official canonical order |
-
-There is no API-group allowlist and no join-mode flag — both were removed because the endpoint
-already encodes intent and `wait-official` semantics are always correct.
-
-### Metrics
-
-Event GVR is carried as three labels — `group`, `version`, `resource` — so PromQL can
-aggregate to group/version without `label_replace`. The event-action label is `verb`
-(the Kubernetes audit `Verb` field) across the whole pipeline.
-
-| Metric | Labels | Meaning |
-| --- | --- | --- |
-| `gitopsreverser_audit_eventlists_total` | `source`, `outcome` | EventList request-boundary counter: `processed`, `empty`, `decode_error`, `process_error` |
-| `gitopsreverser_audit_eventlist_events_total` | `source`, `outcome` | Decoded audit event items delivered in EventLists (no `decode_error` sample) |
-| `gitopsreverser_audit_eventlist_duration_seconds` | `source`, `outcome` | Histogram of webhook request time, including in-pod join wait work |
-| `gitopsreverser_audit_events_received_total` | `source`, `group`, `version`, `resource`, `subresource`, `verb` | Receive counter (username is logged, not labelled). `subresource` is empty for top-level resources and a bounded value (`status`, `exec`, …) otherwise |
-| `gitopsreverser_audit_event_quality_total` | `source`, `quality`, `group`, `version`, `resource`, `verb` | First-class shape classification |
-| `gitopsreverser_audit_join_parked_total` | — | Parked additional bodies |
-| `gitopsreverser_audit_join_emitted_total` | `source`, `result` | Canonical emissions: `as_is`, `merged` |
-| `gitopsreverser_audit_join_duplicate_dropped_total` | `reason` | Drops from existing decision keys: `decision_exists`, `in_flight_claim` |
-| `gitopsreverser_audit_shallow_dropped_total` | `group`, `version`, `resource`, `verb` | Identity-shallow officials dropped because no parked body was available |
-| `gitopsreverser_audit_join_body_late_total` | `group`, `version`, `resource`, `verb` | Additional body arrived after the decision was committed |
-| `gitopsreverser_audit_join_skew_seconds` | `arrival`, `outcome` | Histogram of official↔additional arrival skew: `arrival=body_first` is the proxy's lead time, `arrival=official_first` is how long the official waited on the canonical gate |
-| `gitopsreverser_audit_official_gate_wait_seconds` | — | Histogram of how long an official event waited to acquire the in-pod canonical gate (backpressure signal) |
-| `gitopsreverser_audit_pipeline_events_total` | `group`, `version`, `resource`, `verb`, `outcome` | Consumer-stage counter: `unmatched`, `dropped_no_body`, `dropped_partial_object`, `routed`, `route_failed` |
-| `gitopsreverser_audit_pipeline_route_targets_total` | `git_target_namespace`, `git_target`, `rule_kind`, `outcome` | Per-GitTarget route attempts: `routed`, `route_failed` |
-
-Useful alerts: `audit_shallow_dropped_total` non-zero (operator misconfiguration — install
-proxy or update audit policy); `audit_join_duplicate_dropped_total` spikes (likely webhook
-retry storm); sustained `audit_join_body_late_total` (proxy timing slipping past the TTL);
-`audit_join_skew_seconds` p95 for `arrival=official_first` creeping toward `--audit-event-body-wait`
-(early warning that the grace period is about to be exhausted). The full query cookbook lives in
-[Interpreting metrics](interpreting-metrics.md).
-
-### Operator guidance
-
-A shallow event almost always means one of two things:
-
-- kube-apiserver's audit policy does not request bodies for that resource. See
- [test/e2e/cluster/audit/policy.yaml](test/e2e/cluster/audit/policy.yaml) for a working policy.
-- The request traversed an aggregated API path where kube-apiserver cannot see the backend body.
- Install `apiservice-audit-proxy` and point it at `/audit-webhook-additional`.
-
-### Known gaps
-
-These are explicit, tracked follow-ups — not hidden surprises:
-
-- **Orphan additional bodies expire silently.** A parked additional body that never finds
- an official twin expires on its Redis TTL with no metric and no log. We accept this for
- simplicity: surfacing it would require a periodic sweeper, and the operational signal —
- proxy disconnected from official stream — is visible via the `audit_join_body_late_total`
- pattern and from the proxy side directly. The identity-shallow drop case is *not* silent;
- it surfaces synchronously via `audit_shallow_dropped_total` + WARN log.
-- **No per-item routing for `deletecollection`.** Collection events reach the canonical stream
- intact. The consumer's `extractObject` unmarshals the `*List` envelope as a single object,
- so downstream rule matching may not commit per-item deletes. Forwarding the audit fact is
- correct; per-item Git fan-out is a separate design.
-- **Merge under two body writers is last-write-wins.** Today only `apiservice-audit-proxy`
- writes additional bodies. If a future in-process aggregated-API handler also writes, the
- body store has no source priority. Source-priority merge is deferred.
-- **Multi-cluster identity.** The removed `{clusterID}` path segment was a half-measure.
- Multi-cluster needs a proper source-identity design across rule matching, metrics cardinality,
- and file paths.
+The watch manager bootstraps the RuleStore from existing rules during its `Start` path before the
+initial watch reconciliation.
---
-## What We Don't Do (Yet)
+## Operational Boundaries
+
+Current limitations:
-- **Pull request creation** — the operator writes directly to a branch, it does not create PRs
-- **Multi-cluster routing** — audit ingestion supports it, but rule matching and file paths do not
-- **High availability** — Redis queuing is the foundation, but the consumer and watch manager are leader-elected single-instance
-- **Per-destination queues** — all events flow through a single Redis stream; a Git remote outage stalls all processing
+- no pull-request creation; the operator writes directly to branches;
+- no multi-cluster routing or file identity;
+- no per-destination Redis streams, so a blocked destination can still stall the single consumer
+ path;
+- `deletecollection` reaches the canonical audit stream but does not yet fan out per item;
+- Flux `GitRepository` provider references are schema-visible but not implemented;
+- new-file placement is still canonical path based, not user-configurable.
---
## Package Map
-| Package | Role | Key types |
-|---|---|---|
-| [api/v1alpha1/](api/v1alpha1/) | CRD type definitions | `GitProvider`, `GitTarget`, `WatchRule`, `ClusterWatchRule` |
-| [cmd/](cmd/) | Operator entry point | `main()` |
-| [internal/controller/](internal/controller/) | Kubernetes reconcilers | `GitProviderReconciler`, `GitTargetReconciler`, `WatchRuleReconciler`, `ClusterWatchRuleReconciler` |
-| [internal/events/](internal/events/) | Control and state event types | `ClusterStateEvent`, `RepoStateEvent`, `ControlEvent` |
-| [internal/git/](internal/git/) | Git operations + BranchWorker | `BranchWorker`, `WorkerManager`, `WriteRequest`, `PushAtomic` |
-| [internal/giteaclient/](internal/giteaclient/) | Gitea API client for signing keys | `Client` |
-| [internal/queue/](internal/queue/) | Redis stream producer + consumer | `RedisAuditQueue`, `AuditConsumer` |
-| [internal/reconcile/](internal/reconcile/) | Folder reconciler + event stream | `FolderReconciler`, `GitTargetEventStream`, `ReconcilerManager` |
-| [internal/rulestore/](internal/rulestore/) | Compiled rule cache | `Store`, `CompiledRule`, `CompiledClusterRule` |
-| [internal/sanitize/](internal/sanitize/) | K8s object sanitization | `Sanitize()`, `MarshalToOrderedYAML()` |
-| [internal/ssh/](internal/ssh/) | SSH key helpers | |
-| [internal/sshsig/](internal/sshsig/) | SSH commit signing (OpenSSH format) | |
-| [internal/telemetry/](internal/telemetry/) | OpenTelemetry metrics | |
-| [internal/types/](internal/types/) | Shared domain types | `ResourceIdentifier`, `ResourceReference` |
-| [internal/watch/](internal/watch/) | Dynamic informers, API resource catalog + EventRouter | `Manager`, `EventRouter`, `GVR`, `APIResourceCatalog`, `RuleGVRResolver` |
-| [internal/webhook/](internal/webhook/) | Audit ingress handling | `AuditHandler`, `AuditEventJoiner`, `RedisAuditEventJoiner` |
+| Package | Role |
+|---|---|
+| [api/v1alpha1/](../api/v1alpha1/) | CRD types |
+| [cmd/](../cmd/) | operator entry point and server setup |
+| [internal/auditutil/](../internal/auditutil/) | audit identity, objectRef, and subresource helpers |
+| [internal/controller/](../internal/controller/) | Kubernetes reconcilers |
+| [internal/git/](../internal/git/) | branch workers, Git operations, commit/signing/encryption, manifest writer |
+| [internal/git/manifestedit/](../internal/git/manifestedit/) | YAML document editor |
+| [internal/giteaclient/](../internal/giteaclient/) | Gitea helper client |
+| [internal/manifestanalyzer/](../internal/manifestanalyzer/) | manifest inventory, acceptance, and resync planning |
+| [internal/manifestreport/](../internal/manifestreport/) | projection of Kubernetes objects into comparable manifest reports |
+| [internal/queue/](../internal/queue/) | Redis queues, audit consumer, CommitRequest audit handling |
+| [internal/reconcile/](../internal/reconcile/) | per-GitTarget event stream state |
+| [internal/rulestore/](../internal/rulestore/) | compiled rule cache |
+| [internal/sanitize/](../internal/sanitize/) | Kubernetes object sanitization and stable YAML marshal |
+| [internal/ssh/](../internal/ssh/) | SSH authentication helpers |
+| [internal/sshsig/](../internal/sshsig/) | SSH signature implementation |
+| [internal/telemetry/](../internal/telemetry/) | metrics and OTLP setup |
+| [internal/types/](../internal/types/) | shared resource identity/reference and sensitivity policy |
+| [internal/typeset/](../internal/typeset/) | type followability registry and lookup model |
+| [internal/watch/](../internal/watch/) | discovery catalog, watch manager, watched-type tables, event router, snapshots |
+| [internal/webhook/](../internal/webhook/) | audit ingress and audit event joiner |
---
## Design Documents
-For deeper context on specific decisions:
-
-- [Audit ingestion decision record](design/audit-ingestion-decision-record.md) — why audit is the authoritative live source
-- [GitTarget lifecycle and repo architecture](design/gittarget-lifecycle-and-repo-architecture.md) — GitTarget state machine details
-- [Watch and audit rule matching improvement](design/watch-audit-rule-matching-improvement.md) — known issues with namespace matching and startup bootstrap
-- [Kubernetes API resource catalog](design/kubernetes-api-resource-catalog.md) — the served-resource discovery model behind `APIResourceCatalog`
-- [WatchRule GVR resolution](finished/watchrule-gvr-resolution-plan.md) — how bare rule resources resolve to concrete GVRs, and the snapshot trust model
-- [Multi-cluster audit ingestion implications](design/multi-cluster-audit-ingestion-implications.md) — what multi-cluster means beyond ingestion
-- [SOPS/age key management](design/sops-repo-bootstrap-and-key-management-architecture.md) — encryption architecture
-- [Audit webhook TLS design](design/audit-webhook-tls-design.md) — webhook transport security
-- [Status conditions guide](design/status-conditions-guide.md) — how status conditions are used across CRDs
+Useful deeper dives:
+
+- [Audit ingestion decision record](design/audit-ingestion-decision-record.md)
+- [GitTarget lifecycle and repo architecture](design/gittarget-lifecycle-and-repo-architecture.md)
+- [Watch and catalog architecture](design/watch-and-catalog-architecture.md)
+- [Kubernetes API resource catalog](design/kubernetes-api-resource-catalog.md)
+- [Type followability](design/manifest/version2/type-followability.md)
+- [Type followability implementation log](design/manifest/version2/type-followability-implementation.md)
+- [Per-type reconcile and streaming tail](design/manifest/version2/per-type-reconcile-and-streaming-tail.md)
+- [Manifest current support review](design/manifest/current-manifest-support-review.md)
+- [Reconcile via watchlist mark-and-sweep](design/manifest/reconcile-via-watchlist-mark-and-sweep.md)
+- [SOPS/age key management](design/sops-repo-bootstrap-and-key-management-architecture.md)
+- [Commit signing](commit-signing.md)
+- [Interpreting metrics](interpreting-metrics.md)
diff --git a/docs/ci/image-loading.md b/docs/ci/image-loading.md
index 690321f6..011a6259 100644
--- a/docs/ci/image-loading.md
+++ b/docs/ci/image-loading.md
@@ -19,11 +19,11 @@
| Context | Command |
|---|---|
-| Local full e2e | `task test-e2e-full` |
-| Local install smoke (helm) | `task test-e2e-quickstart-helm` |
-| Local install smoke (manifest) | `task test-e2e-quickstart-manifest` |
-| CI e2e | `PROJECT_IMAGE= task test-e2e-full` |
-| CI smoke | `PROJECT_IMAGE= task test-e2e-quickstart-helm` |
+| Local full e2e | `task test-e2e` |
+| Local Helm install validation | `task test-e2e-quickstart-helm` |
+| Local manifest install validation | `task test-e2e-quickstart-manifest` |
+| CI e2e | `PROJECT_IMAGE= task test-e2e` |
+| CI quickstart install validation | `PROJECT_IMAGE= task test-e2e-quickstart-helm` |
| IDE direct | `go test ./test/e2e/...` (BeforeSuite handles prep) |
## IDE fallback
diff --git a/docs/configuration.md b/docs/configuration.md
index fefb3072..82c3e822 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -6,7 +6,7 @@ steps in the [root README](../README.md).
The short version:
- `GitProvider` defines where and how to push
-- `GitTarget` defines which branch and path to write into
+- `GitTarget` defines which branch and repository path to write into
- `WatchRule` defines which namespaced resources should produce Git writes
- `ClusterWatchRule` does the same for cluster-scoped or cross-namespace watching
@@ -36,7 +36,7 @@ resource still needs a `GitTarget` with `spec.encryption` configured before Git
The usual flow is:
1. Create a `GitProvider` for repository access and commit behavior.
-2. Create a `GitTarget` that points at that provider plus a branch and path.
+2. Create a `GitTarget` that points at that provider plus a branch and repository path.
3. Create one or more `WatchRule` or `ClusterWatchRule` objects that point at that target.
That means one repository connection can back multiple targets, and one target can be fed by
@@ -273,7 +273,8 @@ The important fields are:
- `spec.providerRef`: which `GitProvider` backs this target
- `spec.branch`: which allowed branch to write to
-- `spec.path`: path inside the repository
+- `spec.path`: required relative path inside the repository; use `.` only when you deliberately
+ want the repository root
- `spec.encryption`: how `Secret` resources should be encrypted before commit
Example:
@@ -291,6 +292,14 @@ spec:
path: live-cluster
```
+`spec.path` is required so a target never writes to the repository root by accident. Use a folder
+such as `live-cluster` for the first install. To deliberately target the repository root, set
+`path: "."`. Do not use a leading slash, and do not add a trailing slash.
+
+The target path is authoritative for snapshot reconciliation. A root target can create, update, and
+delete managed manifest files at the repository root, so use `.` only for a repository layout that is
+dedicated to this target.
+
If you enable `spec.encryption`, that applies to `Secret` resource writes for this target. For SOPS
and age details, see [sops-age-guide.md](sops-age-guide.md).
@@ -367,6 +376,9 @@ Keep using the [root README quickstart](../README.md#quick-start) when you want
path. The chart's `quickstart` values create a starter `GitProvider`, `GitTarget`, and `WatchRule`
for you.
+The starter `GitTarget` writes under `live-cluster` by default. Override
+`quickstart.gitTarget.path=.` only when you want the starter target to own the repository root.
+
Move to hand-managed resources when you want:
- more than one `GitTarget`
diff --git a/docs/design/e2e-aggregated-apiserver-test-design.md b/docs/design/e2e-aggregated-apiserver-test-design.md
index e7b25660..70aaddc1 100644
--- a/docs/design/e2e-aggregated-apiserver-test-design.md
+++ b/docs/design/e2e-aggregated-apiserver-test-design.md
@@ -395,12 +395,9 @@ test/e2e/
## Test Scenarios
All tests live under `Label("aggregated-api")` and are `Ordered`.
-
-Recommended label split:
-
-- `S2` should also carry `Label("smoke")`
-- the remaining scenarios stay `aggregated-api` only until we have runtime data showing the
- always-on infrastructure cost is negligible
+There is no separate smoke label split; `task test-e2e` runs the whole package,
+and `task test-e2e-aggregated-api` remains the focused entry point for this
+category.
### Prerequisites (BeforeAll)
@@ -747,12 +744,7 @@ disallowedFlunders:
### Phase 4: CI integration
-1. Add `aggregated-api` to the full test suite (already included via `test-e2e-full` which runs
- all labels)
-2. Add **exactly one** aggregated-api scenario to `smoke`:
- - `S2 — Audit: CREATE a Flunder produces a git commit`
-3. Keep the remaining aggregated-api scenarios out of `smoke` initially
- This gives us one representative end-to-end aggregated API signal in the fast suite without
- forcing every edge-case scenario into the smoke path on day one.
-4. Re-evaluate later whether additional aggregated-api scenarios belong in `smoke`
-5. Document the new test category in `E2E_DEBUGGING.md`
+1. Keep `aggregated-api` included in `task test-e2e`, which runs all labels.
+2. Keep `task test-e2e-aggregated-api` as the focused entry point for aggregated
+ API work.
+3. Document the test category in `E2E_DEBUGGING.md`.
diff --git a/docs/design/e2e-finish-plan.md b/docs/design/e2e-finish-plan.md
index c8ec0c1f..c4bd304e 100644
--- a/docs/design/e2e-finish-plan.md
+++ b/docs/design/e2e-finish-plan.md
@@ -79,7 +79,7 @@ This plan does not require:
Minimum focused checks while working:
```bash
-task test-e2e-signing
+go test -timeout 15m ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=signing
```
Required wrap-up:
diff --git a/docs/design/e2e-full-suite-flakiness-findings-2026-06.md b/docs/design/e2e-full-suite-flakiness-findings-2026-06.md
new file mode 100644
index 00000000..45422235
--- /dev/null
+++ b/docs/design/e2e-full-suite-flakiness-findings-2026-06.md
@@ -0,0 +1,245 @@
+---
+title: e2e full-suite flakiness — forensic findings (poc/manifestedit, June 2026)
+status: investigation
+date: 2026-06-09
+related:
+ - e2e-watchrule-cross-spec-interference.md
+ - e2e-full-suite-shared-state-investigation.md
+ - manifest/version2/per-type-reconcile-and-streaming-tail.md
+---
+
+# e2e full-suite flakiness — forensic findings
+
+This records a concrete investigation triggered while hardening the
+contextual-namespace work on `poc/manifestedit`. The local `task test-e2e` was
+red while GitHub CI was green. This document starts with the raw facts, then
+interprets them, then states what is and is not the cause.
+
+## 1. Facts and observations (no interpretation yet)
+
+### 1.1 CI was green on the recent commits
+
+The last five `CI` runs on `poc/manifestedit` were all `success` (`gh run list`,
+2026-06-08):
+
+| Run | Commit subject | Result | When |
+|---|---|---|---|
+| 27161369593 | feat: flexible manifests | success | 2026-06-08T19:21:52Z |
+| 27160517400 | feat: flexible manifests | success | 2026-06-08T19:06:00Z |
+| 27156885656 | feat: flexible manifests | success | 2026-06-08T17:59:24Z |
+| 27153490992 | feat: flexible manifests | success | 2026-06-08T17:09:07Z |
+| 27144704089 | feat: flexible manifests | success | 2026-06-08T14:29:21Z |
+
+`main` is green as well. The newest green run corresponds to the branch HEAD
+commit `72df204` ("chore: re-enable serial and updating architecture").
+
+CI runs the full suite via `task test-e2e` at **`E2E_GINKGO_PROCS=4`** with **no
+`--flake-attempts`/retry** (`.github/workflows/ci.yml`; the matrix even comments
+"Validated locally at --procs=4; trialing the same in CI"). So CI's parallelism is
+the same as the local run, and there is no retry masking failures.
+
+### 1.2 CI never tested the code that was red locally
+
+The green CI runs validated the **committed** tree at `72df204`. At the time of
+this investigation the working tree additionally contained, all **uncommitted**:
+
+- the contextual-namespace hardening (`internal/manifestanalyzer/store.go`,
+ `internal/git/plan_flush.go`);
+- a **new** e2e spec, `Manager Manifest Folder Editing`
+ (`test/e2e/inplace_edit_e2e_test.go`) — `git log` on that file shows the
+ committed version contains **zero** occurrences of "Manifest Folder Editing";
+- timeout bumps in `test/e2e/crd_lifecycle_e2e_test.go` (60s → 2m).
+
+Therefore **CI has never run this working tree**, and in particular has never run
+the new manifest-folder spec. "CI is green" and "local is red" were never the same
+code.
+
+### 1.3 Local full-suite runs (same box, same cluster, procs=4 unless noted)
+
+| Run | Tree / image | Procs | Result |
+|---|---|---|---|
+| A | working tree (hardening + new specs) | 4 | **12 failed**, 17 passed, 24 skipped |
+| B | `inplace-edit` label only (the feature) | 1 | **0 failed**, 2 passed |
+| C | committed baseline `72df204` (stashed) | 4 | **1 failed**, 39 passed |
+| D | working tree, re-run | 4 | **8 failed**, 22 passed |
+| E | hardening image + baseline spec set (no new spec) | 4 | **10 failed**, 17 passed |
+
+The failure **count is high-variance** (1, 8, 10, 12) across nominally-similar
+runs. Only B (serial, the feature in isolation) was clean.
+
+### 1.4 Every failure is one of two shapes
+
+- `status.conditions not found` — a **WatchRule** that never received *any* status
+ for 90s (`e2e_test.go:224` → `:194`). The GitTarget for the same spec **does**
+ reach Ready; only the WatchRule hangs.
+- `CRD file should exist … icecreamorders…crd…: no such file` at **60s**
+ (`crd_lifecycle_e2e_test.go:150`) — the CRD-install commit did not land in time.
+
+The single baseline failure (run C) was the second shape.
+
+### 1.5 Controller health and the reflector noise
+
+- The controller pod was `Ready`, **Restart Count 0**, no panic, across all runs.
+- The logs carry **403** repetitions of
+ `Failed to watch … icecreamorders.crd-lifecycle.e2e.example.com … the server
+ could not find the requested resource` — a reflector retrying a CRD that a spec
+ has deleted.
+- Specs that only validate a `GitProvider` (no WatchRule readiness) passed every
+ run. The failing specs are the ones that need a WatchRule to go Ready and/or
+ touch the `icecreamorders` custom resource (CRD Lifecycle, the wildcard
+ custom-API WatchRule, Bi-Directional IceCreamOrder, Aggregated API, plus
+ WatchRule-bootstrap-dependent specs caught in the wave).
+
+## 2. What the code actually does (mechanism)
+
+Read against current `main`-line code, not the older design notes:
+
+1. **The catalog tolerates partial discovery.** `discoverCatalogRefresh`
+ (`internal/watch/api_resource_catalog.go`) treats `*ErrGroupDiscoveryFailed`
+ as partial success: it keeps the healthy groups, marks the failed group
+ degraded (keeping its last-known entries), and sets `complete=false` so no
+ group is removed on a wobble. `Registry.Ready()` is a **latch** — once the
+ surface has been observed it stays ready. **So a single deleted CRD does not
+ flip the global registry to not-ready and does not fail-close unrelated
+ GitTargets through the catalog/snapshot path.**
+
+2. **The snapshot mark-and-sweep is GitTarget-global and content-derived, and it
+ fails closed on purpose.** `StreamClusterSnapshotForGitDest` /
+ `resolveSnapshotGVRs` (`internal/watch/snapshot_stream.go`) abort the whole
+ snapshot if any watched type is within the `RemovalGrace` (60s) and currently
+ unserved, because `Desired` is the complete set and the worker deletes any git
+ doc **not** in `Desired` — snapshotting "only the healthy types" would sweep
+ the retained type's mirror. This is anti-destruction safety, not a bug.
+
+3. **The block is bootstrap-only.** `evaluateSnapshotGate`
+ (`internal/controller/gittarget_controller.go:390`) does **not** re-run the
+ resync once `SnapshotSynced` is true. An already-live GitTarget is **not**
+ knocked offline by a later type wobble; "one type fails the whole GitTarget"
+ only bites during the *initial* snapshot.
+
+4. **Event routing is live against the RuleStore.** `handleEvent` →
+ `matchRules` → `RuleStore.GetMatchingRules` (a live read of the store map) →
+ `routeWatchRules` (`internal/watch/informers.go`, `internal/watch/manager.go`).
+ On WatchRule deletion the controller calls `RuleStore.Delete`
+ (`internal/controller/watchrule_controller.go:85`), which removes the rule from
+ that same map. **A deleted WatchRule therefore stops matching immediately** —
+ the only residual is the sub-second async window before the delete-reconcile
+ runs.
+
+## 3. Diagnosis
+
+The first instinct is to reach for the two mechanisms in
+[e2e-watchrule-cross-spec-interference.md](e2e-watchrule-cross-spec-interference.md)
+(April 2026). **Both are already fixed in current code**, and were verified
+*inactive* in these runs — so neither is the cause here:
+
+- **Not the preservation cascade.** `markSuiteWidePreservation`
+ (`test/e2e/suite_state.go`) is called only on a **BeforeSuite failure** or an
+ **OS signal** (`e2e_suite_test.go:57,281`), never on a per-spec failure — the
+ current comment is explicit: "Per-spec failures deliberately do NOT preserve: the
+ run finishes, cleanup runs, and the next spec starts from a known clean state."
+ All four captured run logs contain **zero** `Preserving e2e resources` and
+ **zero** `skipping cleanup for` lines, confirming preservation never engaged.
+- **Not ghost-WatchRule routing.** Matching is live against the RuleStore and
+ deletion removes the rule (§2.4), so a deleted WatchRule stops matching at once.
+ A "drain on delete" change would fix a non-bug.
+
+What *is* going on:
+
+- **Root trigger: discovery-cache lag after a fresh CRD install.**
+ `e2e_test.go:220` literally notes "after a fresh CRD install the controller's
+ discovery cache can lag tens of seconds." Under load that lag exceeds the
+ CRD-lifecycle spec's 60s budget, so that spec fails — the one failure baseline
+ run C also showed.
+- **The 403 `icecreamorders` reflector burst is bounded, not a runaway drain.** A
+ type that leaves the watched set has its informer cancelled by
+ `stopInformerNamespace` (`internal/watch/manager.go:599`). The type lingers only
+ for the 60s `RemovalGrace` (so a discovery wobble never sweeps git), during which
+ the running informer's reflector retries the now-absent CRD and logs the 403s.
+ After the grace it is stopped. So the 403s are a ~60s burst per CRD deletion, not
+ an unbounded hot-loop.
+- **The wave is resource/timing contention under local `--procs=4`, not a named
+ bug.** Every failing spec is wall-clock-bound on asynchronous discovery /
+ WatchRule readiness (60–90s windows). A constrained dev box running a heavy
+ ~50-spec suite four-wide — plus CRD-lifecycle create/delete churn and (in the
+ working-tree runs) one extra heavy concurrent spec — pushes marginal reconciles
+ past those windows. That a *type wobble* anywhere makes the whole **bootstrapping**
+ GitTarget wait (§2.2–2.3) concentrates the pain on WatchRule-readiness specs. The
+ high-variance count (1→8→10→12) is the signature of timing/scheduling, not a
+ deterministic regression.
+
+## 4. The interesting case: green CI, reproducibly-red local
+
+Three independent reasons stack, and together they fully explain it:
+
+1. **Different code.** CI validated `72df204`; the red local runs validated an
+ uncommitted tree that adds a heavy new spec CI never ran (§1.2). A green CI here
+ says nothing about the working tree.
+2. **Timing/load, not logic.** Both failure shapes are wall-clock timeouts around
+ asynchronous discovery/WatchRule machinery, not assertion-of-wrong-content. A
+ more constrained local box under `--procs=4`, plus one extra heavy concurrent
+ spec, tips marginal reconciles past their 60–90s windows. CI's `ubuntu-latest`
+ has more headroom.
+3. **The suite is timing-sensitive here.** The failing specs already carry a trail
+ of band-aids (`e2e-watchrule-cross-spec-interference.md`), and the branch's own
+ recent history bumped the CRD-lifecycle timeout — evidence the author was already
+ fighting this class of flake. Note the *named* bugs from that April doc (ghost
+ routing, preservation cascade) are now fixed (§3); what remains is the wall-clock
+ sensitivity itself.
+
+The trap worth naming: **a green CI lulls you into thinking the suite validates
+your working tree.** It validated a different commit. And because the failure
+*count* of a cascade-prone suite is high-variance, a single green or red run proves
+little on its own — only the serial isolation run (B) is a clean signal.
+
+## 5. Was the contextual-namespace hardening responsible? No.
+
+- The feature passes **serially in isolation** (run B, 2/2), so its own code path
+ is correct end to end.
+- The hardening code is a **no-op for every failing spec**: it only does work when
+ a `kustomization.yaml` is present in the GitTarget's watched worktree, and none
+ of the failing specs' folders have one.
+- It does not touch the WatchRule reconciler, `internal/watch`, or
+ `internal/typeset` — the paths that own WatchRule readiness and the catalog.
+- The 1-vs-{8,10,12} spread is cascade variance + the extra spec's load (run E,
+ with the *baseline* spec set but the hardening image, still produced 10 — i.e.
+ the spread tracks parallel cascade, not the image).
+
+## 6. Recommendations (ordered by leverage ÷ risk)
+
+The honest headline: there is **no warranted controller "quick fix"** here. The two
+bugs one would reach for are already fixed, and the reflector burst is bounded. The
+residual is wall-clock contention, addressed by lowering load or asserting on
+signals — and, properly, by the already-designed M10 work.
+
+1. **Lower local parallelism (zero-risk, immediate).** Run `E2E_GINKGO_PROCS=2` (or
+ 1) locally; the suite is reliably green serially. CI keeps `--procs=4` because
+ `ubuntu-latest` has the headroom. This is a runner-budget knob, not a code fix.
+2. **Reduce the CRD-install discovery-lag flake (the usual *root* trigger).** The
+ 60s→2m timeout bump is a reasonable band-aid; the durable fix is making
+ ClusterWatchRule type-expansion react to the CRD-install event faster, or
+ asserting on a controller signal/metric rather than a wall-clock window.
+3. **Reduce cross-spec timing coupling (test infra).** Specs in the Manager block
+ share a namespace/repo; even with correct cleanup, async timing couples them
+ under load. Unique namespaces per spec (recommendation #3 of
+ `e2e-watchrule-cross-spec-interference.md`) decouples them by construction.
+4. **Per-type reconcile (the proper, larger fix).** So a single bootstrapping type
+ that is slow/unhealthy does not block the whole GitTarget's readiness — the one
+ real product-side contributor (§2.2–2.3). Already designed as M10 in
+ [per-type-reconcile-and-streaming-tail.md](manifest/version2/per-type-reconcile-and-streaming-tail.md);
+ it requires a **type-scoped** sweep, which is exactly why it is not a safe sliver
+ on top of today's global mark-and-sweep.
+5. **Do not** add a ghost-WatchRule drain or a preservation-cascade fix — both are
+ already handled in current code (§3). Verify before reviving an old root cause.
+
+## 7. Appendix: how each claim was checked
+
+- CI status/commit: `gh run list --branch poc/manifestedit`.
+- "CI never ran the new spec": `git log -p -1 -- test/e2e/inplace_edit_e2e_test.go`
+ on the committed tree → 0 hits for "Manifest Folder Editing".
+- Baseline reproduction: `git stash -u` to `72df204`, `E2E_GINKGO_PROCS=4 task
+ test-e2e` → 1 failure (the CRD-install lag), proving the redness is not the
+ uncommitted code.
+- Feature correctness: `ginkgo --procs=1 --label-filter='inplace-edit'` → 2/2.
+- Mechanism: direct reads of `api_resource_catalog.go`, `snapshot_stream.go`,
+ `gittarget_controller.go`, `informers.go`, `manager.go`, `rulestore/store.go`.
diff --git a/docs/design/e2e-full-suite-shared-state-investigation.md b/docs/design/e2e-full-suite-shared-state-investigation.md
index 8a415002..724d1db3 100644
--- a/docs/design/e2e-full-suite-shared-state-investigation.md
+++ b/docs/design/e2e-full-suite-shared-state-investigation.md
@@ -2,7 +2,7 @@
## Purpose
-This note captures an investigation into an intermittent `task test-e2e-full` failure that appeared
+This note captures an investigation into an intermittent `task test-e2e` failure that appeared
after commit `9c7e0bc3c455f99242480e0577ab2776fae16d60` and was observed concretely in CI for image
`ghcr.io/configbutler/gitops-reverser:ci-621200eefaf185dfe1000526b1e0f53dc3a8d93f`.
@@ -17,7 +17,7 @@ investigation much faster if the instability returns.
Current status:
- the CI is green again
-- the failure was observed during `task test-e2e-full`
+- the failure was observed during `task test-e2e`
- targeted local runs of `test-e2e-manager` and `test-e2e-audit-redis` were green when run in isolation
- the strongest remaining explanation is a full-suite shared-state interaction, not a simple single-test regression
- a later repro on then-current `main` showed the same class of failure with a narrower stale path:
@@ -30,7 +30,7 @@ Current status:
- a mitigation was then tried locally:
- the manager secret specs were changed to use a secret-only WatchRule template instead of the broader
`watchrule.tmpl`
- - after that change, `task test-e2e` passed locally, including the manager smoke spec
+ - after that change, `task test-e2e` passed locally, including the manager spec
`should create Git commit when ConfigMap is added via WatchRule`
- this is encouraging, but it is still not proof that the broader full-suite instability is permanently fixed
@@ -327,7 +327,7 @@ Observed result of the mitigation:
- `task lint`
- `task test`
- `task test-e2e`
-- the manager smoke spec
+- the manager spec
`should create Git commit when ConfigMap is added via WatchRule`
passed after the change
@@ -336,7 +336,7 @@ Current interpretation:
- this mitigation is a strong fit for the newer `secret-autogen-test` variant
- it reduces one clear source of cross-spec overlap in the manager suite
- it should be treated as a tested mitigation, not yet a fully proven final root-cause closure for every
- possible `task test-e2e-full` flake
+ possible `task test-e2e` flake
## Files Most Worth Re-Inspecting If It Returns
diff --git a/docs/design/e2e-speedup-plan.md b/docs/design/e2e-speedup-plan.md
index ac2630f2..e10ec110 100644
--- a/docs/design/e2e-speedup-plan.md
+++ b/docs/design/e2e-speedup-plan.md
@@ -169,8 +169,8 @@ Recorded so revisiting is cheap.
```bash
task prepare-e2e
mkdir -p /tmp/e2e-baseline
-go test -timeout 15m ./test/e2e/ \
- -ginkgo.v -ginkgo.label-filter=smoke \
- -ginkgo.json-report=/tmp/e2e-baseline/smoke.json
-go run ./test/e2e/tools/spec-timings /tmp/e2e-baseline/smoke.json
+go test -timeout 30m ./test/e2e/ \
+ -ginkgo.v \
+ -ginkgo.json-report=/tmp/e2e-baseline/full.json
+go run ./test/e2e/tools/spec-timings /tmp/e2e-baseline/full.json
```
diff --git a/docs/design/e2e-test-design.md b/docs/design/e2e-test-design.md
index 23850c2f..754db234 100644
--- a/docs/design/e2e-test-design.md
+++ b/docs/design/e2e-test-design.md
@@ -57,7 +57,7 @@ In practice, that means:
- IDE discoverability matters as much as CLI convenience; the suite layout should stay compatible with standard Go and Ginkgo test discovery instead of hiding scenarios behind shell-only wrappers
- shared setup from `BeforeSuite` may stay, but mutable test state must be isolated so one `Describe` does not depend on another `Describe` having run first
-- the default smoke path and focused Task targets should optimize for the "relevant subset under 10 minutes" goal, while full confidence runs can remain broader and slower
+- the default `task test-e2e` path and focused Task targets should optimize for useful local feedback, while install-matrix confidence comes from dedicated quickstart tasks
- direct `go test` runs should continue to delegate environment preparation to Task targets instead of duplicating cluster bootstrap logic in Go
## Harness Direction
@@ -104,10 +104,7 @@ These are the canonical Task entry points on the current worktree:
| Command | Purpose |
|---|---|
-| `task test-e2e` | Smoke suite only (`-ginkgo.label-filter=smoke`) |
-| `task test-e2e-full` | Entire e2e package |
-| `task test-e2e-manager` | Manager-focused scenarios |
-| `task test-e2e-signing` | Commit-signing scenarios |
+| `task test-e2e` | Standard e2e package, excluding CI-only workflow checks |
| `task test-image-refresh` | Image rebuild / reload invalidation chain |
| `task test-e2e-quickstart-helm` | Quickstart framework with Helm install |
| `task test-e2e-quickstart-manifest` | Quickstart framework with manifest install |
@@ -119,10 +116,11 @@ installing the system under test.
The intended interpretation of these entry points is:
-- `task test-e2e`, `task test-e2e-manager`, `task test-e2e-signing` and `task test-image-refresh` are standard controller behavior checks and should normally run on the default `config-dir` install
+- `task test-e2e` is the standard controller behavior check and should normally run on the default `config-dir` install
+- `task test-image-refresh` validates the rebuild/reload workflow and is run explicitly by CI rather than as part of the normal `task test-e2e` path
- `task test-e2e-quickstart-helm` is the important validation for the Helm install path
- `task test-e2e-quickstart-manifest` is the important validation for the single-file manifest install path
-- `task test-e2e-full` is a full spec run that includes the bi-directional scenario, but it is still not the primary way to validate all install modes
+- `task test-e2e` is a broad spec run that includes the bi-directional scenario, but it is not the primary way to validate every install mode
## Lifecycle Layers
@@ -291,9 +289,9 @@ Current CI e2e matrix in [.github/workflows/ci.yml](/workspaces/gitops-reverser/
The `full` job currently runs:
-- `task test-e2e-full`
+- `task test-e2e`
-On this worktree, `task test-e2e` is now smoke-only. Before that, it was broader.
+`task test-e2e` runs the entire e2e package (there is no longer a smoke-only subset).
So "CI ran before" does not prove the fixture boundaries were healthy. It only proves the exercised combination of:
@@ -365,35 +363,21 @@ The e2e package currently contains:
| `signing_e2e_test.go` | `signing` | Commit signing behavior |
| `commit_window_batching_e2e_test.go` | `audit-consumer` | Commit-window batching through the audit pipeline |
| `commit_request_e2e_test.go` | `audit-consumer` | CommitRequest finalization through the audit pipeline |
-| `commit_author_attribution_e2e_test.go` | `manager`, `smoke` | OIDC claim to Git author attribution |
+| `commit_author_attribution_e2e_test.go` | `manager` | OIDC claim to Git author attribution |
| `image_refresh_test.go` | `image-refresh` | Build/load/restart invalidation chain |
| `quickstart_framework_e2e_test.go` | `quickstart-framework` | Installer-level quickstart flow |
| `bi_directional_e2e_test.go` | `bi-directional` | Flux plus gitops-reverser shared ownership |
| `demo_e2e_test.go` | `demo` | Demo repo preparation, intentionally leaves resources |
-## Smoke Suite Definition
+## Suite Definition
-`task test-e2e` now runs only `Label("smoke")`.
+`task test-e2e` runs the standard e2e package, excluding the rebuild-heavy `image-refresh`
+workflow check. There is no smoke-only subset and no `Label("smoke")`. The `smoke` label was
+removed from every spec.
-The smoke set is intended to answer one question:
-
-> Does the system work end to end at a high level?
-
-Currently that includes:
-
-- core controller health and metrics
-- audit webhook receipt
-- real GitProvider validation
-- secret encryption path
-- ConfigMap create/delete Git commits
-- cluster-scoped CRD install Git commit
-- audit pipeline batching, commit-request, and author-attribution paths
-- one signing verification path
-
-That keeps the default run closer to a product smoke test than to a full infra regression sweep.
-
-It should also stay anchored to the standard `config-dir` install. The smoke suite is not meant to prove all install
-methods on every run.
+It stays anchored to the standard `config-dir` install. The default run is not meant to
+prove all install methods on every invocation; the dedicated `quickstart-*` tasks cover
+the Helm and single-file manifest install paths.
## Recommended Way To Run E2E
@@ -412,16 +396,7 @@ Conceptually, this means:
- validate the product end to end on the standard `config-dir` install
- do not treat this as an install-matrix job
-### Feature-focused runs
-
-Use:
-
-```bash
-task test-e2e-manager
-task test-e2e-signing
-task test-image-refresh
-task test-e2e-full
-```
+CI runs `task test-image-refresh` explicitly for the rebuild/reload workflow check.
### Install-path validation
@@ -445,13 +420,15 @@ In other words:
Use:
```bash
-task test-e2e-full
+task test-e2e
+task test-e2e-quickstart-helm
+task test-e2e-quickstart-manifest
```
This is better suited for:
- larger refactors
-- CI jobs intended to catch slow-path regressions
+- CI jobs intended to catch slow-path regressions and install-mode drift
- release validation
## Notes About Commit Signing Validation
diff --git a/docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md b/docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md
new file mode 100644
index 00000000..389c33cc
--- /dev/null
+++ b/docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md
@@ -0,0 +1,498 @@
+# Contextual namespace support for real manifest folders
+
+> Status: investigation → partially implemented (graph-aware namespace inference and
+> ambiguity refusal landed in the manifest store; GitTarget-level refusal and
+> kustomize scoping still pending)
+> Captured: 2026-06-08
+> Updated: 2026-06-08
+> Related:
+> [file-agnostic-placement.md](file-agnostic-placement.md),
+> [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [current-manifest-support-review.md](current-manifest-support-review.md),
+> [version2/gittarget-repository-validity-and-placement.md](version2/gittarget-repository-validity-and-placement.md),
+> [version2/gittarget-new-file-placement-rules.md](version2/gittarget-new-file-placement-rules.md)
+
+## Summary
+
+The current writer can find and edit resources by content identity instead of by
+canonical path, which is the right direction for existing GitOps folders. The
+next hard edge is namespace-less namespaced YAML:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+data:
+ color: blue
+```
+
+This is valid input for Kustomize when a nearby `kustomization.yaml` supplies a
+namespace, but the raw YAML no longer contains the full Kubernetes object
+identity. Gitops-reverser therefore needs a namespace context model before it
+can safely edit, delete, deduplicate, or create resources in these folders.
+
+The spike in the working tree proved the useful behavior:
+
+- a readable fixture folder with `kustomization.yaml`;
+- multi-document YAML;
+- nested YAML files;
+- hand-authored comments;
+- `kubectl apply -k` as the starting cluster state;
+- in-place edits that should preserve comments and avoid canonical duplicates.
+
+It also showed that a narrow "nearest kustomization namespace" heuristic is too
+thin to bless as architecture. It can make the happy-path test pass, but it does
+not yet define the repository validity rules, API surface, or failure behavior.
+
+## Problem statement
+
+For namespaced resources, the system needs two identities:
+
+| Identity | Source | Used for |
+|---|---|---|
+| Raw manifest identity | `apiVersion`, `kind`, `metadata.name`, optional `metadata.namespace` | Editing the document as it is written in Git |
+| Effective resource identity | Raw identity plus namespace context | Matching live API events, duplicate detection, deletes, status |
+
+When `metadata.namespace` is absent, raw identity is not enough for a live
+resource. The missing namespace must come from an explicit, supported context.
+Without that, several unsafe outcomes are possible:
+
+- a live update creates a canonical duplicate instead of editing the existing
+ file;
+- a delete cannot find the namespace-less document;
+- two namespace-less documents with the same name in different app folders look
+ like duplicates when they are not, or fail to look like duplicates when they
+ are;
+- an in-place edit writes `metadata.namespace` into a file whose layout expects
+ Kustomize to own namespace injection;
+- resync/mark-and-sweep treats a namespace context change as mass create/delete.
+
+## Design constraints
+
+- Existing documents remain match-first. Once a resource is found in Git,
+ updates and deletes should use that location.
+- `kustomization.yaml` is auxiliary context, not a managed KRM document. It must
+ not be swept, patched as a Kubernetes object, or rewritten by the live writer.
+- New resources need explicit placement policy. Existing-resource editing and
+ new-file placement are related but separate decisions.
+- Namespace context is part of repository validity. A GitTarget should not go
+ live if it cannot explain the effective identity of the files it will manage.
+- The scanner and writer must use the same effective identity model in steady
+ state, resync, duplicate detection, and status.
+
+## Option A: keep explicit namespace only
+
+Always require `metadata.namespace` in namespaced resources. Kustomize folders
+can still be scanned, but namespace-less namespaced documents are rejected as
+unsupported repository content.
+
+Pros:
+
+- simplest and safest identity model;
+- no Kustomize semantics in the writer;
+- duplicate detection stays content-only.
+
+Cons:
+
+- rejects common GitOps layouts;
+- forces gitops-reverser to write fields that users intentionally centralize in
+ Kustomize;
+- undermines the "point at a real folder" goal.
+
+This remains the correct fallback when no supported namespace context exists.
+
+## Option B: GitTarget-level "omit namespace" setting
+
+Add a GitTarget setting that tells the writer not to write
+`metadata.namespace`, for example:
+
+```yaml
+spec:
+ manifestStyle:
+ namespace: Omit
+```
+
+This matches the intuition that a target pointed at one namespace may not need
+the namespace repeated in every document.
+
+Pros:
+
+- small API surface;
+- easy for single-namespace targets;
+- does not require parsing Kustomize to decide output style.
+
+Cons:
+
+- it is only an output preference, not a full identity source;
+- it is unsafe for targets that can watch multiple namespaces;
+- it does not explain which namespace a namespace-less existing document belongs
+ to unless paired with another setting;
+- it cannot handle folders where one subtree belongs to namespace `app-a` and
+ another subtree belongs to `app-b`;
+- it can produce YAML that `kubectl apply -f` cannot apply without additional
+ context.
+
+If this option is used, it should not be a bare boolean. It needs an explicit
+namespace source:
+
+```yaml
+spec:
+ manifestStyle:
+ namespace:
+ mode: Omit
+ value: app
+```
+
+That shape is only safe when the GitTarget's selected resources are constrained
+to the same namespace.
+
+## Option C: infer namespace from supported Kustomize context
+
+Scan `kustomization.yaml` files as auxiliary context. A namespace-less
+namespaced resource can be indexed when the scanner can prove that exactly one
+supported Kustomize namespace applies to it.
+
+Possible supported subset:
+
+- local `resources` entries that point to YAML files;
+- local `resources` entries that point to child directories with their own
+ `kustomization.yaml`;
+- `namespace:` as the only namespace transformer initially;
+- no generators, remote bases, components, patches, replacements, plugins, or
+ Helm inflators in the first write-capable subset.
+
+Pros:
+
+- fits real GitOps folders;
+- lets `kustomization.yaml` stay on the ignore/retain list while still providing
+ context;
+- supports multi-document files and nested folders when the graph is simple;
+- avoids writing `metadata.namespace` back into files whose namespace is owned by
+ the kustomization.
+
+Cons:
+
+- Kustomize semantics are graph-based, not simply "nearest parent file";
+- patches and generators can create or mutate resources with no direct source
+ document;
+- parent and child kustomizations can intentionally override namespace;
+- a resource file can be included by more than one kustomization;
+- Kustomize version drift matters if gitops-reverser tries to emulate too much.
+
+This option is viable only if the first implementation deliberately supports a
+small graph and rejects ambiguous cases.
+
+## Option D: render with Kustomize, edit source files by source map
+
+Run `kustomize build` to discover effective resources, then map each rendered
+resource back to its source file/document for in-place editing.
+
+Pros:
+
+- closest to what Flux/Argo/Kustomize will apply;
+- handles namespace transforms more accurately than a hand-rolled folder rule.
+
+Cons:
+
+- Kustomize does not provide a complete, stable source map for every transform;
+- generated resources and patches are not cleanly editable at the rendered
+ object location;
+- editing source files from rendered diffs can be surprising or impossible;
+- introduces an external semantic dependency into every GitTarget scan.
+
+This is a good future research path, but it is too broad for the first safe
+namespace-context implementation.
+
+## Recommended direction
+
+Use a contextual namespace model, not a simple "skip namespace" toggle.
+
+First safe version:
+
+1. Keep the default behavior explicit: new files include `metadata.namespace`.
+2. Support editing existing namespace-less documents only when the scanner can
+ derive exactly one namespace context from a supported source.
+3. Treat `kustomization.yaml` as retained auxiliary context, never as managed KRM.
+4. Record both raw and effective identities in the manifest store.
+5. Preserve the document's namespace style on update:
+ - if namespace is explicit in the file, keep it explicit;
+ - if namespace came from supported context, do not write
+ `metadata.namespace`;
+ - if context is missing or ambiguous, refuse the GitTarget as repository
+ invalid rather than creating a duplicate.
+6. Add placement policy later for creating new namespace-less files under a
+ known context. Until then, new resources fall back to explicit namespace
+ canonical placement.
+
+This lets existing real folders work without promising that gitops-reverser can
+author every Kustomize layout from scratch.
+
+## Kustomize defines the managed set (scope)
+
+When a `kustomization.yaml` is present, it — not the filesystem — decides which
+documents are in scope. The rule:
+
+- **Follow the `resources` graph.** A document is part of the kustomize-managed
+ set only if it is reachable from a kustomization through `resources` (directly,
+ or transitively via a child directory base). Namespace context is attributed by
+ that graph, never by "nearest file on disk".
+- **A document outside the graph is not kustomize-managed.** A YAML file sitting
+ in (or under) a kustomized folder that no `resources` entry references is not
+ part of the build. We do not invent a namespace for it from a nearby
+ kustomization.
+
+What to *do* with those out-of-graph documents is a deliberate, simple choice and
+yes, it is easy to implement because the graph is already computed:
+
+- If the document carries its own full identity (explicit `metadata.namespace`, or
+ it is cluster-scoped), it is just an ordinary managed document — independent of
+ kustomize. Manage it normally.
+- If it is a namespace-less namespaced document with no graph entry, we **cannot**
+ explain its effective identity, so we **refuse the GitTarget** rather than guess
+ or silently mismatch. (See "Strict by default" below.)
+
+The point: kustomize narrows scope, it does not widen it. We only ever *read*
+`kustomization.yaml` as context; we never treat it as a managed object, and we
+never traverse anything but its `resources`/bases edges.
+
+## Bidirectionality: every edit must round-trip
+
+The contextual-namespace model is constrained by a requirement that runs through
+the whole writer: **an edit must be expressible in both directions.** A change
+observed on the live object must map cleanly onto the *source document* in Git
+(live → Git), and the source document as written must map cleanly onto the live
+object's identity (Git → live). The store already encodes this as two identities:
+
+- raw identity = what is literally in the file (may be namespace-less);
+- effective identity = raw identity plus context (the namespace a kustomization
+ supplies).
+
+`NamespaceSource` records which transform was applied so the writer can invert it:
+on write it strips the context-supplied namespace back out, and on lookup it
+matches the live event's effective identity to the file's raw identity. That is a
+clean, reversible 1:1 mapping.
+
+This is the deeper reason the unsupported list is what it is. Generators, patches,
+replacements, Helm inflation, and `namePrefix`/`nameSuffix` are **lossy or
+one-way**: the rendered object has no stable, invertible source location, so a
+live change cannot be written back to "the" source document, and the source
+document does not determine a single live identity. A transform we cannot invert
+breaks round-tripping, so it is refused for write-capable contextual management —
+not because parsing it is hard, but because the edit could not travel back.
+
+A corollary, already decided elsewhere: one source document owns exactly one live
+object, and one live object has exactly one editable source document. Overlays
+that apply one base to several namespaces violate this and must be refused (see
+the open question on namespace overlays).
+
+## Strict by default: refuse, with clear GitTarget status
+
+Start by refusing the hard questions, loudly, rather than handling them softly.
+The default posture for anything outside the supported subset is **refuse the
+GitTarget**, not "manage what we can and ignore the rest". A half-managed folder
+is the dangerous state: it is where silent duplicates, wrong-namespace edits, and
+unexplained deletes come from.
+
+Concretely:
+
+- Unsupported kustomize features, ambiguous namespace context, out-of-graph
+ namespace-less documents, remote bases, and overlay fan-out all resolve to a
+ GitTarget that does **not** go live.
+- The refusal is surfaced as a specific, human-readable `RepositoryValid=False`
+ status: the condition names *what* is unsupported and *where* (the offending
+ file or `kustomization.yaml`), so an operator can fix the repo or narrow the
+ GitTarget path. Status must never dump the whole source graph; it names the
+ first/representative offenders and counts the rest.
+- The store side of this already exists as build-time diagnostics
+ (`ambiguous-namespace`, `unresolved-mapping`, `scope-mismatch`); the pending
+ work is the `RepositoryValid` projection that turns those diagnostics into the
+ refusing condition.
+
+It is always safe to *widen* support later. It is expensive to walk back a folder
+we promised to manage and then corrupted. Refuse first.
+
+## API shape to consider
+
+A future API should separate namespace identity from output style:
+
+```yaml
+spec:
+ manifestStyle:
+ namespace:
+ existing: Preserve
+ newFiles: Explicit
+```
+
+Suggested modes:
+
+| Field | Mode | Meaning |
+|---|---|---|
+| `existing` | `Preserve` | Keep the style already present in Git |
+| `existing` | `Explicit` | Always write `metadata.namespace` on update |
+| `newFiles` | `Explicit` | New namespaced resources include `metadata.namespace` |
+| `newFiles` | `Contextual` | New resources may omit namespace only when placement selects a supported context |
+
+`Preserve` should be the default for existing files once contextual namespace
+support exists. `Explicit` should remain the default for new files until
+placement policy can prove where the namespace context lives.
+
+If a single-namespace GitTarget shortcut is still useful, model it as a context
+source, not an output toggle:
+
+```yaml
+spec:
+ namespaceContext:
+ type: Fixed
+ namespace: app
+```
+
+That should be valid only when WatchRules/ClusterWatchRules select resources
+from the same namespace.
+
+## Repository validity rules
+
+The validity scan should fail the GitTarget before live events start when:
+
+- a namespaced KRM document omits `metadata.namespace` and no supported context
+ supplies it;
+- two supported contexts claim different namespaces for the same source
+ document;
+- a namespace-less document is included by more than one kustomization with
+ different namespaces;
+- a supported context points outside the GitTarget path;
+- a managed file is produced only by a generator or patch and has no editable
+ source document;
+- two effective resource identities resolve to the same resource;
+- one effective resource identity has two editable source documents.
+
+These conditions belong with `RepositoryValid`, not with the live writer. The
+writer should receive a store whose effective identities are already trustworthy.
+
+## Writer implications
+
+What landed: the store keeps the **effective** identity on `DocumentModel`
+(`ManifestIdentity`, with the kustomize namespace already folded in) plus a
+`NamespaceSource` recording provenance. The raw identity is the effective identity
+with the context-supplied namespace stripped back out — derived on demand by the
+writer rather than stored twice:
+
+```go
+type DocumentModel struct {
+ ManifestIdentity manifestedit.Identity // effective: raw + context namespace
+ NamespaceSource NamespaceSource
+ // ...
+}
+
+type NamespaceSource struct {
+ Kind NamespaceSourceKind // Explicit | Kustomize | None (Fixed reserved)
+ Path string // kustomization path when Kind == Kustomize
+}
+```
+
+`NamespaceSource` replaced the spike's `NamespaceFromKustomize bool`: the bool
+captured only the output decision, while the kind/path also explains the
+no-context and ambiguous cases to status, duplicate diagnostics, and future
+placement. `dm.NamespaceInheritedFromContext()` (Kind == Kustomize) is the single
+predicate the writer reads.
+
+Write rules:
+
+- lookup existing documents by effective identity;
+- when patching a context-namespaced document, remove namespace from the desired
+ projection before passing it to `manifestedit`;
+- when deleting, match current bytes by effective identity, not raw identity;
+- when appending or creating, use placement policy to decide whether a
+ namespace context exists; otherwise render explicit namespace;
+- use the same identity logic in resync and steady-state event flushing.
+
+## Kustomize subset proposal
+
+For the first implementation, support only this shape:
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+ - bundle.yaml
+ - nested/sidecar.yaml
+```
+
+Optionally support a child directory when the child directory has its own
+`kustomization.yaml` and no conflicting parent namespace rules. Do not support
+remote bases, generators, patches, replacements, components, plugins, or Helm
+inflation for editable namespace inference yet. Those can remain valid Git files,
+but the GitTarget should mark them unsupported for write-capable contextual
+namespace management.
+
+The important distinction: the implementation should follow the `resources`
+graph, not just search for the nearest `kustomization.yaml` by filesystem path.
+
+## Supported and unsupported example folders
+
+The supported/unsupported boundary is concrete, so it is pinned by a corpus of
+small example folders rather than prose alone. They live under
+`internal/manifestanalyzer/testdata/contextual-namespace/` and are exercised by a
+table-driven test that builds the store over each folder and asserts the outcome
+(effective namespace + `NamespaceSource`, or the refusing diagnostic). The corpus
+is meant to grow — every new "can we support X?" question should arrive as a new
+folder.
+
+| Folder | Shape | Expected outcome |
+|---|---|---|
+| `supported/flat-namespace` | one kustomization, `namespace:`, flat `resources` | namespace inherited (`Kustomize`) |
+| `supported/nested-base` | parent `namespace:` + child dir base with no namespace | namespace propagates through the graph |
+| `supported/multi-doc` | a multi-document file in `resources` | every document inherits |
+| `supported/explicit-namespace` | `metadata.namespace` written in the file | kept as-is (`Explicit`) |
+| `unsupported/ambiguous-two-roots` | two roots assign different namespaces to one file | refused (`ambiguous-namespace`) |
+| `unsupported/patches` | `patches:` present | not a namespace source (`None`) |
+| `unsupported/generators` | `configMapGenerator:` present | not a namespace source |
+| `unsupported/components` | `components:` present | not a namespace source |
+| `unsupported/helm` | `helmCharts:` present | not a namespace source |
+| `unsupported/remote-base` | `resources:` points at a remote base | not a namespace source |
+| `unsupported/name-prefix` | `namePrefix:` present (identity-mutating) | not a namespace source |
+| `unsupported/no-context` | namespace-less namespaced doc, no kustomization | `None` (GitTarget should refuse) |
+
+Today the store records the per-document outcome (`NamespaceSource` and the
+diagnostics); the `unsupported/*` folders that currently resolve to `None` are the
+inputs the pending `RepositoryValid` refusal will turn into a failed GitTarget.
+
+## E2E test shape
+
+The fixture-backed e2e test is still the right acceptance test once the design is
+implemented:
+
+- `test/e2e/fixtures/inplace-edit-folder/kustomization.yaml` sets the namespace;
+- resource YAML omits `metadata.namespace`;
+- one file is multi-document YAML;
+- one resource lives under a nested folder;
+- comments are present and must survive edits;
+- the test starts with `kubectl apply -k`;
+- after edits, no canonical duplicate appears;
+- `kustomization.yaml` is unchanged;
+- resource YAML still omits `metadata.namespace`.
+
+The negative cases live at unit level as the example-folder corpus
+(`internal/manifestanalyzer/testdata/contextual-namespace/`), so the one e2e stays
+a single happy-path acceptance test rather than a matrix:
+
+- namespace-less namespaced resource with no context;
+- two kustomizations assigning different namespaces to the same source file;
+- unsupported Kustomize features in a write-capable target.
+
+## Open questions
+
+- Should `RepositoryValid` reject unsupported Kustomize files outright, or allow
+ the GitTarget to go live for the explicit-namespace resources in the same
+ folder?
+- Should namespace context come only from Git, or can a WatchRule namespace be a
+ context source for namespaced WatchRules?
+- Should `ClusterWatchRule` ever allow `Fixed` namespace context, or is that too
+ easy to misconfigure?
+- What status payload should explain contextual namespace decisions without
+ dumping large source graphs into GitTarget status?
+- Is there a future need to support namespace overlays where the same base file
+ is intentionally applied to multiple namespaces? If yes, that conflicts with
+ "one source document owns one live object" and probably needs to be refused by
+ this controller.
diff --git a/docs/design/manifest/current-manifest-support-review-feedback.md b/docs/design/manifest/current-manifest-support-review-feedback.md
new file mode 100644
index 00000000..8797c35f
--- /dev/null
+++ b/docs/design/manifest/current-manifest-support-review-feedback.md
@@ -0,0 +1,211 @@
+# Architecture Review Feedback: Current Manifest Support Review
+
+> Status: feedback on
+> [current-manifest-support-review.md](current-manifest-support-review.md),
+> captured 2026-06-04. Grounded against the actual code, including the parts the
+> reviewed document does not mention.
+
+## Verdict
+
+The recommended direction — materialized model, dual identity index, first-class
+plan, plan→apply→dirty-flush as the one write mechanism — is **right**, and it
+should be pursued. The low-level reads are accurate: `manifestedit` is a good
+mechanism layer; the writer is the part that "feels off."
+
+But the document has **one major blind spot that undermines its own diagnosis**,
+plus an **internal contradiction** in its most dangerous policy decision, and it
+over-flattens settled decisions with open questions. Fix those three and this
+becomes a strong directional doc.
+
+---
+
+## 1. The blind spot: the review never mentions `FolderReconciler`, which is where the real disease lives
+
+The doc frames the problem as "the live writer in
+[git.go](../../../internal/git/git.go) processes events one at a time" and
+proposes making the inventory authoritative *in the writer*. But the writer is
+**downstream** of the actual reconcile engine, which the doc never names:
+[folder_reconciler.go](../../../internal/reconcile/folder_reconciler.go).
+
+What is actually happening today is worse than "event-by-event," and in a way
+that strengthens the case for the rewrite. There are **three** independent
+"compare git ↔ desired" engines using **two incompatible identity models**:
+
+| Engine | Identity model | Git side sourced from |
+|---|---|---|
+| `FolderReconciler.findDifferences` (the *real* production diff) | **GVR** `ResourceIdentifier` | **path-parsed** via `parseIdentifierFromPath` |
+| `manifestLocator` + `reconcileAgainstExisting` (the writer) | **content GVK** `manifestedit.Identity` | `manifestedit.Inventory` (content scan) |
+| `manifestreport.BuildReport` (read-only, ~dead in prod) | content GVK | `manifestedit.Inventory` |
+
+The headline problem is not "event-by-event." It is that **the authoritative
+diff (`FolderReconciler`) decides creates and deletes from path-derived GVR
+identity** (`listResourceIdentifiersInPath` → `parseIdentifierFromPath`), then
+**hands a flat `[]git.Event` to the writer, which re-scans the same tree by
+content identity** to place each one. The two scans happen at different layers,
+at different times, against possibly different commits, with two different
+notions of "what resource is this."
+
+That is the actual reason "DELETE placement is incomplete when delete events only
+carry GVR/name" (Cons & Gaps in the reviewed doc): a manifest moved off its
+canonical path is *invisible to the reconciler's git scan entirely* —
+`parseIdentifierFromPath` derives identity from the path, so a moved file either
+parses to the wrong identity or not at all. The reconciler then emits a CREATE
+(duplicate at the canonical path) while never seeing the moved copy. The writer's
+content-based locator cannot save it because the reconciler already made the
+wrong decision upstream.
+
+**The proposed `ManifestStore` with both `ByManifestIdentity` and
+`ByResourceIdentity` is exactly the fix** — but the doc presents it as "promote
+the locator cache," when what it really does is **collapse three engines and two
+identity models into one**. Say that. It is a much stronger justification than
+the one currently written, and it tells the reader that `parseIdentifierFromPath`
+and `listResourceIdentifiersInPath` are the things being deleted.
+
+The "Current Architecture" mermaid reinforces the blind spot: it draws the
+writer, `manifestedit`, and `manifestreport`, but omits `reconcile`, `watch`, and
+`events` — i.e. it omits the layer that actually owns the diff. Redraw it to
+include `FolderReconciler` and the cluster/repo-state control events, or the
+diagram is describing a subsystem, not the system.
+
+## 2. Where the store and plan must live — the layering question the doc doesn't resolve
+
+Because the doc skips `FolderReconciler`, it never confronts a hard fact: **the
+desired/cluster state lives in the reconcile layer** (`clusterObjects`, already
+"cluster-as-source-of-truth"), while **the worktree lives in the writer**. The
+doc's data structures put `ManifestStore` "owned by the writer or a new
+integration package" and place the plan there too — but the writer only receives
+events; it has no cluster snapshot to plan against.
+
+The clean answer that matches the existing `fs.FS` purity in
+[analyzer.go](../../../internal/manifestanalyzer/analyzer.go) is to make the
+**Plan the cross-layer contract**, computed from pure inputs:
+
+```text
+ManifestStore = f(fs.FS) // pure, no cluster, no runtime — already exists
+Plan = f(ManifestStore, desiredSet, policy) // pure — this is BuildReport, graduated
+applied = f(ManifestStore, Plan) // pure mutation
+Flush = f(applied, worktree) // the only side effect
+```
+
+Then the *reconcile layer* (which has the cluster snapshot) builds the store and
+computes the plan; the *writer* becomes a dumb "apply plan + flush"; *scan mode*
+is "compute plan, render, don't flush"; the *CLI* is "compute plan, render";
+*status* is "summarize plan." All four consumers fall out of one pure function.
+The doc gestures at this ("the plan is already a first-class value") but its
+struct placement quietly contradicts it by nesting store+plan inside the writer.
+Resolve it in favor of the pure boundary and push it **up** to the layer that
+owns desired state.
+
+Corollary the doc should state: **the plan is valid only for a `(commit SHA,
+cluster snapshot revision)` pair.** Today `FolderReconciler` waits for two
+independently-delivered async events (`OnClusterState`, `OnRepoState`) and
+reconciles when both have *ever* arrived — there is no shared revision. The
+"single repository transaction" trust model borrowed from `BuildReport` is the
+right instinct; extend it to pin both sides to one snapshot, or plan-then-flush
+will flush a plan computed against a stale tree.
+
+And say it plainly: **`manifestreport.BuildReport` is not a throwaway — it
+graduates into the Plan computation.** It is already 80% of the planner
+(create/update/delete/skip, duplicate losers, non-editable handling) and is
+currently near-dead in production (only `EditInPlace` is called live). The doc
+treats the *analyzer* as the seed of the model; `BuildReport` is the seed of the
+*plan*. Connect them.
+
+## 3. The internal contradiction: "prune unwatched KRM by default" vs "refuse by default"
+
+This is the riskiest decision in the doc and it contradicts itself:
+
+- **Acceptance Checks:** "**Unwatched (bucket 4) is pruned.** … We deliberately
+ choose pruning over the safer-looking 'leave it inert' option." Stated as *the
+ rule*.
+- **Adoption Policy:** "`refuse` (safest, good default for first materialization)"
+ and "`prune` … should be opt-in."
+
+These cannot both be the default. As written, a literal reader implements "delete
+every KRM doc with no matching watched API resource" — which **deletes
+`kustomization.yaml`, every unwatched CRD, every Flux `Kustomization`/`HelmRelease`
+not watched, sealed secrets, etc.** in any real GitOps repo. The doc notices the
+`kustomization.yaml` edge but treats destruction as the default and preservation
+as the escape-hatch allowlist. That is the safety polarity inverted.
+
+Strong recommendation: **make unwatched-KRM pruning opt-in per GVK (a
+prune-allowlist of kinds you are willing to delete), not opt-out with an
+exemption-allowlist.** Reasons: (a) it matches the doc's own `refuse` default;
+(b) the analyzer already proved how fragile "valid KRM" classification is — the
+`generateName`-only object gets misfiled as non-KRM today, and a misclassification
+on the prune path *deletes data*; (c) the source-of-truth conviction justifies
+pruning resources you *manage*, but an unwatched kind is by definition one you
+have made no claim over — deleting it asserts authority you explicitly declined to
+take. Keep the strong conviction for *watched* GVKs; downgrade unwatched to
+"report, prune only on explicit per-kind opt-in."
+
+Separately: the conviction "the API is the source of truth, git is a pure
+projection" is stated as "non-negotiable," but it is a **product decision**, not
+an architectural one, and it is in tension with the tool being usable on a
+Flux-consumable repo that mixes generated and hand-authored manifests. The
+architecture should *support* the strict-projection mode without *mandating* it.
+Frame it as a configurable posture (the `refuse`/`scan`/`prune` settings already
+exist), not a conviction the data model bakes in.
+
+## 4. Smaller but real
+
+- **The RESTMapper / GVK↔GVR layer does not exist yet.** Grep finds `RESTMapper`
+ only in comments and docs, never in code. The doc leans on "resolve GVK to
+ watched GVR using the watch/catalog/RESTMapper layer" as if it is a layer to
+ call; it is a layer to *build*, and it must satisfy the analyzer's no-cluster
+ constraint (so it is an injected interface with a live-informer impl, a
+ kubeconfig impl, a static-snapshot impl, and a nil "structure-only" impl). This
+ is phase 2 in the doc but it is underweighted — it is the hardest
+ dependency-injection problem in the whole plan and everything in phases 3–7
+ depends on it. Promote it and spec the interface.
+
+- **Full materialization has an unbounded memory/CPU cost the doc waves away.**
+ `FileModel` holds `Original` + `Current` full bytes for *every* file, and the
+ plan says build node trees for all documents. For the cluster-wide CRD watch
+ the doc itself cites as the perf motivation, that is the entire tree in memory
+ and fully parsed per batch. Recommend bounded/lazy materialization: identity
+ indexing needs only a cheap header parse (`apiVersion`/`kind`/`metadata`), and
+ the expensive `manifestedit` node tree should be built **only for documents a
+ plan action touches**. Keep this as an explicit design constraint, not a
+ "phase 9 optimization" — it changes the `DocumentModel` shape (a
+ `SnapshotRef`/lazy handle rather than eager bytes), so retrofitting it later is
+ exactly the kind of rewrite the doc says it wants to avoid.
+
+- **The doc reads at uniform confidence across settled and unsettled decisions.**
+ Plan-then-flush, dual index, dirty-flush, "BuildReport graduates" — these are
+ settled and well-argued. Prune-unwatched default, kustomization allowlist,
+ refuse-vs-prune default — these are open product questions. Right now they are
+ written with the same authority, which makes the genuinely strong parts easier
+ to dismiss. Split into a short **Decisions (non-negotiable)** block and an
+ **Open policy questions** block. For a doc whose goal is "strong architectural
+ direction," that separation *is* the strength.
+
+## What to change in the reviewed doc
+
+1. Add `FolderReconciler` + the path-derived/content-derived dual-scan problem as
+ the headline diagnosis (and fix the architecture mermaid to include the
+ reconcile/watch/events layer).
+2. Resolve the store/plan layering explicitly toward the pure `f(fs.FS)` → `Plan`
+ → apply → flush boundary, with the plan as the cross-layer contract and
+ `BuildReport` named as its origin.
+3. Fix the prune default contradiction and invert the unwatched-prune safety
+ polarity to opt-in-per-GVK.
+4. Pull the RESTMapper/source abstraction and bounded materialization up into
+ first-class constraints.
+5. Split Decisions vs Open Questions.
+
+## Code references
+
+- [`internal/reconcile/folder_reconciler.go`](../../../internal/reconcile/folder_reconciler.go)
+ — the real production diff engine (cluster-as-source-of-truth), unmentioned by
+ the reviewed doc.
+- [`internal/git/helpers.go`](../../../internal/git/helpers.go) —
+ `parseIdentifierFromPath`, the path-derived GVR identity to be deleted.
+- [`internal/git/branch_worker.go`](../../../internal/git/branch_worker.go) —
+ `listResourceIdentifiersInPath`, the path-derived git scan.
+- [`internal/git/git.go`](../../../internal/git/git.go) — `manifestLocator` and
+ `reconcileAgainstExisting`, the content-derived second scan.
+- [`internal/manifestreport/report.go`](../../../internal/manifestreport/report.go)
+ — `BuildReport`, the near-dead planner prototype to graduate.
+- [`internal/manifestanalyzer/analyzer.go`](../../../internal/manifestanalyzer/analyzer.go)
+ — the existing pure `f(fs.FS)` boundary to extend.
diff --git a/docs/design/manifest/current-manifest-support-review.md b/docs/design/manifest/current-manifest-support-review.md
new file mode 100644
index 00000000..b9b1a14f
--- /dev/null
+++ b/docs/design/manifest/current-manifest-support-review.md
@@ -0,0 +1,1241 @@
+# Current Manifest Support Review
+
+> Status: architecture review, captured 2026-06-04
+> Related:
+> [implementation-plan.md](implementation-plan.md),
+> [reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md),
+> [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md),
+> [current-manifest-support-review-feedback.md](current-manifest-support-review-feedback.md),
+> [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [manifestedit-abstraction-plan.md](manifestedit-abstraction-plan.md),
+> [manifestedit-writer-followups.md](manifestedit-writer-followups.md),
+> [`internal/git/manifestedit/DECISION.md`](../../../internal/git/manifestedit/DECISION.md)
+
+## Summary
+
+The current manifest support is part-way through the move from path-derived
+storage to content-derived placement.
+
+The good news: the low-level manifest editor already understands multi-document
+YAML, can patch or delete one document without rewriting siblings, and has a
+clear comparison API. The writer also now does match-first placement for updates
+and object-backed deletes, so a manifest moved away from the generated path can be
+updated in place.
+
+The awkward part is deeper than "event-by-event." Today three separate engines
+compare git to the desired state, across two incompatible identity models: the
+production diff (`FolderReconciler`) decides creates/deletes from **path-derived**
+GVR identity, then hands a flat event list to the writer, which **re-scans** the
+same tree by **content** identity to place each one. `BuildReport` is a third,
+read-only, content-based comparison. The two scans run at different layers, at
+different times, with two different notions of "what resource is this."
+
+The recommended direction is one materialized in-memory model with both a
+manifest-identity and a resource-identity index, fed by a first-class plan that is
+the same value for the writer, scan mode, the CLI, and status. The initial
+reconcile is driven by a streaming-list watch and a mark-and-sweep against that
+model — see
+[reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md).
+The feedback that drove this sharpening is in
+[current-manifest-support-review-feedback.md](current-manifest-support-review-feedback.md).
+
+## Non-Negotiable Design Decisions
+
+These are settled decisions, not options. The rest of this document — especially
+the data model — is shaped by them.
+
+1. **A GitTarget takes total responsibility for the KRM it materializes.**
+ Adopting a folder is an all-or-nothing claim over every API-backed Kubernetes
+ manifest in it. There is no such thing as an API-backed KRM document that lives
+ in a managed folder while being "not ours." We either fully manage it, or we
+ refuse the folder.
+
+2. **No partially materialized multi-document file — ever.** A multi-document YAML
+ file is either entirely managed (every document is a tracked, in-scope resource
+ we own) or the GitTarget refuses the whole folder. Allowlisted non-API KRM must
+ live as its own retained file; it cannot share a multi-document file with
+ managed resources. We will not materialize some documents in a file and leave
+ the others as untracked passengers. That split state is exactly the drift this
+ design exists to remove, and it is the kind of thing that quietly corrupts a
+ file on the next write.
+
+3. **Refuse API KRM we do not own; retain allowlisted non-API KRM.** Anything
+ API-backed that we cannot take full responsibility for is an **acceptance
+ failure with an error condition**, not a silent exclusion:
+ - non-KRM YAML (a CI config, a loose values file),
+ - duplicate manifest identities,
+ - KRM of an unknown / unwatched API-backed GVK,
+ - watched KRM that falls **outside this GitTarget's scope** (right kind, wrong
+ namespace).
+ Each of these stops the GitTarget with a clear, file-naming diagnostic and
+ reconciles nothing until a human cleans the folder. We do not guess, and we do
+ not prune unwatched API-backed KRM. The one carve-out is an explicit allowlist
+ for non-API KRM such as `kustomization.yaml`: accepted, retained on disk,
+ never materialized, never swept, and never edited.
+
+4. **The rule is about manifests, not every byte.** Non-manifest files — non-YAML
+ such as `README.md`, `.gitignore`, images, and scripts — are not KRM, are never
+ materialized, and never cause a refusal. "Full responsibility" is over the
+ Kubernetes resources the folder projects, not over auxiliary files that are not
+ resources at all. Only YAML that *parses as KRM* is subject to the all-or-nothing
+ rule above, except for explicitly allowlisted non-API KRM retained outside the
+ model.
+
+5. **GitTargets never overlap.** Within a repository, no GitTarget path may be
+ equal to, an ancestor of, or a descendant of another GitTarget's path. Sibling
+ folders are fine (`/a` and `/b`); nesting is forbidden (`/a` *and* `/a/b`).
+ Overlap would let two targets fight over which documents each one tracks — the
+ same two-owners drift this design exists to remove — and it would make
+ mark-and-sweep ambiguous (whose orphan is a document in the shared subtree?).
+ This is enforced when a GitTarget is admitted/configured: a target whose path
+ overlaps an existing one is rejected before it ever builds a store, so every
+ materialized folder has exactly one owner.
+
+**Why this matters to the model.** Because no API-backed KRM document is ever a
+non-member, the in-memory model is dramatically simpler and safer:
+
+- `FileModel.Documents` is exactly the set of managed documents — there is no
+ hidden API-backed document a managed file holds without the model knowing.
+- **Retained allowlisted files are not `FileModel`s in the store at all.** Like
+ non-YAML auxiliary files, they are known to acceptance but live outside
+ `ManifestStore.FilesByPath` (see Concrete Data Structures), so they have no
+ document set to empty and can never be swept or deleted.
+- File deletion is driven by the **byte-derived `Deleted()`** — a hydrated file
+ whose last managed document was dropped, so `Current` became nil — never by a
+ bare `len(Documents) == 0` test. That distinction is load-bearing now that the
+ allowlist exists. Because the store holds only managed files, an empty managed
+ document set safely means an empty file.
+- "Membership" is not a permanent per-document partition we maintain; it is simply
+ the acceptance outcome. Acceptance passes and every document is a member, or
+ acceptance fails and the GitTarget reconciles nothing. Allowlisted non-API KRM
+ is outside the model entirely.
+
+## Current Architecture
+
+```mermaid
+flowchart TD
+ subgraph Inputs
+ A[Watch or audit event]
+ B[Git worktree at GitTarget path]
+ C[Live object or ResourceIdentifier]
+ end
+
+ subgraph Reconcile["internal/reconcile + watch — the real diff"]
+ W[Cluster snapshot]
+ V[listResourceIdentifiersInPath: path-derived GVR scan]
+ U[FolderReconciler.findDifferences]
+ end
+
+ subgraph Writer["internal/git live writer"]
+ D[BranchWorker applyPendingWriteEvents]
+ E[manifestLocator]
+ F{Canonical file exists?}
+ G[IndexDir once per base path]
+ H[manifestTarget path + documentIndex]
+ I[handleCreateOrUpdateOperation]
+ J[handleDeleteOperation]
+ end
+
+ subgraph ManifestReport["internal/manifestreport"]
+ K[Project via sanitize.Sanitize]
+ L[Render via sanitize.MarshalToOrderedYAML]
+ M[EditInPlace]
+ N[BuildReport read-only reconcile]
+ end
+
+ subgraph ManifestEdit["internal/git/manifestedit"]
+ O[IndexFiles / IndexDir]
+ P[splitDocuments / joinDocuments]
+ Q[Inventory by manifest identity]
+ R[Decide Comparison]
+ S[Apply patch / replace / delete]
+ T[DeleteDocument]
+ end
+
+ A --> D
+ C --> D
+ B --> V
+ W --> U
+ V --> U
+ U -->|flat event batch| D
+ B --> E
+ D --> E
+ E --> F
+ F -- yes --> H
+ F -- no --> G
+ G --> O
+ O --> P
+ O --> Q
+ Q --> H
+ H --> I
+ H --> J
+ I --> K
+ I --> L
+ I --> M
+ M --> O
+ M --> R
+ R --> S
+ J --> T
+ T --> P
+ N --> O
+ N --> R
+```
+
+## How It Works Today
+
+`manifestedit` is the lowest-level mechanism. It scans YAML files, splits
+multi-document files with a byte-preserving splitter, derives manifest identity
+from `apiVersion`, `kind`, `metadata.namespace`, and `metadata.name`, detects
+duplicates, and exposes `Decide` / `Apply` for a single existing document.
+
+`manifestreport` is the integration layer. It supplies policy that
+`manifestedit` intentionally does not own: the sanitized Git projection and the
+canonical renderer. `BuildReport` already compares an inventory to a desired
+object set, but it is read-only. `EditInPlace` is used by the live writer as a
+helper for formatting-preserving updates.
+
+The live writer in `internal/git/git.go` still processes events one at a time.
+For each batch it creates a `manifestLocator`, which can scan the GitTarget path
+once per base path and cache the resulting `manifestedit.Inventory`. Location is
+match-first: if the canonical generated file exists, use it; otherwise scan the
+inventory and find the existing document by manifest identity. If nothing is
+found, create at the generated path.
+
+Deletes call `manifestedit.DeleteDocument`, so deleting from a multi-document
+file removes only the targeted document and deletes the file only when no
+documents remain.
+
+## Key Code Paths
+
+- [`internal/git/manifestedit`](../../../internal/git/manifestedit) owns YAML
+ splitting, inventory, comparison, in-place patching, and per-document deletion.
+- [`internal/manifestreport`](../../../internal/manifestreport) supplies the
+ sanitizer/renderer policy and exposes the read-only report plus `EditInPlace`.
+- [`internal/git/git.go`](../../../internal/git/git.go) owns the production
+ locator, create/update handling, and delete handling.
+- [`internal/git/commit_executor.go`](../../../internal/git/commit_executor.go)
+ creates one `manifestLocator` per pending write batch.
+- [`internal/types/identifier.go`](../../../internal/types/identifier.go) defines
+ the GVR-based `ResourceIdentifier` used by events and generated paths.
+
+## What Works Well
+
+- The low-level YAML document support is stronger than the writer shape around
+ it. `splitDocuments` / `joinDocuments` preserve sibling document bytes, and
+ `DeleteDocument` handles per-document deletion.
+- `manifestedit.Decide` and `Apply` have the right conceptual split: preflight is
+ pure, application reparses and validates a snapshot before mutating bytes.
+- The mechanism/policy boundary is mostly healthy. Sanitization and canonical
+ rendering live in `manifestreport`, while `manifestedit` stays focused on YAML
+ and manifest identity.
+- Match-first placement is the right invariant for existing repositories: update
+ where the manifest already lives, and use generated placement only for new
+ resources.
+- Duplicate detection already exists in the inventory, with deterministic
+ first-occurrence-wins behavior.
+- Multi-document data-loss prevention exists in the live writer: if an in-place
+ edit cannot be applied to a multi-doc file, the writer refuses the unsafe
+ wholesale fallback instead of dropping sibling documents.
+
+## Cons And Gaps
+
+- **The git tree is scanned twice, with two identity models, in two layers.** The
+ production diff (`FolderReconciler.findDifferences`) lists git resources by
+ **path** (`listResourceIdentifiersInPath` → `parseIdentifierFromPath`, a GVR
+ derived from the file path) and decides creates/deletes; the writer then
+ **re-scans** by **content** (`manifestedit.Inventory`) to place each event. A
+ manifest moved off its canonical path is invisible to the path scan, so the
+ reconciler emits a spurious create at the canonical path and never sees the moved
+ copy — the writer's content match cannot fix a decision already made upstream.
+ This is the real disease the materialized model cures, and `parseIdentifierFromPath`
+ / `listResourceIdentifiersInPath` are what it deletes.
+- The inventory is not the source of truth for the writer. It is a locator cache
+ used opportunistically after a canonical-path stat fast path.
+- DELETE placement is still incomplete when delete events only carry GVR/name and
+ no live object. The inventory is keyed by GVK/name, so moved manifests cannot be
+ found unless the delete event includes manifest identity or the writer can map
+ GVR to GVK.
+- Duplicate cleanup is report-only. The inventory can find duplicate losers and
+ `BuildReport` can classify them as deletes, but the writer does not act on them.
+ The decided behavior is to *refuse* duplicate identities at acceptance rather
+ than prune them (see Adoption Policy), so this gap closes by refusal, not by a
+ prune feature.
+- GVK and GVR are still split across layers. `manifestedit.Identity` is GVK-based;
+ `types.ResourceIdentifier` and watch events are GVR-based; there is no central
+ model that records both and indexes both.
+- Multi-document support is real in `manifestedit`, but not first-class in the
+ writer. The writer still starts from "event -> target file" and then guards
+ against multi-doc hazards, instead of planning against documents as primary
+ entities.
+- Semantic no-op detection in the writer canonicalizes YAML as a single object.
+ That is useful for generated one-object files, but it is not a complete
+ multi-document comparison model.
+- Encrypted manifests are intentionally not patchable in place. That is a good
+ safety rule, but it means the inventory must distinguish "authoritative
+ location" from "editable content" everywhere.
+- The current scan cache is per write batch. That avoids O(events x tree), but it
+ is still not an incrementally maintained repository model.
+
+## Recommended Direction
+
+Move to a fully materialized in-memory manifest model per GitTarget path.
+
+This rests on one strong, non-negotiable conviction: **the Kubernetes API is the
+source of truth.** The GitTarget folder is a materialized projection of the
+watched API resources, not a repository the writer merely edits alongside.
+
+That conviction cuts two ways, and the cut is the resolution of what used to be a
+contradiction in this document. When a GitTarget instructs us to take a folder
+under control and reconcile it, we accept a serious duty — we cannot be sloppy and
+leave wrong files in place — but we can discharge that duty conservatively:
+
+- **For watched (tracked) KRM, we keep the folder honest.** A watched document
+ whose resource the API no longer has is wrong, and we **drop** it. This is by
+ design and intentional: leaving a stale managed manifest would reintroduce a
+ second, drifting source of truth — exactly what this design exists to remove.
+- **For API-backed KRM we do *not* manage, we refuse rather than delete.**
+ Unwatched KRM, duplicate identities, and non-KRM YAML are content we are not
+ entitled to take responsibility for. Instead of guessing — and instead of
+ deleting files a human authored — we **fail the GitTarget with a clear status
+ and reconcile nothing until the folder is cleaned**. Allowlisted non-API KRM is
+ retained outside the managed model.
+
+So the source-of-truth duty is discharged by *dropping the managed resources we
+own that the API no longer has*, and by *refusing the whole folder* when it
+contains API-backed KRM we are not entitled to manage. We do **not** prune
+unwatched API-backed KRM. Allowlisted non-API KRM is accepted only as retained
+auxiliary input: no model record, no plan action, no sweep.
+
+### What the model collapses, and where it lives
+
+The single model replaces three things with one: the path-derived diff in
+`FolderReconciler`, the content re-scan in the writer, and the read-only
+`BuildReport`. `BuildReport` is not discarded — it is **the seed of the plan** (it
+already does create/update/delete/skip against an inventory).
+
+The plan is the **cross-layer contract**, computed from pure inputs:
+
+```text
+# per event — cheap: no worktree I/O, no YAML parse; coalesces last-writer-wins per identity
+PendingChanges = fold(watch events)
+
+# per commit boundary — bounded; fired by the existing batch/commit mechanism
+ManifestStore = f(fs.FS headers) # cheap header parse; resident, byte-free
+touched = locate(ManifestStore, PendingChanges) # only the files the batch references
+hydrated = read + snapshot(touched) # Original bytes + node trees, touched files ONLY
+Plan = f(ManifestStore, hydrated, PendingChanges, policy)
+applied = f(hydrated, Plan) # pure mutation of the touched files
+Flush = f(applied, worktree) # the only side effect; one commit
+```
+
+The analyzer / scan / CLI / status are the same pipeline with `Flush` omitted; the
+structure-only analyzer stops after `ManifestStore` (no API source, no hydration).
+The whole-folder cost is the cheap header parse — everything expensive is
+proportional to the batch, not the folder, and not to the event rate.
+
+Store and plan are therefore computed in the layer that owns the cluster state
+(reconcile), the writer becomes a dumb "apply plan + flush," and scan mode / CLI /
+status are the same function with the flush omitted. A plan is valid for exactly
+one **`(commit SHA, cluster snapshot revision)`** pair; the streaming-watch
+bookmark (see the reconcile doc linked above) pins that revision.
+
+Two constraints to respect from the start, not retrofit:
+
+- **The GVK↔GVR resolver must be built**, as an injectable source (live
+ informers, kubeconfig, static snapshot, or nil for structure-only). Everything
+ downstream depends on it; see
+ [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md).
+- **Materialization must be bounded — spatially and temporally.** *Spatially:*
+ identity indexing needs only a cheap header parse; build the full `manifestedit`
+ node tree only for documents a plan action touches (`DocumentModel.Snapshot` as a
+ lazy handle, not eager bytes for every document). *Temporally:* the per-event hot
+ path records only a coalesced intent (`PendingChanges`); file bytes are read and
+ documents hydrated only at the commit boundary, and only for the files that batch
+ touches. The resident `ManifestStore` is therefore byte-free. See
+ [Two boundaries](#two-boundaries-cheap-per-event-materialize-per-commit) below.
+
+```mermaid
+classDiagram
+ class ManifestStore {
+ +FilesByPath
+ +ByManifestIdentity
+ +ByResourceIdentity
+ +ByGVK
+ +Diagnostics []Diagnostic
+ }
+
+ class FileModel {
+ +Path string
+ +Original []byte
+ +Current []byte
+ +Documents []DocumentModel
+ +Dirty() bool
+ +Deleted() bool
+ }
+
+ class DocumentModel {
+ +ManifestIdentity ManifestIdentity
+ +ResourceIdentity *ResourceIdentifier
+ +Mapping MappingStatus
+ +Editable bool
+ +Cause DocumentCause
+ +Snapshot SnapshotRef
+ }
+
+ class ManifestIdentity {
+ +APIVersion string
+ +Kind string
+ +Namespace string
+ +Name string
+ }
+
+ class ResourceIdentifier {
+ +Group string
+ +Version string
+ +Resource string
+ +Namespace string
+ +Name string
+ }
+
+ ManifestStore "1" --> "*" FileModel
+ FileModel "1" --> "*" DocumentModel
+ DocumentModel --> ManifestIdentity
+ DocumentModel --> ResourceIdentifier
+```
+
+The resident structure index is built once from the checked-out commit/worktree
+snapshot with a **cheap header parse** (no full node trees, no eager bytes), then
+maintained incrementally. The batch planner runs at the commit boundary:
+
+1. Header-scan all YAML files under the GitTarget path (`apiVersion`/`kind`/
+ `metadata` only); split files into document models without building node trees.
+2. Derive manifest identity for valid KRM documents.
+3. Resolve manifest GVK to watched GVR using the watch/catalog/RESTMapper layer.
+4. Populate indexes by manifest identity, resource identity, and GVK.
+5. Fold the batch's coalesced `PendingChanges` (desired live resources) against the
+ index, and **hydrate only the files those changes reference** — read their bytes
+ and build `manifestedit` snapshots for the touched documents.
+6. Produce a plan: create, patch, whole-replace, delete document, delete file,
+ drop a watched resource the API no longer has, skip. (Duplicate identities and
+ unwatched API-backed KRM never reach planning — they are refused at acceptance;
+ allowlisted non-API KRM is retained outside the model.)
+7. Apply the plan to the hydrated file models.
+8. Flush changed files and staged deletions to the worktree in one commit.
+
+## Why This Fits The Requirements
+
+Multiple manifests in one file become normal because the unit of planning is a
+document record, not a generated file path.
+
+Deletion becomes less special. A delete plan targets a `RecordRef`, and the file
+model decides whether removing that document leaves a file to rewrite or a file
+to delete.
+
+GVK lookups become cheap because the store owns explicit indexes. GVR lookups also
+become cheap once each record carries both manifest identity and resolved resource
+identity.
+
+Duplicate handling becomes an ordinary acceptance check. A duplicate identity
+fails the GitTarget before planning, so the writer never has to decide which copy
+wins or delete one a human authored.
+
+Status and diagnostics become bounded summaries of the store and plan, instead of
+being re-derived in multiple layers.
+
+## Writer Model: Plan, Apply, Dirty Flush
+
+The write path should have exactly one mechanism:
+
+```text
+scan worktree -> ManifestStore
+ManifestStore + desired API state -> Plan
+Plan applied to mutable ManifestStore
+Mutable ManifestStore -> flush dirty/deleted files
+```
+
+The important choice is that the model is organized around **files and documents**,
+not only object records — so a plan can express multi-document edits, document
+deletion, and file deletion. The resident model stays byte-free; the bytes for a
+file are hydrated only at the commit boundary (see Two boundaries below), and once
+the plan is applied the hydrated file model is the source of truth for what will be
+written to Git.
+
+Do **not** render every object model back to files on each commit. That would be
+simple, but it would discard hand-authored formatting, make multi-document files
+feel like an exception, and bypass the existing `manifestedit` preservation
+mechanism.
+
+Also do **not** keep only a list of changed or removed resources. That repeats
+the current split-brain shape: resource-level changes still need file-level and
+document-level context for multi-document edits, document deletion, file deletion,
+duplicate cleanup, and moved manifests.
+
+Instead, build the structure model once per checked-out GitTarget path (cheap
+header parse, byte-free), hydrate only the files a batch touches, apply the plan to
+those, and flush only the files whose bytes changed or whose file was deleted.
+
+The initial reconcile (and any resync) is the same mechanism with its deletes
+derived differently: a streaming-list watch folds every existing object over the
+store, and a **mark-and-sweep** drops the watched, in-scope documents the stream
+never touched. Untracked content is never a member of that swept model, so it can
+never be deleted by construction. Steady-state events are single plan actions over
+the maintained store, not a re-sweep. See
+[reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md).
+
+### Two boundaries: cheap per event, materialize per commit
+
+The model has a **temporal** boundary as sharp as its spatial one, and it lines up
+with a mechanism the writer already has: events are coalesced into a single commit.
+A GitTarget can take a high rate of watch events, and most of them never need to
+touch a byte of YAML — many are no-ops, supersede each other, or cancel out before
+the next commit fires. So the write path is two-tier:
+
+- **Per event (hot path — no I/O, no YAML parse).** A watch event resolves to a
+ resource identity (via the mapper) and is recorded in a `PendingChanges` buffer,
+ last-writer-wins per identity. A create-then-delete within one batch cancels; a
+ create-then-modify keeps only the final desired object. This is bookkeeping on a
+ map keyed by `ResourceIdentifier`; it never reads the worktree, never splits YAML,
+ never builds a `manifestedit` node tree.
+- **Per commit (cold path — bounded materialization).** When the existing batch /
+ commit mechanism fires, the coalesced `PendingChanges` are resolved against the
+ resident, byte-free structure index, and only the files those changes reference
+ are **hydrated**: their `Original` bytes are read, the touched documents get their
+ `manifestedit` snapshot built, the plan is computed and applied, and dirty/deleted
+ files flush into one commit.
+
+This is the **temporal** twin of bounded materialization. The spatial constraint
+("build the node tree only for documents a plan action touches") bounds the work
+*across the folder*; the commit boundary bounds it *across the event stream*.
+Together, the cost of a batch is proportional to *what actually changed in that
+batch* — not to the size of the folder, and not to the number of events that
+arrived.
+
+Two things keep it safe:
+
+- **No-op detection still happens, but lazily and once.** Whether a pending change
+ is a real change (patch) or already satisfied (skip) is decided at commit, by
+ hydrating that one document and comparing — not per event, and never for documents
+ no pending change references.
+- **Correctness comes from comparing to git at commit, not from event order.** The
+ buffer holds only the final desired state per identity; the plan compares that to
+ the document actually in git at the commit's checkout. Intermediate event churn is
+ irrelevant by construction.
+
+This does **not** reintroduce the "keep only a list of changed resources" anti-shape
+warned against above. The per-event buffer is *intent*, not the write model: it is
+resolved against the full file/document structure at commit, where multi-document
+edits, document deletion, and file deletion all still have the context they need.
+
+### Concrete Data Structures
+
+The exact package names can change, but the shape should be close to this:
+
+```go
+type ManifestStore struct {
+ Root string
+
+ // FilesByPath holds only MANAGED files — those with at least one tracked,
+ // in-scope KRM document. Non-YAML auxiliary files and retained allowlisted
+ // non-API KRM files (e.g. kustomization.yaml) are deliberately NOT here: they
+ // are known to acceptance but never become FileModels, so they have no document
+ // set to empty and can never be swept or deleted. A FileModel therefore always
+ // has at least one document until its last is dropped — at which point Current
+ // goes nil and Deleted() correctly fires.
+ FilesByPath map[string]*FileModel
+
+ // Indexes hold pointers into FilesByPath, not (path, index) pairs, so a
+ // document delete that shifts a file's slice never invalidates them.
+ //
+ // Post-acceptance these are single-valued by construction: duplicate manifest
+ // identities refuse the GitTarget (see Non-Negotiable Design Decisions), so a
+ // store that became the planning model has exactly one document per identity.
+ // The pre-acceptance builder (and the structure-only analyzer, which reports
+ // collisions instead of refusing) must see duplicates, so it collects
+ // candidates multi-valued and collapses to these once acceptance passes.
+ ByManifestIdentity map[manifestedit.Identity]*DocumentModel
+ ByResourceIdentity map[types.ResourceIdentifier]*DocumentModel
+ ByGVK map[schema.GroupVersionKind][]*DocumentModel
+
+ Diagnostics []manifestedit.Diagnostic
+}
+
+type FileModel struct {
+ Path string
+
+ // Documents and their classification are resident and cheap (header parse only).
+ Documents []*DocumentModel // every managed document in the file, in document order
+
+ // Original/Current are HYDRATED LAZILY at the commit boundary, and only for the
+ // files a batch touches — they are nil for every untouched file, so the resident
+ // store is byte-free (see "Two boundaries" above). An implementation may move
+ // these onto a separate commit-scoped working type so the type system enforces
+ // "resident = no bytes"; the semantics are what matter here.
+ Original []byte // worktree bytes once hydrated; nil for a new or unhydrated file
+ Current []byte // bytes after applying plan actions; nil means "delete this file"
+}
+
+// Dirty and Deleted are derived, never stored. Two byte slices are the whole
+// state machine, so there is no flag to forget to flip:
+//
+// new file: Original == nil, Current != nil
+// deleted: Original != nil, Current == nil
+// dirty: both non-nil and not byte-equal
+func (f *FileModel) Dirty() bool { return f.Current != nil && !bytes.Equal(f.Current, f.Original) }
+func (f *FileModel) Deleted() bool { return f.Current == nil && f.Original != nil }
+
+type DocumentModel struct {
+ ManifestIdentity manifestedit.Identity // apiVersion + kind + namespace + name
+ ResourceIdentity *types.ResourceIdentifier // nil until the mapper resolves a GVR
+
+ Mapping MappingStatus // why ResourceIdentity is or is not set (resolved / unserved / ambiguous / structure-only / ...)
+ Editable bool // false for SOPS-encrypted or otherwise non-patchable documents
+ Cause DocumentCause // structured reason behind Editable and diagnostics — not free text
+
+ // Snapshot is a lazy handle. The full manifestedit node tree is built only
+ // when a plan action touches this document; identity indexing needs only a
+ // cheap header parse.
+ Snapshot manifestedit.SnapshotRef
+}
+
+// RecordRef is a plan-level value, not a model field. The Plan is serializable
+// and pinned to one (commit SHA, snapshot RV) pair, so a (path, index) pair is a
+// stable reference for that plan's lifetime. The live, mutable store navigates by
+// the *DocumentModel pointers above instead, and DocumentModel deliberately does
+// not store its own FilePath or Index — both are derivable. The file path is the
+// containing FileModel's; the document's TRUE file index is reconstructed from the
+// record-less diagnostic gaps (every empty/non-KRM/invalid document leaves a
+// diagnostic at its position, so the managed documents fill the remaining positions
+// in order — `reconstructManagedIndices`). That recovers the right index even in a
+// non-contiguous file the acceptance gate is refusing; at apply time manifestedit is
+// handed the position directly.
+type RecordRef struct {
+ FilePath string
+ DocumentIndex int
+}
+
+// PendingChanges is the per-event hot path: cheap bookkeeping, no worktree I/O and
+// no YAML parsing. Watch events fold into it last-writer-wins per identity, so a
+// create-then-delete within one batch cancels and a create-then-modify keeps only
+// the final desired object. It is drained at the commit boundary, where it is
+// resolved against the resident store and the touched files are hydrated.
+type PendingChanges struct {
+ Desired map[types.ResourceIdentifier]PendingChange
+}
+
+type PendingChange struct {
+ Resource types.ResourceIdentifier
+ Object *unstructured.Unstructured // nil ⇒ delete intent (tombstone)
+ // No rendered bytes: rendering is deferred to commit and runs once per identity.
+}
+```
+
+What changed from the first sketch of these types, and why:
+
+- **`DocumentModel.Index` and `DocumentModel.FilePath` are gone.** A stored index
+ is the most fragile field in the model: every document delete shifts a file's
+ slice and would force re-syncing the field plus every index entry. Deriving it
+ removes that bug class. `FilePath` is redundant because every access path already
+ carries it (top-down iteration, or a plan `RecordRef`).
+- **`FileModel.Dirty` / `Deleted` became methods.** They are pure functions of
+ `Original` vs. `Current`, so storing them only created a desync hazard.
+- **`FileModel.Encrypted` and `DocumentModel.Encrypted` collapsed into
+ `Editable` + `Cause`.** Encryption is one *cause* of non-editability; modeling it
+ as a separate bool invites "encrypted but editable" contradictions.
+- **`DocumentModel.Duplicate` is gone.** Duplicate identities refuse the GitTarget
+ at acceptance, so no duplicate document ever reaches the planning model. Duplicate
+ detection is an acceptance/analyzer concern surfaced through `Diagnostics`.
+- **`DocumentModel.RawBody` became `Snapshot` (a lazy handle).** Holding eager bytes
+ per document violates bounded materialization.
+- **`Reason string` became a structured `Cause`.** Phase 1 explicitly forbids
+ classification that depends on diagnostic message text.
+- **One GVK type.** The store and the mapper both use `schema.GroupVersionKind`; the
+ per-document GVK is derived from `ManifestIdentity` rather than stored as a third
+ GVK-shaped field.
+- **`Duplicates` and `AcceptedOnce` left the store.** The first is a diagnostic; the
+ second is lifecycle state that does not belong in the data model.
+- **Bytes are lazy and the per-event path is byte-free.** `FileModel.Original` /
+ `Current` are hydrated only at the commit boundary for the files a batch touches;
+ the resident store holds no bytes, and events accumulate in `PendingChanges`
+ without reading or parsing anything. This is the temporal half of bounded
+ materialization (see "Two boundaries"), promoted from a deferred optimization to a
+ structural property of the model.
+
+**Why deletion is not a `DocumentModel` flag (and how mark-and-sweep runs without
+one).** It is tempting to put `Deleted` (and a mark bit) on `DocumentModel` so the
+sweep can flip it per document. We deliberately do not: the durable model holds
+*structure*, while transient per-batch decisions live in the **plan**.
+
+- *Removing a document is a `PlanAction`* (`PlanDropOrphan` / `PlanDeleteDocument`)
+ targeting a `RecordRef`. Apply calls `manifestedit.DeleteDocument`, which rewrites
+ the file's bytes; the document then simply leaves `file.Documents` rather than
+ lingering as a `Deleted`-flagged tombstone the rest of the code must remember to
+ filter out.
+- *Flush state is file-level and derived.* `git add` / `git rm` act on files and
+ `Current` is whole-file bytes, so `Dirty()` / `Deleted()` are properties of the
+ file, not the document. A multi-document file with one document dropped is dirty,
+ not deleted; it becomes deleted only when its last document is dropped.
+- *The sweep needs no flag.* Per
+ [reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md),
+ the "mark" is the set of streamed identities and the "sweep" is a one-pass
+ set-difference (`orphans = members − streamed`) computed at the bookmark, which
+ then *emits* drop actions. This is isomorphic to per-document flag-toggling but
+ safe under a partial or failed stream, because the model is never half-mutated.
+
+The plan should also be explicit:
+
+```go
+type Plan struct {
+ Actions []PlanAction
+ Diagnostics []manifestedit.Diagnostic
+}
+
+type PlanAction struct {
+ Kind PlanActionKind
+
+ Ref RecordRef
+ Identity manifestedit.Identity
+ Resource types.ResourceIdentifier
+
+ Desired *unstructured.Unstructured
+ Reason string
+}
+
+type PlanActionKind string
+
+const (
+ PlanCreate PlanActionKind = "create"
+ PlanPatch PlanActionKind = "patch"
+ PlanReplace PlanActionKind = "replace"
+ PlanDeleteDocument PlanActionKind = "delete-document"
+ PlanDeleteFile PlanActionKind = "delete-file"
+ // PlanDropOrphan deletes a watched resource the API no longer has (the managed
+ // drop). Duplicate identities and unwatched API-backed KRM produce no plan
+ // action — they refuse the GitTarget at acceptance, before planning.
+ // Allowlisted non-API KRM is retained outside the model.
+ PlanDropOrphan PlanActionKind = "drop-orphan"
+ PlanSkip PlanActionKind = "skip"
+)
+```
+
+The flush operation should be intentionally boring. In pseudo-code:
+
+```go
+func Flush(root string, store *ManifestStore, worktree *git.Worktree) error {
+ for _, file := range store.FilesByPath {
+ switch {
+ case file.Deleted():
+ os.Remove(filepath.Join(root, file.Path))
+ worktree.Remove(file.Path)
+ case file.Dirty():
+ os.WriteFile(filepath.Join(root, file.Path), file.Current, 0o600)
+ worktree.Add(file.Path)
+ }
+ }
+ return nil
+}
+```
+
+This means there is no separate generated-path writer. New files, in-place
+updates, whole-document replacements, and document deletes all produce changes by
+mutating `FileModel.Current` (and, for a delete that empties a file, setting it to
+nil). `Dirty()` and `Deleted()` follow from that automatically — there is no flag
+to set by hand.
+
+### Concrete Examples
+
+Each example shows the **commit-time apply**, so the touched file is already
+hydrated; the per-event path that preceded it only recorded the desired change in
+`PendingChanges` without reading or parsing anything.
+
+**Example 1: update one document in a multi-document file**
+
+Input file:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ color: blue
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: default
+spec:
+ replicas: 1
+```
+
+Desired API state changes only `ConfigMap/default/app` to `color: green`.
+
+Plan:
+
+```text
+patch apps.yaml#0 v1/ConfigMap/default/app
+```
+
+Apply:
+
+- look up `RecordRef{FilePath: "apps.yaml", DocumentIndex: 0}`
+- call `manifestedit.Apply` for document 0
+- replace `FileModel.Current` with the returned full-file bytes
+- `apps.yaml` is now dirty because `Current` differs from `Original`
+- keep document 1 byte-for-byte; the splitter preserves sibling document bytes
+
+Flush:
+
+```text
+git add apps.yaml
+```
+
+No other files are rendered.
+
+**Example 2: delete one document from a multi-document file**
+
+Input file:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: token
+ namespace: default
+```
+
+The API no longer has `Secret/default/token`.
+
+Plan:
+
+```text
+delete-document apps.yaml#1 v1/Secret/default/token
+```
+
+Apply:
+
+- call `manifestedit.DeleteDocument` for document 1 (its position is derived at
+ this point, not read from a stored field)
+- update `FileModel.Current` to the surviving ConfigMap document
+- drop document 1 from `apps.yaml`'s slice; `Dirty()` now reports true because
+ `Current` differs from `Original`, and `Deleted()` stays false because a
+ document remains
+- no index bookkeeping and no map rewrites: the indexes hold `*DocumentModel`
+ pointers, which survive the slice shift
+
+Flush:
+
+```text
+git add apps.yaml
+```
+
+If the deleted document had been the only document in the file, the same plan
+action would leave the file with zero documents and set `Current = nil`, so
+`Deleted()` reports true and flush stages a file removal instead. This is
+unconditionally safe under the design decisions above: an empty managed document
+set means an empty file, because a managed *file* never carries unmanaged
+passenger documents (mixed managed/allowlisted files are refused at acceptance),
+and retained allowlisted files are not `FileModel`s in the store.
+
+**Example 3: create a new resource**
+
+The API has `Deployment/default/api`, and no record exists in `ManifestStore`.
+
+Plan:
+
+```text
+create apps/v1/deployments/default/api.yaml apps/v1/Deployment/default/api
+```
+
+Apply:
+
+- placement policy chooses the path
+- canonical renderer creates one new document
+- add a new `FileModel` with `Original = nil`, `Current = rendered`; `Dirty()`
+ reports true (new file)
+- add its document to the in-memory indexes
+
+Flush:
+
+```text
+git add apps/v1/deployments/default/api.yaml
+```
+
+**Example 4: refuse a folder with duplicate identities**
+
+The store contains the same manifest identity twice:
+
+```text
+apps/app.yaml#0 v1/ConfigMap/default/app
+legacy/app.yaml#0 v1/ConfigMap/default/app
+```
+
+This is a human-authored ambiguity we will not guess at. Acceptance fails before
+any plan is applied:
+
+```text
+refuse: duplicate manifest identity v1/ConfigMap/default/app
+ at apps/app.yaml#0 and legacy/app.yaml#0
+```
+
+No file is written and no file is deleted. The GitTarget surfaces the collision in
+its status, and reconcile resumes only once a human removes one of the copies.
+
+### Why This Is Efficient Enough
+
+- Per event the cost is O(1) bookkeeping in `PendingChanges` — no worktree read and
+ no YAML parse — so a high event rate does not translate into per-event work.
+- Per batch the worktree is header-scanned once (cheap, and cacheable across
+ batches), and only the files the batch touches are read in full and parsed.
+- Only plan-targeted files are mutated in memory.
+- Only dirty or deleted files are written to disk and staged.
+- Document deletes shift a slice and rewrite one file's bytes; nothing reindexes,
+ because positions are derived and the indexes hold pointers.
+- Longer-lived caching can come later, keyed by checkout state and GitTarget path.
+
+The first implementation should optimize for one correct mechanism. Once the
+store is authoritative, cache invalidation becomes an optimization around a clear
+model rather than a second write path.
+
+## Implementation Recommendations
+
+- Promote `manifestedit.Inventory` from a locator helper into a richer
+ `ManifestStore` owned by the writer or a new integration package. Keep
+ `manifestedit` as the YAML mechanism underneath it.
+- Resolve delete events through the GVK/GVR mapper before writing. Delete events
+ usually carry GVR/name, while manifests are keyed by GVK/name; the writer should
+ receive a resolved `ResourceIdentifier`/manifest identity pair from the planning
+ layer and delete by `RecordRef`. See
+ [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md).
+- Add a resource-identity index beside the existing manifest-identity index. The
+ writer should locate watched resources by `ResourceIdentifier`, while retaining
+ GVK for YAML fidelity and diagnostics.
+- Do not build duplicate-loser pruning. Duplicate identities fail the acceptance
+ check and refuse the GitTarget instead. Once the unified content-derived store
+ with match-first placement is in place, the controller no longer produces
+ duplicates of its own, so there is no remaining "safe to prune" duplicate case
+ to special-case.
+- Make the batch planner operate on the materialized store, then flush once. This
+ avoids repeated disk reads, avoids stale per-event assumptions, and gives a
+ single place to update document indexes after deletions.
+- Make dirty/deleted file flushing the only Git write mechanism. Generated-path
+ creation, in-place patching, whole replacement, document deletion, and file
+ deletion should all mutate `FileModel.Current`; `Dirty()` and `Deleted()` are
+ derived from it, never set by hand.
+- Keep the current safety rules: no in-place edits for SOPS documents, and no
+ wholesale replacement of a multi-document file when the target document cannot
+ be edited safely. The managed drop of watched resources the API no longer has is
+ gated behind acceptance: a folder that does not pass the acceptance checks
+ refuses outright and drops nothing. Scan mode (see below) renders the full plan,
+ including managed drops, before any write happens.
+- Keep creation placement as policy above the editor. The materialized model
+ should answer "does this resource already exist?" and "where is it?"; a separate
+ placement policy should answer "where should a new resource go?"
+
+## Acceptance Checks On First Materialization
+
+When a GitTarget path is materialized for the first time — an empty or
+never-before-reconciled target whose existing files we are adopting, not state
+the controller produced — the store should run **acceptance checks before it is
+allowed to become the planning model**, and fail the GitTarget loudly when they
+do not pass. The guiding rule: we cannot fix everything, and trying to be clever
+about ambiguous human-authored content is more dangerous than refusing it.
+
+The first such check is **duplicate manifest identities**. If the initial scan
+finds two or more documents that resolve to the same `ManifestIdentity`
+(`apiVersion` + `kind` + `namespace` + `name`), the GitTarget should error out
+with a diagnostic naming the colliding files/documents, rather than silently
+picking a winner. We genuinely do not know which copy the author intended, and
+guessing risks deleting the one they cared about.
+
+Note this is deliberately keyed on full manifest identity, not on GVK alone.
+Many resources of the same kind (several `Deployment`s, several `ConfigMap`s)
+are normal and must pass. Only same-identity collisions are rejected.
+
+Duplicate identities are **refused**, full stop. We cannot know which copy the
+author intended, and guessing risks deleting the one they cared about, so the
+GitTarget fails until a human resolves the collision. Once the store is the single
+content-derived model with match-first placement, the controller no longer
+produces duplicates of its own, so there is no separate "controller-produced
+duplicate" case to prune — refusal is the one behavior, and the earlier tension
+between pruning and refusing duplicates disappears.
+
+The second check is **unrecognized files**. The hard question is what to do with
+files in the folder that are not watched resources. Lumping them into one
+"unrecognized" bucket is the trap; they have very different danger levels, so the
+store should classify every file into one of five buckets:
+
+1. **Non-YAML files** (`README.md`, `.gitignore`, scripts, images): ignored
+ entirely. Never read, never planned, never pruned. No ambiguity.
+2. **YAML that is not KRM** (no `apiVersion` + `kind`, or not a Kubernetes-shaped
+ object — a CI config, a loose values blob): the genuinely dangerous unknown.
+ We cannot model it and cannot reason about whether mutating the folder is safe.
+3. **Valid KRM, watched GVK**: the managed happy path — fully modeled and planned.
+4. **Valid KRM, unwatched API-backed GVK**: recognizable, but not selected by
+ this GitTarget's watched set.
+5. **Allowlisted non-API KRM** (for example `kustomization.yaml`): recognizable
+ KRM that is intentionally retained as auxiliary input, not as a managed API
+ resource.
+
+The **starter requirement** (structure-only, no cluster needed) is to define
+*recognized = parses as KRM* and to fail the GitTarget when any YAML file falls in
+bucket 2 (non-KRM YAML), alongside the duplicate-identity gate. Once the watched
+API surface is available through the
+[GVK/GVR mapping layer](gvk-gvr-mapping-layer.md), bucket 4 (unwatched API-backed
+KRM) joins the same refusal. Bucket 5 is the explicit exception: allowlisted
+non-API KRM is accepted but not materialized. Non-YAML files (bucket 1) are always
+ignored and never cause failure.
+
+Within the recognized set, watched and unwatched KRM are treated differently, and
+here the guiding conviction is decisive: **the API is the source of truth.** The
+GitTarget folder is a projection of the watched API resources, not an independent
+repository we merely edit around.
+
+- **Watched (bucket 3)** is managed and planned against live API state. A watched
+ document whose resource the API no longer has is **dropped** — this is the
+ source-of-truth duty, and it is intentional.
+- **Unwatched API-backed KRM (bucket 4) is refused, not pruned.** A KRM document
+ of a served kind we do not watch is something we have made no claim over. Rather
+ than delete content we never managed, the GitTarget fails its acceptance check
+ with a diagnostic naming the offending files, and reconciles nothing until a
+ human removes them. We do **not** build a delete option for unwatched
+ API-backed KRM.
+- **Allowlisted non-API KRM (bucket 5) is retained, not materialized.** Documents
+ such as `kustomization.yaml` are kept on disk as auxiliary input, excluded from
+ `FileModel.Documents`, excluded from every plan, and excluded from sweep. If an
+ allowlisted document shares a multi-document file with managed resources, the
+ folder is refused; we do not partially materialize a file.
+- **Watched but out of scope is also refused.** A document of a watched kind that
+ falls outside this GitTarget's scope (right kind, wrong namespace) is KRM we
+ recognize but are not entitled to manage here. Per the Non-Negotiable Design
+ Decisions we do **not** leave it as a silent non-member — doing so would create
+ exactly the half-managed file we have ruled out — so it refuses the GitTarget
+ with a file-naming diagnostic until a human resolves it.
+
+This keeps a sharp edge honest rather than dangerous: build directives like
+`kustomization.yaml` are KRM but never appear in the API. They are handled by the
+non-API KRM allowlist, not by the watched-resource planner. They stay on disk, do
+not become managed documents, and cannot be swept. Unwatched API-backed KRM still
+refuses the GitTarget; it is never pruned as cleanup.
+
+Implementation notes:
+
+- Run acceptance checks as a distinct step between "build the store" and "use it
+ as the planning model", so a failing GitTarget never proceeds to create/update/
+ delete planning with an ambiguous model. This is implemented as
+ [`manifestanalyzer.Accept`](../../../internal/manifestanalyzer/acceptance.go) (M4).
+- Surface the failure through GitTarget status/diagnostics with enough detail
+ (offending identity + file paths) for a human to resolve it, then re-reconcile.
+- Treat the check list as extensible. Duplicate identity, non-KRM YAML,
+ unwatched API-backed KRM, and mixed files containing both managed and
+ allowlisted documents are pre-planning acceptance failures.
+- **A managed file must be entirely valid KRM (the "impure managed file" rule).**
+ Decision #2 ("no partially materialized multi-document file") is enforced
+ literally: a file that holds at least one managed resource is refused if it *also*
+ holds any non-managed document — an empty/comment-only document, a non-KRM
+ document, or an invalid one. A *standalone* empty document (a file with no managed
+ document) is still ignored; the rule bites only inside a file we would manage. This
+ is what lets `DocumentModel` carry no per-document index: an accepted managed file's
+ documents are contiguous from 0, so positions are derived top-down rather than
+ stored. A practical consequence is that a stray trailing `---` (which yields an
+ empty document) in an *adopted* managed file is refused until cleaned — deliberate
+ strictness over a stored-index workaround.
+- **The non-API KRM allowlist is filename-based.** A real `kustomization.yaml` has
+ no `metadata.name`, so it is not a KRM record and a GVK-keyed allowlist would never
+ match it. The allowlist matches build-directive basenames (as kustomize itself
+ does), retains the whole file outside `FilesByPath`, and suppresses its per-document
+ classification diagnostics. A *named* managed resource found inside an allowlisted
+ file is refused (mixed file) rather than silently un-managed.
+
+## Adoption Policy: Refuse First
+
+The source-of-truth conviction says git must not carry a divergent state. There is
+more than one way to *enforce* that, and we are deliberately choosing the most
+conservative one to start. Two behaviors are easy to confuse, so name them apart:
+
+- **Managed drop (always on once the folder is accepted, not a policy knob).** A
+ *watched* KRM document whose resource the API no longer has is deleted. This is
+ the source-of-truth duty for the resources we own, and it is not configurable
+ away — a GitTarget that asked us to manage these kinds asked us to keep them
+ honest.
+- **Adoption acceptance (a gate, currently single-mode: refuse).** Before a folder
+ is allowed to become the planning model at all, it must pass the acceptance
+ checks. Anything we cannot take responsibility for — duplicate identities,
+ non-KRM YAML, or unwatched API-backed KRM — makes the GitTarget **refuse**: it
+ fails with a diagnostic listing the offending files and reconciles nothing until
+ a human cleans the folder. Allowlisted non-API KRM is retained outside the
+ managed model.
+
+We do **not** build an active "prune the unwatched API-backed KRM for you" mode.
+Refusing is safer, it forces a human to look before anything is touched, and it
+never deletes a file the controller did not author.
+
+`scan` (dry-run) remains valuable alongside refuse: it builds the store, runs the
+acceptance checks, and renders the full plan — including the managed drops — so an
+operator can see exactly what reconcile would do before granting it write access.
+See the next section.
+
+## Scan Mode (Dry-Run)
+
+Because the source-of-truth duty makes the writer willing to delete managed
+resources the API has dropped, the writer must be able to show its hand before it
+is trusted with write access. Scan mode is a dry-run that builds the store, runs
+the acceptance checks, and computes the full plan — creates, updates,
+whole-replaces, document deletes, file deletes, and managed drops — but stops
+before flushing anything to the worktree. It also reports any acceptance refusal
+(duplicate identity, non-KRM YAML, unwatched API-backed KRM, or mixed
+managed/allowlisted files) so an operator sees why a folder would be rejected.
+Instead of writing, it reports what it *would* do if given write rights.
+
+This is the deliberate gate the existing safety rules ask for: no deletion until
+the plan has an explicit, reviewable form. Scan mode is that gate. It matters most
+on first materialization, where the managed-drop set can be large and a mistake is
+destructive — it lets a human see "I am about to delete these N files" before any
+of them are touched.
+
+It also falls out naturally from the plan-then-flush architecture: the plan is
+already a first-class value, so scan mode is simply "compute the plan, render it,
+do not flush". The same plan rendering doubles as the human-facing diff and as the
+basis for status/diagnostics.
+
+## Standalone Analyzer CLI
+
+The same machinery that builds the store, classifies files, runs the acceptance
+checks, and renders a plan is valuable on its own, outside the controller: a small
+CLI that analyzes any existing folder of manifests. Anyone could point it at a
+directory and learn what we would learn — duplicate identities, multi-document
+files, KRM vs. non-KRM YAML, the GVK inventory, and (given cluster access) what
+would be created, updated, or pruned. That is useful for auditing a repo before
+adopting it, for debugging a GitTarget, and as a low-stakes way to exercise the
+core logic.
+
+> **Current role (2026-06-04).**
+> [`internal/manifestanalyzer`](../../../internal/manifestanalyzer) is the
+> runtime-independent analysis library, and
+> [`cmd/manifest-analyzer`](../../../cmd/manifest-analyzer) is the CLI. The
+> library walks an `fs.FS`, classifies every file (non-yaml, empty, invalid-yaml,
+> non-krm, krm), detects duplicates, builds a bounded summary, reports the
+> inventory of every GVK found, and emits acceptance issues in text or JSON.
+> `--policy refuse` makes any acceptance issue a non-zero exit, which is the
+> CLI shape of the refuse adoption mode.
+>
+> Deliberately deferred: comparing those GVKs against a live API to decide what
+> is *watched / unwatched / orphaned*. The next model needs the
+> [GVK/GVR mapping layer](gvk-gvr-mapping-layer.md), the
+> watched/unwatched/orphan comparison, the plan computation, and wiring the same
+> library into the live writer.
+>
+> Running it against `config/samples` already surfaced a real constraint:
+> `manifestedit` derives manifest identity from a concrete `metadata.name`, so a
+> `generateName`-only object (`commitrequest.yaml`) is classified non-KRM. That
+> is correct given today's identity rules and is exactly the kind of gap the
+> analyzer is meant to make visible.
+
+This is not just a nice extra; it imposes useful constraints on the software
+design, and those constraints push in exactly the direction the rest of this
+review already wants:
+
+- The store, classification, acceptance checks, planner, and renderer must be a
+ **library with no hard dependency on the controller runtime** (no manager, no
+ informers, no reconcile loop). The controller and the CLI both become thin
+ callers of that library.
+- **"What is in the API" must be an injectable source**, not an ambient global.
+ Back it with live informers in the controller, a kubeconfig-backed client in
+ the CLI, a static snapshot in tests, or nothing at all for structure-only
+ analysis (duplicate detection, parse validity, and GVK inventory all work with
+ no cluster). The set of watched GVKs is likewise an input, not a constant.
+- **Filesystem access must be abstracted** so the same code runs against a git
+ worktree (controller) and an arbitrary directory (CLI).
+- The **analysis path must be strictly read-only and side-effect-free**; writing
+ is a separate capability layered on top, never entangled with analysis. This is
+ also what makes `scan` mode trivial to expose in both contexts.
+- **Plan/diagnostic rendering should be reusable** as controller status, as
+ CLI human-readable output, and as a machine-readable (JSON) form.
+
+In short, the analyzer use case reinforces the mechanism / policy / runtime
+separation this document argues for anyway, and it gives that separation a second
+real consumer to keep it honest.
+
+## Suggested Phases
+
+> The concrete, PR-sized ordering — with dependencies, file targets, and
+> "done when" criteria — lives in [implementation-plan.md](implementation-plan.md).
+> The phases below are the rationale; that document is the execution order.
+
+The current baseline is a runtime-independent analyzer library and CLI that can
+walk a directory, classify files and YAML documents, report GVK inventory, detect
+duplicate manifest identities, and render text/JSON. The remaining plan should
+turn that read-only structure model into the shared model used by scan mode and
+the live writer.
+
+1. **Stabilize the materialized model.** Promote the analyzer's report shape into
+ a true byte-free `ManifestStore`: file models with document summaries,
+ manifest identity, editability plus structured cause, mapping status, and
+ structured diagnostics. Keep raw bytes and full node trees behind lazy
+ commit-scoped hydration. Keep duplicate state in diagnostics/acceptance, not in
+ the planning model. Keep `fs.FS` as the read boundary and keep
+ controller-runtime dependencies out. Replace any classification that depends on
+ diagnostic message text with structured reasons from `manifestedit`.
+2. **Add the API/source-of-truth input.** Define an injectable source for the
+ watched API surface and current desired resources by implementing
+ [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md). It should work with live
+ controller state, a CLI kubeconfig-backed source, and test snapshots. Resolve
+ every API-backed KRM document's GVK to a `ResourceIdentifier` where possible,
+ record unresolved GVKs as diagnostics, and add indexes by both manifest
+ identity and resource identity.
+3. **Build the plan model.** Compare the `ManifestStore` to the desired API set
+ and produce a first-class plan: create, patch, whole-replace, delete document,
+ delete file, drop a watched resource absent from the API, or skip. Duplicate
+ identities and unwatched API-backed KRM produce no plan actions — they are
+ refused at acceptance, before planning. Allowlisted non-API KRM also produces
+ no plan action; it is retained outside the model. The plan should carry enough
+ detail to render human-readable output, JSON, and GitTarget status without
+ recomputing decisions.
+4. **Apply adoption acceptance before writes.** Implement the acceptance gate over
+ the store plus plan. The posture we are building: fail (refuse) on duplicate
+ identities, invalid YAML, non-KRM YAML, unwatched API-backed KRM, and mixed
+ managed/allowlisted multi-document files, reconciling nothing until the folder
+ is cleaned. The managed drop of watched resources absent from the API is core
+ behavior, not an opt-in. We do not prune unwatched API-backed KRM. Allowlisted
+ non-API KRM such as `kustomization.yaml` is retained on disk but not
+ materialized.
+5. **Wire scan mode end to end.** Use the same planner for the CLI and controller
+ dry-run path. Scan mode should build the store, resolve API state when
+ available, run adoption acceptance, render the full plan, and write nothing.
+ This must exist before the destructive managed drop is enabled.
+6. **Close the delete identity gap through the mapper.** Ensure deletes can target
+ moved manifests without a live object body by resolving event GVR/name through
+ the same GVK/GVR mapper used by the store. The planning layer should hand the
+ writer a `RecordRef`; the writer should not regenerate paths.
+7. **Move the live writer to plan-then-flush.** Replace the event-by-event
+ locator/write path with: build store for the checked-out GitTarget path,
+ compute plan for the pending batch, apply plan to in-memory file models, then
+ flush changed files and staged removals once. `manifestedit.Apply` and
+ `DeleteDocument` remain the per-document edit mechanisms.
+8. **Keep deletion scoped to the managed drop.** The only deletion we perform is
+ of watched KRM the API no longer has, through the per-document delete path, and
+ only for folders that passed acceptance. We are explicitly not pruning
+ unwatched API-backed KRM or "duplicate losers"; those refuse instead.
+ Allowlisted non-API KRM is retained outside the model.
+9. **Optimize after correctness.** Once the store is authoritative in the writer,
+ add longer-lived cache invalidation across batches. Rebuild on checkout/remote
+ tip changes, external branch movement, GitTarget path changes, or local flushes
+ that cannot be represented incrementally.
+
+## Bottom Line
+
+The low-level abstractions are close: `manifestedit` is a good mechanism layer.
+The higher-level writer abstraction is the part that still feels off. It treats
+the inventory as an optimization for finding a file, when it should become the
+actual model of the GitTarget's manifests.
+
+Moving to a materialized in-memory model should make multi-document files,
+deletes, duplicate cleanup, and GVK/GVR lookups simpler rather than a set of
+special cases around generated paths.
diff --git a/docs/design/manifest/file-agnostic-placement.md b/docs/design/manifest/file-agnostic-placement.md
new file mode 100644
index 00000000..388f6909
--- /dev/null
+++ b/docs/design/manifest/file-agnostic-placement.md
@@ -0,0 +1,26 @@
+The whole cool thing of KRM and the Kubernetes API is that the exact content of the YAML file contains everything needed to reflect the actual resource inside the Kuberntes API. The actual location of the file doesnt matter.
+
+At this moment gitops-reverser has a fixed convention on where it expects/places files. Obviously the placement of new files is something that always needs some rule (either hardcoded or fixed). But why would it be important to start workin? For normal GitOps application like ArgoCD or Flux it's also not important (ok, if configured well offcourse).
+
+There is some interesting problems to this:
+* It's normal/allowed in YAML files to place multiple resources in one file, the `/n---/n` trick is used as seperator.
+* For some resources it would be very beneficial to NOT write the namespace, actually all my examples until now have the problem that it's not entirly logical to also place the namespace. It might be a nice option on a GitTarget to drop it.
+* Most examples also would have benefitted from having a simple folder based set of 'bootstrap' files. I already do this for the .sops.yaml file, but why not for a simple kustomization.yaml? Which allows to easily hook it up to normal GitOps tools and deploy it into a different namespace?
+* It would be very logical for people to hook up a cloud version of this to an existing Gitrepo: I will never be able to support everyhing but to be so strict in where files are to placed is madness, then it will certainly not work.
+
+Requirements:
+* Parse an existing folder and parse all yaml/KRM that can be found in it.
+ * Have a notion / index of this (can be in memory) so that I can write back changes or updates at the right place.
+* Recognize a kustomization / the current structure and be able to not write the namespace.
+* Be able to (or even require GitOps tooling!) to stream the initial set of yamls into some location so that we can start looking for changes. Not sure how to cope with this one, it's again a form if bidirectional GitOps.
+* Detect helm shizzle and ignore it with a good error message.
+* Detect Kustomize and only support the very basic constructs in both directions (what would that exactly look like).
+
+The dream would to be able to point gitops-reverser (potenially combined with flux for example) at a folder and to be able to provide a GitOps API for it without even thinking. People would have insigt in which objects are detected, they can edit them and a pull request is automatically created out of it.
+
+Boundries:
+* I don't believe that gitops-reverser should get knowledge on things like GitHub (so creating that PR is a respnsiblity for another layer, but pushing changes to a branch is fine).
+* We really shouldnt get into the details of kustomize or helm to soon: we can also start with a clean folder of yaml manifests.
+
+Follow-up investigation:
+* [contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace-and-kustomize-folder-editing.md)
diff --git a/docs/design/manifest/gvk-gvr-mapping-layer.md b/docs/design/manifest/gvk-gvr-mapping-layer.md
new file mode 100644
index 00000000..53c0444c
--- /dev/null
+++ b/docs/design/manifest/gvk-gvr-mapping-layer.md
@@ -0,0 +1,457 @@
+# Manifest GVK/GVR Mapping Layer
+
+> Status: design, captured 2026-06-04
+> Related:
+> [current-manifest-support-review.md](current-manifest-support-review.md),
+> [reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md),
+> [../kubernetes-api-resource-catalog.md](../kubernetes-api-resource-catalog.md),
+> [`internal/watch/api_resource_catalog.go`](../../../internal/watch/api_resource_catalog.go),
+> [`internal/watch/rule_gvr_resolver.go`](../../../internal/watch/rule_gvr_resolver.go),
+> [`internal/manifestanalyzer/analyzer.go`](../../../internal/manifestanalyzer/analyzer.go)
+
+## Summary
+
+The manifest materialization plan depends on a layer that does not exist yet:
+mapping manifest identity (`apiVersion`, `kind`, namespace, name) to resource
+identity (`group`, `version`, `resource`, namespace, name), and back again.
+
+`APIResourceCatalog` and `RuleGVRResolver` already solve the watch side of this
+problem: rules become concrete watched GVRs through trusted Kubernetes discovery.
+They do not yet provide a manifest-facing `GVK <-> GVR` abstraction, and the
+manifest analyzer deliberately has a no-cluster mode. The missing layer must
+therefore be an injected interface, not a package-global call to discovery.
+
+This is not decorative plumbing. It is the boundary that decides whether an
+existing manifest document is watched, unwatched, stale, ambiguous, disallowed,
+or unknowable. Every later phase of the materialized model depends on that
+decision being stable and explicit.
+
+## What Kubernetes Actually Provides
+
+Kubernetes gives us an API-surface catalog through discovery. From `/api`,
+`/apis`, and the per-group/version discovery documents, a client can learn:
+
+- API groups and versions the server currently supports.
+- The preferred version for each API group.
+- Resource entries for each group/version.
+- The REST resource name, normally plural, such as `configmaps` or
+ `deployments`.
+- The `kind` served by that resource entry.
+- Whether the resource is namespaced.
+- The verbs the endpoint advertises, such as `get`, `list`, `watch`, `create`,
+ `update`, `patch`, and `delete`.
+- Short names, categories, and singular names when the server reports them.
+
+In client-go terms, this is the data returned by discovery calls such as
+`ServerGroupsAndResources()`, `ServerPreferredResources()`, and
+`ServerResourcesForGroupVersion()`. The project already stores the important
+part of that shape in `APIResourceEntry`:
+
+```text
+GVR
+GVK
+Namespaced
+Verbs
+Preferred
+Subresource
+Allowed
+PolicyReason
+```
+
+That is enough for the mapping layer's first job: exact mapping between a served
+GVK and its served GVR, plus scope and verb metadata.
+
+Kubernetes does **not** give us everything:
+
+- Discovery is not a live object snapshot.
+- Discovery is not OpenAPI schema validation.
+- Discovery does not prove the operator's RBAC can list or watch the resource.
+- Discovery can partially fail; an incomplete discovery response must not be
+ treated as authoritative absence.
+- Discovery can expose subresources like `deployments/status` and
+ `deployments/scale`, which are not normal mirrored objects.
+- Discovery does not mean a later `List()` call will succeed against every
+ aggregated API server.
+
+So the right mental model is: discovery tells us what resource types the API
+server claims to serve, with enough metadata to map GVK and GVR. Object state,
+permissions, and destructive reconciliation still require their own trust gates.
+
+## How Much Theory Matters
+
+In ordinary Kubernetes usage, the types usually line up:
+
+- `apiVersion: v1`, `kind: ConfigMap` maps to `v1/configmaps`.
+- `apiVersion: apps/v1`, `kind: Deployment` maps to
+ `apps/v1/deployments`.
+- A CRD with `spec.names.kind: Widget` and `spec.names.plural: widgets` appears
+ in discovery as `example.com/v1`, kind `Widget`, resource `widgets`.
+
+That practical reality is useful. It means the mapper should be boring in the
+happy path and should not block the whole project on theoretical edge cases.
+
+But pluralization cannot be guessed safely, and the edge cases matter exactly
+where GitOps Reverser is most dangerous:
+
+- Some resource names are irregular or non-obvious.
+- Multiple resources can share a kind shape across groups or versions.
+- A manifest can use an API version the current cluster no longer serves.
+- A CRD can serve multiple versions for the same kind.
+- A resource can be served but excluded by GitOps Reverser policy.
+- Subresources can share familiar-looking kinds while not being objects we mirror.
+- Aggregated APIs can appear in discovery but fail later when listed.
+
+The conclusion is deliberately modest: do not build a giant schema system, but
+also do not infer GVRs from kind strings or path layout. Use discovery/catalog
+data when available; otherwise mark the mapping as unresolved and keep the
+analyzer structure-only.
+
+## Responsibilities
+
+The mapping layer owns these decisions:
+
+- Map a manifest GVK to one concrete served GVR.
+- Map an event or object GVR to the served GVK used for manifest identity.
+- Attach namespaced/cluster-scoped information to the mapping.
+- Report whether the mapping is trusted, stale, ambiguous, disallowed, or
+ unavailable.
+- Tell the materialized manifest store whether a document belongs to a watched
+ set for this GitTarget.
+- Preserve the analyzer's no-cluster mode by allowing a nil implementation that
+ records only structure.
+
+The mapping layer does not:
+
+- validate object schemas,
+- convert one API version to another,
+- prove RBAC,
+- list objects,
+- choose Git file placement,
+- decide whether unwatched content is refused, reported, or pruned.
+
+## Source Of Truth Versus Mapper Source
+
+Kubernetes discovery is the source of truth for served GVK/GVR mappings. The API
+server is the only authority that can say "this cluster serves kind `Deployment`
+at `apps/v1` through resource `deployments`" or "this CRD's plural resource name
+is `icecreamorders`." GitOps Reverser should not invent that relationship from
+English pluralization, generated paths, or hard-coded assumptions.
+
+`MapperSource` does **not** mean there are four competing truths. It describes how
+this process obtained, cached, replayed, or intentionally declined to obtain
+Kubernetes discovery data.
+
+- `live-catalog`: the controller reads the in-process `APIResourceCatalog`, which
+ is refreshed from Kubernetes discovery and shared with watch planning. The API
+ truth is current cluster discovery, cached locally.
+- `kubeconfig`: a CLI command contacts a cluster through kubeconfig, builds a
+ temporary catalog, then maps through the same catalog semantics. The API truth
+ is current cluster discovery, fetched on demand.
+- `static-snapshot`: tests or offline review load a serialized catalog-shaped
+ fixture. The API truth is a captured or declared snapshot, not live truth.
+- `structure-only`: no API discovery data is available or desired; the analyzer
+ only parses manifest structure. There is no API truth, so mapping is
+ deliberately unknown.
+
+That distinction is why `MappingStructureOnly` is not an error. It is the honest
+answer for "we know this YAML looks like KRM, but we did not ask any API surface
+what REST resource serves it." Conversely, when a mapper is backed by discovery
+data, absence can only be trusted if the relevant group/version discovery is not
+degraded.
+
+## Interface
+
+> **Status (2026-06-04): implemented.** The interface and the runtime-independent
+> implementations live in [`internal/mapping`](../../../internal/mapping)
+> (`ResourceMapper`, `StructureOnlyMapper`, `StaticSnapshotMapper`, and the shared
+> `ResolveGVK` reduction); the catalog-backed implementation is
+> [`watch.CatalogMapper`](../../../internal/watch/catalog_mapper.go), built on the
+> catalog `byGVK`/`LookupGVK` additions
+> ([`api_resource_catalog.go`](../../../internal/watch/api_resource_catalog.go)).
+> The doc warned the concrete Go names could move, and two did: to satisfy the
+> repository's no-stutter lint, `MappingResult` is `mapping.Result` and
+> `MappingStatus` is `mapping.Status`. The `Mapping*` status constants below kept
+> their names. `internal/mapping` has no dependency on `internal/watch`, so the
+> analyzer can resolve mappings without importing the watch manager.
+
+The concrete Go names can move, but the interface should make the dependency
+explicit:
+
+```go
+type ResourceMapper interface {
+ Source() MapperSource
+ Ready() MapperReadiness
+ Generation() uint64
+
+ GVRForGVK(ctx context.Context, gvk schema.GroupVersionKind) (Result, error)
+}
+
+type MapperSource string
+
+const (
+ MapperSourceLiveCatalog MapperSource = "live-catalog"
+ MapperSourceKubeconfig MapperSource = "kubeconfig"
+ MapperSourceStaticSnapshot MapperSource = "static-snapshot"
+ MapperSourceStructureOnly MapperSource = "structure-only"
+)
+
+type MapperReadiness struct {
+ Ready bool
+ Degraded bool
+ Generation uint64
+ Reason string
+}
+
+type Result struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+
+ Namespaced bool
+ Verbs []string
+ Preferred bool
+ Allowed bool
+
+ Status Status
+ Reason string
+}
+
+type Status string
+
+const (
+ MappingResolved Status = "Resolved"
+ MappingUnserved Status = "Unserved"
+ MappingAmbiguous Status = "Ambiguous"
+ MappingDisallowed Status = "Disallowed"
+ MappingSubresource Status = "Subresource"
+ MappingCatalogUnavailable Status = "CatalogUnavailable"
+ MappingDiscoveryDegraded Status = "DiscoveryDegraded"
+ MappingStructureOnly Status = "StructureOnly"
+)
+```
+
+`GVRForGVK` must require an exact group/version/kind match. It should not
+silently map `extensions/v1beta1 Deployment` to `apps/v1 Deployment`, even though
+a human understands the relationship. Version conversion is a later feature and
+requires a real conversion source, not a REST mapping guess.
+
+Resource-to-manifest identity is not part of this mapper contract. Delete planning
+starts from the GitTarget folder's `ByResourceIdentity` inventory; if that inventory
+does not already contain the resource, the planner does not re-derive a manifest
+identity through a reverse lookup.
+
+Errors should be reserved for implementation failures: discovery call failed,
+snapshot could not be loaded, malformed static data, context cancellation.
+Expected lookup outcomes should be returned as `Status` so callers can
+make policy decisions without parsing error strings.
+
+## Implementations
+
+These implementations share the same mapping contract. They differ only in how
+the catalog data is obtained and how much trust callers can place in freshness.
+
+### Live Catalog Mapper
+
+Used by the controller and watch manager.
+
+This wraps the existing `APIResourceCatalog`. The catalog already carries GVR,
+GVK, scope, verbs, preferred-version, subresource, allowed-policy, readiness, and
+generation data. The main missing pieces are lookup methods/indexes for GVK and
+status-rich mapping results.
+
+Required catalog additions:
+
+- `byGVK[schema.GroupVersionKind] -> []APIResourceEntry`
+- exported lookup for exact GVK
+- mapping status helpers that preserve degraded lookup state
+- generation-aware result reporting
+
+The live mapper must not call Kubernetes discovery directly. The catalog owns
+discovery refresh and trust state; the mapper reads that trusted local view.
+
+### Kubeconfig Discovery Mapper
+
+> **Status (2026-06-04): deferred.** `MapperSourceKubeconfig` exists as a constant,
+> but the kubeconfig-backed mapper itself is not built yet. The controller uses
+> `live-catalog` and tests use `static-snapshot`, so nothing on the B3/M3/M6 path
+> needs this; it lands with the optional CLI cluster-check mode (Implementation
+> Order step 5).
+
+Used by a CLI or offline command that is allowed to contact a cluster.
+
+This implementation builds an `APIResourceCatalog` from a kubeconfig-backed
+discovery client, then exposes the same mapping behavior as the live catalog
+mapper. It gives humans a way to run the manifest analyzer in "check this folder
+against this cluster's API surface" mode without starting the controller.
+
+This mode can report mapping and watched/unwatched classification, but it should
+still not list live objects unless a separate command explicitly asks for a full
+plan against cluster state.
+
+This is not a second interpretation of Kubernetes semantics. It is the same
+discovery truth as `live-catalog`, just fetched by a short-lived command instead
+of by the controller's continuously refreshed catalog.
+
+### Static Snapshot Mapper
+
+Used by tests, CI, and offline review.
+
+This implementation loads a serialized API-resource catalog snapshot. The shape
+should be close to `APIResourceEntry`, not raw Kubernetes discovery JSON, because
+tests and design fixtures should express the project contract directly.
+
+Static snapshots are also the right way to make manifest materialization tests
+deterministic. A fixture can say "this cluster serves apps/v1/deployments and
+v1/configmaps" without needing a kube-apiserver.
+
+Because a static snapshot is not live discovery, it should be treated as an
+explicit test/review input. It can model old clusters, partial catalogs, policy
+exclusions, ambiguity, and degraded discovery on purpose, but it must not be
+mistaken for proof about the cluster currently running the controller.
+
+### Structure-Only Mapper
+
+Used by the current `manifest-analyzer` default.
+
+This implementation always returns `MappingStructureOnly`. It is not a failure.
+It means the analyzer can inventory valid KRM, duplicate identities,
+multi-document files, encryption boundaries, and GVK counts, but cannot decide
+whether a document is watched or orphaned.
+
+This is the mode that preserves the analyzer's current no-cluster promise.
+It should never produce creates, deletes, watched/unwatched conclusions, or
+destructive adoption decisions.
+
+## Manifest Store Integration
+
+The materialized store should carry both identities when a mapper can provide
+them:
+
+```text
+ManifestIdentity = apiVersion + kind + namespace + name
+GVK = group + version + kind
+ResourceIdentity = group + version + resource + namespace + name
+mapping.Status = resolved / unserved / ambiguous / ...
+```
+
+Store construction should do the cheap parse first:
+
+1. Read YAML headers and manifest identity.
+2. Parse `apiVersion` and `kind` into GVK.
+3. Ask the injected mapper for GVR.
+4. If resolved, populate the resource-identity index.
+5. If unresolved, keep the document in the manifest-identity and GVK indexes and
+ attach a diagnostic.
+
+This keeps bounded materialization intact. Full YAML node trees are still built
+only for documents a plan action touches.
+
+## Watched Classification
+
+Mapping a document to a GVR is not enough to say it is managed by a GitTarget.
+The document must also match the GitTarget's effective watch selection.
+
+Classification should be a second step:
+
+```text
+document GVK -> mapper -> document GVR
+GitTarget rules -> RuleGVRResolver -> watched GVR set
+document GVR in watched set? -> tracked/untracked/orphan decision
+```
+
+This intentionally reuses the existing WatchRule resolution semantics instead
+of making the manifest layer interpret rules on its own.
+
+Status categories:
+
+| Category | Meaning |
+|---|---|
+| `tracked` | Mapping resolved and GVR is selected by this GitTarget. |
+| `unwatched` | Mapping resolved but GVR is not selected by this GitTarget. |
+| `unserved` | GVK is not served by trusted catalog data. |
+| `ambiguous` | More than one served resource could match. |
+| `disallowed` | Served, but excluded by GitOps Reverser resource policy. |
+| `unknown` | Mapper is unavailable, degraded, or structure-only. |
+
+Only `tracked` documents can be swept as managed resources. `unwatched`,
+`unserved`, `ambiguous`, `disallowed`, and `unknown` documents are acceptance or
+status facts; they are not delete targets by default.
+
+## GitTarget Start Conditions
+
+The GitTarget lifecycle should gain an explicit API-surface/mapping gate before
+the initial snapshot can make destructive decisions.
+
+Proposed condition:
+
+```text
+Type: APIMappingReady
+Reason: Resolved | CatalogUnavailable | DiscoveryDegraded | MappingFailed
+Message: API resource mapping is ready for all watched resource types
+```
+
+`Ready=True` should require this gate in addition to the existing validation,
+encryption, snapshot, and event-stream gates.
+
+Startup rule:
+
+1. The catalog must have trusted initial data.
+2. The GitTarget's WatchRules and ClusterWatchRules must resolve through
+ `RuleGVRResolver`.
+3. Every watched GVR needed for the initial snapshot must have a resolved watched-type
+ table entry carrying its GVK.
+4. Any manifest-store acceptance mode that needs watched/unwatched decisions must
+ have a mapper source stronger than `structure-only`.
+5. If discovery is degraded for a lookup scope that could affect the target, hold
+ the target at `APIMappingReady=False` instead of starting a destructive
+ snapshot from partial knowledge.
+
+This does not mean every KRM document in the repository must be served before
+GitTarget startup. It means the system must know enough to classify the watched
+set it is about to manage. Unwatched or unserved documents can still be refused
+by adoption policy without being deleted.
+
+## Failure Policy
+
+Mapping failures should be boring and visible:
+
+- `CatalogUnavailable`: wait or fail closed; do not start initial snapshot.
+- `DiscoveryDegraded`: preserve last-known-good mappings; do not turn absence
+ into deletes.
+- `Unserved`: refuse or report the document, depending on adoption policy.
+- `Ambiguous`: ask for a more specific rule or static mapping; do not guess.
+- `Disallowed`: surface as policy, not as "not served."
+- `StructureOnly`: continue inventory-only analysis; skip watched/orphan
+ classification.
+
+The destructive invariant is the same as the watch/catalog architecture: an
+inability to observe the API surface is not evidence that a resource should be
+deleted from Git.
+
+## Implementation Order
+
+1. ✅ Add `byGVK` and exact GVK lookup to `APIResourceCatalog` (also `LookupGVR`
+ and the `CatalogLookup` trust state).
+2. ✅ Introduce the `ResourceMapper` interface and a catalog-backed implementation
+ (`watch.CatalogMapper`).
+3. ✅ Add a static-snapshot implementation for unit tests
+ (`mapping.StaticSnapshotMapper`), alongside the structure-only mapper.
+4. Change manifest store construction to accept a mapper and record the resolved
+ `mapping.Status` per document.
+5. Keep `manifest-analyzer` structure-only by default; add an optional
+ kubeconfig/static-catalog mode later.
+6. Add the GitTarget mapping readiness gate before initial snapshot planning.
+7. Teach status/reporting to summarize mapped, unmapped, watched, unwatched, and
+ degraded counts.
+
+## References
+
+- Kubernetes API discovery:
+ https://kubernetes.io/docs/concepts/overview/kubernetes-api/#discovery-api
+- Kubernetes API concepts:
+ https://kubernetes.io/docs/reference/using-api/api-concepts/
+- Kubernetes API definitions:
+ https://kubernetes.io/docs/reference/kubernetes-api/definitions/
+- client-go discovery:
+ https://pkg.go.dev/k8s.io/client-go/discovery
+- metav1 `APIResource`:
+ https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#APIResource
diff --git a/docs/design/manifest/implementation-plan.md b/docs/design/manifest/implementation-plan.md
new file mode 100644
index 00000000..082aa96e
--- /dev/null
+++ b/docs/design/manifest/implementation-plan.md
@@ -0,0 +1,770 @@
+# Manifest Materialization — Implementation Plan
+
+> Status: implementation plan, captured 2026-06-04
+> Related:
+> [current-manifest-support-review.md](current-manifest-support-review.md),
+> [reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md),
+> [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md),
+> [`internal/git/manifestedit/DECISION.md`](../../../internal/git/manifestedit/DECISION.md)
+
+## What this document is
+
+The three design docs above settle *what* we are building and *why*. This document
+is the concrete *order*: PR-sized milestones, what each one touches, what it
+unblocks, and how to know it is done. It is deliberately operational — read the
+design docs for rationale, read this for sequencing.
+
+Each milestone lists:
+
+- **Depends on** — what must merge first.
+- **Touches** — the real packages/files in play today.
+- **Unblocks** — what becomes possible once it lands.
+- **Done when** — the testable signal it is complete.
+
+Validation follows [AGENTS.md](../../../AGENTS.md): for any non-docs
+implementation change, `task lint`, `task test`, and `task test-e2e` must pass.
+Run e2e sequentially after confirming Docker is available. Docs-only edits can use
+the AGENTS markdown sanity-check exception. Milestones flagged **[runtime]** below
+are the ones where e2e coverage is especially meaningful, but they are not the
+only milestones that require the command.
+
+## The shape of the work
+
+Three **independent foundation tracks** run first and can proceed in parallel. They
+**join at the Plan (M3)**, after which the path is mostly linear up to the live
+writer cutover (M7) and the streaming resync (M8).
+
+```mermaid
+flowchart LR
+ subgraph TrackA["Track A — ManifestStore spine (no cluster)"]
+ A1[A1 Store types + Report projection] --> A2[A2 Indexes + structured cause]
+ end
+ subgraph TrackB["Track B — ResourceMapper (GVK<->GVR)"]
+ B1[B1 Catalog byGVK lookup] --> B2[B2 ResourceMapper + impls]
+ end
+ subgraph TrackC["Track C — Topology guard"]
+ C1[C1 GitTarget non-overlap guard ✅]
+ end
+
+ A2 --> B3[B3 Mapper into store construction]
+ B2 --> B3
+ A2 --> M3[M3 Plan model]
+ B3 --> M3
+ M3 --> M4[M4 Acceptance gate]
+ M4 --> M5[M5 Scan mode end-to-end]
+ B2 --> M6[M6 Delete identity via mapper]
+ M3 --> M6
+ M5 --> M7[M7 Live writer: plan-then-flush]
+ M6 --> M7
+ C1 -.must land before.-> M7
+ M7 --> M8[M8 Streaming mark-and-sweep resync]
+ M8 --> M9[M9 Cross-batch cache]
+```
+
+**Critical path:** A1 → A2 → B3 → M3 → M4 → M5 → M7 → M8.
+**Parallelizable now:** Track A, Track B (B1→B2), and Track C are mutually
+independent — three people, or three sittings, can start at once.
+
+---
+
+## Track A — the `ManifestStore` spine
+
+The byte-free structure model. No cluster, no controller runtime, fully
+unit-testable. This is the backbone everything else consumes. Seeded by the
+existing [`internal/manifestanalyzer`](../../../internal/manifestanalyzer).
+
+### A1 — Store types + `Report` as a projection
+
+> **Status: ✅ landed** as a no-behavior-change refactor.
+> `ManifestStore`/`FileModel`/`DocumentModel`/`RecordRef` live in
+> [`internal/manifestanalyzer/store.go`](../../../internal/manifestanalyzer/store.go);
+> `Analyze` builds the store and renders `Report` as a projection over it. The A1
+> change itself kept CLI text+JSON output byte-identical and left the analyzer
+> tests untouched. **A2 (below) then deliberately changes that report contract** —
+> so the byte-identical property describes A1 in isolation, not the current tree.
+
+- **Depends on**: nothing.
+- **Touches**: new types in/beside `internal/manifestanalyzer`
+ (`ManifestStore`, `FileModel`, `DocumentModel`, `RecordRef`); build them from the
+ `manifestedit.IndexFiles` data that
+ [`Analyze`](../../../internal/manifestanalyzer/analyzer.go) already produces;
+ re-express [`Report`](../../../internal/manifestanalyzer/analyzer.go) as a
+ projection over the store.
+- **Unblocks**: A2, and gives the CLI/tests a safety net for the refactor.
+- **Done when**: `Analyze` builds a `ManifestStore` and the existing
+ `manifest-analyzer` CLI output (text + JSON) is unchanged — the `Report` is now
+ rendered *from* the store. All current analyzer tests pass untouched.
+- **Notes**: zero behavior change. This PR proves the store carries everything the
+ report needed. `DocumentModel` is byte-free; `manifestedit.SnapshotRef`
+ (already exists in
+ [`decision.go`](../../../internal/git/manifestedit/decision.go)) is the lazy
+ handle.
+
+### A2 — Pointer indexes + structured cause, drop the old fields
+
+> **Status: ✅ landed.** This milestone **intentionally changed the
+> analyzer/report output contract**: `DocumentReport` dropped `Encrypted`/
+> `Duplicate`/`Reason` in favour of a structured `Cause`, and duplicate identity
+> moved out of the per-document report into diagnostics + acceptance issues
+> (`IssueDuplicate`, `ManifestStore.IsDuplicate`). The duplicate collapse mirrors
+> `manifestedit` exactly — identity-claiming documents (clean *and* encrypted)
+> participate; disallowed-construct documents do not. The `ByResourceIdentity`
+> index exists but stays empty until the mapper populates it (B3).
+
+- **Depends on**: A1.
+- **Touches**: `ManifestStore` indexes
+ (`ByManifestIdentity`/`ByResourceIdentity`/`ByGVK` as `*DocumentModel` maps);
+ replace `DocumentReport`'s `Encrypted` + `Duplicate` + `Reason string` with
+ `Editable` + a structured `Cause` (sourced from `manifestedit` diagnostics, not
+ message text); standardize on `schema.GroupVersionKind`.
+- **Unblocks**: M3, B3.
+- **Done when**: indexes are multi-valued during build and **collapse to
+ single-valued** after the duplicate check, emitting a duplicate *diagnostic* for
+ collisions (the analyzer's existing duplicate detection becomes exactly this).
+ No classification reads a diagnostic message string.
+- **Notes**: this is where the data-model decisions from the review land in code —
+ encode them now while they are fresh.
+- **Known transitional field — `DocumentModel.index` (resolved in M4: dropped).**
+ The target model derives a document's position top-down instead of storing it, but
+ that only works when `FileModel.Documents` is the complete, contiguous document
+ list. The field was retained through A2/B3/M3 because the pre-acceptance,
+ structure-only analyzer legitimately sees non-managed documents (non-KRM / empty /
+ invalid) interspersed between managed ones. **M4 dropped it.** The fix was not to
+ keep the field but to make the acceptance gate refuse any managed file that is not
+ entirely valid KRM (the impure-managed-file rule = Non-Negotiable Decision #2), so
+ an accepted file's managed documents are contiguous. Positions are recovered
+ without the field by `reconstructManagedIndices`: every record-less document
+ (empty / non-KRM / invalid) leaves a diagnostic at its position, so the managed
+ documents fill the remaining positions in order. The `Analyze` report, the planner
+ (`documentLocations`), and the acceptance gate's refusal messages all share this
+ reconstruction, so each recovers the **true** file index even on a *non*-accepted,
+ non-contiguous tree — a scan-mode plan over an impure file names the right document,
+ not a misleading loop index.
+
+---
+
+## Track B — the `ResourceMapper`
+
+The GVK↔GVR resolver. The review calls it a "build from the start, not retrofit"
+dependency; it does not exist yet. Independent of Track A.
+
+### B1 — Catalog `byGVK` + exact GVK lookup
+
+> **Status: ✅ landed.** `byGVK[schema.GroupVersionKind][]APIResourceEntry` sits
+> beside `byGVR` in [`api_resource_catalog.go`](../../../internal/watch/api_resource_catalog.go);
+> `LookupGVK`/`LookupGVR` return a `CatalogLookup` carrying matched entries plus
+> degraded/ready/generation trust state, so degraded discovery is reported, never
+> treated as absence. Covered by
+> [`api_resource_catalog_lookup_test.go`](../../../internal/watch/api_resource_catalog_lookup_test.go).
+
+- **Depends on**: nothing.
+- **Touches**: [`internal/watch/api_resource_catalog.go`](../../../internal/watch/api_resource_catalog.go)
+ — add a `byGVK[schema.GroupVersionKind][]APIResourceEntry` index beside the
+ existing `byGVR`, plus an exported exact-GVK lookup and generation-aware result.
+- **Unblocks**: B2.
+- **Done when**: catalog answers exact GVK→entry and GVR→entry; degraded/partial
+ discovery is reported, not silently treated as absence; unit-tested.
+
+### B2 — `ResourceMapper` interface + implementations
+
+> **Status: ✅ landed.** The interface and runtime-independent impls live in the new
+> [`internal/mapping`](../../../internal/mapping) package (`ResourceMapper`,
+> `StructureOnlyMapper`, `StaticSnapshotMapper`, and the shared
+> `ResolveGVK` reduction); the catalog-backed impl is
+> [`watch.CatalogMapper`](../../../internal/watch/catalog_mapper.go). `mapping` does
+> not import `watch`, so the analyzer can resolve without pulling in the watch
+> manager. Naming note: the doc's `MappingResult`/`MappingStatus` are
+> `mapping.Result`/`mapping.Status` in code (no-stutter lint); the `Mapping*` status
+> constants kept their names.
+>
+> **Deferred within B2: `MapperSourceKubeconfig`.** Three of the four sources ship
+> here — `live-catalog` (`watch.CatalogMapper`), `static-snapshot`, and
+> `structure-only`. The kubeconfig source is a declared constant only; the
+> kubeconfig-backed discovery mapper is the optional CLI "check this folder against
+> this cluster" mode the mapping doc and Implementation Order (step 5) push to
+> *later*. It is not needed by the controller (live-catalog) or tests
+> (static-snapshot), so deferring it does not block B3/M3/M6.
+
+- **Depends on**: B1.
+- **Touches**: new `ResourceMapper` interface (per
+ [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md)) with `GVRForGVK` returning
+ `mapping.Result` (whose `Status` is a `mapping.Status`); a
+ **catalog-backed** impl (reads B1, never calls discovery directly), a
+ **static-snapshot** impl for tests, and a **structure-only** impl returning
+ `MappingStructureOnly`.
+- **Unblocks**: B3, M3, M6.
+- **Done when**: the three shipped `MapperSource`s (live-catalog, static-snapshot,
+ structure-only) behave per the doc; expected lookup outcomes are statuses, not
+ errors; static-snapshot fixtures make tests cluster-free. (`kubeconfig` is
+ explicitly deferred — see status note above.)
+
+### B3 — Wire the mapper into store construction
+
+> **Status: ✅ landed.** `buildStore`/`BuildStore` now take a `context.Context` and
+> an injected `mapping.ResourceMapper`
+> ([`store.go`](../../../internal/manifestanalyzer/store.go),
+> [`analyzer.go`](../../../internal/manifestanalyzer/analyzer.go)); a **nil mapper is
+> normalized to structure-only**, so the analyzer's no-cluster promise holds. Each
+> KRM document's GVK is resolved through `GVRForGVK`, the returned `mapping.Status`
+> is recorded on `DocumentModel.Mapping`, and a `Resolved` lookup builds the
+> `ResourceIdentity` (GVR + the manifest's namespace/name) and indexes it. The doc's
+> transitional local `MappingStatus`/`MappingStructureOnly` were folded into the
+> canonical `mapping.Status` now that Track B has landed.
+>
+> Judgment calls the plan left open: (1) `ByResourceIdentity` **collapses on the
+> same first-occurrence winners as `ByManifestIdentity`**, so a resolved winner is
+> reachable by either identity and duplicate losers never claim a slot; (2)
+> "unresolved GVKs become diagnostics" is implemented as a `reasonUnresolvedMapping`
+> `manifestedit.DiagReason` value **defined in `manifestanalyzer`, not added to
+> `manifestedit`** — keeping the API-mapping concept out of the YAML-editing package
+> while reusing its shared `Diagnostic` type; (3) the **mapper's scope is
+> authoritative** — a `Resolved` cluster-scoped resource is keyed with no namespace,
+> and a manifest that nonetheless sets `metadata.namespace` has it dropped for
+> indexing plus a `reasonScopeMismatch` diagnostic (refusal is M4's call), so it is
+> never indexed under a wrong namespaced key; (4) a **lookup that returns a Go error**
+> (impl failure / cancelled context, never an expected outcome) is recorded as
+> `MappingCatalogUnavailable` (the design's fail-closed bucket) with a `DiagError`,
+> so an error never masquerades as intentional structure-only analysis. `Analyze`
+> stays structure-only (passes nil), so its Report output is unchanged.
+
+- **Depends on**: A2, B2.
+- **Touches**: store builder takes an injected `ResourceMapper`; for each KRM
+ document, resolve GVK→`ResourceIdentity` and populate `ByResourceIdentity`;
+ unresolved GVKs become diagnostics; `MappingStatus` recorded per document.
+- **Unblocks**: M3 (full), M4 (watched/unwatched classification).
+- **Done when**: with a static-snapshot mapper, documents carry resolved
+ `ResourceIdentity` + `MappingStatus`; with the nil/structure-only mapper the store
+ still builds (no resource index), preserving the analyzer's no-cluster promise.
+
+---
+
+## Track C — topology guard (independent, cheap, land early)
+
+### C1 — GitTarget non-overlap guard [runtime]
+
+> **Status: ✅ landed.** Implemented as a **reconcile-time `Validated` gate**, not
+> an admission webhook — this repo has no admission-webhook infrastructure and the
+> rule fits the established status-condition pattern. It extends the GitTarget
+> reconciler's existing `checkForConflicts` (which already rejected exact-path
+> duplicates) to also reject ancestor/descendant nesting. An overlapping target
+> goes `Validated=False` / `Ready=False`, reason `TargetConflict`, with a clear
+> message, and writes nothing. The guard now fails closed if the controller cannot
+> list peer GitTargets, so a cache/API list failure cannot silently pass the
+> one-owner check.
+
+- **Depends on**: nothing.
+- **Touches**: `checkForConflicts` in
+ [`gittarget_controller.go`](../../../internal/controller/gittarget_controller.go);
+ segment-aware path helpers in
+ [`gittarget_path_overlap.go`](../../../internal/controller/gittarget_path_overlap.go)
+ (`gitTargetPathsOverlap` + a deterministic `gitTargetLosesConflict` tie-breaker);
+ `git.IsValidTargetPath` in [`git.go`](../../../internal/git/git.go), which reuses
+ the writer's `sanitizePath` so the guard and the write path agree on what a target
+ may own.
+- **Scope key**: overlap is evaluated within the same
+ `(namespace, providerRef, branch)`, reusing the existing conflict scoping. Known
+ gap: two GitTargets in different namespaces whose providers resolve to the *same*
+ git URL are not yet detected — future work.
+- **Unblocks**: the "one owner per folder" invariant that M7/M8 rely on — **must
+ land before** the destructive writer/sweep.
+- **Done when**: nested/equal paths are rejected (reconcile-time, `Validated=False`,
+ reason `TargetConflict`) with a clear message; sibling paths pass; writer-invalid
+ paths (absolute, `..`, backslash) are skipped and left to their own gate; ties on
+ equal `creationTimestamp` are broken deterministically by identity; e2e covers
+ accept + reject. ✅
+- **Notes**: small and self-contained; landed before M7 as planned.
+- **Review hardening**: `checkForConflicts` must treat GitTarget list failures as
+ reconciliation errors, not "no conflict" results; this is covered by
+ `TestCheckForConflicts_ListErrorFailsClosed`.
+
+---
+
+## The join and the linear tail
+
+### M3 — Plan model
+
+> **Status: ✅ landed.** The first-class `Plan` / `PlanAction` / `PlanActionKind`
+> and the pure `BuildPlan(store, files, desired, policy)` live in
+> [`internal/manifestanalyzer/plan.go`](../../../internal/manifestanalyzer/plan.go),
+> graduating `manifestreport.BuildReport`'s read-only create/update/delete/skip into
+> the materialized model. Covered by
+> [`plan_test.go`](../../../internal/manifestanalyzer/plan_test.go) against the
+> static-snapshot fixtures (patch, create, no-op, drop-orphan, encrypted skip,
+> structure-only-never-drops, non-editable construct, injected projection, and
+> missing-hydration).
+>
+> Judgment calls the plan left open: (1) **placement in `manifestanalyzer`, not a new
+> package** — when M3 landed the planner read the transitional `DocumentModel.index`
+> directly; M4 then **dropped that field** and the planner now reconstructs each
+> document's true file position via `documentLocations` /
+> `reconstructManagedIndices` (the record-less diagnostic gaps), keeping it in the
+> same package as the store and the report it shares that reconstruction with;
+> (2) **policy is injected** (`Policy{Project, EditOptions}`) so the
+> planner stays a pure, dependency-light function — production wires
+> `manifestreport.Project` / `EditOptions()` at the call sites, keeping the
+> integration layer out of the structure library; (3) the planner **emits
+> create/patch/replace/skip/drop-orphan**; `delete-document` / `delete-file` are
+> defined as the apply vocabulary but realized mechanically at flush (M7), not
+> emitted here — a managed drop is one `PlanDropOrphan` regardless of whether
+> removing it empties the file; (4) a **no-op produces no action** (an in-sync
+> resource is not a change) while a **skip is surfaced** (out of sync but not safely
+> editable); (5) **`PlanDropOrphan` is gated on `mapping.MappingResolved`** — a
+> watched, policy-allowed document absent from the desired set — so a structure-only
+> store never drops, and disallowed/unserved KRM and unwatched API-backed KRM produce
+> no plan action exactly as the acceptance contract requires; (6) **a duplicate
+> identity collision suppresses actions for the whole identity — first-occurrence
+> winner and losers alike** — because a duplicate refuses the entire GitTarget at
+> acceptance (M4), so the planner must never edit or drop one arbitrary copy; (7)
+> **`BuildPlan` is the full-snapshot "Resync" planner, not the steady-state one** —
+> per the design's "Two Paths, One Plan Type"
+> ([reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md)),
+> it mark-and-sweeps (every watched document absent from `desired` is a managed drop),
+> so `desired` is the **complete** `[]DesiredResource{Resource, Object}` snapshot, never
+> a partial batch. Each entry carries the API-side `ResourceIdentifier` the controller
+> resolved from the GVR it watched, so a `create` carries everything
+> `ResourceIdentifier.ToGitPath` needs to place a new file at apply time (M7) with no
+> re-resolution. A nil `Object` is an **ignored malformed entry, not a delete
+> tombstone** — a lone tombstone in a sweeping planner is indistinguishable from "every
+> other document is orphaned", so per-event delete intents that emit an explicit
+> `delete-document` without sweeping are the separate steady-state path (M7, on M6's
+> delete-identity resolution); (8) **hydration is the `FileContent` slice the store was
+> built from**, so a plan and its store read identical bytes, and a touched file whose
+> bytes were not supplied becomes a skip plus a diagnostic rather than a wrong edit.
+
+- **Depends on**: A2, B3.
+- **Touches**: a first-class `Plan` / `PlanAction` (`create` / `patch` / `replace` /
+ `delete-document` / `delete-file` / `drop-orphan` / `skip`), computed from
+ `(ManifestStore, desired set, policy)`. Graduate
+ [`manifestreport.BuildReport`](../../../internal/manifestreport/report.go) into
+ this — it already does create/update/delete/skip read-only.
+- **Unblocks**: M4, M5, M6.
+- **Done when**: the plan is a pure function of its inputs, carries enough detail to
+ render text/JSON/status without recomputation, and is unit-tested against
+ static-snapshot fixtures. Duplicates and unwatched API KRM produce **no** plan
+ action (they are acceptance facts); allowlisted non-API KRM produces none either.
+
+### M4 — Acceptance gate
+
+> **Status: ✅ landed.** The gate is
+> [`manifestanalyzer.Accept(store, AcceptancePolicy)`](../../../internal/manifestanalyzer/acceptance.go)
+> returning `Acceptance{Accepted, Issues, Retained}`. It runs the five-bucket
+> classification and refuses: duplicate identity; an **impure managed file** (a file
+> holding managed resources that also holds an empty/comment/non-KRM/invalid
+> passenger — Decision #2's "a multi-document file may hold only valid KRM");
+> standalone non-KRM / invalid YAML (bucket 2); unwatched API-backed KRM
+> (`MappingDisallowed`, bucket 4); recognised KRM the mapper cannot resolve to a
+> single watched resource (`Unserved`/ambiguous/subresource/degraded/unavailable);
+> out-of-scope watched KRM (an injected `InScope` predicate); and a managed resource
+> hiding in an allowlisted file. The mapping-aware refusals fire only when the store
+> has an API source, so a structure-only store runs just the structure checks (the
+> design's "starter requirement"). `Acceptance` reuses the analyzer's
+> `AcceptanceIssue` type, extended with the mapping-aware `IssueKind`s, so the
+> CLI/status renderer is shared.
+>
+> Judgment calls the plan left open:
+> 1. **`DocumentModel.index` is now genuinely dropped** (per the user's explicit
+> request, and the A2/M4 note's original intent). The earlier A2 note feared this
+> was unsafe because empty/comment-only documents are valid and *not* refused, so
+> a managed file `managed --- # comment --- managed` keeps its managed documents
+> at non-contiguous file indices and a top-down loop index would edit the wrong
+> document. The resolution was **not** to keep the index but to make the gate
+> refuse any managed file that is not entirely valid KRM — exactly Non-Negotiable
+> Decision #2. Positions are then **reconstructed from the record-less diagnostic
+> gaps** (`reconstructManagedIndices` in
+> [analyzer.go](../../../internal/manifestanalyzer/analyzer.go), shared by the
+> `Analyze` report, the planner's `documentLocations`, and the acceptance gate's
+> refusal messages): every empty/non-KRM/invalid document leaves a diagnostic at
+> its position, so the managed documents fill the rest in order. This recovers the
+> **true** file index for *every* file, contiguous or not, so a scan-mode plan over
+> an impure (refused) file still names the right document rather than a misleading
+> loop index. The impure-file refusal is therefore Decision #2 enforcement, not an
+> index-safety crutch; it trades a small strictness — a stray trailing `---` in an
+> *adopted* managed file is now refused — for deleting the most fragile field in the
+> model. The live writer (M7) re-derives a document's position against the freshly
+> hydrated file at apply time.
+> 2. **The allowlist is filename-based, not GVK-based.** A real `kustomization.yaml`
+> has no `metadata.name`, so it is never a KRM record and a GVK match would never
+> see it. `DefaultAllowlist` matches build-directive basenames
+> (`kustomization.yaml` / `.yml`), retains the whole file outside `FilesByPath`,
+> and suppresses its per-document index diagnostics. A *named* KRM record found
+> inside an allowlisted file is retained **with** its identity so the gate refuses
+> it (`IssueMixedFile`) rather than silently un-managing it. The plan's separate
+> "mixed managed/allowlisted file" rule is thus split two ways: a (nameless)
+> Kustomization document in a *non*-allowlisted managed file is just a non-KRM
+> passenger → impure-managed-file; a *managed* document in an allowlisted file →
+> mixed-file.
+> 3. **Scope is an injected predicate.** The runtime-independent gate takes
+> `AcceptancePolicy.InScope func(types.ResourceIdentifier) bool` (nil = no scope
+> restriction). The CLI passes nil; the controller will inject a namespace-aware
+> predicate at M7.
+
+- **Depends on**: M3.
+- **Touches**: a distinct step between "build store" and "use as planning model"
+ implementing the five-bucket classification and the refuse rules (duplicate
+ identity, non-KRM YAML, unwatched API-backed KRM, out-of-scope watched KRM, impure
+ managed files / mixed managed-allowlisted files); a filename-based allowlist for
+ non-API KRM (retained, not a `FileModel`).
+- **Unblocks**: M5, and gates M7/M8.
+- **Done when**: refusal produces file-naming diagnostics and reconciles nothing; a
+ clean folder passes; retained allowlisted files never enter `FilesByPath`. ✅
+
+### M5 — Scan mode end-to-end
+
+> **Status: ✅ landed.**
+> [`manifestanalyzer.Scan(ctx, fsys, mapper, desired, ScanPolicy)`](../../../internal/manifestanalyzer/scan.go)
+> (and `ScanDir`) is the one dry-run pipeline shared by the CLI and the controller's
+> scan path: build store (with the allowlist) → `Accept` → `BuildPlan`, writing
+> nothing. It returns `ScanResult{Store, Acceptance, Plan}`. The plan is **always**
+> computed, even on refusal, so an operator sees what reconcile would do; the caller
+> (M7) gates the apply on `Acceptance.Accepted`.
+> [`RenderScanText` / `RenderScanJSON`](../../../internal/manifestanalyzer/render.go)
+> render acceptance + plan for the CLI and double as the machine-readable status
+> form. The `manifest-analyzer` CLI gains `--mode scan` (structure-only here: no
+> cluster, so the plan is empty, but the full acceptance gate runs — applying the
+> allowlist and the impure/mixed-file refusals). The full plan-with-drops path is
+> proven by the static-snapshot `Scan` unit tests, since the CLI has no cluster
+> access yet (the kubeconfig mapper is the deferred B2 item).
+
+- **Depends on**: M3, M4.
+- **Touches**: one planner shared by the `manifest-analyzer` CLI and a controller
+ dry-run path — build store, resolve API state when available, run acceptance,
+ render the full plan, **write nothing**.
+- **Unblocks**: human review of destructive plans; precondition for arming any
+ flush.
+- **Done when**: CLI renders the full plan (incl. managed drops) and refusals; same
+ renderer feeds GitTarget status. **This must exist before M7/M8 enable deletes.** ✅
+
+### M6 — Delete identity via the mapper
+
+> **Status: ✅ landed.** The delete-identity resolution is the pure planning-layer
+> primitive
+> [`manifestanalyzer.PlanDelete(store, resource)`](../../../internal/manifestanalyzer/delete_plan.go)
+> returning `(PlanAction, emitted bool)`. It is the steady-state per-event
+> delete path of the design's "Two Paths, One Plan Type" — it targets exactly one
+> identity and **never sweeps**, so a lone delete intent can never be read as "every
+> other document is now an orphan." It emits a single `PlanDeleteDocument` (not a
+> `PlanDropOrphan`, which stays the resync sweep's kind), per the reconcile doc's "a
+> live `DELETED` event is an explicit delete-document." Covered by
+> [`delete_plan_test.go`](../../../internal/manifestanalyzer/delete_plan_test.go)
+> (by-resource-identity, moved manifest, multi-doc index, not-in-Git no-op, encrypted
+> still deletes, duplicate suppressed).
+>
+> Judgment calls the plan left open:
+> 1. **The lookup is the content-derived `ByResourceIdentity` index.** A DELETE event
+> carries no object body, so identity cannot come from the event; the resource-identity
+> index B3 built *with* the mapper already keys exactly on the event's
+> GVR/namespace/name. That direct inventory lookup is what makes a manifest
+> **moved off its canonical path** resolvable (the review's "the writer should locate
+> watched resources by `ResourceIdentifier`"). If the store has no resource-identity
+> entry, there is no managed document to delete; the planner does not re-derive a
+> manifest identity through a delete-time reverse mapper.
+> 2. **Editability does not gate a delete.** `manifestedit.DeleteDocument` is
+> content-agnostic (it never decrypts or merges), so an encrypted or non-editable
+> document is still removed when its resource leaves the cluster — editability gates
+> patches, not removals.
+> 3. **A duplicate-identity collision is suppressed** defensively, even though M7 gates
+> steady state on `Accept` (which refuses such a folder): deleting one arbitrary copy
+> of a collided identity is the exact ambiguity the design refuses to guess at.
+> 4. **The writer half ("delete by `RecordRef`, never a regenerated path") lands in
+> M7.** M6 delivers the `RecordRef`-producing resolution; the live writer that
+> consumes it is the M7 cutover. `PlanDelete` already returns the true `RecordRef`
+> (reusing `documentLocations`, so the index is correct even for a non-canonical or
+> impure file), so M7 hands that position straight to `manifestedit.DeleteDocument`.
+
+- **Depends on**: M3, B2.
+- **Touches**: resolve delete-event GVR/name → identity through the mapper in the
+ planning layer; the writer deletes by `RecordRef`, never by a regenerated path.
+- **Unblocks**: correct deletes for moved manifests in M7.
+- **Done when**: a delete with only GVR/name targets the right document even when
+ the manifest was moved off its canonical path. ✅
+
+### M7 — Live writer: plan-then-flush [runtime]
+
+> **Status: ✅ landed.** The per-event `locate → write` loop is replaced by
+> plan-then-flush in
+> [`internal/git/plan_flush.go`](../../../internal/git/plan_flush.go).
+> `applyPendingWriteEvents` now groups a batch by GitTarget base path and, per
+> subtree, builds the byte-free `ManifestStore` once
+> (`manifestanalyzer.BuildStoreFromFiles`), resolves each event to a
+> **single-identity** action over that model, applies the actions to hydrated
+> commit-scoped `fileBuffer`s, and flushes only the files whose bytes changed or
+> were deleted (`Dirty()`/`Deleted()` are the byte state machine, exactly as the
+> design's `FileModel`). The replaced machinery — `manifestLocator` / `inventoryFor`
+> / `locate`, `applyEventToWorktree`, `handleCreateOrUpdateOperation` /
+> `handleDeleteOperation`, `reconcileAgainstExisting` / `preserveExistingFormatting`
+> — is deleted (~260 lines from [`git.go`](../../../internal/git/git.go)); the
+> per-document mechanism (`manifestedit.Apply` / `DeleteDocument`),
+> `ResourceIdentifier.ToGitPath` placement, and the SOPS/no-op guards survive as plan
+> decisions. The model is reused via two new exported analyzer entry points,
+> `BuildStoreFromFiles` and `ManifestStore.DocumentLocations`
+> ([`store.go`](../../../internal/manifestanalyzer/store.go)). `task lint` / `test` /
+> `test-e2e` are all green.
+>
+> Judgment calls the plan left open:
+> 1. **Steady state is single-identity, never a batch mark-and-sweep** (the design's
+> "Two Paths, One Plan Type"). The writer resolves each event on its own — an
+> object-bearing event is an upsert (in-place patch when a managed document for its
+> identity already lives in the subtree, even if moved off the canonical path;
+> otherwise a wholesale canonical write), a `DELETE` is a delete-document — and
+> **never** drops the other managed documents just because they are absent from the
+> batch. Whole-folder mark-and-sweep stays the M8 resync mechanism.
+> 2. **The no-op / in-place / whole-replace / skip decisions survive as
+> `manifestedit.Decide` plan decisions**, not the old per-event heuristics. A
+> canonical file is now patched in place (preserving any formatting) rather than
+> re-rendered wholesale; idempotence holds because `Decide`'s object-level no-op
+> check makes the next reconcile a no-change even if the patched bytes differ from a
+> fresh canonical render. The multi-document data-loss guard survives as
+> `writeCanonical`'s refusal to overwrite a multi-document canonical file, and
+> multi-document in-place edits are inherently safe (manifestedit replaces only the
+> target document).
+> 3. **Sensitive (SOPS) resources keep the re-encrypting wholesale path** and are never
+> patched in place — an in-place merge would drop the sops metadata and write the
+> secret back in cleartext — exactly as the per-event writer did.
+> 4. **Deletes are content-first, and the mapper builds the writer's inventory in
+> production.** A `DELETE` is matched by manifest identity when it still carries its
+> object — and the live watch path *does* carry the deleted object
+> ([`informers.go`](../../../internal/watch/informers.go)), so a manifest moved off
+> its canonical path is deleted correctly in steady state even before any mapper. A
+> GVR-only delete (no object) is resolved through `PlanDelete` over the resolved
+> resource-identity index. The live-catalog mapper is now injected end to end —
+> `watch.Manager.Mapper()` → `WorkerManager.SetMapper` → each `BranchWorker`
+> ([`worker_manager.go`](../../../internal/git/worker_manager.go),
+> [`cmd/main.go`](../../../cmd/main.go)) — so the writer builds its store with the
+> live catalog and a GVR-only moved delete resolves by content in production (covered
+> by a static-snapshot unit test and a wiring test). There is no delete-time reverse
+> lookup or canonical-path fallback for an object-less delete: without a resource
+> inventory entry, the writer has no managed document to delete.
+> 5. **Stale positions are re-derived, not trusted.** A document's index within a
+> multi-document file can shift inside one batch (an earlier delete renumbers its
+> successors), so every edit and delete recomputes the target document's position from
+> the buffer's *current* bytes (`currentDocIndex`) rather than the index captured when
+> the store was built. Sensitive resources are located by identity too, then
+> re-encrypted wholesale *at their existing path* — never patched in place (cleartext
+> leak) and never duplicated at the canonical path (orphaning the moved copy).
+> 6. **Scope limit — the writer is content-derived, but the upstream snapshot diff is
+> not yet.** M7 makes the *writer* match-first by content. The snapshot/atomic
+> reconcile that feeds it (`FolderReconciler.findDifferences` over the **path-derived**
+> `listResourceIdentifiersInPath` / `parseIdentifierFromPath`) is still live and still
+> path-derived, so it can emit a spurious create at the canonical path for a manifest a
+> human moved — the writer's content match cannot undo a wrong decision made upstream.
+> The moved-manifest disease is therefore only *half* cured until **M8** replaces that
+> diff with a streaming mark-and-sweep over the same content-derived store. Steady-state
+> live events (which carry the object) are already fully content-derived through the new
+> writer; the gap is the snapshot path only.
+> 7. **The live writer does not yet run the M4 acceptance gate.** It builds the store
+> with the empty allowlist (materialise every KRM document, indexing the whole
+> subtree for placement exactly as the old per-event inventory) and applies events
+> unconditionally. Gating the live apply on `Accept` (allowlist / scope / refusals)
+> is a real behaviour change — it would start refusing existing folders — so it is a
+> separate, deliberate follow-on rather than part of this mechanism swap.
+> 8. **Coalescing and commit-boundary hydration reuse the existing machinery.** The
+> open commit window already coalesces per path (last-writer-wins), and the per-base
+> `fileBuffer`s give per-identity coalescing within a batch, so no separate
+> `PendingChanges` type was introduced. The subtree is scanned once per batch at the
+> commit boundary (the same cost as the old per-batch `IndexDir`); buffers hydrate
+> lazily per touched file. Header-only parsing and cross-batch caching are the M9
+> optimisations.
+
+- **Depends on**: M5, M6 (and C1 landed).
+- **Touches**: replace the event-by-event path with build-store → plan → apply →
+ flush-once. **Deletes** (per the reconcile doc):
+ `manifestLocator` / `inventoryFor` / `locate`,
+ `applyEventToWorktree` / `handleCreateOrUpdateOperation` /
+ `handleDeleteOperation` ([`git.go`](../../../internal/git/git.go)),
+ `parseIdentifierFromPath` ([`helpers.go`](../../../internal/git/helpers.go)),
+ `listResourceIdentifiersInPath` ([`branch_worker.go`](../../../internal/git/branch_worker.go)).
+ **Keeps**: `manifestedit.Apply` / `DeleteDocument` as the per-document mechanism,
+ `ResourceIdentifier.ToGitPath` as new-file placement.
+ Introduce `PendingChanges` (per-event coalescing) and commit-boundary hydration.
+- **Unblocks**: M8.
+- **Done when**: the controller writes via plan-then-flush; the no-op/in-place/
+ whole-replace decisions (`reconcileAgainstExisting`,
+ `manifestsAreSemanticallyEqual`) survive as **plan decisions**; e2e green.
+- **Notes**: the largest cutover. Land it behind scan review (M5) and the topology
+ guard (C1). The two planning halves it folds at the commit boundary already exist:
+ `BuildPlan` (M3) for a full-snapshot resync, and `PlanDelete` (M6) for a per-event
+ `DELETED` intent. The remaining steady-state piece M7 must add is the per-event
+ **create/patch** resolution (a `PendingChange` whose `Object` is non-nil) — the
+ single-identity twin of `BuildPlan`'s desired-side loop — plus the `PendingChanges`
+ coalescing buffer, commit-boundary hydration, and the `Flush` over
+ `Dirty()`/`Deleted()` files. M7 should also hoist the per-commit `documentLocations`
+ / `collidedIdentities` maps that `PlanDelete` recomputes per call, so folding many
+ intents stays bounded by the batch (M9 then caches across batches).
+
+### M8 — Streaming mark-and-sweep resync [runtime]
+
+> **Status: ✅ landed.** The resync is now a content-derived, revision-pinned
+> mark-and-sweep over a streaming-list snapshot, and the path-derived two-snapshot
+> handshake is gone. Three pieces replace it:
+>
+> 1. **The streaming-list snapshot gatherer** —
+> [`watch.Manager.StreamClusterSnapshotForGitDest`](../../../internal/watch/snapshot_stream.go)
+> opens one Kubernetes streaming-list watch per watched `(GVR, namespace)` with
+> `sendInitialEvents=true`, `resourceVersionMatch=NotOlderThan`,
+> `allowWatchBookmarks=true`, folds every initial `ADDED` into the desired set, and
+> returns at the joined `initial-events-end` bookmark (revision = max across types).
+> If any stream errors or closes before its bookmark, the whole gather **aborts and
+> returns nothing** — a partial mark never drives a sweep. Streaming is the primary
+> path and there is **no return to the old LIST+WATCH steady-state architecture**; the
+> one narrow concession is a per-type consistent LIST for a server that cannot stream
+> at all (judgment call #6 below), needed because aggregated apiservers reject
+> `sendInitialEvents`.
+> 2. **The content-derived apply** —
+> [`BranchWorker.applyResyncToWorktree`](../../../internal/git/resync_flush.go) builds
+> the byte-free `ManifestStore` for the GitTarget subtree, runs the M3 `BuildPlan`
+> (the authoritative mark-and-sweep over the resolved resource-identity index), and
+> applies it: every desired resource is upserted through M7's proven content-derived
+> single-identity path (`applyUpsert` — moved-manifest patch, sensitive re-encrypt,
+> canonical create), and every `PlanDropOrphan` is deleted by its true `RecordRef`.
+> Nothing flushes until all actions apply, so a mid-resync error commits nothing.
+> 3. **The synchronous resync request** — a `ResyncRequest` rides the worker queue
+> (`EnqueueResync` → `handleResyncRequest`), so it is applied in order with live
+> events and replies with the plan's create/update/delete stats. The GitTarget
+> snapshot gate and `ReconcileForRuleChange` both drive it through
+> [`EventRouter.EmitResyncForGitDest`](../../../internal/watch/event_router.go).
+>
+> **The teardown (a real "new start").** Deleted outright: `FolderReconciler` and its
+> path-derived `findDifferences`, `ReconcilerManager`, the whole `internal/events`
+> package (the `ClusterState`/`RepoState` two-snapshot handshake + control events),
+> `Manager.GetClusterStateForGitDest` (the LIST snapshot), and the path-derived git
+> identity (`ListResourcesInPath` / `listResourceIdentifiersInPath` /
+> `parseIdentifierFromPath`). Git identity is now **exclusively content-derived**, so
+> the moved-manifest disease is cured for the snapshot path too — the only gap M7 left.
+>
+> Judgment calls the plan left open:
+> 1. **Desired-side via `applyUpsert`, sweep-side via `BuildPlan`'s `PlanDropOrphan`.**
+> A pure plan-apply would regress sensitive resources (the planner emits `PlanSkip`
+> for an encrypted document, but resync must still re-encrypt a changed Secret), so
+> the upserts reuse the steady-state writer verbatim and only the destructive drops
+> come from the plan. The two agree because both read the same store and `Decide`.
+> 2. **No worker yet is a hard error → prompt requeue.** With reconcilers gone there is
+> one not-ready signal (no live `BranchWorker`); `EmitResyncForGitDest` errors so the
+> caller requeues, and the target stays pending until its worker exists — self-healing
+> and simpler than the old missing-reconciler/missing-worker split.
+> 3. **Two triggers, no replay callback.** The initial snapshot is the GitTarget
+> controller's synchronous gate; rule-change re-snapshots flow through
+> `ReconcileForRuleChange`. The reconciler-creation `MaybeReplaySnapshot` callback is
+> removed; a pending target is retried by the periodic reconcile. A redundant no-op
+> resync may run once at startup (it produces no commit).
+> 4. **Steady-state informers stay.** The streaming-list watch gathers only the
+> consistent snapshot and is then closed; the existing informer pipeline remains the
+> live-event watch (buffered during the snapshot window, flushed after). Folding both
+> into one connection is a larger change than M8's resync swap.
+> 5. **Acceptance is not yet gated on the resync apply** (same deliberate deferral as
+> M7): the store is built with the empty allowlist and the plan applied
+> unconditionally. The sweep only drops `MappingResolved` documents, so it never
+> prunes unclassified content even without the gate.
+>
+> Hardening the e2e surfaced (each pinned by its own spec):
+> 6. **Per-type LIST fallback for non-streaming servers.** Aggregated apiservers reject
+> `sendInitialEvents` outright, so a streaming-only gather aborted the whole snapshot
+> and poisoned every reconcile. The gatherer now falls back to one consistent LIST at
+> the latest revision for a type whose `Watch` reports streaming unsupported — the
+> design's "availability fallback", scoped per type. It is *not* LIST+WATCH steady
+> state (the informers still own live events); a transient watch error still aborts.
+> 7. **The resync bootstraps the GitTarget path before applying**, so a first resync into
+> a fresh subtree has the directory (and any `.sops.yaml`) SOPS needs to encrypt a
+> Secret — the per-event path did this via `ensureBootstrapTemplateInPath`; the resync
+> carries no events, so it calls it explicitly.
+> 8. **The resync renders the provider's snapshot commit template** (`SnapshotTemplate`,
+> counting the changed resources) rather than a hardcoded message, so a custom snapshot
+> template is honoured exactly as the old atomic snapshot honoured it.
+> 9. **The rule-change resync is fire-and-forget; a deleted GitTarget is skipped.**
+> `ReconcileForRuleChange` enqueues each target's resync without blocking on the commit
+> (`TriggerResyncForGitDest`), so many targets' commits proceed in parallel at their
+> workers instead of serializing on the reconcile goroutine. A rule that briefly
+> outlives its GitTarget (a `NotFound` lookup) is skipped benignly, never a hard error
+> that would storm the reconcile. The GitTarget gate keeps the synchronous path (it
+> needs the stats for status).
+> 10. **A no-op resync is never retained or pushed.** An empty initial snapshot (no rule
+> has selected a resource yet) produces no commit, so it must not advance the push
+> cooldown — otherwise the next real snapshot's push is delayed past its window. Only a
+> resync that actually committed is retained and pushed.
+>
+> An external review then hardened three more points:
+> 11. **Destination identity is immutable** (CEL transition rules on the CRDs). A
+> GitTarget writes at exactly one (provider, branch, folder), and a GitProvider's
+> `spec.url` is the repository those targets resolve to; changing any of them would
+> silently orphan the old materialization. So `GitTarget.spec.providerRef/branch/path`
+> and `GitProvider.spec.url` are fixed — relocating is a delete + recreate. Everything
+> operational stays mutable, deliberately: `GitProvider.spec.allowedBranches` (widening
+> or narrowing the writable set must not require tearing down every GitTarget), plus
+> auth, push tuning, and commit identity/signing. Rather than reconcile a destination
+> *move* (which would need a generation-aware snapshot gate and event-stream/worker
+> rebinding — the review's findings 2 and 3), the destination is fixed. This removes
+> that whole bug class instead of handling it, and keeps the "SnapshotSynced ⇒ skip"
+> gate correct (a successful snapshot can never be silently invalidated).
+> 12. **A rule-change resync is marked delivered on ENQUEUE, not on apply — deliberately.**
+> The review's finding 1 (mark delivered only after the apply commits, so a failed
+> resync retries) was tried and reverted: gating delivery on the apply turned a slow or
+> failed apply into an unbounded re-resync loop. Because the target stayed pending,
+> every subsequent reconcile re-gathered the whole snapshot *synchronously* (a
+> cluster-wide CRD target re-streamed dozens of objects each pass), starving the single
+> reconcile goroutine and piling resync requests onto the worker until commits stopped
+> landing — the e2e went from green to five timeouts. Delivery is therefore marked once
+> the resync is enqueued; a failed resync is recovered by the steady-state live-event
+> path (which writes any later change) and the next genuine rule-set change, not by
+> re-running the whole snapshot on a tight loop. The lesson: a *synchronous* gather on
+> the reconcile goroutine must never be put behind a condition that can re-fire it every
+> pass.
+> 13. **Resync stats are counted from the apply, not the plan.** `applyUpsert` reports
+> create/update/no-change, so a sensitive (SOPS) resource — `PlanSkip` in the plan but
+> re-encrypted and committed by the apply — is reported as Updated, not Skipped, and is
+> included in the commit-message count.
+
+- **Depends on**: M7.
+- **Touches**: the streaming-list watch (`sendInitialEvents`) folded over the
+ managed model; set-difference orphan computation at the joined bookmark. **Deletes**
+ the two-snapshot handshake and `FolderReconciler.findDifferences`
+ (`folder_reconciler.go`, `events.go`).
+- **Unblocks**: M9.
+- **Done when**: initial reconcile/resync is one consistent snapshot; sweep gated on
+ all bookmarks, aborts and drops nothing on a partial stream; e2e covers
+ create+update+managed-drop at a pinned revision. ✅
+
+### M9 — Optimize after correctness
+
+- **Depends on**: M8.
+- **Touches**: longer-lived cross-batch caching of the structure index, keyed by
+ checkout state + GitTarget path; rebuild on tip/branch/path change or
+ non-incremental local flush.
+- **Done when**: repeated batches reuse the cached header scan; invalidation is
+ correct under the listed triggers.
+
+---
+
+## First three PRs (concretely)
+
+1. **A1** ✅ — `ManifestStore`/`FileModel`/`DocumentModel`, `Report` as a
+ projection. No behavior change; existing analyzer tests are the net.
+2. **B1** ✅ — catalog `byGVK` + exact lookup. Tiny, isolated, unblocks the mapper.
+3. **C1** ✅ — GitTarget non-overlap guard (reconcile-time `Validated` gate, not a
+ webhook). Cheap, self-contained, locks the one-owner invariant in before anything
+ destructive depends on it.
+
+A2 ✅, B2 ✅, and B3 ✅ have followed; B3 joined the tracks (the store now builds
+with an injected mapper). M3 ✅ landed on top of them — the plan is a pure function
+of `(store, files, desired, policy)`. **M4 (acceptance gate) ✅ and M5 (scan mode) ✅
+have now landed** — `Accept` is the distinct gate between build and plan, `Scan` is
+the shared write-nothing dry-run, and `DocumentModel.index` was dropped (M4 refuses
+any managed file that is not entirely valid KRM, so an accepted file is contiguous).
+**M6 (delete identity) ✅ has now landed too** — `PlanDelete` resolves a GVR-only
+`DELETED` event to a single `delete-document` action over the content-derived store, so
+a moved manifest is still deleted by its true `RecordRef`. **M7 (live writer:
+plan-then-flush) ✅ has now landed** — the per-event `locate → write` loop is gone, the
+controller writes by building the store, resolving each event to a single-identity
+action, applying to hydrated file buffers, and flushing dirty/deleted files; the
+no-op/in-place/whole-replace/skip decisions survive as `manifestedit.Decide` plan
+decisions, the live-catalog mapper is wired end to end (so GVR-only moved deletes resolve
+by content in production), and e2e is green. C1 ✅ landed independently and preceded M7.
+**M8 (streaming mark-and-sweep resync) ✅ has now landed**, finishing the cure M7 left
+half-done: the path-derived two-snapshot GVR diff (`FolderReconciler.findDifferences`
+over `listResourceIdentifiersInPath` / `parseIdentifierFromPath`) and the whole
+`internal/events` handshake are deleted, and the snapshot is now one revision-pinned
+streaming-list watch (`StreamClusterSnapshotForGitDest`, `sendInitialEvents`, no
+`LIST+WATCH` steady-state path — only a per-type LIST fallback for non-streaming servers)
+folded over the content-derived store and mark-and-swept by
+`BuildPlan`. Git identity is exclusively content-derived end to end, so a moved manifest
+is correct on the snapshot path too. That leaves the critical path at **M9 (cross-batch
+cache)**, the last remaining milestone — and the only optimisation work, after
+correctness.
diff --git a/docs/design/manifest/manifest-inventory-file-agnostic-placement.md b/docs/design/manifest/manifest-inventory-file-agnostic-placement.md
new file mode 100644
index 00000000..e47f81c3
--- /dev/null
+++ b/docs/design/manifest/manifest-inventory-file-agnostic-placement.md
@@ -0,0 +1,572 @@
+# Manifest inventory for file-agnostic placement
+
+> Status: proposed (vision)
+> Related: [file-agnostic-placement.md](file-agnostic-placement.md),
+> [manifest-parser-poc.md](manifest-parser-poc.md),
+> [bi-directional.md](../../bi-directional.md)
+
+This is the vision document. It holds the requirements and the bigger "why".
+The first concrete step is scoped separately in
+[manifest-parser-poc.md](manifest-parser-poc.md).
+
+## Summary
+
+GitOps Reverser currently treats the generated file path as part of the storage
+contract. A Kubernetes resource is written to a deterministic path derived from
+its API identity, and Git state is discovered by parsing that path back into a
+resource identifier.
+
+That is simple, but it makes GitOps Reverser hard to attach to existing
+repositories. Kubernetes manifests already carry their API identity in
+`apiVersion`, `kind`, `metadata.name`, and, for namespaced resources,
+`metadata.namespace`. The file path should be placement metadata, not the source
+of truth for identity.
+
+The proposed direction is to introduce a manifest inventory layer:
+
+- scan a target folder for YAML manifests
+- parse Kubernetes resources from their content
+- map each resource identity to its exact file location
+- update existing resources in place
+- use a configurable placement policy only when a new resource has no known
+ location yet
+
+## Why this matters
+
+Existing GitOps repositories rarely follow GitOps Reverser's current generated
+layout. They may use application folders, environment overlays, namespace
+folders, multi-document YAML files, bootstrap files, or generated output from a
+larger delivery pipeline.
+
+If GitOps Reverser can only write:
+
+```text
+{group}/{version}/{resource}/{namespace}/{name}.yaml
+```
+
+then it can mirror resources into Git, but it cannot comfortably become a GitOps
+API for an existing repository. A file-agnostic placement model lets users point
+at a folder and have GitOps Reverser discover what is already there.
+
+## Principle
+
+Resource identity comes from Kubernetes object identity. File path is an
+implementation detail of where that object is stored.
+
+For a normal manifest this means:
+
+- `apiVersion` and `kind` identify the Kubernetes type as written in YAML
+- API discovery / RESTMapper maps that GVK to the watched GVR
+- `metadata.name` identifies the object name
+- `metadata.namespace` identifies the namespace for namespaced resources
+
+Namespace elision is a special case. If a manifest omits
+`metadata.namespace`, then the YAML content alone no longer fully identifies the
+live object. The missing namespace must come from an explicit context, such as a
+GitTarget setting or a very small supported Kustomize namespace rule. That should
+be treated as contextual identity, not as pure content identity.
+
+For the first implementation, keep writing `metadata.namespace`. Namespace
+elision can come later behind an explicit GitTarget setting, such as
+`writeNamespace`, after the contextual identity rules are clear.
+
+## Authority model
+
+The API remains leading. The manifest inventory changes how GitOps Reverser
+finds and edits files, but it does not change the reconciliation direction.
+
+On initial reconcile, GitOps Reverser expects the normal GitOps system, user, or
+managing layer to have already synced valid KRM from the target folder into the
+watched Kubernetes API surface. GitOps Reverser then compares:
+
+- valid watched KRM found in Git
+- resources currently present in the watched Kubernetes API
+
+The reconciliation behavior stays the same as today:
+
+- if a watched resource exists in the API but not in the Git folder, add it to
+ Git using the placement policy
+- if valid watched KRM exists in Git but is not present in the API, remove it
+ from Git
+- if a resource exists in both places, update the Git manifest from the API
+ state while preserving the existing file location and formatting where safe
+
+Only watched GVRs participate in add/remove. Documents and files for unwatched
+types are never pruned and are left untouched.
+
+### Initial-reconcile prune risk
+
+The remove rule has a real risk worth stating plainly: on initial reconcile,
+valid watched KRM that exists only in Git is deleted. If the surrounding system
+has not yet synced the source-of-truth manifests into the watched API, those
+files look like deletions and are removed even though they should have stayed.
+
+GitOps Reverser does not try to make this safe on its own, and that is
+intentional. The API is leading; the orchestration around GitOps Reverser must
+sequence initialization so the watched API is populated before reverse pruning
+runs. This tooling must be orchestrated well to prevent disasters. Making the
+prune path self-protecting is explicitly out of scope for this feature.
+
+### No separate reconciliation-state layer
+
+Because the API is leading and almost every change originates from GitOps
+Reverser itself, the design must converge to "no change" on repeated reconciles
+without keeping a separate persisted projection or shadow state. The guard
+against edit oscillation (a commit that is immediately reverted on the next
+reconcile) is a single, consistent desired-content projection plus semantic
+no-op detection, validated by tests — not a stateful reconciliation layer. In
+practice the existing bi-directional patterns have not shown this oscillation.
+Keeping extra state is not forbidden, but it is explicitly not the first measure.
+
+## Encrypted manifests (SOPS)
+
+GitOps Reverser already manages SOPS encryption (the `.sops.yaml` bootstrap and
+the existing encryptor path). File-agnostic placement has to coexist with
+encrypted manifests, and the rules here are opinionated and load-bearing — they
+will make or break the implementation.
+
+- **Detection is by file extension for now.** A file is treated as SOPS-managed
+ based on its extension (the existing encrypted-file convention). Anything more
+ precise is deferred.
+- **Partial encryption only.** An encrypted manifest must keep its identity
+ fields — `apiVersion`, `kind`, `metadata.name`, `metadata.namespace` — in
+ cleartext. Only data-bearing fields (`data`, `stringData`, and similar, per
+ the `.sops.yaml` rules) are encrypted. The inventory reads identity straight
+ from the file, exactly as it would for a plaintext manifest.
+- **Must have readable identity and a `sops` key, or it is invalid.** A
+ SOPS-managed file must have both readable cleartext identity and a `sops` key.
+ If either is missing — including full-file encryption that hides the identity —
+ the file is flagged invalid and ignored with a diagnostic. This is a hard
+ requirement, not best effort.
+- **One document per encrypted file.** Encrypted files are single-document.
+ Multi-document handling does not apply to them.
+- **No decryption, ever.** GitOps Reverser never decrypts SOPS material. It does
+ not read, compare, or preserve encrypted values. This is a deliberate security
+ measure: the reverser should not need the decryption keys to do its job.
+- **Re-encrypt on write.** Because it cannot read the existing ciphertext, the
+ writer renders the desired object and re-encrypts the whole file through the
+ existing encryptor on initial reconcile and on every write.
+- **No formatting preservation for encrypted files.** Re-encryption rewrites the
+ file, so comment and scalar-style preservation goals do not apply to encrypted
+ content. This is an accepted, intentional trade-off.
+- **Optional caching to avoid spurious commits.** Re-encrypting always produces
+ fresh ciphertext, which would otherwise look like a change on every reconcile.
+ An optional cache of the last-encrypted desired plaintext (by identity) lets
+ the writer skip a real commit when the underlying object has not changed. This
+ is an optimization, not a correctness requirement.
+
+The net effect: strong security posture (no keys needed to read secrets) in
+exchange for losing formatting fidelity on encrypted files only.
+
+Open questions are tracked in [Remaining questions](#remaining-questions).
+
+## Manifest inventory
+
+The inventory is an index built from a GitTarget folder. It can start as
+process-local state. It only needs to be rebuilt when the GitTarget is
+initialized, when the tracked branch moves externally, or when the local worktree
+is refreshed from an incoming remote change. Normal API-driven writes can update
+the inventory incrementally as part of the write.
+
+### Manifest identity and resource identity
+
+The inventory deals with two views of the same object, and the document keeps
+them distinct on purpose:
+
+- **Manifest identity** is what is written in the YAML: `apiVersion` and `kind`
+ (a GroupVersionKind), plus `metadata.name` and, for namespaced resources,
+ `metadata.namespace`. This is content identity — what a human reads in the
+ file.
+- **Resource identity** is the API-side key GitOps Reverser already uses on the
+ watch/reconcile path: group, version, resource (a GroupVersionResource), plus
+ namespace and name. The manifest GVK is mapped to the watched GVR through API
+ discovery / RESTMapper.
+
+So manifest identity is the on-disk representation and resource identity is the
+normalized API key the inventory indexes by. The inventory's core job is to keep
+the mapping `resource identity -> file location` while remembering the manifest
+identity that produced it.
+
+Each discovered resource should record:
+
+- resource identity: group, version, resource, namespace, name
+- manifest identity: apiVersion, kind, namespace, name
+- file path relative to the GitTarget path
+- document index inside that file
+- byte range or AST node location for the document, if available
+- whether the manifest came from plain YAML, basic Kustomize context, or an
+ unsupported source
+- diagnostics such as duplicate identity, invalid YAML, unsupported template, or
+ missing namespace context
+
+This changes the write decision from:
+
+```text
+resource id -> generated path
+```
+
+to:
+
+```text
+resource id -> existing inventory location, else placement policy
+```
+
+### Document index is authoritative
+
+GitOps Reverser is in control of the Git side. Almost every write originates
+from it, so it updates the document index as part of writing, and any external
+change to the branch triggers a full rescan that rebuilds the inventory from
+scratch. Within a given inventory state the document index is therefore
+authoritative: the writer locates the document to edit directly by its index and
+does not need to re-derive the position from manifest identity on every edit.
+
+### Rebuild and rescan
+
+The rescan stays deliberately simple. A rebuild is triggered when something
+changed under the GitTarget base path. The cheap gate is "did anything in the
+base folder change" — if nothing changed, do nothing. If something did, do a
+full rebuild. Because the API is leading and most changes originate from GitOps
+Reverser itself, a full rebuild on change is acceptable and we do not want
+anything smarter than the change gate. The rescan must stay doable and no more
+advanced than strictly needed.
+
+### Untrusted input and disallowed constructs
+
+The folder is parsed as untrusted input, so the scan must be bounded:
+
+- **Alias / anchor expansion bombs.** YAML anchors (`&x`) and aliases (`*x`),
+ and merge keys (`<<`), allow a small file to reference nodes repeatedly and
+ expand exponentially when fully materialized — the classic "billion laughs"
+ YAML bomb that can exhaust CPU and memory. These constructs are also unsafe to
+ edit through. Anchors, aliases, and merge keys go on a disallow list: a file
+ using them is ignored as non-editable, with a diagnostic, and must not be
+ fully materialized.
+- **Symlink traversal.** A symlink under the base folder could point outside the
+ tree or form a cycle. The scan does not follow symlinks; it skips them.
+
+Both behaviors must have tests.
+
+## Edit preservation
+
+File-agnostic placement must be more careful than the current canonical writer.
+If a user has comments, document ordering, blank lines, or nearby resources in a
+YAML file, GitOps Reverser should avoid rewriting the entire file when it only
+needs to update one object.
+
+The target behavior:
+
+- preserve comments when possible
+- preserve unrelated YAML documents in the same file byte-for-byte
+- update only the matching document in a multi-document YAML file
+- detect whether the desired object is semantically different before writing
+- when possible, update only the changed fields instead of re-rendering the
+ whole object
+- delete only the matching document on resource deletion
+- avoid changing sibling resources in the same file
+- preserve scalar style for unchanged values, especially block strings
+- keep existing file names and folder structure for known resources
+- fall back to canonical YAML only for newly placed resources or cases where
+ round-trip preservation is not possible
+
+A pure whole-file round-trip through `yaml.v3` cannot meet the byte-for-byte
+requirement for unrelated documents, because re-encoding a whole file normalizes
+indentation, quoting, and spacing across every document. So the baseline is
+decided: split a file into its documents textually and only re-render the one
+document that changed; unrelated documents are spliced back verbatim. The
+in-document editor and its parser are the subject of
+[manifest-parser-poc.md](manifest-parser-poc.md).
+
+## Editing strategy: structural merge
+
+GitOps Reverser already avoids commits when existing and desired manifests are
+semantically equal. File-agnostic placement should go one step further: when a
+resource does change, touch only the parts of the YAML document that changed.
+
+The mechanism is a **structural merge of the desired object onto the existing
+document's node tree**, not a "compute a diff, then map field paths back to
+nodes" pass. Path-string-to-node mapping is the brittle part (sequences, merge
+keys, ambiguous matches); walking both trees together keeps the target node in
+hand at every step, and add, update, and delete all fall out of the same
+traversal:
+
+- for a mapping: for each key in the desired object, find the matching key in
+ the existing node
+ - both are maps or sequences → recurse
+ - scalar and unchanged → leave the existing node untouched (its style and
+ comments are preserved for free)
+ - scalar and changed → update the value, keeping the existing scalar style
+ when the new value is safely representable in it, otherwise let the encoder
+ choose
+ - key absent in the existing node → insert it
+ - key present in the existing node but absent in the desired object → delete it
+- for sequences, start with index-based alignment; Kubernetes-aware keyed
+ matching (for example by container `name`) is a later nicety
+- a merge that mutates no node is a semantic no-op and produces no write
+
+Because the merge only ever visits nodes that exist in the desired object,
+unrelated nodes are preserved by construction. A common example is a ConfigMap
+containing a script:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+data:
+ start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server
+```
+
+If the only change is `metadata.labels.app: demo`, the merge walks
+`metadata` → `labels`, inserts one key, and never descends into `data.start.sh`.
+The block scalar style, indentation, blank lines, and chomping indicator (`|-`)
+survive because that node is never visited. Rewriting it as an escaped one-line
+string, a folded block, or a differently chomped literal block would make the
+Git diff noisy and damage readability.
+
+This does not require preserving every byte of an edited field. It requires that
+unrelated fields — especially human-authored strings such as scripts,
+certificates, templates, and policy snippets — are not reformatted as collateral
+damage. When a node cannot be merged unambiguously, fall back to replacing the
+whole document with a diagnostic.
+
+## Multi-document files
+
+YAML files can contain multiple Kubernetes resources separated by `---`. The
+inventory must treat each document as a distinct editable slot.
+
+Example:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+```
+
+If the Deployment changes, only the second document should be updated. The
+ConfigMap document should be preserved, including comments and formatting when
+possible.
+
+Deletion should remove just the target document and leave a valid YAML file. If
+the deleted document was the only document in the file, the file can be removed.
+
+## Duplicate resources
+
+> Superseded for implementation: see
+> [gittarget-repository-validity-and-placement.md](gittarget-repository-validity-and-placement.md).
+> Duplicate KRM should block `RepositoryValid` instead of being auto-pruned.
+
+The same Kubernetes object may appear in more than one file, or more than once
+in a multi-document file. Because the API is leading and there must be exactly
+one source location per resource, GitOps Reverser resolves this deterministically
+instead of refusing to act: **the first occurrence wins, and the extra manifests
+are deleted automatically** so Git converges to a single copy.
+
+"First" is defined by a stable scan order (for example lexicographic file path,
+then document index) so the outcome is reproducible. A diagnostic records what
+was removed:
+
+```text
+apps/v1/deployments/default/app: keeping apps/app.yaml document 2,
+removing duplicate in overlays/dev/app.yaml document 1
+```
+
+Scan ordering can be annoying when the "wrong" copy wins, but a single
+authoritative location matters more than preserving an ambiguous duplicate. This
+applies to plain manifests; a future overlay-aware mode could model intentional
+duplicates explicitly.
+
+## Placement policy for new resources
+
+The inventory solves updates and deletes for resources that already exist in
+Git. New resources still need a destination.
+
+The first version can keep a conservative default:
+
+```text
+{group}/{version}/{resource}/{namespace}/{name}.yaml
+```
+
+but this should become a placement policy rather than a hardcoded identity rule.
+Possible policy knobs:
+
+- default generated layout
+- namespace folder layout
+- resource-kind folder layout
+- one file per resource
+- append new resources to a specific file
+- custom template using resource identity fields
+
+The important distinction is that placement policy only applies when the
+inventory has no existing location for the resource.
+
+## Bootstrap files
+
+GitTarget path bootstrapping already creates `.sops.yaml`. The same idea could
+extend to simple GitOps bootstrap files, especially `kustomization.yaml`.
+
+This should stay modest:
+
+- create missing bootstrap files for clean folders
+- preserve existing bootstrap files
+- avoid taking ownership of complex user-authored bootstrap files
+- make this optional per GitTarget
+
+## Kustomize
+
+Kustomize should be supported carefully and in phases.
+
+The first useful step is detection:
+
+- detect `kustomization.yaml`
+- identify listed resource files when the structure is simple
+- detect `namespace:` as contextual namespace only when it is unambiguous
+- report unsupported generators, patches, remote bases, components, and complex
+ overlays instead of editing through them
+
+Basic support could allow GitOps Reverser to understand a simple folder that a
+normal GitOps tool can apply, without pretending to reverse-engineer every
+Kustomize transform.
+
+## Helm
+
+Helm source editing should be out of scope for this feature.
+
+GitOps Reverser can still write normal Kubernetes resources such as Flux
+`HelmRelease` objects, because those are plain KRM resources. But it should not
+edit Helm chart templates, rendered chart output mixed with source templates, or
+values-driven manifests.
+
+The desired behavior is detection with a clear diagnostic:
+
+```text
+Skipping chart templates in charts/api/templates: Helm source editing is not supported.
+```
+
+## Phased plan
+
+### Phase 1: plain manifest inventory
+
+- recursively scan `.yaml` and `.yml` files below a GitTarget path
+- parse Kubernetes-looking documents
+- build `ResourceIdentifier -> ManifestLocation`
+- read identity from partially-encrypted SOPS files; skip fully-encrypted ones
+- ignore disallow-listed constructs (anchors, aliases, merge keys) and symlinks
+- detect invalid YAML, empty documents, non-KRM YAML, duplicates, and Helm
+ source folders
+- surface inventory diagnostics in GitTarget status
+- keep the current generated path for new resources
+
+This phase can improve snapshot reconciliation immediately because Git state can
+be discovered from YAML content rather than path shape. It does not change the
+API-first reconcile contract: Git-only watched KRM is removed, and API-only
+watched resources are added.
+
+### Phase 2: in-place updates and deletes
+
+- update existing resources at their inventory location
+- update only the matching document in multi-document files
+- structurally merge the desired object onto the existing node tree, touching
+ only changed nodes
+- delete only the matching document
+- preserve comments and unrelated documents when possible
+- preserve scalar styles such as literal blocks, folded blocks, quoting, and
+ chomping indicators for unchanged fields
+- refuse or warn when round-trip preservation is unsafe
+
+This is the phase that makes the feature feel respectful of real repositories.
+
+### Phase 3: configurable placement policy
+
+- expose placement options on GitTarget
+- support one or two simple layouts first
+- keep the generated layout as the default fallback
+- add status diagnostics showing where new resources will be placed
+
+Appending new resources to an existing multi-document file should be a later,
+explicit placement mode. It would be useful to recognize local patterns, such as
+"all ConfigMaps in this folder are appended to `configmaps.yaml`", but GitOps
+Reverser should not infer that behavior until the pattern is clear enough to
+explain and override.
+
+### Phase 4: basic Kustomize context
+
+- detect simple `kustomization.yaml` files
+- use `namespace:` as contextual namespace where safe
+- optionally maintain the `resources:` list when GitOps Reverser creates a new
+ file in that folder
+- reject complex transforms with diagnostics
+
+## Non-goals
+
+- creating pull requests directly
+- full Kustomize reverse transformation
+- Helm chart or values editing
+- decrypting SOPS material
+- preserving every formatting detail in every YAML edge case
+- making two autonomous GitOps controllers safely own the same resources
+- making the initial-reconcile prune path safe on its own
+
+## Current decisions
+
+- The Kubernetes API remains leading.
+- The initial reconcile expects valid KRM in the target folder to have already
+ been synced into the watched API by the user or a normal GitOps tool. The
+ surrounding orchestration is responsible for sequencing this; the prune path
+ is not self-protecting.
+- Valid watched KRM found only in Git is removed from Git; only watched GVRs
+ participate in add/remove.
+- Watched API resources missing from Git are added to Git.
+- Duplicate copies of the same resource are resolved first-occurrence-wins by a
+ stable scan order; the extra copies are deleted automatically.
+- Convergence to no-change relies on a consistent desired-content projection plus
+ semantic no-op detection, not a separate reconciliation-state layer.
+- SOPS files are detected by file extension and must have both readable cleartext
+ identity and a `sops` key, otherwise they are flagged invalid and ignored. They
+ are single-document, never decrypted, and re-encrypted on write. Formatting is
+ not preserved for encrypted files.
+- Identity is the inventory key. The document index is authoritative within an
+ inventory state, because GitOps Reverser updates it on write and rebuilds it
+ via a full rescan on any external change.
+- The rescan is gated on "did anything under the base path change" and is a full
+ rebuild otherwise — nothing smarter.
+- Anchors, aliases, and merge keys are disallow-listed; symlinks are not
+ followed.
+- A whole-file `yaml.v3` round-trip is rejected; per-document text splitting is
+ the baseline.
+- Inventory diagnostics start in GitTarget status.
+- `metadata.namespace` stays written for now.
+- Namespace elision is deferred until there is an explicit GitTarget setting and
+ a clear contextual identity model.
+- Appending to multi-document files is a later configurable placement mode, not
+ the initial default.
+
+## Remaining questions
+
+- **`.sops.yaml` rule changes:** when the encryption rules change which fields
+ are covered, every matching file must be re-encrypted. Is that an intended mass
+ commit, or should it be gated?
+- **New encrypted resources:** when a brand-new encrypted resource is created,
+ which fields get encrypted — derived purely from the in-repo `.sops.yaml`, or
+ from a GitTarget setting? This ties into the existing TODO about non-Secret
+ sensitive custom resources.
+- **Diagnostics surface:** GitTarget status will not scale to thousands of
+ manifests. Start with high-level stats, and consider a small read API on
+ GitOps Reverser itself for the per-resource detail later. Open.
+- How far can structural-merge patching be pushed before the safer behavior is to
+ replace the whole document?
+- Which preservation requirements are hard guarantees, and which are best-effort
+ niceties with diagnostics?
diff --git a/docs/design/manifest/manifest-parser-poc.md b/docs/design/manifest/manifest-parser-poc.md
new file mode 100644
index 00000000..fd5d9ebb
--- /dev/null
+++ b/docs/design/manifest/manifest-parser-poc.md
@@ -0,0 +1,322 @@
+# POC: in-document YAML editor choice
+
+> Status: implemented — see `internal/git/manifestedit` and its
+> [DECISION.md](../../../internal/git/manifestedit/DECISION.md).
+> Outcome: `gopkg.in/yaml.v3` node editing + per-document text splitting passes
+> the implemented hard requirements; goccy/kyaml/text-slice were not needed. One
+> drift limitation is recorded (yaml.v3 normalizes flush-left sequence
+> indentation in the *edited* document), and the larger vision items
+> (Helm/Kustomize detection, GVK→GVR mapping, watched-GVR filtering, placement
+> policy) are explicitly left to writer integration, not this parser POC.
+> Related: [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md)
+
+## Goal
+
+The manifest-inventory vision document already decides the editing architecture.
+This POC has one narrow job: prove whether `yaml.v3` can edit a **single YAML
+document** with enough formatting fidelity to honor the preservation
+requirements, and if not, pick the fallback.
+
+The decision is driven by tests, not preference.
+
+## What is already decided (not part of this POC)
+
+These come from the vision document and are not re-litigated here:
+
+- **Document isolation is textual.** A file is split into its documents on
+ document boundaries, and only the changed document is re-rendered; unrelated
+ documents are spliced back verbatim. A whole-file `yaml.v3` round-trip is
+ rejected, because re-encoding the whole file normalizes indentation, quoting,
+ and spacing across every document and so cannot keep unrelated documents
+ byte-for-byte identical.
+- **Editing is a structural merge.** The desired (sanitized) object is merged
+ onto the existing document's node tree, touching only changed nodes. There is
+ no separate "diff then map field paths back to nodes" pass.
+- **`kyaml` is not a separate candidate.** `sigs.k8s.io/kustomize/kyaml` is built
+ on `yaml.v3` and inherits its formatting and comment fidelity, so it cannot
+ fix a `yaml.v3` preservation gap — it only adds KRM ergonomics. It is dropped
+ from the parser comparison.
+- **Encrypted files are out of POC scope.** SOPS files are single-document,
+ never decrypted, and re-encrypted through the existing encryptor on write
+ (see the vision document). The only inventory behavior the POC needs to honor
+ is reading identity from a partially-encrypted file and skipping a file whose
+ identity is encrypted.
+
+## The one open question
+
+Within a single changed document, is `yaml.v3` node fidelity good enough?
+
+The fallback order if it is not:
+
+1. `yaml.v3` — primary; already the closest to today's code.
+2. `goccy/go-yaml` (`github.com/goccy/go-yaml`) — fallback if `yaml.v3` loses
+ formatting; it exposes token/CST-level detail and generally has stronger
+ comment and position fidelity.
+3. Per-scalar text-slice within the changed document — last resort, only if
+ neither parser can preserve directly changed-adjacent scalars.
+
+| Option | Shape | Strength | Risk |
+|---|---|---|---|
+| `yaml.v3` node merge | Parse the one document into `yaml.Node`, merge the desired object onto it, encode back | Comments, node kinds, scalar styles, line/column metadata; smallest step from today | Known round-trip drift on block scalars, indentation, and comments |
+| `goccy/go-yaml` | Same merge, richer token/CST access | Better comment and position fidelity | New dependency and behavior to learn |
+| Per-scalar text-slice | Keep the document as text, replace only the spans of directly changed scalars | Best chance at exact preservation | Byte ranges and YAML edge cases are hard |
+
+## POC boundaries
+
+Build a small isolated prototype, not production integration. It must be easy to
+delete or rewrite after the decision.
+
+Suggested package:
+
+```text
+internal/git/manifestedit
+```
+
+A deliberately small API:
+
+```go
+type Location struct {
+ Path string
+ DocumentIndex int
+}
+
+type EditResult struct {
+ Content []byte
+ Mode EditMode
+}
+
+func IndexFile(path string, content []byte) (Inventory, []Diagnostic)
+func PatchDocument(content []byte, documentIndex int, desired *unstructured.Unstructured) (EditResult, []Diagnostic)
+```
+
+The exact API is less important than the tests.
+
+## Experiment order
+
+This order front-loads the cheapest decisive experiment.
+
+1. **No-op round-trip baseline.** Parse → encode each document in a real manifest
+ corpus with no change, and measure how much `yaml.v3` drifts before any
+ editing. If unrelated nodes already drift here, that bounds everything else.
+2. Document splitting and inventory detection.
+3. Semantic no-op detection using the existing sanitizer path.
+4. Whole-document replacement for one document in a multi-document file.
+5. Structural merge for simple maps and scalars.
+6. Run the ConfigMap script-block tests.
+7. Run comment and quote preservation tests.
+8. Decide whether `yaml.v3` is sufficient.
+9. Only if it is not, repeat the relevant tests against `goccy/go-yaml`, then a
+ per-scalar text-slice approach.
+
+## Required test cases
+
+Hard requirements are marked. The rest are strong preferences whose limitations
+must be documented if unmet.
+
+### 1. No-op round-trip drift (hard, gating)
+
+Parse and re-encode a corpus of real manifests with no edits. Record exactly
+where the encoder changes bytes (block scalar re-wrapping, indentation, quoting,
+comment placement). This is the baseline the merge editor inherits.
+
+### 2. Multi-document inventory
+
+A file with a ConfigMap, an empty document, and a Deployment should index the
+ConfigMap as document 0, ignore or diagnose the empty document without failing
+the file, index the Deployment as document 2, and preserve document indexes.
+
+### 3. Non-KRM YAML
+
+Ordinary YAML without `apiVersion`, `kind`, and `metadata.name` should be
+ignored or get a non-fatal diagnostic, and must not block editing valid
+manifests in the same folder.
+
+### 4. Duplicate identity
+
+The same object in two files, or twice in one file, should be detected as a
+duplicate set with a diagnostic. Resolution follows the vision document: the
+first occurrence by a stable scan order wins, and the extra copies are deleted.
+The investigation still matters here — the POC must detect the duplicate set
+reliably and pick the deterministic winner — so the result is one authoritative
+copy kept and the other removed.
+
+### 5. Semantic no-op vs cleaning (hard)
+
+Two opposite directions that must not be confused. The API is the truth and Git
+holds only clean, GitOps-compatible manifests; the sanitizer defines that clean
+projection.
+
+**True no-op — preserve bytes.** Operational fields the sanitizer strips
+(`resourceVersion`, `managedFields`, `status`, and similar) are removed from the
+*desired* side, so they are never written. A Git manifest that is already clean
+and otherwise matches is therefore equal: return "no change" and preserve the
+original bytes exactly.
+
+```yaml
+# already in Git (clean); API returns the same object plus
+# metadata.resourceVersion and managedFields -> no write
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ color: blue
+```
+
+**Cleaning — not a no-op.** If the *Git* document itself carries operational
+noise such as `resourceVersion`, that field is absent from the clean desired
+projection, so Git differs and must be rewritten to remove it. This is field
+deletion (see test 12), not a preserved no-op.
+
+```yaml
+# in Git (dirty) -> resourceVersion must be deleted, rest preserved
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+ resourceVersion: "12345"
+data:
+ color: blue
+```
+
+The asymmetry is the point: sanitizer fields are stripped from the desired object
+so they are never written, and if Git already carries them they are cleaned out.
+
+### 6. Document-scoped update (hard)
+
+In a multi-document file where only the second document changes, documents 0 and
+2 remain byte-for-byte identical, only document 1 changes, and the separators
+stay valid.
+
+### 7. Comment preservation
+
+```yaml
+# app config
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config # stable name
+ namespace: default
+ labels:
+ app: demo # selector label
+```
+
+Adding or changing one label should preserve comments not attached to the
+changed node. Comments on the changed node should be preserved if the parser can
+do so cleanly; otherwise the behavior must be documented.
+
+### 8. ConfigMap script block survives unrelated edits (hard)
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+data:
+ start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server
+```
+
+When the desired object only adds `metadata.labels.app: demo`, `data.start.sh`
+must remain byte-for-byte identical. The literal block style and chomping
+indicator must not change.
+
+### 9. ConfigMap script block changes intentionally
+
+When `data.start.sh` itself changes, the editor should keep literal block style
+if possible — a changed script should still render as a readable block scalar,
+not an escaped one-line string. Strong preference; record the limitation if
+exact chomping preservation is not possible.
+
+### 10. Quoted and string-like values
+
+```yaml
+data:
+ build: "00123"
+ enabled: "false"
+```
+
+Unrelated edits should preserve quoting; direct edits must not convert these into
+numeric or boolean YAML values.
+
+### 11. List item update
+
+Updating one container image in a multi-container Deployment should change only
+that image field when the list item can be identified safely. Index-based
+patching is fine for the POC; field-keyed matching is a later nicety.
+
+### 12. Field deletion
+
+A field present in Git but absent from the sanitized desired object should be
+removed without rewriting unrelated fields, including deleting one label from a
+map that has comments and sibling labels.
+
+### 13. Disallowed constructs are ignored, not materialized (hard)
+
+A document using anchors, aliases, or merge keys (`&`, `*`, `<<`) — including a
+deliberately crafted alias-expansion bomb — must be ignored as non-editable with
+a diagnostic, and must not be fully materialized (no memory/CPU blowup). Files
+with duplicate keys or unusual tags fall in the same bucket: diagnose and skip,
+never silently rewrite. Symlinked files under the scan root are skipped.
+
+### 14. Line-ending and boundary fidelity (hard)
+
+CRLF line endings, a UTF-8 BOM, presence or absence of a trailing newline, a
+leading `---`, and a trailing `...` must survive an unrelated edit. These are
+where "byte-for-byte" quietly fails.
+
+### 15. Partially-encrypted manifest indexing
+
+A SOPS file with cleartext identity and encrypted `data` is indexed by its
+identity. A file whose identity fields are encrypted is skipped with a
+diagnostic. The POC does not decrypt or edit encrypted content.
+
+## Decision criteria
+
+The editor is acceptable only if it satisfies the hard requirements:
+
+- parse multi-document YAML and preserve document indexes
+- identify KRM resources from YAML content
+- detect duplicates
+- avoid writes for semantic no-ops
+- update only the matching document, keeping unrelated documents byte-for-byte
+- keep an unrelated ConfigMap script block byte-for-byte
+- ignore disallow-listed constructs without materializing them
+- preserve line-ending and document-boundary bytes on unrelated edits
+- index partially-encrypted manifests by cleartext identity and skip
+ identity-encrypted files
+- emit diagnostics when preservation is unsafe
+
+Nice-to-have:
+
+- preserve comments around changed nodes
+- preserve scalar style for directly changed strings
+- field-level deletion without whole-document replacement
+- safe list-item updates
+- output stable enough to keep Git history clean
+
+If `yaml.v3` passes the hard requirements, use it. If it fails mainly on
+ergonomics but preserves formatting, wrap it with local helpers. If it fails on
+preservation, evaluate `goccy/go-yaml`. If both fail on exact document/scalar
+preservation, use a per-scalar text-slice approach within the changed document.
+
+## Expected outcome
+
+A short decision record:
+
+- chosen in-document editor (`yaml.v3`, `goccy/go-yaml`, or text-slice)
+- tests passed and failed
+- preservation guarantees GitOps Reverser can honestly promise
+- cases that produce diagnostics or whole-document replacement
+- implementation impact for the manifest inventory feature
+
+The default expectation is an AST-based editor with canonical Kubernetes
+serialization retained for semantic comparison. The open question is how much
+exact source preservation that AST delivers on its own within a single document.
diff --git a/docs/design/manifest/manifestedit-abstraction-plan.md b/docs/design/manifest/manifestedit-abstraction-plan.md
new file mode 100644
index 00000000..6fb1501d
--- /dev/null
+++ b/docs/design/manifest/manifestedit-abstraction-plan.md
@@ -0,0 +1,337 @@
+# Follow-up plan: the `manifestedit` package abstraction
+
+> Status: proposed (follow-up to the parser POC)
+> Related: [manifest-parser-poc.md](manifest-parser-poc.md),
+> [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> POC decision record: `internal/git/manifestedit/DECISION.md`
+
+The parser POC is done: `gopkg.in/yaml.v3` node editing plus per-document text
+splitting passes the implemented hard requirements (with recorded drift
+limitations) and converges. This document is about the
+*next* concern — getting the **abstraction** right so the package stays a small,
+self-contained, well-tested library that the rest of GitOps Reverser builds on,
+rather than something tangled into the controller and writer.
+
+## The core idea: we always compare two versions of a resource
+
+Everything this package does is a function of **two representations of the same
+Kubernetes object**:
+
+- the **Git version** — a document at a known location: raw bytes, a parsed node
+ tree, and a manifest identity;
+- the **desired version** — what Git *should* contain for that resource: a clean
+ Kubernetes object (typically the API object after the reverser's projection).
+
+Every operation is one cell in this table:
+
+| Git version | Desired version | Decision |
+|---|---|---|
+| absent | present | **create** — out of scope: placement is upstream, and this is *not* a valid `Comparison` (`Git` is required; see invariants) |
+| present | absent | **delete** the matching document |
+| present | present, equal | **no-op** (preserve bytes) |
+| present | present, different | **patch** in place (or whole-document replace fallback) |
+| present (encrypted) | present | **refuse** in-place; route to the re-encrypt writer |
+| present (anchors/dupes/…) | present | **skip** with a diagnostic |
+
+Making this comparison explicit and first-class is the whole game. The package
+should express it directly, not bury it inside a `PatchDocument` side effect.
+
+## Principle: mechanism, not policy
+
+The package is **mechanism**: "make this Git document equal this desired object,
+with the smallest, most faithful edit possible." It must not own **policy**:
+
+- what "clean" means (which server fields to drop, kind-specific rules) →
+ that is the **projection/sanitizer**, owned by the caller;
+- which resources are watched, prune sequencing, placement of new files →
+ the **reconcile loop**, owned by the integration layer.
+
+Keeping mechanism and policy apart is what lets the package stand on its own and
+be tested without a cluster, a worktree, or the controller.
+
+### Concretely: drop the `internal/sanitize` dependency
+
+Today `PatchDocument` calls `sanitize.Sanitize(desired)` internally. That couples
+the library to one projection policy. **Invert it: the caller passes the desired
+Git projection already computed.** Then:
+
+- the package depends only on `gopkg.in/yaml.v3` and
+ `k8s.io/apimachinery/.../unstructured` (both stable, neither GitOps-Reverser
+ specific);
+- the comparison becomes honest and symmetric — "Git bytes vs the object you say
+ should be there" — with no hidden cleaning step.
+
+What must **not** happen is reinventing the canonical render. The whole-document
+fallback and new-file rendering define the house output format, so that render is
+**policy, not mechanism**. Inject it — a `func(*unstructured.Unstructured)
+([]byte, error)`, satisfied today by `sanitize.MarshalToOrderedYAML` — rather than
+duplicating it in the package, where it could silently diverge from the existing
+writer's output contract. There is **no silent production default**: a path that
+needs canonical output (`Replace`, or a new file) with no renderer injected fails
+loudly with a diagnostic, so a missing wiring cannot mask itself as plausible
+YAML. The preservation and delete paths need no renderer at all; tests inject a
+small one explicitly. This keeps the preservation editor (mechanism, owned)
+cleanly separate from canonical rendering (policy, injected).
+
+Input type decision: keep `*unstructured.Unstructured`. It is already the
+currency used by the watch and Git writer paths, keeps metadata access ergonomic,
+and is a stable dependency. `manifestedit` still treats the object as plain data:
+the caller passes the already-computed Git projection, and this package must not
+import `internal/sanitize`.
+
+## Recommended API shape
+
+Make the comparison itself a value, and split deciding from applying:
+
+```go
+type Comparison struct {
+ Git *Document // required: an existing parsed document
+ Desired *unstructured.Unstructured // nil means "absent" -> delete
+ Options EditOptions // injected strategies (see below)
+}
+
+type EditOptions struct {
+ // Render is the canonical renderer for whole-document replacement and new
+ // files — the house output format, so it is policy: injected, not owned here.
+ // Nil is allowed only when no canonical output is needed (pure patch, no-op,
+ // delete); a path that needs it with no Render fails loudly.
+ Render func(*unstructured.Unstructured) ([]byte, error)
+ // ListMatch aligns sequences (default: by index). A keyed strategy names the
+ // field to match on; the GVK->field choice is made above this layer.
+ ListMatch ListMatchStrategy
+ // Owns reports whether a field path is owned by the reverser (default: all).
+ // The field-ownership seam: an absent field is deleted only when owned.
+ Owns func(path FieldPath) bool
+}
+
+// Decide is a pure preflight: it inspects and compares, never mutating Git.
+func Decide(c Comparison) Decision
+
+type Decision struct {
+ Action DecisionAction // NoChange | Patch | Replace | Delete | Skip
+ Reason string // human-readable, for diagnostics
+ Snapshot SnapshotRef // observed identity + target-doc hash; Apply validates against this
+}
+
+// Apply is authoritative: it re-parses c.Git, performs the edit, and returns what
+// actually happened. There is no separate file argument — c.Git is the single
+// source of truth for the bytes.
+func Apply(c Comparison, d Decision) (EditResult, []Diagnostic)
+```
+
+`Document` is immutable data: the **whole file content**, the target document
+index, and the manifest identity — enough for `Apply` to splice the edited
+document back among untouched siblings. The snapshot fingerprint is *not* part of
+`Document`; it is derived by `Decide` and carried in the `Decision` (see
+invariants). `Document` is deliberately not a shared mutable node tree (see below).
+
+`Desired == nil` models deletion as just another cell of the same comparison, with
+no second code path. The *trigger* for deletion still belongs to the reconcile
+layer (its set-difference: "Git has watched KRM the API lacks"); the package only
+formalizes the per-document consequence.
+
+`PatchDocument`/`DeleteDocument` become thin wrappers over `Decide` + `Apply`.
+`Options` is the one seam where later strategies plug in (renderer, keyed-list
+key, field-ownership predicate), so the core merge stays small and pure.
+
+### API invariants
+
+These contracts keep the comparison honest; make them explicit before
+implementing:
+
+- **`Git` is required.** A `Comparison` always describes an *existing* document.
+ `Git == nil, Desired != nil` is not a valid comparison: creating a brand-new
+ resource is a **placement** decision (where does the file go?) owned upstream.
+ Once a path is chosen, a new file is just `Options.Render(desired)` — it never
+ goes through `Decide`. So the table's `absent | present -> create` row lives in
+ the integration layer, not here.
+- **`Apply` uses `c.Git` and validates the snapshot.** There is no separate file
+ argument. `Apply` re-parses `c.Git`, confirms the document at the recorded index
+ still has the identity (and a content fingerprint) that `Decide` compared, and
+ refuses with a conflict diagnostic if the file drifted in between. One source of
+ truth; no stale edit applied to a changed shape.
+- **`Decision` is preflight; `EditResult.Mode` is authoritative.** Because
+ `Decide` does not merge, it states an *intent* (e.g. `Patch`). `Apply` re-parses
+ and merges, and may legitimately land elsewhere — `Replace` if a node turns out
+ ambiguous, `Skip` if the snapshot drifted. The returned `EditResult` is the
+ truth about what happened, not the `Decision`.
+- **Deletion is content-agnostic, so refusals never block prune.** The encrypted
+ and disallowed-construct refusals apply only to *content edits* (`Patch` /
+ `Replace`), which read and rewrite the object. `Delete` only removes a document
+ (splicing siblings verbatim) — it never decrypts or merges — so an encrypted
+ resource, a disallowed-construct document, or a duplicate loser can always be
+ pruned.
+
+### Decide must not mutate
+
+The current merge mutates the node tree as it walks while returning `changed`.
+That is fine inside `Apply`, but it must never leak into `Decide`, or a "decision"
+could silently change Git before anything is applied. Two rules keep it honest:
+
+- **The node tree is never shared.** `Document` carries raw bytes, identity, and
+ index — not a mutable parsed tree. `Decide` and `Apply` each parse internally,
+ so nothing one mutates can affect the other.
+- **`Decide` does not run the structural merge at all.** It needs only cheap,
+ non-mutating checks: parseable? disallowed construct? encrypted? non-mapping
+ root (→ `Replace`)? and the object-level equality the no-op path already uses
+ (Git-as-written vs desired → `NoChange` or `Patch`). The node mutation happens
+ only in `Apply`, on its own fresh parse.
+
+This is simpler than clone-before-merge or a dry-run merge: by not sharing the
+tree and not merging in `Decide`, there is nothing to clone. We also deliberately
+avoid building a serializable "patch plan" in `Decide` and replaying it in
+`Apply` — that duplicates structure for no current benefit; revisit only if we
+ever need to *show* a diff before applying.
+
+## Internal layering (one package, clear seams)
+
+Keep a single package but with hard internal seams, so a layer can later graduate
+to a sub-package if it earns reuse:
+
+1. **Document model (pure YAML, no Kubernetes):** `split`/`join` (byte-preserving),
+ decode/encode nodes, `reskin` framing, the structural `merge`. Knows nothing
+ about KRM. This is the part most likely to become `manifestedit/yamldoc`.
+2. **Manifest model (Kubernetes identity):** `Identity`, `Inventory`,
+ KRM/SOPS/disallowed-construct detection. Maps content to identity and location.
+3. **Decision layer (the two-version comparison):** `Decide` and `Apply`, plus the
+ encrypted-refusal and skip rules.
+
+Rule of thumb: nothing in layers 1–2 imports the controller, git worktree,
+rulestore, or telemetry. If it needs those, it belongs in the integration layer,
+not here.
+
+## "More specific edits": granularity and ownership
+
+The structural merge already gives **field-level specificity** — it only rewrites
+nodes whose value actually differs, so unrelated fields, comments, and block
+scalars are untouched. "More specific" is really a question of **how granular the
+comparison is and what the reverser owns**. Three axes, in increasing ambition:
+
+1. **Keyed list matching.** Today lists are index-based, so a reorder rewrites
+ slots and mis-attributes item comments (pinned limitation). Match list items by
+ a key field so an item is compared to its counterpart, not its slot. Crucially,
+ the pure document model only ever sees a generic *list-match strategy* ("match
+ this list by field X"); the Kubernetes knowledge of which key applies to which
+ GVK (`name` for containers, and so on) lives in the manifest/decision layer or
+ the caller, never baked into the YAML merge.
+2. **Field ownership (the big one).** Right now the desired object is the *whole*
+ truth: any field present in Git but absent from desired is deleted. A more
+ specific model owns only certain field paths (server-side-apply-style managed
+ fields) and leaves user-managed fields in Git alone. This is a real product
+ decision — "does the reverser own the entire object or a declared subset?" —
+ and it lives naturally as a predicate over the merge walk (own this path / skip
+ that path). It should be decided explicitly, not drifted into.
+3. **Per-path preservation policy.** Narrower than ownership: always keep certain
+ annotations/labels, never touch a given subtree. Same mechanism (a path
+ predicate), smaller scope.
+
+All three hang off the *same* comparison engine: they are strategies that answer
+"for this node/path, what does it mean to be equal, and do we own it?" Keeping
+them as injected strategies (not branches baked into `merge`) keeps the core
+small and each strategy independently testable.
+
+## Invariant that must survive every change: convergence
+
+The POC proved repeated reconciles settle to a byte-stable no-op after the first
+write (`convergence_test.go`). Any new strategy — keyed lists, field ownership —
+must preserve this. The rule: **`Decide` after an `Apply` with the same desired
+must return `NoChange`.** Make it a property test that every strategy runs through.
+
+## Test taxonomy (the package carries its own proof)
+
+The package already tests this way; the plan is to keep the discipline as it
+grows. Each capability lands with tests in its own group:
+
+- **document model:** split/join, framing (CRLF/BOM/`...`/leading `---`), merge.
+- **manifest model:** identity, inventory, duplicates, SOPS, disallowed constructs.
+- **decision:** no-op vs cleaning, patch, whole-replace fallback, delete, skip,
+ encrypted refusal.
+- **fidelity:** comments (head/line/delete-with-field), literal vs folded blocks,
+ quoting.
+- **recorded limitations:** flush-left sequences, folded reflow, list-reorder
+ comment migration — pinned so a future fix announces itself by failing.
+- **convergence:** the property above, ideally run across the whole corpus.
+- **corpus:** `testdata/corpus` gates byte-for-byte round-trip.
+
+A good signal that the abstraction is right: a new strategy needs a new test file
+and a small predicate, and touches nothing in layers 1–2.
+
+## Current implementation anchors
+
+This plan is a refactor of the working POC, not a rewrite from blank paper. The
+important existing anchors are:
+
+- `PatchDocument` in `internal/git/manifestedit/patch.go` is the current mixed
+ seam: it validates the target document, refuses unsafe edits, sanitizes the
+ desired object, decides no-op vs patch, applies the merge, and falls back to
+ whole-document rendering. `Comparison`, `Decide`, `Apply`, and injected
+ rendering should tease those responsibilities apart without changing the
+ proven behavior.
+- `mergeMapping` in `internal/git/manifestedit/merge.go` is today's whole-object
+ ownership rule: a field present in Git but absent from desired is deleted.
+ Keep that as the default `Owns == nil` behavior, then add ownership predicates
+ as an extension point rather than changing the baseline semantics.
+- `IndexFiles` and `DocumentRecord` in `internal/git/manifestedit/index.go`
+ already define the manifest identity, duplicate resolution, encrypted flag, and
+ editability diagnostics that the decision layer should consume.
+- `DeleteDocument` in `internal/git/manifestedit/delete.go` is already the
+ content-agnostic document-splice operation. The new delete action should route
+ through that behavior, including the existing `FileEmpty` signal for callers
+ that should remove the file.
+- `sanitize.MarshalToOrderedYAML` is already the house renderer used by the Git
+ writer. `manifestedit` should receive it through `EditOptions.Render`; it
+ should not grow its own canonical renderer or keep calling `sanitize`
+ internally.
+- `convergence_test.go` is the guardrail for the whole abstraction: after
+ `Apply`, running `Decide` again with the same desired object must settle to
+ `NoChange` and preserve the resulting bytes.
+
+## Work sequence
+
+1. **Decouple `sanitize`** — caller supplies the desired projection, and the
+ canonical renderer is injected (trivial default for tests), not reinvented in
+ the package. Small, unlocks true standalone status.
+2. **Introduce `Comparison` + `Decide`/`Apply`** — make the two-version comparison
+ a first-class, pure value (`Decide` non-mutating, `Desired == nil` = delete);
+ keep `PatchDocument`/`DeleteDocument` as wrappers.
+3. **Convergence property test** across the corpus, wired so every later strategy
+ inherits it.
+4. **Keyed list matching** as the first injected strategy (fixes the reorder
+ limitation).
+5. **Field-ownership design spike** — decide whole-object vs declared-subset; this
+ is a product decision and deserves its own short doc.
+6. **Integration milestone (separate doc):** a read-only, inventory-driven
+ reconcile that *reports* what it would add/remove/update against a real cluster,
+ consuming this package unchanged. Defers the prune hazard and the GVK→GVR
+ mapping (`docs/TODO.md`) until after the comparison is trusted end to end.
+
+Steps 1–4 keep all work inside `internal/git/manifestedit` with no controller
+coupling. Only step 6 reaches into the writer/commit path
+(`commit_executor.go`, `branch_worker.go`), and it consumes the library rather
+than changing it.
+
+## Implementation decisions
+
+- **Desired input stays `*unstructured.Unstructured`.** This matches the current
+ event and writer path: watch/audit code already routes sanitized
+ `unstructured` objects, and the Git writer already renders them. The package
+ must not sanitize internally; the caller passes the desired Git projection.
+- **Field ownership starts as whole-object truth.** This matches today's merge
+ behavior and convergence tests: a field present in Git but absent from desired
+ is removed. Keep the `Owns(FieldPath) bool` seam with default "own all", but
+ defer declared managed paths to a separate product/design spike because it
+ changes what GitOps Reverser promises to preserve.
+- **The document model stays in one package for now.** The YAML document seams
+ (`split`/`join`, framing, decode/encode, merge) should remain file-separated
+ inside `internal/git/manifestedit`. Graduate them to `manifestedit/yamldoc`
+ only after another caller needs that API.
+- **The snapshot fingerprint is the target document body, not the whole file.**
+ Sibling documents can change without invalidating the target edit. `Decide`
+ records the observed identity plus target-document hash in the `Decision`;
+ `Apply` re-parses the current document and returns a soft `Skip` diagnostic on
+ drift, so the next reconcile can re-decide cleanly.
+- **Projection and canonical rendering stay in `internal/sanitize` for now.**
+ The integration layer injects `sanitize.MarshalToOrderedYAML` as
+ `EditOptions.Render`. Add a contract test around that adapter so
+ whole-document replacement and new-file output cannot drift from the existing
+ writer output.
diff --git a/docs/design/manifest/manifestedit-field-ownership-spike.md b/docs/design/manifest/manifestedit-field-ownership-spike.md
new file mode 100644
index 00000000..8c49f06c
--- /dev/null
+++ b/docs/design/manifest/manifestedit-field-ownership-spike.md
@@ -0,0 +1,101 @@
+# Field ownership: the API wins
+
+> Status: decided
+> Related: [manifestedit-abstraction-plan.md](manifestedit-abstraction-plan.md)
+> (step 5), [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [bi-directional.md](../../bi-directional.md),
+> POC decision record: `internal/git/manifestedit/DECISION.md`
+
+The abstraction plan flagged field ownership — "does the reverser own the whole
+object or a declared subset?" — as a product decision. Here is the decision, and
+it is blunt:
+
+**This is an API-first system. The API wins. A field that is not in the API
+projection is deleted from Git. Full stop.**
+
+GitOps Reverser does not own a "declared subset" of fields. When you point a
+GitTarget at a branch and folder path, GitTarget owns that path and makes it
+**exactly** match the managed cluster state. The desired object is the *entire*
+truth for every document it writes. There is no per-field ownership predicate to
+configure, no list of "fields we promise to leave alone," no managed-fields
+bookkeeping. Git is a faithful mirror of the API, and the reverser keeps it that
+way.
+
+## "But that deletes my hand-edited field"
+
+It deletes a field that exists in Git but not in the cluster. That sounds
+hostile, and in a one-directional world it would be. We are not building a
+one-directional world.
+
+The mitigation is the rest of the product: **bi-directional GitOps.** A change
+made directly in Git is picked up and applied to the live cluster
+([bi-directional.md](../../bi-directional.md)). Once it is in the cluster it is in
+the API, so it is in the next projection, so the reverser preserves it — because
+now it genuinely *is* part of the API object.
+
+So the loop closes:
+
+- Edit a field in the cluster → the reverser writes it to Git.
+- Edit a field in Git → the bi-directional reconciler applies it to the cluster →
+ the reverser keeps it in Git.
+- A field that is in Git but in neither the cluster nor a pending Git-to-cluster
+ apply is, by definition, orphaned state. Deleting it is correct.
+
+There is no "foreign field" category once both directions are wired. A field is
+either in the shared truth (cluster API) or it is garbage. The fear only exists
+if you imagine Git as an independent write surface the cluster never learns
+about — which is exactly the gap bi-directional GitOps removes.
+
+This also resolves the apparent tension with the file-agnostic-placement vision.
+Attaching to an existing repository does not mean tiptoeing around its contents;
+it means GitTarget *adopts* that path and brings it into lockstep with the
+cluster. Getting the bi-directional game straight is what makes that safe — not a
+field-ownership escape hatch.
+
+## What GitTarget promises
+
+> If you ask a GitTarget to take care of a git branch and folder path, it does
+> exactly that: that path is kept exactly in line with the managed cluster
+> state. GitTarget owns the path; the API is the source of truth.
+
+Everything under a GitTarget's path is the reverser's to write. Documents are
+whole-object truth. New files are placed by the inventory/placement layer; existing
+documents are edited in place (preserving formatting of the bytes that did not
+change — that is the `manifestedit` mechanism, and it is *fidelity*, not
+ownership). Removal of a managed resource removes its document.
+
+## Explicitly rejected (do not build)
+
+The following were considered and rejected. They are a maintenance minefield and
+they fight the API-first model:
+
+- **Declared-subset / managed-paths ownership.** A configured set of "fields we
+ own" is a heuristic that drifts from reality, needs a config surface, needs
+ per-key rules for labels/annotations, and turns every "why didn't my change
+ propagate?" into a support puzzle. No.
+- **Server-Side-Apply `managedFields`-driven ownership.** Principled in theory,
+ but `fieldsV1` set-encoding, shared ownership across managers, and apply-vs-update
+ semantics make a faithful predicate expensive to build and brittle to maintain —
+ for a guarantee bi-directional GitOps already provides more simply. No.
+
+If a real, concrete need for partial ownership ever appears, it must come back
+through its own proposal with a worked use case that bi-directional GitOps cannot
+serve. Until then, the answer is whole-object truth.
+
+## Mechanism note: the `Owns` seam
+
+The merge carries an `Owns(FieldPath) bool` seam (`EditOptions.Owns`,
+default `nil` = own everything). That seam stays, because it keeps the merge
+honest about *where* a deletion decision is made and it is free. But it is a
+mechanism detail, **not** a product feature: policy is pinned to own-all, and we
+do not ship or support any non-trivial predicate. Do not grow configuration on top
+of it.
+
+## Convergence
+
+Own-all is the clean, already-proven case. Because the desired object is the whole
+truth, `Decide`'s object-equality fast path fires exactly when Git already equals
+the projection, and the convergence property (`assertConverges`: a second `Decide`
+after `Apply` is `NoChange`, byte-stable) holds with no caveats. Whole-object truth
+is the model the convergence tests already assume — choosing it changes nothing
+and adds no new failure modes. That is the point.
diff --git a/docs/design/manifest/manifestedit-integration-readonly-reconcile.md b/docs/design/manifest/manifestedit-integration-readonly-reconcile.md
new file mode 100644
index 00000000..558b247c
--- /dev/null
+++ b/docs/design/manifest/manifestedit-integration-readonly-reconcile.md
@@ -0,0 +1,132 @@
+# Step 6: the read-only, inventory-driven reconcile
+
+> Status: in progress (the read-only report is implemented; writer wiring landed, narrowly — see below)
+> Related: [manifestedit-abstraction-plan.md](manifestedit-abstraction-plan.md)
+> (step 6), [manifestedit-field-ownership-spike.md](manifestedit-field-ownership-spike.md),
+> [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [architecture.md](../../architecture.md),
+> POC decision record: `internal/git/manifestedit/DECISION.md`
+
+This is the integration milestone. Steps 1–5 built and proved a cluster-free,
+well-tested comparison library (`internal/git/manifestedit`) plus the policy
+decision (API-first, whole-object truth). Step 6 connects it to the real world —
+but **read-only first**: a reconcile that *reports* what it would add, remove, or
+update against a real cluster, consuming the library unchanged, before anything
+is allowed to write or prune.
+
+The point of read-only-first is trust. The comparison must be demonstrably
+correct end to end — over real repositories and real cluster state — before it is
+wired into the commit path, where a wrong "delete" is a destroyed file.
+
+## What is built now
+
+Package `internal/manifestreport` is the integration layer. It supplies the two
+pieces of policy `manifestedit` deliberately refuses to own, and a read-only
+reconcile:
+
+- **`Project`** = `sanitize.Sanitize` — the Git projection (what "clean" means).
+- **`Render`** = `sanitize.MarshalToOrderedYAML` — the house canonical renderer,
+ the *same* function the live writer uses
+ ([content_writer.go](../../../internal/git/content_writer.go) `buildContentForWrite`).
+ A contract test (`render_contract_test.go`) pins that whole-replace/new-file
+ output is byte-identical to what the writer commits, so the two cannot drift.
+- **`EditOptions`** = production options: the house renderer, index-based list
+ matching (no global keyed strategy — see below), and `Owns == nil`
+ (whole-object truth).
+- **`BuildReport(files, desired)`** indexes the Git folder, compares it to the
+ desired cluster state, and returns a `Report` of per-resource `Action`s
+ (`no-change` / `update` / `create` / `delete` / `skip`). It uses
+ `manifestedit.Decide` **only** — never `Apply` — so it cannot mutate Git or
+ reach the writer. It is the set-difference reconcile made observable.
+
+The report maps every cell of the two-version comparison:
+
+| Cluster (desired) | Git (inventory) | Report action |
+|---|---|---|
+| present | absent | `create` (placement is upstream; only flagged) |
+| present | present, equal | `no-change` |
+| present | present, different | `update` |
+| present | present, encrypted/disallowed | `skip` (route elsewhere; never in-place) |
+| absent | present (authoritative) | `delete` (prune candidate) |
+| — | present (duplicate loser) | `delete` (prune candidate) |
+| — | present (non-editable) | `skip` |
+
+## The Git transaction boundary (read this before wiring writes)
+
+The folder index is **only** trustworthy for the exact bytes of a single
+checked-out commit. An inventory built from one worktree and applied against a
+different remote tip is a stale decision — and `manifestedit`'s snapshot
+validation (index + identity + body hash) will reject the individual edit, but
+the *set-level* verdicts (what to create, what to prune) are equally
+snapshot-bound. The whole report is valid only for the commit it was built from.
+
+So any future writing reconcile must run as one repository transaction, and this
+ordering is not optional:
+
+1. **Fetch/checkout** the target branch to a clean worktree (the existing
+ `BranchWorker` clone, see [architecture.md](../../architecture.md) §Git Operations).
+2. **Index** that worktree's files for the GitTarget's path → `Inventory`.
+3. **Compare** against the current desired cluster state → `Report` (this package).
+4. **Edit** via `manifestedit.Apply` per entry, against the *same* worktree bytes
+ the report was built from. `Apply`'s snapshot check is the per-document guard
+ that the worktree did not shift mid-transaction.
+5. **Commit** the resulting tree.
+6. **Push with lease** (compare-and-swap on the remote ref): if the remote moved,
+ the push is rejected — do **not** force.
+7. **On rejection, discard and replay**: re-fetch, re-index, re-compare, re-edit.
+ This is the project's existing "checkout fresh + replay" strategy
+ ([git_atomic_push.go](../../../internal/git/git_atomic_push.go),
+ [git_smart_fetch.go](../../../internal/git/git_smart_fetch.go)), and it is safe
+ precisely because the API is the source of truth: any stale commit can be
+ regenerated from current object state.
+
+The single-writer-per-branch invariant ([BranchWorker](../../../internal/git/branch_worker.go))
+already serializes step 1–6 within a pod; push-with-lease covers the cross-pod /
+external-writer race. The report layer must never assume it is the only writer:
+it produces verdicts for a snapshot and lets the transaction boundary enforce
+freshness.
+
+## Deliberately deferred
+
+Per the plan, step 6 does not yet take on:
+
+- **The prune hazard.** `delete` is only *reported*. Automatically removing files
+ for "absent from cluster" is dangerous when the cluster view is partial (a
+ degraded discovery or an incomplete snapshot looks like mass deletion — see
+ [architecture.md](../../architecture.md) §Watch / Informer System on partial
+ snapshots). Acting on `delete` needs the same partial-state guards the existing
+ `FolderReconciler` already reasons about, wired explicitly.
+- **GVK→GVR mapping.** The inventory keys on manifest identity (GVK + name +
+ namespace), not the API-side GVR. Turning a desired-set difference into actual
+ cluster reads/writes needs the RESTMapper-backed mapping tracked in
+ `docs/TODO.md`. Read-only reporting against a caller-supplied desired set sides
+ steps this until the comparison is trusted.
+- **Writer wiring (now landed, narrowly).** The live writer
+ ([git.go](../../../internal/git/git.go) `handleCreateOrUpdateOperation`) now calls
+ `manifestreport.EditInPlace` through `preserveExistingFormatting`: when an
+ existing file genuinely differs and is **non-sensitive** and **not already in the
+ operator's canonical format** (i.e. it carries hand-authored comments/layout),
+ the resource update is applied as a minimal in-place edit instead of a wholesale
+ rewrite, so the formatting survives. A canonical, operator-authored file keeps
+ the wholesale path and stays byte-identical to before — existing behavior is
+ untouched. This consumes `manifestreport` + `manifestedit` unchanged. It is still
+ scoped to the **deterministic path**: matching a resource to an arbitrary
+ existing file location via the folder inventory (full file-agnostic placement)
+ is the remaining reach.
+
+## Notes and non-goals
+
+- **No global keyed list matching.** Production `EditOptions` keeps list matching
+ index-based. A blanket `ListMatch.KeyField: "name"` would silently change every
+ named mapping list's behavior; keyed matching should arrive with a
+ path/GVK-aware strategy chosen above the merge, not a global default.
+- **Relationship to `FolderReconciler`.** The existing
+ [FolderReconciler](../../../internal/reconcile/folder_reconciler.go) diffs cluster
+ vs. Git for the initial snapshot and emits a whole-file `WriteRequest`. The
+ manifestedit path is the finer-grained, in-place, formatting-preserving successor
+ for *editing existing documents*; this read-only report is the bridge that lets
+ us validate it against the same inputs before swapping anything.
+- **Whole-object truth end to end.** Because Git was written by `Render(Project(obj))`
+ and the report compares against `Project(obj)`, a freshly mirrored resource
+ reports `no-change` and round-trips byte-stably — the convergence property from
+ step 3, now observed across the integration boundary.
diff --git a/docs/design/manifest/manifestedit-new-file-placement-spike.md b/docs/design/manifest/manifestedit-new-file-placement-spike.md
new file mode 100644
index 00000000..8465b70b
--- /dev/null
+++ b/docs/design/manifest/manifestedit-new-file-placement-spike.md
@@ -0,0 +1,182 @@
+# New-file placement: where does a brand-new resource's file go?
+
+> Status: decided (first version) — supersedes the original open brainstorm seed
+> Related: [file-agnostic-placement.md](file-agnostic-placement.md),
+> [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [manifestedit-integration-readonly-reconcile.md](manifestedit-integration-readonly-reconcile.md),
+> [manifestedit-field-ownership-spike.md](manifestedit-field-ownership-spike.md),
+> [manifestedit-abstraction-plan.md](manifestedit-abstraction-plan.md),
+> [architecture.md](../../architecture.md)
+
+## The one question
+
+When the reverser must write a resource that has **no existing document in Git**,
+*where does the file go?* The manifestedit comparison is explicit that this is
+out of its scope: the two-version comparison requires an existing `Git` document,
+and the read-only report already classifies this cell as `ActionCreate` with the
+reason *"no existing document in Git; placement is an upstream decision"*
+([internal/manifestreport/report.go](../../../internal/manifestreport/report.go)).
+Placement is precisely that upstream decision, and it is currently unowned as a
+configurable policy.
+
+## What is already true (don't re-litigate)
+
+- **Today's placement is deterministic and implicit.** New files are written to
+ `{spec.path}/{group}/{version}/{resource}/{namespace}/{name}.yaml`
+ (`.sops.yaml` for encrypted Secrets), via `generateFilePath` /
+ `ResourceIdentifier.ToGitPath()`
+ ([internal/git/git.go](../../../internal/git/git.go),
+ [internal/types/identifier.go](../../../internal/types/identifier.go)). Repo state
+ was historically discovered by parsing that path back into an identity.
+- **Identity is content, not path.** The inventory
+ ([internal/git/manifestedit](../../../internal/git/manifestedit)) keys resources by
+ GVK + namespace + name read from the YAML, so a resource can be *found* at any
+ path, not only the deterministic one.
+- **In-place editing already landed (narrowly).** When a document already exists
+ for a resource, the writer edits it in place preserving formatting
+ ([git.go](../../../internal/git/git.go) `preserveExistingFormatting` →
+ `manifestreport.EditInPlace`). Placement is the missing other half: it only
+ matters when the resource is *absent* from Git.
+- **The policy north star is API-first, whole-object truth**
+ ([manifestedit-field-ownership-spike.md](manifestedit-field-ownership-spike.md)).
+ Whatever placement does, the API stays the source of truth.
+
+## The reframe that drives every decision
+
+The thing that makes today's behavior feel rigid is that the path is a *pure
+function of identity, recomputed on every write* (`ToGitPath`). The file-agnostic
+vision flips that: identity is content, and **location is data the inventory
+owns**.
+
+So placement is not "a better path formula." It is a **resolver that runs once,
+when a resource is brand-new, and whose result is then recorded in the inventory**
+like any other location. Recomputing a path on every write is what couples the
+layout to the storage contract; resolving once and recording removes that
+coupling — and quietly defuses most of the original open axes (renames, append)
+before they have to be designed.
+
+## Decisions (first version)
+
+### 1. Match-first is an invariant, not a setting
+
+The write path always asks the inventory "do we already have a location for this
+identity anywhere under the GitTarget path?" first, and only invokes placement
+when the answer is no. This is not a user knob — it is simply correct, and it is
+the bridge from "edit at the deterministic path" to true file-agnostic placement.
+
+Cost: the inventory is built from the already-checked-out commit and cached for
+the lifetime of the Git transaction (never a per-write re-scan). The
+rebuild-on-change gate in
+[manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md)
+already governs when it is rebuilt.
+
+### 2. Placement is create-time and non-retroactive
+
+Once a file is placed, its location lives in the inventory. Changing the
+placement policy later affects **only resources created after the change** — it
+does **not** move existing files. This is load-bearing:
+
+- No rename/move machinery in this version.
+- No new prune-hazard surface: the writer never relocates a file, so it never
+ produces a "delete the old path + write the new path" pair.
+- A future explicit "reorganize / migrate layout" action can come later as its
+ own opt-in operation, separate from placement.
+
+### 3. A `Placement` seam returning a `ManifestLocation`
+
+Placement is *policy* and lives in the integration/writer layer
+(`manifestreport` / the writer), never in the `manifestedit` mechanism — the same
+mechanism-vs-policy line drawn elsewhere. Shape it so the type can express
+everything we will ever want, even though the first version only uses part of it:
+
+```go
+type ManifestLocation struct {
+ Path string // relative to the GitTarget path
+ DocumentIndex int // -1 == new file
+ Mode PlacementMode // CreateFile | AppendToFile
+}
+
+type Placement interface {
+ Locate(id types.ResourceIdentifier, spec GitTargetSpec) (ManifestLocation, error)
+}
+```
+
+The first version always returns `{path, -1, CreateFile}` (a discrete,
+one-resource file). Because the *type* can already say "append to this file at
+this document index," multi-document append becomes a new `Placement`
+implementation later with **zero reshaping** of the seam.
+
+### 4. `spec.placement.layout` is a closed enum, default `apiStructure`
+
+The user-facing surface is a small closed enum, not a free-text template. Every
+layout must encode the **full identity** (group/version/resource + namespace +
+name) somewhere in its path, just arranged into a different folder shape. That
+makes each layout a **bijection with identity**: two distinct resources can never
+resolve to the same path, so the enum needs **no uniqueness/collision validation
+at all**. "Is this layout bijective with identity?" is the admission test for any
+future layout.
+
+```text
+spec.placement.layout:
+ apiStructure # DEFAULT — {group}/{version}/{resource}/{namespace}/{name}.yaml
+ # byte-identical to ToGitPath today → an absent spec.placement
+ # field reproduces current behavior exactly (zero migration).
+ namespaceFolder # {namespace}/{group}/{version}/{resource}/{name}.yaml
+ # namespace on top: a namespace's resources live under one
+ # folder, which is what makes dropping a kustomization.yaml in
+ # it (and hooking it into Flux/Argo per namespace) natural.
+```
+
+Ship exactly these two. `flat` (single folder, identity encoded into the
+filename) and a kind-first variant are each a few lines to add later on the same
+seam and are intentionally *not* shipped speculatively — they are listed as
+future-trivial, not as v1 knobs. Per-target setting for now; a provider-level
+default with per-target override is a later refinement if it is ever asked for.
+
+### 5. The SOPS extension decision lives inside the placer
+
+The `.sops.yaml` extension for sensitive resources is decided **inside**
+`Locate`, not bolted on around it. This guarantees no current or future layout
+can route a Secret (or other sensitive resource) to a cleartext path — the
+sensitive-path rule is structurally inside the one place that computes the
+destination.
+
+## Deferred (and absorbed by the seam, so no future reshaping)
+
+- **Multi-document append** — a future `Placement` implementation; the
+ `ManifestLocation` type already expresses it.
+- **Renames / reorg / layout migration** — a future explicit opt-in action;
+ decision 2 keeps existing files put in the meantime.
+- **Namespace elision** (`writeNamespace`) — already deferred in the vision doc;
+ orthogonal to placement.
+- **Path templates** — deliberately not in the closed enum; would reintroduce
+ collision/path-safety validation that the bijection rule avoids. Can be added
+ later as one more `Placement` implementation if a concrete need appears.
+
+## Hard constraints any answer must respect
+
+- **The Git transaction boundary.** Placement decisions are valid only for the
+ checked-out commit: fetch → index → compare → place/edit → commit →
+ push-with-lease → replay-on-race
+ ([manifestedit-integration-readonly-reconcile.md](manifestedit-integration-readonly-reconcile.md)).
+- **The prune hazard stays deferred.** Moving/placing files must not turn a
+ partial cluster view into spurious deletions; deletes are still report-only.
+ Decision 2 (non-retroactive placement) adds no new prune surface.
+- **Encryption.** Secrets must land at `.sops.yaml` paths and never be written in
+ cleartext — enforced by decision 5.
+- **Convergence.** A placed file, re-read next reconcile, must be a byte-stable
+ no-op (the property `assertConverges` guards inside manifestedit).
+- **Mechanism vs policy.** Placement is *policy* and belongs in the integration
+ layer (`manifestreport` / the writer / a GitTarget setting), never baked into
+ the `manifestedit` mechanism (decision 3).
+
+## Suggested implementation order
+
+1. Introduce the `Placement` seam + `ManifestLocation` with a single
+ `apiStructure` implementation that reproduces `ToGitPath` exactly, and wire the
+ writer to call it (behind match-first) instead of `generateFilePath`. Pure
+ refactor — output is byte-identical, existing e2e assertions unaffected.
+2. Add `spec.placement.layout` to `GitTargetSpec` defaulting to `apiStructure`;
+ confirm an absent field is still byte-identical.
+3. Add the `namespaceFolder` implementation + tests (including a placed-then-
+ re-read convergence assertion and a SOPS-extension test).
diff --git a/docs/design/manifest/manifestedit-writer-followups.md b/docs/design/manifest/manifestedit-writer-followups.md
new file mode 100644
index 00000000..fd4a9ac7
--- /dev/null
+++ b/docs/design/manifest/manifestedit-writer-followups.md
@@ -0,0 +1,132 @@
+# manifestedit writer: follow-ups after the file-agnostic placement fixes
+
+> Status: open follow-ups — captured 2026-06-03 on branch `poc/manifestedit`
+> Related: [manifestedit-new-file-placement-spike.md](manifestedit-new-file-placement-spike.md),
+> [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [file-agnostic-placement.md](file-agnostic-placement.md),
+> [manifestedit-integration-readonly-reconcile.md](manifestedit-integration-readonly-reconcile.md),
+> [manifestedit-field-ownership-spike.md](manifestedit-field-ownership-spike.md)
+
+## What this branch already fixed
+
+A review of the live writer on this branch turned up four defects. Three were
+fixed; each has a regression test that started red and now pins the corrected
+behavior. This document records what is fixed and what is deliberately left for a
+follow-up, so the remaining edges are not lost.
+
+| # | Defect | Fix | Guard test |
+|---|---|---|---|
+| High | Writer was not file-agnostic: update wrote a second copy at the canonical path and delete missed a moved manifest. | `resolveManifestLocation` indexes the GitTarget tree and edits/deletes the resource where it already lives (match-first); only a genuinely new resource uses the deterministic path. Delete is now per-document via `manifestedit.DeleteDocument`. | `TestApplyEvent_UpdateMustFollowExistingPlacement`, `TestApplyEvent_DeleteMustFollowExistingPlacement` ([internal/git/known_placement_bugs_test.go](../../../internal/git/known_placement_bugs_test.go)) |
+| Medium | An in-place no-op (multi-doc edge) was staged as a change and could drive an empty commit. | `handleCreateOrUpdateOperation` returns `false` when the preserved edit equals the bytes on disk. | `TestHandleCreateOrUpdate_NoOpInMultiDocReportsNoChange` (same file) |
+| Medium | `BuildReport` classified a desired resource whose only Git doc is non-editable as both `Create` and `Skip`. | When there is no editable location but a non-editable record exists, the desired side no longer emits `Create`; the single `Skip` from `gitOnlyEntries` stands. | `TestBuildReport_NonEditableDesiredIsNotDoubleClassified` ([internal/manifestreport/noneditable_desired_bug_test.go](../../../internal/manifestreport/noneditable_desired_bug_test.go)) |
+| (perf, load-bearing) | Match-first scanned the tree **per event**. A snapshot of many large manifests (cluster-wide CRD watch ≈ O(events × tree) on big YAML) blew the per-commit deadline — a real e2e failure, not just slowness. | `manifestLocator` scans each base path **once per write batch** (the checked-out commit) and caches it. | Covered by the CRD-install e2e (`crd_lifecycle_e2e_test.go`); see "Follow-up 2 (done)". |
+
+Code: [internal/git/git.go](../../../internal/git/git.go) (`manifestLocator`,
+`handleDeleteOperation`, `handleCreateOrUpdateOperation`),
+[internal/git/commit_executor.go](../../../internal/git/commit_executor.go) (one
+locator per batch),
+[internal/manifestreport/report.go](../../../internal/manifestreport/report.go).
+
+## Follow-up 1 — DELETE match-first needs the resource identity
+
+Match-first resolves a resource's location from its **content identity** (GVK +
+namespace + name), read from the event's object. Production DELETE events carry
+only the API identifier (GVR + namespace + name) and **no object**
+([internal/reconcile/folder_reconciler.go](../../../internal/reconcile/folder_reconciler.go)
+builds `toDelete` events with `Identifier` only). The inventory keys by GVK, and
+mapping GVR→GVK needs a live RESTMapper, which the manifestedit POC deliberately
+does not own.
+
+Consequence today: a delete of a resource a user **moved** off the canonical path
+falls back to the deterministic path and therefore misses the moved file (same as
+before this branch — safe, not a regression). Update/create placement is fully
+fixed; deletes are only fixed when the event happens to carry an object.
+
+Options for the follow-up:
+
+- Attach a minimal identity (apiVersion/kind/name/namespace) to DELETE events in
+ the reconcile layer so the writer can content-match without a RESTMapper.
+- Or give the writer a GVR→GVK resolver (RESTMapper) and match deletes by GVR.
+
+The first is smaller and keeps the GVR→GVK mapping out of the writer.
+
+## Follow-up 2 (done) — cache the inventory per write batch
+
+Originally deferred as "just performance," this turned out to be **load-bearing**.
+The first implementation rebuilt the inventory (`manifestedit.IndexDir`) per event,
+so a batch of N writes was O(N × tree). The CRD-install e2e — whose `ClusterWatchRule`
+watches **all** CustomResourceDefinitions cluster-wide — snapshots ~40 large CRDs
+into one commit; the O(N²) re-scan of those big schemas pushed the single commit
+past the test's 60 s deadline, so the spec failed (zero commits). The bug-for-bug
+controlled test confirmed it: neutralizing match-first made the spec pass; restoring
+it with the per-batch cache also passed.
+
+`manifestLocator` ([internal/git/git.go](../../../internal/git/git.go)) now scans each
+base path **once per write batch** (the checked-out commit) and caches it; the batch
+gets one locator in [commit_executor.go](../../../internal/git/commit_executor.go).
+Building the inventory once from the pre-batch state is also the semantically correct
+unit per decision 1 of
+[manifestedit-new-file-placement-spike.md](manifestedit-new-file-placement-spike.md)
+(location is valid for the checked-out commit).
+
+On top of that, `locate` takes a **stat fast-path**: the operator writes each
+resource to its canonical path, so if a file already exists there, the resource
+lives there and no scan happens at all. The inventory scan only fires for a resource
+whose canonical file is absent — a genuinely new resource, or one a user moved off
+the canonical path (the only case match-first actually needs). In steady state every
+resource is at its canonical path, so the amortized cost is ~one `stat` per event,
+i.e. back to the pre-match-first baseline. This was needed because the per-batch
+cache alone still re-scanned the (growing) base path on every reconcile, which under
+full-suite load was enough for the cluster-wide CRD watch to miss the commit
+deadline even after the O(N²)→O(N) fix.
+
+Remaining (smaller) optimization: a cache that lives **longer than one batch**,
+governed by the rebuild-on-change gate in
+[manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+so back-to-back batches on a warm worktree don't each re-scan. Not required for
+correctness.
+
+Note: the scan is rooted at the GitTarget `spec.path`, so a non-empty path keeps
+`.git` out of the walk. A target with an empty path walks the worktree root
+(including `.git`, which holds no manifests) — another reason the longer-lived cache
+is worthwhile.
+
+## Follow-up 3 — cleanup of duplicate ("double") entries
+
+**Yes — this is part of the same theme.** Match-first reduces how often we *create*
+duplicates, but it does not yet *remove* duplicates that already exist, and the
+incomplete delete in Follow-up 1 can still leave a stale copy behind. Those are
+"double entries": the same resource identity present in Git at more than one path.
+
+What exists:
+
+- The inventory already detects duplicates with first-occurrence-wins
+ (lexicographically first path is authoritative; later copies are losers) —
+ [internal/git/manifestedit/index.go](../../../internal/git/manifestedit/index.go).
+- `BuildReport` already surfaces every duplicate loser as an `ActionDelete`
+ "prune candidate", and every Git-only resource the cluster lacks as a prune
+ candidate too — [internal/manifestreport/report.go](../../../internal/manifestreport/report.go).
+
+What is missing: this is **report-only**. Nothing in the writer acts on those
+prune candidates, by design — the prune hazard (a partial cluster view turning
+into spurious deletions) is deliberately deferred across the placement docs. So a
+duplicate, once present, is reported but never cleaned up.
+
+The follow-up is to wire duplicate/orphan pruning from the read-only report into
+the writer, behind whatever safety gate the prune-hazard decision lands on
+(e.g. only prune duplicate *losers*, which is safe because the authoritative copy
+is kept, before touching cluster-absent orphans). Doing duplicate-loser cleanup
+first is attractive: it is the half with no prune hazard (the resource still
+exists in exactly one place afterwards) and it directly cancels any double entry
+that Follow-up 1's fallback delete might leave.
+
+## Suggested order
+
+1. Follow-up 1 (identity on deletes) — closes the last placement correctness gap.
+2. Follow-up 3, duplicate-loser pruning only — safe, cancels leftover double
+ entries, no prune-hazard exposure.
+3. Orphan pruning (cluster-absent resources) — only after the prune-hazard gate is
+ designed.
+
+(Follow-up 2, the per-batch inventory cache, is already done — see above. A
+longer-lived cache across batches remains an optional optimization.)
diff --git a/docs/design/manifest/pr164-review-completion.md b/docs/design/manifest/pr164-review-completion.md
new file mode 100644
index 00000000..574bfc96
--- /dev/null
+++ b/docs/design/manifest/pr164-review-completion.md
@@ -0,0 +1,97 @@
+# PR #164 Review Completion Plan
+
+> Status: Phases 1–3 implemented (2026-06-04); full validation green —
+> `task fmt/vet/lint/test` pass and `task test-e2e` passed (47/49, 2 skipped, 0 failed)
+> Source: triage of the 16 bot review comments (4 gemini, 12 coderabbit) on PR #164
+> (branch `poc/manifestedit`), cross-checked against the actual code.
+> Related: [manifestedit-abstraction-plan.md](manifestedit-abstraction-plan.md),
+> [manifestedit-integration-readonly-reconcile.md](manifestedit-integration-readonly-reconcile.md)
+
+A phased plan to close the review. Three phases, ordered so the riskiest
+production change lands with the most validation. Cosmetic/optional items are
+explicitly out of scope unless opted in.
+
+## Phase 1 — Production correctness (the only behavioral changes)
+
+- [x] **1.1 Guard the multi-doc wholesale-overwrite fallback** —
+ [git.go:764-786](../../../internal/git/git.go#L764-L786)
+ - Before the fallback `os.WriteFile(fullPath, content)`, refuse to clobber a
+ file that holds documents other than the target. If the existing file is
+ multi-document and the in-place edit did not apply, skip the write and emit a
+ diagnostic rather than overwriting siblings.
+ - **Do not** adopt coderabbit's suggested diff — it removes the `canonicalize`
+ gate and breaks the "operator-authored canonical files stay byte-identical
+ wholesale" guarantee.
+ - *Acceptance:* a new test seeding a hand-authored multi-doc file where the
+ in-place edit fails proves siblings survive;
+ `TestHandleCreateOrUpdate_CanonicalFileStaysWholesale` still passes.
+
+- [x] **1.2 Propagate replacement failure in `mergeValue`** —
+ [merge.go:115-125](../../../internal/git/manifestedit/merge.go#L115-L125)
+ - Change all three `return replaceNode(node, desired), true` to
+ `changed := replaceNode(...); return changed, changed`, so an encode failure
+ flips `ok=false` and triggers whole-document fallback instead of a silent drop.
+ - *Acceptance:* existing merge tests stay green; coverage holds >90%.
+
+- [x] **1.3 Nil-object guards** —
+ [render.go:97](../../../internal/manifestreport/render.go#L97),
+ [report.go:99](../../../internal/manifestreport/report.go#L99)
+ - Add `if obj == nil { return nil, false }` at the top of `EditInPlace`;
+ `if obj == nil { continue }` in the `BuildReport` loop. This subsumes gemini's
+ git.go:821 comment — no separate change needed there.
+ - *Acceptance:* a table-test row passing `nil` returns the guard result instead
+ of panicking.
+
+## Phase 2 — Test fix
+
+- [x] **2.1 De-vacuum the alias-bomb test** —
+ [manifestedit_test.go:522](../../../internal/git/manifestedit/manifestedit_test.go#L522)
+ - Switch `bomb.sops.yaml` → `bomb.yaml`, add
+ `require.Len(t, inv.Records, 1, …)`, and assert `inv.Records[0].Editable ==
+ false`. A `.sops.yaml` file with no `sops:` key produces zero records, so the
+ current `for _, r := range inv.Records` loop never runs and asserts nothing.
+ - *Acceptance:* test fails if the alias bomb were ever marked editable.
+
+## Phase 3 — Doc consistency (docs-only)
+
+- [x] **3.1 Fix status contradiction** —
+ [manifestedit-integration-readonly-reconcile.md:3](manifestedit-integration-readonly-reconcile.md):
+ change "writer wiring is deferred" → "writer wiring landed (narrowly)" to match
+ the body.
+
+- [x] **3.2 Remove dangling scratchpad URLs** —
+ [TODO.md:96-98](../../TODO.md): delete the three loose URLs and the `Replace with…`
+ line (or fold the VictoriaMetrics link into a real backlog bullet if still
+ wanted). Resolved directly in TODO.md: the URLs were moved into a structured
+ "Research work:" section with one-line context each, which satisfies the
+ reviewers' intent.
+
+## Validation
+
+- After Phases 1–2 (Go code): full [AGENTS.md](../../../AGENTS.md) sequence —
+ `task fmt` → `task generate` → `task manifests` → `task vet` → `task lint` →
+ `task test` → `task test-e2e` (e2e sequentially; `docker info` first).
+- Phase 3 alone qualifies for the docs-only exception if committed separately —
+ link sanity check only.
+- Suggested commits: one for Phases 1–2 (code, full suite), one for Phase 3 (docs).
+
+## Explicitly out of scope (optional — confirm before doing)
+
+- `PatchDocument` nil guard ([patch.go:42](../../../internal/git/manifestedit/patch.go#L42))
+ — defensive only, not on the prod path.
+- `hasDocEndMarker` inline-comment edge
+ ([framing.go:61](../../../internal/git/manifestedit/framing.go#L61)) — marginal;
+ `reskinDocument` still drops the comment even if detection is fixed.
+- Spelling in [file-agnostic-placement.md](file-agnostic-placement.md) and MD040
+ fenced-code-language in
+ [wildcard-ci-failure-findings.md](../../wildcard-ci-failure-findings.md) —
+ markdownlint is not CI-enforced; cosmetic.
+
+## False positive / already handled (no work)
+
+- coderabbit "critical" *"Don't delete the whole file for a single-resource
+ prune"* (git.go 604-623): already correct.
+ [handleDeleteOperation](../../../internal/git/git.go#L683-L713) routes through
+ `manifestedit.DeleteDocument` and only unlinks when `result.FileEmpty`;
+ otherwise it writes surviving documents back. The cited lines point at `locate`,
+ not the delete path.
diff --git a/docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md b/docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md
new file mode 100644
index 00000000..bb414569
--- /dev/null
+++ b/docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md
@@ -0,0 +1,313 @@
+# Reconcile via WatchList + Mark-and-Sweep
+
+> Status: design direction, captured 2026-06-04.
+> Related:
+> [current-manifest-support-review.md](current-manifest-support-review.md),
+> [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md),
+> [current-manifest-support-review-feedback.md](current-manifest-support-review-feedback.md),
+> [`internal/git/manifestedit/DECISION.md`](../../../internal/git/manifestedit/DECISION.md)
+
+## Summary
+
+The initial reconcile of a GitTarget — bringing the git folder in line with the
+watched API resources — should be driven by the Kubernetes **streaming list
+watch** (`sendInitialEvents`), folded over a fully materialized in-memory model of
+the git folder, and closed with a **mark-and-sweep** of the resources the API did
+not stream.
+
+This replaces the current `FolderReconciler` design (a GVR diff between a
+path-derived git scan and a cluster snapshot — see the feedback note) with a
+single, consistent mechanism:
+
+```text
+build managed model from worktree
+open one streaming-list watch per tracked type
+fold every initial ADD over the model (mark touched)
+wait for every type's initial-events-end bookmark
+orphans = managed docs the stream never touched (sweep)
+plan = creates + updates + managed drops (orphans)
+apply plan to the model, flush dirty/deleted files once
+then transition into steady-state watch
+```
+
+The streaming watch gives us, for free, the one guarantee the old design lacked:
+a **single consistent snapshot revision** to pin the whole plan to.
+
+## The Central Invariant: the managed model only ever holds tracked KRM
+
+The mark-and-sweep model contains **only valid, tracked (watched), in-scope KRM
+documents**. Nothing else is a member, and only members can ever carry a delete
+candidacy.
+
+Concretely, a document is a member of the managed model iff **all** hold:
+
+- it parses as valid KRM (has `apiVersion` + `kind` + a concrete identity);
+- its GVK is in the GitTarget's **watched** set;
+- its `(namespace)` is within the GitTarget's watch **scope**.
+
+Everything that is not a tracked, in-scope KRM document is handled before the
+model is built (see the Non-Negotiable Design Decisions in the main review):
+
+- **non-YAML files** (`README.md`, scripts, images) — not manifests; ignored,
+ never loaded, never a refusal;
+- **non-KRM YAML** — refused at acceptance, never reaches the managed model;
+- **unwatched API-backed KRM** — refused at acceptance, never pruned;
+- **allowlisted non-API KRM** such as `kustomization.yaml` — retained on disk,
+ never materialized, never swept, and refused if mixed into a multi-document file
+ with managed resources;
+- **watched KRM out of scope** (right kind, wrong namespace) — also refused at
+ acceptance. We do **not** leave it as a silent non-member: a managed folder
+ carrying a KRM document we will not materialize is precisely the half-managed
+ state this design forbids.
+
+This is the safety property that matters most, and it now rests on an even simpler
+foundation than per-document membership flags: **the managed model contains
+nothing but tracked, in-scope KRM.** API-backed KRM outside that set refuses the
+GitTarget outright. Allowlisted non-API KRM is outside the model by design; it is
+retained as auxiliary input, not as a managed document.
+
+This sits *after* the acceptance gate from the main review. Acceptance still scans
+the whole folder, classifies every file, and **refuses** the GitTarget on
+duplicate identities, non-KRM YAML, unwatched API-backed KRM, or mixed
+managed/allowlisted multi-document files. By the time the managed model is built,
+every materialized document is content we are entitled to manage.
+
+```mermaid
+flowchart TD
+ A[All files in GitTarget folder] --> B[Classify every file/document]
+ B --> C{Acceptance gate}
+ C -- duplicate / non-KRM / unwatched API KRM / mixed file --> R[Refuse: error status, reconcile nothing]
+ C -- allowlisted non-API KRM --> X[Retain outside model]
+ X --> D
+ C -- clean --> D[Build managed model: watched ∧ in-scope ∧ valid KRM only]
+ D --> E[WatchList stream folds ADDs over the model]
+ E --> F[Sweep: orphans = members the stream never touched]
+ F --> G[Plan: create + update + managed drop]
+ G --> H[Apply to model, flush once]
+```
+
+## Why the Streaming List Watch
+
+Kubernetes' streaming-list watch — a `WATCH` with `sendInitialEvents=true`,
+`resourceVersionMatch=NotOlderThan`, and `allowWatchBookmarks=true` — emits a
+synthetic `ADDED` event for **every existing object**, then a **bookmark** event
+carrying the `k8s.io/initial-events-end` annotation and the resourceVersion at
+which that initial set is consistent, and then continues with live changes.
+
+This is a better fit than `LIST` + `WATCH` for three reasons:
+
+1. **It is the consistency boundary.** The bookmark's resourceVersion is exactly
+ the `(commit SHA, cluster snapshot RV)` pin the feedback note asked for. The
+ plan computed at the bookmark is valid for a single, named cluster revision —
+ no "did the cluster change between my LIST and my WATCH" race.
+2. **The bookmark is the sweep gate.** "Initial sync complete for this type" is an
+ explicit, observable event, not a guess. Sweep is only safe once marking is
+ complete; the bookmark tells us precisely when that is (see below).
+3. **It folds straight into steady state.** The same stream that delivered the
+ initial set keeps delivering live events from the same revision, so the
+ bootstrap reconcile and the steady-state watch are one connection, not two
+ subsystems with a handover.
+
+**Availability fallback.** Streaming lists need a reasonably modern cluster
+(client-go `WatchListClient`; server `WatchList`). Put both paths behind the
+injectable API-source abstraction: streaming where available, classic
+`LIST(at RV) → WATCH(from RV)` otherwise. The reconcile logic does not care which
+produced "the set of tracked resources at RV X" — it only consumes that set and
+the revision. This is the same source abstraction the analyzer/CLI already needs.
+
+## Mark-and-Sweep, Done Safely
+
+The instinct — initialize every managed document as a delete candidate, clear the
+candidacy on the matching ADD, delete the survivors — is textbook mark-and-sweep
+(it is how `kubectl apply --prune` and most GitOps prune work). It maps onto the
+`FileModel` / `DocumentModel` shape from the main review (where a file's
+deletion is the derived `Deleted()` state, not a stored flag). Four edges make it
+safe rather than destructive:
+
+### 1. Document granularity, not file
+
+The mark is on the **DocumentModel**. A multi-document file may have some touched
+and some swept documents; a file is deleted only when *all* of its managed
+documents are swept and none survive. Non-member documents in the same file do
+not exist here: unwatched API-backed KRM and mixed managed/allowlisted files are
+refused before planning, while allowlisted non-API KRM lives in retained files
+outside the model.
+
+### 2. Membership replaces the "is it watched?" check
+
+Per the central invariant, sweep operates only over members of the managed model.
+There is no per-document "is this one safe to delete?" branch on the sweep path,
+because non-members are not in the set. This is the safety-by-construction the
+GitTarget owner asked for: unwatched API-backed KRM is refused before sweep, and
+allowlisted non-API KRM is never assigned a delete candidacy.
+
+### 3. No bookmark, no sweep
+
+Marking must be **complete** before sweeping, across **every** tracked type:
+
+- Sweep runs only after every watched type's `initial-events-end` bookmark has
+ arrived. Sweeping after type A's bookmark but before type B's would delete all
+ of B's manifests as phantom orphans.
+- If any initial sync **fails** before its bookmark (connection drop, throttle,
+ partial stream), the whole reconcile **aborts and drops nothing.** A partial
+ mark must never drive a sweep.
+
+This is the same "fail loudly, never act on a partial view" rule already in
+`Manager.GetClusterStateForGitDest`. The managed drop inherits it verbatim.
+
+### 4. Set-difference over mutable flags
+
+Rather than toggling `Deleted` on the live model as ADDs stream in, collect the
+streamed identities into a set and compute orphans as a **pure, one-pass
+set-difference at the bookmark**:
+
+```text
+orphans = { d ∈ managedModel | d.identity ∉ streamedSet }
+```
+
+Preferred over live flag mutation because:
+
+- the model stays immutable until the bookmark, so a failed/partial stream leaves
+ no half-applied delete flags to unwind;
+- the sweep is a pure function of `(managedModel, streamedSet)`, which composes
+ with the `f(fs.FS) → Plan` boundary the main review argues for;
+- it is the same `ByResourceIdentity` index doing the work either way — the
+ immutable version is simply safe by construction instead of safe-if-you-reset.
+
+The flag-toggle and the set-difference are isomorphic; we choose the one that is
+safe without remembering to clean up.
+
+## Two Paths, One Plan Type
+
+| Path | When | How deletes are derived |
+|---|---|---|
+| **Resync** | initial reconcile, resync, store rebuild | mark-and-sweep: orphans = members not in the streamed set |
+| **Steady state** | after all bookmarks | one plan action per live watch event (patch / delete-document / create) |
+
+Both emit the same `Plan`. Steady state does **not** re-mark-and-sweep the whole
+folder on every event — sweep is the bootstrap/resync mechanism only. A live
+`DELETED` event is an explicit `delete-document`; a live `ADDED`/`MODIFIED` is a
+create/patch. The store is built once and maintained incrementally; sweep is what
+runs when we (re)establish the snapshot, not per event.
+
+## Source Code Concepts That Could Be Thrown Away
+
+This design removes whole concepts, not just lines. The following are deletion
+candidates once the store + streaming reconcile + plan-then-flush are in place.
+Names are grounded against today's code.
+
+### Thrown away outright
+
+- **The path-derived git identity.** `parseIdentifierFromPath`
+ ([internal/git/helpers.go](../../../internal/git/helpers.go)) and
+ `listResourceIdentifiersInPath`
+ ([internal/git/branch_worker.go](../../../internal/git/branch_worker.go)). Git
+ identity now comes from document **content** in the store
+ (`ByResourceIdentity` / `ByManifestIdentity`), never from the file path.
+- **The GVR set-difference diff.** `FolderReconciler.findDifferences` and the
+ `clusterResources` / `gitResources` / `objectForResource` /
+ `lastSnapshotStats` machinery
+ ([internal/reconcile/folder_reconciler.go](../../../internal/reconcile/folder_reconciler.go)).
+ Replaced by mark-and-sweep set-difference over the managed model.
+- **The two-snapshot request/response handshake.** The `RequestClusterState` and
+ `RequestRepoState` control events, the `ClusterStateEvent` / `RepoStateEvent`
+ pair, and the `ReconcileResource` per-resource reminder
+ ([internal/events/events.go](../../../internal/events/events.go)), together with
+ `OnClusterState` / `OnRepoState` / `ResetState` / `HasBothStates` /
+ `StartReconciliation`
+ ([internal/reconcile/folder_reconciler.go](../../../internal/reconcile/folder_reconciler.go)).
+ Cluster state now arrives as the streaming-watch initial events; repo state is
+ the in-memory store. There is no "reconcile when both have ever arrived" gate —
+ there is one snapshot pinned to one bookmark RV.
+- **The per-event locator and per-batch inventory cache.** `manifestLocator`,
+ `manifestTarget`, `newManifestLocator`, `inventoryFor`, `locate`, and the
+ canonical-path stat fast path inside `locate`
+ ([internal/git/git.go](../../../internal/git/git.go)). The store *is* the
+ inventory, built once and maintained; placement is a property the store answers,
+ not a per-event lookup.
+- **The event-by-event write control flow.** `applyEventToWorktree` and the
+ `handleCreateOrUpdateOperation` / `handleDeleteOperation` dispatch
+ ([internal/git/git.go](../../../internal/git/git.go)). Replaced by apply-plan +
+ flush-once.
+
+### Absorbed, not deleted (logic survives, shape changes)
+
+Do **not** delete these — their decision logic moves into Plan computation / Apply:
+
+- `reconcileAgainstExisting`, `preserveExistingFormatting`,
+ `manifestsAreSemanticallyEqual`, `canonicalizeManifestForComparison`
+ ([internal/git/git.go](../../../internal/git/git.go)) — the no-op detection,
+ in-place-vs-whole-replace choice, and multi-doc safety guard become **plan
+ decisions** (patch / replace / skip) computed once, not re-derived per event.
+- `manifestreport.BuildReport`
+ ([internal/manifestreport/report.go](../../../internal/manifestreport/report.go))
+ — graduates into the Plan computation; it is already the create/update/delete/
+ skip comparison, just read-only today.
+- `manifestedit.Apply` / `manifestedit.DeleteDocument` — kept verbatim as the
+ per-document edit mechanism the Apply step calls.
+
+### Stays (do not confuse with the throwaway path)
+
+- `ResourceIdentifier.ToGitPath`
+ ([internal/types/identifier.go](../../../internal/types/identifier.go)) — still
+ the **new-file placement** policy (identity → path). Only the *reverse*
+ (`parseIdentifierFromPath`, path → identity) is thrown away.
+
+## Consistency and Failure Model
+
+- The plan is valid for one `(commit SHA, snapshot RV)` pair. The commit SHA is
+ the checked-out worktree; the RV is the max across the joined initial-sync
+ bookmarks (or each type pinned to its own bookmark RV).
+- No bookmark for a type → that type's sync is incomplete → abort, drop nothing.
+- A stream that errors after its bookmark (during steady state) does not threaten
+ the sweep; it triggers a re-list/re-watch and, if the snapshot is rebuilt, a
+ fresh mark-and-sweep at a new RV.
+- Acceptance failures (duplicate / non-KRM / unwatched API-backed KRM / mixed
+ managed-allowlisted file) short-circuit before any stream is opened: refuse,
+ reconcile nothing.
+
+## Lazy Materialization Synergy
+
+Mark-and-sweep needs only **identity + location + membership** per managed
+document — a cheap header parse (`apiVersion` / `kind` / `metadata`), not the full
+`manifestedit` node tree. Parse a document's body only when a plan action touches
+it (a patch). This keeps the resync of a large, cluster-wide watch bounded: the
+streamed set and the managed identity index are small per-document, and only the
+touched documents pay the full-parse cost.
+
+Steady state has the same shape on the time axis. High-rate watch events fold into
+a coalesced `PendingChanges` buffer (last-writer-wins per identity) without touching
+the worktree, and file bytes are hydrated only when the existing batch/commit
+mechanism fires — and only for the files that batch references. See "Two boundaries"
+in the main review. Bounded *spatially* (touched documents only) and *temporally*
+(commit boundary only), the per-batch cost tracks what actually changed, not the
+folder size or the event rate.
+
+## Open Questions
+
+- **Multi-stream join cost.** A GitTarget watching many GVKs opens many streams
+ and waits for many bookmarks. Is there a bound on concurrent streams, and how do
+ we surface "waiting for N of M initial syncs" in status?
+- **Out-of-scope watched-GVK documents.** Decided: **refused** at acceptance, not
+ silently left (see Non-Negotiable Design Decisions). The earlier "what if two
+ GitTargets share the folder" nuance is also closed: GitTargets never overlap
+ (no nesting, no shared paths — enforced at admission), so every folder has exactly
+ one owner and "out of scope for this target" can never be "in scope for some other
+ target" claiming the same documents. Out-of-scope content simply refuses.
+- **Resync trigger policy.** When do we rebuild the snapshot and re-run
+ mark-and-sweep vs. trust incremental steady-state events (watch error budget,
+ resourceVersion-too-old, GitTarget spec change, worktree drift)?
+
+## Sequencing
+
+This slots into the main review's phases as the concrete reconcile mechanism for
+phases 2 (API source), 3 (plan model), 5 (scan mode), and 7 (plan-then-flush):
+
+1. Define the API source abstraction with a streaming-list implementation and a
+ `LIST + WATCH` fallback; both yield `(tracked resource set, snapshot RV)`.
+2. Build the managed model as the watched ∧ in-scope ∧ valid-KRM view over the
+ store, with membership decided at classification time.
+3. Implement the resync mark-and-sweep as set-difference at the joined bookmark,
+ producing managed-drop plan actions; gate on all bookmarks, abort on partial.
+4. Render the resync plan in scan mode (dry-run) before arming the flush.
+5. Wire steady-state per-event plan actions over the same maintained model.
diff --git a/docs/design/manifest/sops-single-file-no-multidoc.md b/docs/design/manifest/sops-single-file-no-multidoc.md
new file mode 100644
index 00000000..3923183f
--- /dev/null
+++ b/docs/design/manifest/sops-single-file-no-multidoc.md
@@ -0,0 +1,156 @@
+# SOPS and multi-document YAML: single-file decision
+
+> Status: decided
+> Captured: 2026-06-08
+> Related:
+> [file-agnostic-placement.md](file-agnostic-placement.md),
+> [contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace-and-kustomize-folder-editing.md),
+> [../sops-repo-bootstrap-and-key-management-architecture.md](../sops-repo-bootstrap-and-key-management-architecture.md),
+> [../sops-repo-bootstrap-out-of-scope.md](../sops-repo-bootstrap-out-of-scope.md)
+
+## Decision
+
+For SOPS-encrypted content, gitops-reverser keeps **one Kubernetes resource per
+SOPS file**. We do **not** write SOPS-encrypted multi-document YAML (no
+`\n---\n`-separated documents inside an encrypted file).
+
+Plaintext manifests may still be multi-document where that is convenient (see
+[file-agnostic-placement.md](file-agnostic-placement.md)); this decision is
+scoped to files we encrypt with SOPS.
+
+## Why this came up
+
+The file-agnostic placement work allows multiple resources in one YAML file via
+the `---` separator. The natural question was whether the same trick is usable
+for SOPS-encrypted files — i.e. can we keep several encrypted resources in one
+file and still edit/replace them independently, the way we edit one resource
+without disturbing its neighbours. The answer changes how the writer must treat
+encrypted files, so it is worth recording.
+
+The findings below are from reading the upstream SOPS sources (`getsops/sops`,
+cloned locally under `external-sources/sops`, which is gitignored — paths below
+reference upstream packages, not that local copy).
+
+## How SOPS handles multi-document YAML
+
+Multi-document YAML *is* a first-class, tested feature of the SOPS YAML store
+(`stores/yaml/store.go`). The store is built around `sops.TreeBranches` — a
+slice of branches — where each `---`-separated document is one branch.
+
+- **Load** (`LoadPlainFile`): a decoder loop calls `Decode` until `io.EOF`,
+ turning each document into its own branch.
+- **Emit** (`EmitPlainFile`): loops over the branches and encodes each as a
+ separate document node, producing `---`-delimited output.
+
+Constraints in the store: each document root must be a **mapping**; a top-level
+sequence or scalar document is rejected, and an empty/`null` document yields an
+empty branch.
+
+## Why the documents are cryptographically one unit
+
+Even though the documents are physically separate, SOPS binds them together:
+
+1. **One data key for the whole file.** `Tree.Encrypt(key, cipher)` runs once
+ over the entire tree (all branches) with a single data key.
+
+2. **One file-wide MAC over all documents.** In `sops.go`, `Tree.Encrypt`
+ creates a single `sha512` hash and folds **every** branch into it, in order:
+
+ ```go
+ hash := sha512.New()
+ ...
+ for _, branch := range tree.Branches { // every document
+ walk(branch) // hash.Write(...) per value
+ }
+ return fmt.Sprintf("%X", hash.Sum(nil)) // one MAC for the file
+ ```
+
+ On decrypt, SOPS recomputes that MAC over all branches and compares it to the
+ stored value. Changing any value in any document changes the file-wide MAC; a
+ stale MAC fails with `MacMismatch` (unless `--ignore-mac`).
+
+3. **The same metadata (including that one MAC) is written into every
+ document.** `SerializeMetadata` appends the `sops:` block to every branch on
+ emit. On load, `ExtractMetadata` reads the `sops:` block **only from document
+ 0** (`if bi == 0`) and **strips** the `sops:` key from every other document
+ unconditionally.
+
+Consequence: you cannot replace or swap a single encrypted document in place.
+Any change forces a re-MAC over the whole tree, i.e. a whole-file re-encrypt.
+
+## Why the "roll your own multi-doc" trick does not work
+
+The tempting workaround is to SOPS-encrypt each document independently and
+concatenate them with `---`. Stock `sops decrypt` cannot read that:
+
+- It reads metadata only from document 0 and strips the `sops:` key from the
+ rest (`ExtractMetadata`).
+- It then tries to decrypt documents 1..N with document 0's data key and verify
+ against document 0's MAC, which was computed over all branches concatenated.
+- Different data key → AES-GCM auth failure; same data key → MAC mismatch.
+ Either way decryption fails.
+
+It is only viable if *we* own the decrypt path and split on `---` first, handing
+each standalone document to SOPS individually. That is not how the encrypted
+files are consumed downstream (Flux / sops-aware tooling expect the canonical
+single-MAC format), so it is a dead end for us.
+
+## The deeper constraint: editing needs the data key
+
+The real reason we want to avoid re-encryption is to avoid needing the data key
+at write time. That goal is incompatible with in-place editing of *any* SOPS
+document, multi-doc or not, because **the MAC is stored encrypted under the data
+key**. Even a one-value change forces a MAC update, and updating the MAC
+requires the data key. So "edit without re-encrypt" reduces to "edit without the
+key", which SOPS does not allow for an in-place change.
+
+(Aside: the IV stash in `aes/cipher.go` reuses the IV for unchanged
+`(plaintext, additionalData)` pairs within a single decrypt→edit→encrypt cycle,
+so unchanged values keep byte-identical ciphertext and the git diff stays
+minimal. That keeps re-encrypt cheap and low-churn — but it still re-MACs, and
+the additional data is the in-document key path with no document index, so it
+does not give per-document independence.)
+
+## The trilemma
+
+You can have any two of the following, not all three:
+
+| Want | Cost |
+| -------------------------------------- | ----------------------------------------------------------- |
+| Single file + stock `sops decrypt` | Must re-MAC on any change → need the key, no partial edits |
+| Single file + no re-MAC | Custom decrypt path (split on `---` first, decrypt per doc) |
+| Stock `sops decrypt` + no re-MAC | Multiple files — one SOPS document per file |
+
+Per-resource independence (add / replace / drop a resource as a pure file
+operation, no data key, no re-MAC) is only available with **one SOPS document
+per file** — the third row.
+
+## Rationale for the decision
+
+- We want encrypted resources to be independently placeable and replaceable by
+ the manifest writer without holding decryption keys, matching how plaintext
+ resources are edited by content identity.
+- The single multi-doc file is exactly the construct that couples resources
+ through one shared MAC, defeating that.
+- Downstream consumers expect canonical SOPS files; the roll-your-own format
+ would not decrypt.
+
+One SOPS document per file gives us the property we want with no custom crypto
+and full compatibility with stock SOPS and Flux.
+
+## Implications for gitops-reverser
+
+- When the writer encrypts a resource, it writes that resource to its own file;
+ it never appends an encrypted resource as an extra `---` document.
+- Placement logic for encrypted resources must therefore not co-locate multiple
+ resources in a single encrypted file, even where the plaintext placement rules
+ would allow a multi-doc file.
+- Re-encrypting a single-resource file is acceptable and expected when that
+ resource's content changes; it is not the thing we are avoiding. What we avoid
+ is one resource's change forcing a re-encrypt that touches *other* resources.
+
+## Out of scope
+
+- Changing or forking SOPS multi-doc handling.
+- A custom split-then-decrypt path owned by gitops-reverser.
+- Any change to how plaintext multi-document YAML is parsed or written.
diff --git a/docs/design/manifest/version2/api-catalog-watched-type-architecture.md b/docs/design/manifest/version2/api-catalog-watched-type-architecture.md
new file mode 100644
index 00000000..04084a35
--- /dev/null
+++ b/docs/design/manifest/version2/api-catalog-watched-type-architecture.md
@@ -0,0 +1,907 @@
+# API catalog and watched type architecture
+
+> Status: architecture proposal, captured 2026-06-08.
+>
+> A simpler greenfield redesign — fewer layers, one unified per-type output
+> instead of separate followability and health reports — lives in
+> [type-followability.md](type-followability.md). Read that first; this doc is kept
+> for the grounded, layer-by-layer reasoning behind it.
+>
+> Question: how should `APIResourceCatalog` and `WatchedTypeTable` relate when
+> both currently carry Kubernetes type identity and policy facts?
+>
+> Short answer: keep `APIResourceCatalog` as the raw discovery cache, introduce a
+> resolved type surface as the policy boundary, and make `WatchedTypeTable` a
+> GitTarget-specific projection of already-resolved types.
+
+## Problem
+
+The current implementation has useful pieces, but the boundaries are blurry.
+
+`APIResourceCatalog` owns discovery refresh, group/version trust state, indexes,
+and raw type facts. `WatchedTypeTable` owns GitTarget selection, namespace
+operation filters, conflict visibility, pending removals, and snapshot/informer
+safety.
+
+The overlap is that both objects now handle type identity:
+
+```text
+APIResourceEntry
+ GVK, GVR, namespaced, verbs, preferred, subresource, allowed, policy reason
+
+WatchedType
+ GVK, GVR, namespaced, served version, preferred, scope, namespace operations
+```
+
+That overlap forces `WatchedTypeTable` to look back into the raw catalog with
+`LookupGVR`, then repeat part of the GVK/GVR validity policy locally. It also
+means product policy is spread across `APIResourceCatalog`, `CatalogMapper`,
+`RuleGVRResolver`, `WatchedTypeTable`, and the git writer's sensitive-resource
+policy.
+
+The neat model is to make raw discovery, type policy, and GitTarget selection
+three different layers.
+
+## Current Comparison
+
+| Concern | `APIResourceCatalog` today | `WatchedTypeTable` today | Desired owner |
+| --- | --- | --- | --- |
+| Discovery refresh | Calls Kubernetes discovery and handles partial failure | Does not refresh discovery | `APIResourceCatalog` |
+| Raw indexes | Indexes by GVK, GVR, resource, group/resource, group/version | Calls `LookupGVR` while building a target table | `APIResourceCatalog` |
+| Readiness and degraded discovery | Tracks ready, generation, degraded group/versions | Stores `ResolvedAt`; blocks gather on blocking misses and pending removals | Catalog for raw source state; resolved surface for live-set stability |
+| Type facts | Stores GVK, GVR, namespaced, verbs, preferred, subresource | Repeats GVK, GVR, namespaced, preferred, served version | Resolved type surface should expose accepted type facts |
+| Allowed/disallowed policy | Computes `Allowed` and `PolicyReason` during catalog entry creation | Receives misses and avoids unplanned types indirectly | Resolved type surface/type policy |
+| GVK ambiguity | Catalog can return multiple entries for a GVK | Detects conflicts only among selected GVRs | Resolved type surface |
+| GVR validation | `LookupGVR` returns one raw catalog entry | Treats the returned GVR as enough to build a watched type | Resolved type surface |
+| WatchRule expansion | Raw resource/group indexes support wildcard and omitted-group lookups | Consumes resolved selections after expansion | Watch rule resolver, backed by type surface |
+| GitTarget scope | None | Owns namespace ops, cluster-wide selection, target identity | `WatchedTypeTable` |
+| Pending removals | None | Owns grace period and sweep safety | Resolved type surface live set |
+| Sensitive resource handling | None | None | Type policy/type surface, consumed by git writer |
+
+The important asymmetry: `APIResourceCatalog` is cluster-global; `WatchedTypeTable`
+is GitTarget-specific. Any shared type decision between them belongs in a layer
+between them, not inside either one.
+
+## Target Layers
+
+```text
+APIResourceCatalog
+ raw Kubernetes discovery cache
+ indexes, generation, readiness, degraded group/versions
+
+APIResourceEnricher
+ joins discovery with CRDs and APIServices
+ classifies resource origin and records CRD subresource details
+
+TypePolicy
+ GitOps Reverser policy over resource types
+ allowed/disallowed, sensitive/encrypted, optional policy reasons
+
+ResolvedTypeSurface
+ stable interface over catalog + policy
+ followability requirements, live-set hysteresis
+ exact GVK lookup, exact GVR lookup, candidate listing, one refusal vocabulary
+
+ResourceMapper
+ narrow adapter for manifest analysis
+ GVK -> GVR only
+
+RuleGVRResolver
+ WatchRule selector semantics
+ wildcards, omitted groups, preferred version, scope, followability support
+
+WatchedTypeTable
+ GitTarget-selected operational view
+ namespace operations, conflicts/misses for visibility
+```
+
+## APIResourceCatalog Responsibility
+
+`APIResourceCatalog` should answer: what did Kubernetes discovery currently say,
+and how trustworthy is that observation?
+
+It should own:
+
+- discovery refresh;
+- raw discovery source observations;
+- preserving previous clean group/versions when discovery is partially degraded;
+- generation increments;
+- raw indexes for efficient lookups;
+- cloning and sorting raw entries;
+- catalog metrics facts.
+
+It should not own:
+
+- GitTarget rule semantics;
+- snapshot, informer, or sweep behavior;
+- sensitive-resource write behavior;
+- final "is this type usable by GitOps Reverser?" decisions.
+
+The catalog may still store raw decorations such as preferred version and
+subresource shape because those come from discovery or are direct projections of
+discovery names. But a caller should not treat a raw catalog entry as a permission
+to watch or write that type.
+
+## Resource Origin and Enrichment
+
+Every resource type should expose where GitOps Reverser believes it came from:
+
+```text
+kubernetes-internal
+crd
+aggregated-api
+unknown
+```
+
+This is not available from `APIResourceList` alone. Discovery tells us that a
+group/version/resource is served, but not whether it is built in, CRD-backed, or
+proxied through an aggregated API server. So the raw discovery catalog should be
+enriched from two additional API surfaces:
+
+| Origin | Evidence | Notes |
+| --- | --- | --- |
+| `crd` | `apiextensions.k8s.io/v1 CustomResourceDefinition` whose `spec.group`, served `spec.versions[*].name`, and `spec.names.plural` match the GVR | This is the only source that contains CRD `/scale` JSONPaths. |
+| `aggregated-api` | `apiregistration.k8s.io/v1 APIService` claiming the resource's group/version and forwarding to a service-backed API server | The APIService identifies the group/version backend; discovery still supplies the resource list underneath it. |
+| `kubernetes-internal` | Served by discovery and not matched by CRD or aggregated API evidence | Includes core resources and built-in Kubernetes API groups served by kube-apiserver. |
+| `unknown` | Discovery data exists but the enrichment inputs were unavailable or degraded | Must be surfaced as health, not hidden as a successful classification. |
+
+Classification should be stored with provenance:
+
+```go
+type ResourceOrigin string
+
+const (
+ ResourceOriginKubernetesInternal ResourceOrigin = "kubernetes-internal"
+ ResourceOriginCRD ResourceOrigin = "crd"
+ ResourceOriginAggregatedAPI ResourceOrigin = "aggregated-api"
+ ResourceOriginUnknown ResourceOrigin = "unknown"
+)
+
+type ResourceSourceFact struct {
+ Origin ResourceOrigin
+
+ // Confidence is "observed" when backed by a clean CRD/APIService snapshot,
+ // "inferred" when built-in is inferred by exclusion, and "unknown" when the
+ // required enrichment surface was unavailable.
+ Confidence string
+
+ // Ref is bounded operator context, for example:
+ // crontabs.stable.example.com
+ // v1beta1.metrics.k8s.io
+ // Empty for inferred Kubernetes-internal resources.
+ Ref string
+}
+```
+
+CRD matching is per GVR, not just per group/version, because one CRD defines one
+plural resource and may serve multiple versions. Aggregated API matching is
+normally per group/version because `APIService` owns a whole group/version path;
+the resource list still comes from discovery.
+
+Origin should be a fact on every resolved type and every refused type report. A
+health report that says "deployment is healthy" but does not say whether it is
+built-in, CRD, or aggregated is incomplete.
+
+## Subresource Facts
+
+The resolved type surface should expose the supported subresources that matter to
+GitOps Reverser without making subresources independent watched types.
+
+Minimum shape:
+
+```go
+type SubresourceFacts struct {
+ Status StatusSubresourceFact
+ Scale ScaleSubresourceFact
+}
+
+type StatusSubresourceFact struct {
+ Enabled bool
+ Source string // discovery, crd, builtin-registry, unknown
+}
+
+type ScaleSubresourceFact struct {
+ Enabled bool
+ Source string // discovery, crd, builtin-registry, aggregated-api, unknown
+
+ // ResponseKind is normally autoscaling/v1, Kind=Scale.
+ ResponseGVK schema.GroupVersionKind
+
+ // Parent write paths. For built-in resources these come from the built-in
+ // registry. For CRDs these are read from CRD spec.versions[*].subresources.scale.
+ SpecReplicasPath string
+ StatusReplicasPath string
+ LabelSelectorPath string
+ LabelSelectorKind string // serialized-string, label-selector, unknown
+
+ // UsableForGit is true only when a mutating /scale audit event can be mapped
+ // back to a durable parent desired-state field.
+ UsableForGit bool
+ UnusableReason string
+}
+```
+
+For CRDs, the scale pointers come directly from the CRD version:
+
+```yaml
+subresources:
+ status: {}
+ scale:
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .status.labelSelector
+```
+
+CRD pointer rules from Kubernetes matter and should be retained as facts:
+
+- `specReplicasPath` is required, must be dot-notation JSONPath under `.spec`,
+ and maps to `Scale.Spec.Replicas`.
+- `statusReplicasPath` is required, must be dot-notation JSONPath under
+ `.status`, and maps to `Scale.Status.Replicas`.
+- `labelSelectorPath` is optional, must be dot-notation JSONPath under `.status`
+ or `.spec`, and maps to `Scale.Status.Selector`.
+- `labelSelectorPath` must point at a string field containing a serialized label
+ selector; it is needed for HPA/VPA support.
+
+This changes the previous deferred CRD-scale story: CRD `/scale` remains
+unsupported only until the enriched type object can carry these paths. Once
+`ScaleSubresourceFact.SpecReplicasPath` is populated from the CRD, a CRD scale
+event has enough information to patch the parent desired field without guessing.
+
+Built-in scalable resources should use the same fields, populated from an
+explicit built-in scale pointer registry. That makes built-ins and CRDs look the
+same to the translator, health report, and any future GUI:
+
+```yaml
+builtinScalePointers:
+ - gvr: apps/v1/deployments
+ responseGVK:
+ group: autoscaling
+ version: v1
+ kind: Scale
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .spec.selector
+ labelSelectorKind: label-selector
+ - gvr: apps/v1/statefulsets
+ responseGVK:
+ group: autoscaling
+ version: v1
+ kind: Scale
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .spec.selector
+ labelSelectorKind: label-selector
+ - gvr: apps/v1/replicasets
+ responseGVK:
+ group: autoscaling
+ version: v1
+ kind: Scale
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .spec.selector
+ labelSelectorKind: label-selector
+ - gvr: v1/replicationcontrollers
+ responseGVK:
+ group: autoscaling
+ version: v1
+ kind: Scale
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .spec.selector
+ labelSelectorKind: label-selector
+```
+
+The registry should be data, not scattered conditionals. It can start small and
+only include built-in resources whose scale paths are verified. Discovery still
+decides whether the resource and `{resource}/scale` are actually served on the
+cluster; the registry only supplies parent paths and response shape for known
+built-in types.
+
+The selector field is intentionally typed. A CRD `labelSelectorPath` must point to
+a serialized string because that is the CRD contract. Built-in resources can
+derive `Scale.Status.Selector` from a structured label selector, so their registry
+entry should report both the source path and `labelSelectorKind: label-selector`.
+The scale write path still depends only on `specReplicasPath`; selector details
+are health and GUI facts.
+
+Aggregated API scale should also land in `ScaleSubresourceFact`, but it is only
+usable when the aggregated API surface provides an equivalent trusted parent path.
+Plain discovery of `{resource}/scale` is not enough.
+
+Subresource entries from discovery such as `deployments/status` and
+`deployments/scale` should be folded into the parent type's `SubresourceFacts`.
+They should not appear as standalone `WatchedType` entries.
+
+## Per-Type Health Report
+
+Every resource type should have an actual health report. This report is not just
+for selected WatchRule types; it should be available for any cataloged resource
+type so operators can answer "why is this type watched, ignored, or unsafe?"
+
+Sketch:
+
+```go
+type TypeHealthLevel string
+
+const (
+ TypeHealthHealthy TypeHealthLevel = "healthy"
+ TypeHealthDegraded TypeHealthLevel = "degraded"
+ TypeHealthRefused TypeHealthLevel = "refused"
+ TypeHealthUnknown TypeHealthLevel = "unknown"
+)
+
+type TypeHealthReport struct {
+ Level TypeHealthLevel
+
+ // One-line bounded summary suitable for status, logs, and diagnostics.
+ Summary string
+
+ // Conditions are bounded and stable. They should not include object names,
+ // request URIs, or unbounded backend messages.
+ Conditions []TypeHealthCondition
+}
+
+type TypeHealthCondition struct {
+ Type string // Discovery, Source, Policy, Identity, Subresources, Watch, Scale, Followability, Selection
+ Status string // True, False, Unknown
+ Reason string
+ Message string
+}
+```
+
+Recommended conditions:
+
+| Condition | Healthy when | Degraded/refused examples |
+| --- | --- | --- |
+| `Discovery` | group/version was discovered from trusted data | catalog unavailable, group/version degraded, stale preserved entry |
+| `Source` | origin is classified as internal, CRD, or aggregated with evidence | CRD/APIService enrichment unavailable, origin unknown |
+| `Policy` | type is allowed by product policy | disallowed resource, sensitive resource without required handling, subresource-only match |
+| `Identity` | GVK ↔ GVR is a clean 1:1 mapping in both directions | `ambiguous_gvk` (one GVK served by multiple GVRs), `ambiguous_gvr` (one GVR resolving to multiple Kinds) |
+| `Watch` | type supports `get`, `list`, `watch`, and `patch` when selected for a WatchRule | missing required verb, selected scope mismatch |
+| `Subresources` | status/scale facts are internally consistent | discovery says scale exists but CRD scale paths are missing |
+| `Scale` | scale is disabled or has a known parent replica path when enabled | `scale_path_unresolved`, invalid CRD JSONPath, aggregated scale without path source |
+| `Followability` | type is in the live set | denied, missing verb, retained during grace, expired absence |
+| `Selection` | GitTarget selection folded cleanly | conflicting GVRs across selections for this target |
+
+Examples:
+
+```yaml
+gvr: apps/v1/deployments
+source:
+ origin: kubernetes-internal
+ confidence: inferred
+subresources:
+ status:
+ enabled: true
+ source: discovery
+ scale:
+ enabled: true
+ source: builtin-registry
+ responseGVK:
+ group: autoscaling
+ version: v1
+ kind: Scale
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .spec.selector
+ labelSelectorKind: label-selector
+ usableForGit: true
+health:
+ level: healthy
+ summary: built-in resource is watchable and scale paths are known
+```
+
+```yaml
+gvr: stable.example.com/v1/crontabs
+source:
+ origin: crd
+ confidence: observed
+ ref: crontabs.stable.example.com
+subresources:
+ status:
+ enabled: true
+ source: crd
+ scale:
+ enabled: true
+ source: crd
+ responseGVK:
+ group: autoscaling
+ version: v1
+ kind: Scale
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
+ labelSelectorPath: .status.labelSelector
+ labelSelectorKind: serialized-string
+ usableForGit: true
+health:
+ level: healthy
+ summary: CRD resource is watchable and scale paths are known
+```
+
+```yaml
+gvr: metrics.k8s.io/v1beta1/pods
+source:
+ origin: aggregated-api
+ confidence: observed
+ ref: v1beta1.metrics.k8s.io
+subresources:
+ status:
+ enabled: false
+ scale:
+ enabled: false
+health:
+ level: refused
+ summary: aggregated metrics resource is read-only or not watchable for Git mirroring
+```
+
+The health report should be part of `TypeResult` for exact lookups and part of
+catalog diagnostics for list-all views. `WatchedTypeTable` can then copy the
+accepted type's health into target status without recalculating catalog facts.
+
+## Followability Requirements
+
+GitOps Reverser should distinguish "served by Kubernetes discovery" from
+"followable by GitOps Reverser." A served type is raw cluster fact. A followable
+type is safe enough for the product to mirror, watch, snapshot, route audit
+events for, and potentially sweep from Git.
+
+The default lookup path should return only followable live types. Callers that
+need diagnostics can ask for an inspection result, but ordinary mapper, resolver,
+and writer code should not accidentally act on raw or unstable discovery entries.
+
+Each requirement below has a sharp, stable name and exactly one refusal reason.
+The refusal reason is the single vocabulary GitOps Reverser uses everywhere a type
+can be turned away — default lookup status, `FollowabilityReport.Reasons`, and
+health conditions — so an operator always gets the same machine-readable answer to
+"why isn't this type picked up?" A type enters the live set only when every
+required row passes; the first failing row supplies the refusal reason.
+
+| Requirement | Rule | Required | Refused with |
+| --- | --- | --- | --- |
+| `parent-resource` | The type is a top-level parent resource, not a subresource. Subresources are folded into the parent's `SubresourceFacts`. | yes | `subresource_only` |
+| `policy-allowed` | The type is not on the GitOps Reverser deny list. | yes | `denied_by_policy` |
+| `sensitive-handled` | The type is not sensitive, or its sensitivity has supported encryption/write handling. | yes | `sensitive_unsupported` |
+| `discovery-trusted` | The backing group/version was discovered from trusted, non-degraded data. | yes | `discovery_degraded` |
+| `served-stable` | The type survived transient registration wobble, or is still inside the 60-second removal grace. | yes | `served_unstable`, `absence_expired` |
+| `gvk-resolves-to-one-gvr` | The type's GVK is served by exactly one accepted GVR. | yes | `ambiguous_gvk` |
+| `gvr-resolves-to-one-gvk` | The type's GVR resolves back to exactly one Kind. | yes | `ambiguous_gvr` |
+| `scope-known` | The system knows whether the get/list/watch paths are namespaced or cluster-scoped. | yes | `scope_unknown` |
+| `required-verbs` | Discovery advertises `get`, `list`, `watch`, and `patch`. | yes | `missing_verb_` |
+| `source-classified` | Origin is classified as `kubernetes-internal`, `crd`, or `aggregated-api` with evidence. | yes | `source_unknown` |
+| `scale-resolved` | When scale routing is required, the parent `SpecReplicasPath` is known and not guessed. | conditional | `scale_path_unresolved` |
+
+### Unambiguous GVK ↔ GVR identity
+
+GitOps Reverser follows a type only when its identity is a clean 1:1 mapping in
+**both** directions. This is two named requirements, not one:
+
+- `gvk-resolves-to-one-gvr`: exactly one GVR serves a given GVK; and
+- `gvr-resolves-to-one-gvk`: that GVR resolves back to exactly one Kind.
+
+The round trip must close. `GVK -> GVR -> GVK` must return the original GVK, and
+`GVR -> GVK -> GVR` must return the original GVR. If either direction forks, the
+type is refused with `ambiguous_gvk` or `ambiguous_gvr` respectively and never
+enters the live set; it remains visible only through `Inspect*` and the health
+report.
+
+This is deliberately strict, and the strictness is the point. A served but
+ambiguous identity is exactly where silent mis-mirroring, wrong-parent writes, and
+confusing sweeps come from. The ambiguity is almost always a symptom of an
+unhealthy cluster: duplicate CRDs claiming the same kind, an aggregated API
+shadowing a built-in group/version, or two resources sharing a kind across
+versions. GitOps Reverser should not paper over that with a guess. It refuses the
+type, names the conflict in the health report, and lets the operator fix it —
+**keep your cluster healthy** is a precondition Reverser relies on, not something
+it tries to work around. Keeping ambiguous identities out of the live set removes
+a large, recurring source of unclarity in one move.
+
+The deny list belongs in type policy, not in WatchRule expansion. A rule that asks
+for a denied resource should resolve to a typed refusal with the policy reason.
+The denied type should still appear in inspection diagnostics and health reports;
+it should not appear in the default live list.
+
+`patch` is deliberately part of the live-set requirement. GitOps Reverser may not
+patch the Kubernetes API directly for every flow, but audit events and
+subresource translations include patch-shaped desired-state changes. Treating
+patchless resources as live would create an uneven contract for writer and UI
+code.
+
+## Live Type Set
+
+The resolved type surface should maintain a hysteresis-protected live set:
+
+```text
+raw discovery + enrichment + policy + followability checks
+ -> candidate type facts
+ -> live type set with removal hysteresis
+```
+
+Additions should be fast. When a new CRD becomes served and passes the
+requirements, it can enter the live set immediately. Removals should be slow:
+once a type is live, a failed refresh, missing group/version, missing resource, or
+temporary CRD registration wobble must not remove it from the live set until the
+absence has persisted for **60 seconds**.
+
+The 60-second removal grace should be fixed, not configurable. It is product
+safety, not tuning. The goal is to avoid turning a short API discovery gap into a
+large destructive Git sweep.
+
+Sketch:
+
+```go
+const liveTypeRemovalGrace = 60 * time.Second
+
+type FollowabilityStatus string
+
+const (
+ FollowabilityLive FollowabilityStatus = "live"
+ FollowabilityPending FollowabilityStatus = "pending"
+ FollowabilityRetained FollowabilityStatus = "retained"
+ FollowabilityRefused FollowabilityStatus = "refused"
+ FollowabilityUnknown FollowabilityStatus = "unknown"
+)
+
+type FollowabilityReport struct {
+ Status FollowabilityStatus
+ Live bool
+
+ // Reasons are bounded machine-readable requirement outcomes drawn from the
+ // single refusal vocabulary, for example: subresource_only, denied_by_policy,
+ // sensitive_unsupported, discovery_degraded, served_unstable, absence_expired,
+ // ambiguous_gvk, ambiguous_gvr, scope_unknown, missing_verb_patch,
+ // source_unknown, scale_path_unresolved.
+ Reasons []string
+
+ FirstObserved time.Time
+ LastObserved time.Time
+ MissingSince *time.Time
+ RetainedUntil *time.Time
+}
+```
+
+`Retained` means the type failed the latest raw observation but remains in the
+live set because the 60-second removal grace has not elapsed. During that window,
+consumers should continue treating the previous type fact as live for planning,
+snapshot, informer, and writer identity purposes. Health should show the degraded
+condition, but lookup should not drop the type.
+
+Once the grace expires, the type leaves the default live set and becomes an
+inspection result with `FollowabilityRefused` or `FollowabilityUnknown`,
+depending on the reason. A deletion/sweep workflow can then make a deliberate
+decision from a stable absence instead of a discovery blink.
+
+This moves the current `WatchedTypeTable` pending-removal behavior one level up.
+`WatchedTypeTable` should not be the only place that protects against spurious
+type disappearance; all GitOps Reverser components need the same stable type
+view.
+
+## Lookup Modes
+
+The type surface should make the safe path the easy path.
+
+```go
+type TypeSurface interface {
+ Ready() mapping.MapperReadiness
+ Generation() uint64
+
+ // Default lookups return only live followable types.
+ ForGVK(ctx context.Context, gvk schema.GroupVersionKind) (TypeResult, error)
+ ForGVR(ctx context.Context, gvr schema.GroupVersionResource) (TypeResult, error)
+ LiveTypes(ctx context.Context) ([]TypeFact, error)
+
+ // Inspection lookups return served, retained, refused, and unstable facts for
+ // status, GUI, CLI, and debugging. They never authorize mirroring by
+ // themselves.
+ InspectGVK(ctx context.Context, gvk schema.GroupVersionKind) (TypeResult, error)
+ InspectGVR(ctx context.Context, gvr schema.GroupVersionResource) (TypeResult, error)
+ InspectTypes(ctx context.Context) ([]TypeResult, error)
+}
+```
+
+Default lookup behavior:
+
+- `ForGVK` and `ForGVR` return `Resolved` only for live followable types.
+- A denied, ambiguous, missing-verb, source-unknown, subresource-only, or
+ expired-missing type returns a refusal status.
+- A retained type still returns as live, with health/followability conditions
+ explaining that it is retained during the removal grace.
+- Callers that need to render "why not?" use `Inspect*`, not raw catalog lookups.
+
+## Component Needs
+
+Different components need different projections of the same type fact. The point
+of the abstraction is to avoid each component rediscovering or weakening the
+rules.
+
+| Component | Needs live-only lookup? | Extra facts needed | Why |
+| --- | --- | --- | --- |
+| Manifest analyzer / mapper | yes | GVK, GVR, scope, refusal reason | Map committed manifests to live API identities without accepting denied or ambiguous types. |
+| WatchRule resolver | yes | resource names, preferred versions, scope, get/list/watch/patch verbs | Expand user selectors only into followable resources. |
+| WatchedTypeTable | yes | namespace operation filters plus copied `TypeFact` health | Project already-live types into a GitTarget view. |
+| Snapshot / informer manager | yes | GVR, scope, list/watch/get support, retained-state health | Avoid starting/stopping streams on transient discovery gaps. |
+| Audit consumer | yes | parent GVR, source, subresource facts, scale `SpecReplicasPath` | Route events only for live parents and translate `/scale` without guessing. |
+| Git writer | yes | sensitivity, source, health, scale field-patch source | Decide write/encryption behavior from the same accepted type facts. |
+| CLI / GUI / status | no, also needs inspect | origin, verbs, subresources, followability report, health conditions | Show all cluster resource types, including denied/refused/unstable, and explain what GitOps Reverser will do. |
+
+The `/scale` case is the useful pressure test. The audit consumer should be able
+to ask for a parent type and receive:
+
+```yaml
+gvr: apps/v1/deployments
+followability:
+ status: live
+ live: true
+verbs: [get, list, watch, patch]
+subresources:
+ scale:
+ enabled: true
+ specReplicasPath: .spec.replicas
+ usableForGit: true
+```
+
+If the same call is made for a CRD with `/scale` but no resolved
+`SpecReplicasPath`, the default lookup should either refuse the type for scale
+routing or return the parent as live with `scale.usableForGit: false`, depending
+on whether the parent itself is otherwise followable. In both cases, the scale
+translator must not guess `.spec.replicas`.
+
+## Resolved Type Surface Responsibility
+
+The missing abstraction is the policy boundary between raw discovery and consumers.
+
+Sketch:
+
+```go
+type TypeFact struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+
+ Namespaced bool
+ Verbs []string
+ Preferred bool
+ Subresource bool
+
+ Source ResourceSourceFact
+ Subresources SubresourceFacts
+
+ Allowed bool
+ Sensitive bool
+ PolicyReason string
+
+ Followability FollowabilityReport
+ Health TypeHealthReport
+}
+
+type TypeResult struct {
+ Fact TypeFact
+ Status mapping.Status
+ Reason string
+ Generation uint64
+}
+
+type TypeSurface interface {
+ Ready() mapping.MapperReadiness
+ Generation() uint64
+
+ // Default lookups return only live followable types.
+ ForGVK(ctx context.Context, gvk schema.GroupVersionKind) (TypeResult, error)
+ ForGVR(ctx context.Context, gvr schema.GroupVersionResource) (TypeResult, error)
+ LiveTypes(ctx context.Context) ([]TypeFact, error)
+
+ // Inspection lookups return served, retained, refused, and unstable facts for
+ // status, GUI, CLI, and debugging.
+ InspectGVK(ctx context.Context, gvk schema.GroupVersionKind) (TypeResult, error)
+ InspectGVR(ctx context.Context, gvr schema.GroupVersionResource) (TypeResult, error)
+ InspectTypes(ctx context.Context) ([]TypeResult, error)
+}
+```
+
+`ForGVK` and `ForGVR` must share the same product policy:
+
+- no trusted data means `CatalogUnavailable`;
+- degraded lookup scope means `DiscoveryDegraded`;
+- served but excluded means `Disallowed`;
+- subresource-only matches are refused;
+- one GVK served by more than one GVR is `Ambiguous` (`ambiguous_gvk`);
+- one GVR resolving to more than one Kind is `Ambiguous` (`ambiguous_gvr`);
+- exact GVR lookup must validate the entry's GVK against the full GVK lookup and
+ confirm the round trip `GVR -> GVK -> GVR` returns the original GVR;
+- live lookups only return types that pass followability requirements or are
+ retained inside the 60-second removal grace.
+
+Those identity points close the current gap: `LookupGVR` is single-valued in the
+index because GVR strings are unique, but a unique index key is not proof of a
+healthy identity. The required property is a closed GVK ↔ GVR bijection in both
+directions. If the GVR's GVK is globally ambiguous, or the GVR itself resolves to
+more than one Kind, selecting that GVR is still refused.
+
+`ForGVK` and `ForGVR` should return health even on refusal. A refused result that
+only says "Disallowed" forces UI/status code to reverse-engineer the real issue.
+A refused result that includes source, policy, subresource, and watch conditions
+is useful immediately.
+
+## WatchedTypeTable Responsibility
+
+`WatchedTypeTable` should answer: for this GitTarget, which already-accepted
+types are operationally active, under which namespaces and operations?
+
+It should own:
+
+- GitTarget destination identity;
+- selected namespace and operation filters;
+- cluster-wide versus named namespace stream shape;
+- resident table generation;
+- blocking snapshot safety;
+- conflict and miss visibility for target planning.
+
+It should not own:
+
+- raw GVR to GVK lookup;
+- global GVK ambiguity policy;
+- allowed/disallowed policy;
+- sensitive-resource classification;
+- Kubernetes discovery freshness;
+- discovery hysteresis and removal grace.
+
+In the target shape, `buildWatchedTypeTable` receives resolved facts, not raw GVRs
+that it must validate against the catalog:
+
+```go
+type resolvedSelection struct {
+ fact TypeFact
+ namespace string
+ ops []configv1alpha1.OperationType
+}
+```
+
+Then the table builder only folds selections by accepted type identity and merges
+namespace operation sets. If ambiguity or disallowed state exists, it arrives as a
+typed miss from the resolver instead of being rediscovered inside the table.
+
+`WatchedType` should retain enough of `TypeFact` to render an operator report:
+
+```go
+type WatchedType struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+
+ Source ResourceSourceFact
+ Subresources SubresourceFacts
+ Followability FollowabilityReport
+ Health TypeHealthReport
+
+ Namespaced bool
+ Scope configv1alpha1.ResourceScope
+ ServedVersion string
+ Preferred bool
+ NamespaceOps map[string]OperationSet
+}
+```
+
+That does not make the watched table responsible for calculating source,
+subresource, followability, or health facts. It only preserves the
+already-resolved report for status and diagnostics.
+
+## Sensitive Resources
+
+The startup flag for additional sensitive resources is currently consumed by the
+git writer. That works for encryption, but it keeps an important type policy
+outside the type system.
+
+Prefer `Sensitive bool` on `TypeFact`, backed by the existing
+`types.SensitiveResourcePolicy`.
+
+This should mean:
+
+- core `v1/secrets` are always sensitive;
+- startup additional sensitive resources are classified at type-policy time;
+- writer code can still receive the policy directly during migration;
+- eventually the selected type fact can carry sensitivity into write planning.
+
+The field should be named `Sensitive`, not `Secret`, because configured resources
+may be Secret-shaped CRDs rather than Kubernetes `Secret` objects.
+
+## RuleGVRResolver Role
+
+`RuleGVRResolver` should keep WatchRule-specific syntax:
+
+- omitted apiGroups;
+- wildcard groups/resources/versions;
+- preferred-version selection when versions are omitted;
+- namespaced versus cluster scope;
+- followability capability checks;
+- operator-facing miss detail.
+
+But it should not use raw catalog entries as final answers. It can use catalog or
+surface candidate listing for expansion, then validate concrete candidates through
+the type surface.
+
+Practical target:
+
+```text
+WatchRule selector
+ -> candidate raw resources for expansion
+ -> TypeSurface.ForGVR for each concrete GVR
+ -> resolvedSelection or ResolveMiss
+ -> WatchedTypeTable fold
+```
+
+`ResolveMiss` can remain a watch-domain type, but it should optionally carry the
+core mapping status so UI/status/logging does not need to parse reason strings.
+
+## Refactor Path
+
+1. Add a `TypeFact`/`TypeResult` shape in the mapping layer or a small new core
+ package that does not import `internal/watch`.
+2. Add `ResourceSourceFact`, `SubresourceFacts`, and `TypeHealthReport` to that
+ shape.
+3. Add `FollowabilityReport`, the fixed 60-second live-set removal grace, and
+ live-only default lookup methods.
+4. Add enrichment from CRDs and APIServices:
+ - CRD GVR source classification;
+ - CRD `/status` and `/scale` flags;
+ - CRD scale `specReplicasPath`, `statusReplicasPath`, and `labelSelectorPath`;
+ - aggregated API group/version source classification.
+5. Add a built-in scale pointer registry that populates the same
+ `ScaleSubresourceFact` fields used by CRDs.
+6. Fold discovery subresource entries into parent resource facts.
+7. Add exact GVR resolution beside exact GVK resolution and make it validate
+ global GVK uniqueness.
+8. Move allowed/disallowed and sensitive classification behind a small type-policy
+ object used by the live surface and static tests.
+9. Compute per-type health reports during resolution and expose them for accepted
+ and refused types.
+10. Keep `CatalogMapper` as a thin `ResourceMapper` adapter over the surface.
+11. Change `RuleGVRResolver` so concrete GVRs are accepted only through the surface.
+12. Change `WatchedTypeTable` so it folds resolved `TypeFact` selections instead of
+ calling `catalog.LookupGVR`.
+13. Remove local conflict detection from the table once ambiguity is guaranteed to
+ arrive as a typed planning miss.
+14. Move pending-removal behavior out of `WatchedTypeTable` and into the shared
+ live-set hysteresis.
+15. Add regression coverage for the GVK ↔ GVR bijection in both directions:
+ - one GVK served by two GVRs, only one selected by a GitTarget, still refused
+ with `ambiguous_gvk`;
+ - one GVR resolving to more than one Kind refused with `ambiguous_gvr`;
+ - a clean type whose `GVK -> GVR -> GVK` and `GVR -> GVK -> GVR` round trips
+ both close is accepted.
+16. Add regression coverage for live-set hysteresis:
+ - a newly served CRD can enter the live set immediately;
+ - a previously live type remains live while absent for less than 60 seconds;
+ - a previously live type leaves the live set after 60 seconds of stable absence;
+ - retained types report degraded health but still resolve from default lookups.
+17. Add regression coverage for built-in and CRD scale path extraction and health:
+ - built-in scale paths populate `ScaleSubresourceFact` from the registry;
+ - CRD with status and scale reports both enabled;
+ - CRD scale paths are version-specific;
+ - CRD scale without usable `specReplicasPath` is unhealthy/refused for scale
+ routing;
+ - aggregated API scale without a trusted parent path reports
+ `scale_path_unresolved`.
+
+## Decision
+
+Do not merge `APIResourceCatalog` and `WatchedTypeTable`.
+
+They have different lifetimes and scopes:
+
+- `APIResourceCatalog` is cluster-global and discovery-shaped.
+- `WatchedTypeTable` is GitTarget-local and operation-shaped.
+
+The overlap should be removed by inserting a resolved type surface between them.
+That surface becomes the single place where GitOps Reverser turns raw discovery
+into accepted or refused type facts.
+
+## References
+
+- [catalog-mapper-vs-watched-type-table.md](catalog-mapper-vs-watched-type-table.md)
+- [subresource-scope-reduction.md](subresource-scope-reduction.md)
+- [gvk-gvr-mapping-layer.md](../gvk-gvr-mapping-layer.md)
+- [resource-types.md](../../../facts/resource-types.md)
+- [subresources.md](../../../facts/subresources.md)
+- [`internal/watch/api_resource_catalog.go`](../../../../internal/watch/api_resource_catalog.go)
+- [`internal/watch/watched_type_table.go`](../../../../internal/watch/watched_type_table.go)
+- [`internal/watch/rule_gvr_resolver.go`](../../../../internal/watch/rule_gvr_resolver.go)
+- [`internal/watch/catalog_mapper.go`](../../../../internal/watch/catalog_mapper.go)
+- [`internal/types/sensitive_resource.go`](../../../../internal/types/sensitive_resource.go)
diff --git a/docs/design/manifest/version2/catalog-mapper-vs-watched-type-table.md b/docs/design/manifest/version2/catalog-mapper-vs-watched-type-table.md
new file mode 100644
index 00000000..d2262a44
--- /dev/null
+++ b/docs/design/manifest/version2/catalog-mapper-vs-watched-type-table.md
@@ -0,0 +1,330 @@
+# Raw catalog, resolved type surface, GitTarget selection
+
+> Status: design investigation, captured 2026-06-05
+>
+> Question: should `internal/watch/watched_type_table.go` become the single
+> GVK/GVR abstraction, including for CLI/offline tooling, or should
+> `internal/watch/catalog_mapper.go` stay separate?
+>
+> Short answer: neither object is the core abstraction. The core abstraction is a
+> resolved type surface behind an interface. `CatalogMapper` and
+> `WatchedTypeTable` should both consume that surface.
+
+## Verdict
+
+The right model is layered:
+
+```text
+APIResourceCatalog
+ raw discovery observations, indexes, freshness, degradation
+
+Resolved type surface
+ core interface over trusted type facts:
+ exact GVK lookup, exact GVR lookup, one refusal vocabulary
+
+ResourceMapper
+ narrow GVK->GVR adapter for manifest analysis, CLI, tests
+
+WatchedTypeTable
+ per-GitTarget selected subset plus watch lifecycle
+```
+
+This keeps the nasty cluster details at the edge. Discovery wobble, missing
+catalog data, disallowed resources, and ambiguous type relationships are
+classified before lower-level code acts on them. The lower a caller gets, the more
+it should be able to trust what it receives.
+
+So the direction should be:
+
+1. Keep programming to interfaces.
+2. Keep `internal/watch` out of CLI and manifest analysis.
+3. Keep `WatchedTypeTable` as a GitTarget-specific operational table.
+4. Keep `CatalogMapper` as a narrow adapter/interface.
+5. Extract the shared type surface into core mapping code.
+6. Treat ambiguous GVK->GVR as a hard, observable refusal.
+
+## 1. What is shared
+
+Both the mapper and the watched table are built from the same type facts:
+
+```text
+GVK
+GVR
+namespaced
+verbs
+preferred
+subresource
+allowed
+```
+
+That shape already exists as `mapping.Entry` in
+[`internal/mapping/mapper.go`](../../../../internal/mapping/mapper.go).
+
+The shared abstraction is not "a table" and not "a mapper." It is a resolved view
+of API resource discovery:
+
+```text
+Given a GVK, either return the one allowed served GVR or a refusal.
+Given a GVR, either return the one allowed served type fact or a refusal.
+```
+
+Both entry points must apply the same product policy. In particular, exact GVR
+lookup must not become a bypass around ambiguous GVK detection.
+
+## 2. What is not shared
+
+`WatchedTypeTable` is not just a generic set of types today. It also carries
+GitTarget behavior:
+
+- selected rules and resource scope;
+- namespace operation filters;
+- pending removals and removal grace;
+- sweep safety;
+- informer/snapshot/per-type reconcile lifecycle;
+- GitTarget destination identity.
+
+Those concerns belong in `internal/watch`. They should not be imported by CLI,
+offline analysis, or lower-level manifest code.
+
+The reusable part is the type fact and the resolution contract underneath the
+table.
+
+## 3. The interface boundary
+
+The important move is to program against a small core interface rather than against
+the live watch implementation.
+
+Sketch:
+
+```go
+type TypeSurface interface {
+ Ready() MapperReadiness
+ Generation() uint64
+
+ ForGVK(ctx context.Context, gvk schema.GroupVersionKind) (Result, error)
+ ForGVR(ctx context.Context, gvr schema.GroupVersionResource) (Result, error)
+}
+```
+
+The concrete implementations can vary:
+
+- live catalog in the controller;
+- kubeconfig-backed discovery for CLI;
+- static snapshot for tests/offline review;
+- structure-only implementation that declines mapping.
+
+Callers should not care which implementation they received. That is the point of
+the abstraction.
+
+`ResourceMapper` can remain as the narrower interface for callers that only need
+manifest GVK->GVR resolution:
+
+```go
+type ResourceMapper interface {
+ GVRForGVK(ctx context.Context, gvk schema.GroupVersionKind) (Result, error)
+}
+```
+
+That narrow adapter is still useful. It keeps manifest analysis from knowing about
+watch rules, informers, or discovery internals.
+
+## 4. Ambiguity policy
+
+Product policy:
+
+> A cluster that serves one GVK through multiple GVRs is misconfigured for GitOps
+> Reverser. We do not pick a winner. We do not serve that type.
+
+The code must still detect and report this condition, but it should not model it
+as a valid operating mode.
+
+This matters for exact GVR lookup.
+
+It is true that `APIResourceCatalog` has a single entry for an exact GVR. But that
+does not prove the type is acceptable. If that GVR's GVK is globally ambiguous, the
+selected GVR must still be refused.
+
+Correct `ForGVR` behavior:
+
+```text
+1. Look up the exact GVR.
+2. If missing, return unavailable/degraded/unserved as appropriate.
+3. Read the entry's GVK.
+4. Resolve that GVK against the full catalog.
+5. Accept only if the GVK resolves to exactly this GVR.
+6. Otherwise return the same refusal status the GVK path would return.
+```
+
+This prevents a GitTarget rule from accidentally selecting one side of an
+ambiguous cluster shape and making it look safe.
+
+Current caveat: `WatchedTypeTable` detects conflicts among selected GVRs. That is
+narrower than the product policy above. If only one ambiguous GVR is selected, the
+table may not currently observe the global ambiguity. A shared type surface should
+close that gap.
+
+## 5. Status vocabulary
+
+There should be one core vocabulary for catalog/type lookup outcomes:
+
+```text
+Resolved
+Unserved
+Ambiguous
+Disallowed
+Subresource
+CatalogUnavailable
+DiscoveryDegraded
+StructureOnly
+```
+
+That vocabulary belongs with the resolved type surface.
+
+But watch-rule planning has extra domain concepts that should not be forced into
+the core lookup vocabulary:
+
+- wildcard expansion;
+- omitted apiGroups matching multiple groups;
+- version preference;
+- scope filtering;
+- list/watch support;
+- operation-specific planning messages.
+
+So do not blindly collapse `ResolveMissReason` into `mapping.Status`.
+
+Better:
+
+```text
+mapping.Status
+ core type lookup outcome
+
+watch.ResolveMiss
+ watch-rule planning miss, optionally carrying a mapping.Status plus
+ rule-specific detail
+```
+
+That keeps lower-level mapping clean while allowing higher-level watch code to
+explain rule failures precisely.
+
+## 6. Layer responsibilities
+
+### APIResourceCatalog
+
+Owns raw discovery facts:
+
+- indexes by GVK and GVR;
+- catalog readiness;
+- degraded group/version state;
+- generation.
+
+It can be messy because Kubernetes discovery is messy.
+
+### Resolved type surface
+
+Owns product policy over those facts:
+
+- exact GVK resolution;
+- exact GVR validation;
+- allowed/disallowed filtering;
+- subresource refusal;
+- ambiguity refusal;
+- trusted absence versus degraded absence.
+
+It returns normalized results. Consumers should not need to interpret catalog
+internals.
+
+### ResourceMapper
+
+Owns the manifest-facing interface:
+
+- manifest GVK -> served GVR;
+- no dependency on `internal/watch`;
+- usable by CLI, tests, controller, and structure-only analysis.
+
+It can be an adapter over the resolved type surface.
+
+### RuleGVRResolver
+
+Owns WatchRule selector expansion:
+
+- resource and group wildcards;
+- explicit or preferred versions;
+- namespaced versus cluster scope;
+- list/watch capability;
+- rule-specific miss details.
+
+It should call the resolved type surface when it needs to validate concrete type
+facts, but it should keep rule semantics in `internal/watch`.
+
+### WatchedTypeTable
+
+Owns the GitTarget-selected operational view:
+
+- watched types;
+- namespace operation filters;
+- conflicts and misses for visibility;
+- pending removals;
+- sweep and informer safety.
+
+It should be built from trusted type-surface results, not by reinterpreting raw
+catalog entries.
+
+## 7. What this rejects
+
+Do not move `WatchedTypeTable` into CLI. It is too operational and too coupled to
+GitTarget lifecycle.
+
+Do not delete `CatalogMapper`. The interface boundary is valuable, even if the
+implementation becomes a thin adapter.
+
+Do not add reverse mapping to delete planning. Delete planning should rely on the
+GitTarget folder inventory. If the inventory does not know the resource identity,
+we should not invent it at delete time.
+
+Do not let exact GVR lookup bypass global GVK ambiguity.
+
+Do not make lower layers parse string reasons from higher layers. Use typed
+statuses and typed misses.
+
+## 8. Recommended refactor path
+
+1. Add a core exact-GVR lookup/validation path beside `ResolveGVK`.
+2. Ensure exact GVR validation checks global GVK uniqueness.
+3. Keep `ResourceMapper` as the GVK-only interface for manifest analysis.
+4. Have the live catalog mapper implement the core surface internally.
+5. Have `buildWatchedTypeTable` consume validated surface results instead of raw
+ `LookupGVR(...).Entries[0]`.
+6. Let `RuleGVRResolver` keep rule-specific misses, but attach or translate from
+ core `mapping.Status` where appropriate.
+7. Add tests for the important policy case: one GVK served by two GVRs, only one
+ selected by the GitTarget, still refused.
+
+## 9. Design principle
+
+The lower layers should be boring.
+
+At the top, the system deals with Kubernetes discovery churn, partial catalogs,
+ambiguous clusters, rule syntax, and operator-facing diagnostics.
+
+At the bottom, the writer, analyzer, sweep, and per-type reconcile should operate
+on already-classified facts:
+
+```text
+this type is resolved and trusted
+this type is refused and observable
+this surface is degraded, so destructive work must stop
+```
+
+That is the reason to push the shared abstraction into core and program toward
+interfaces. It simplifies the lower layers without pretending the cluster is
+always clean.
+
+## References
+
+- [gvk-gvr-mapping-layer.md](../gvk-gvr-mapping-layer.md)
+- [per-type-reconcile-and-streaming-tail.md](per-type-reconcile-and-streaming-tail.md)
+- [`internal/mapping/mapper.go`](../../../../internal/mapping/mapper.go)
+- [`internal/watch/catalog_mapper.go`](../../../../internal/watch/catalog_mapper.go)
+- [`internal/watch/watched_type_table.go`](../../../../internal/watch/watched_type_table.go)
+- [`internal/watch/rule_gvr_resolver.go`](../../../../internal/watch/rule_gvr_resolver.go)
+- [`internal/watch/api_resource_catalog.go`](../../../../internal/watch/api_resource_catalog.go)
diff --git a/docs/design/manifest/version2/discovery-catalog-typeset-boundary.md b/docs/design/manifest/version2/discovery-catalog-typeset-boundary.md
new file mode 100644
index 00000000..049df327
--- /dev/null
+++ b/docs/design/manifest/version2/discovery-catalog-typeset-boundary.md
@@ -0,0 +1,411 @@
+# Discovery catalog and typeset boundary
+
+> Status: proposal, partly landed. Migration step 0 (the registry change signal, Stage 10)
+> and steps 1–3 (rule status moved onto the registry's followable set; `RuleGVRResolver` and
+> the dead catalog lookup/enumeration APIs deleted, Stage 11) have landed. Steps 4–6 are still
+> forward-looking. See
+> [type-followability-implementation.md](type-followability-implementation.md).
+>
+> Companion to [type-followability.md](type-followability.md),
+> [type-followability-implementation.md](type-followability-implementation.md), and
+> [type-followability-naming-proposal.md](type-followability-naming-proposal.md).
+
+## Question
+
+After the followability work, `internal/typeset` owns the type decision surface:
+identity uniqueness, followability checks, the live set, retention grace, and GVK
+lookup for the writer/analyzer path. That leaves
+[`APIResourceCatalog`](../../../../internal/watch/api_resource_catalog.go) looking
+smaller and a bit suspicious.
+
+Should the catalog be folded into `typeset`? Or is it still doing enough to stay?
+
+## Short answer
+
+Do not fold the catalog into `typeset`.
+
+Keep a small discovery-shaped catalog in `internal/watch`, but narrow it to raw
+Kubernetes discovery state and rename it when we do the broader naming pass:
+
+```text
+DiscoveryCatalog
+ Refresh(discovery client)
+ Ready()
+ Generation()
+ Stats()
+ DegradedGroupVersions()
+ Entries() or Observations(...)
+
+typeset / typeinventory
+ Entry
+ Observation
+ Registry
+ TypeRecord
+ Followability
+ Lookup
+```
+
+`typeset` should remain a leaf package. It should not depend on Kubernetes
+discovery clients, controller-runtime, dynamic informers, manager logging, or
+telemetry. The live manager owns those integrations and feeds neutral entries into
+`typeset`.
+
+The cleanup is still worthwhile: several catalog APIs are now only there because
+rule status still uses the old `RuleGVRResolver`.
+
+## Current production callers
+
+Search scope: `internal` and `cmd`, excluding `*_test.go`.
+
+| Caller | Uses | Current reason | Move to `typeset`? |
+| --- | --- | --- | --- |
+| `Manager.RefreshAPIResourceCatalog` | `Refresh`, `Stats`, `logCatalogTransitions` | Pulls Kubernetes discovery, records refresh metrics, rebuilds registry after a successful scan. | No. Discovery and metrics integration stay in watch/manager. |
+| `Manager.logCatalogTransitions` | `Ready`, `DegradedGroupVersions` | Edge-triggered logs for first ready catalog and degraded/recovered group versions. | No for degraded GV logs; maybe use registry stats for known/followable counts. |
+| `recordCatalogStats` | `CatalogStats` | Emits API catalog gauges. | Partially. Raw discovery counts stay catalog; policy/refusal/followability counts should move to registry/type stats. |
+| `Manager.refreshTypeRegistry` | `Ready`, `Observations`, `Generation` | Converts current catalog scan to `typeset.Observation` and publishes `Registry`. | Keep as boundary adapter, but make catalog provide raw entries and let the adapter apply product policy. |
+| `RuleGVRResolver` | `entriesForResource`, `entriesForGroup`, `entriesForGroupResource`, `allEntries`, `hasDegradedLookup`, `Ready` | Old WatchRule/ClusterWatchRule status resolver. | Yes. Replace with registry-backed rule projection/status. |
+| `Manager.ResolveWatchRuleResources` | `NewRuleGVRResolver` | Status feedback for one namespaced WatchRule. | Yes. Should use the same registry/type selection as actual informers/snapshots. |
+| `Manager.ResolveClusterWatchRuleResources` | `NewRuleGVRResolver` | Status feedback for one ClusterWatchRule. | Yes. Same as above. |
+| `Manager.ReconcileForRuleChange` | `RefreshAPIResourceCatalog` | Refreshes discovery before computing target type sets. | No. It should keep asking the manager to refresh discovery+registry. |
+| `resolveSnapshotGVRs` | `RefreshAPIResourceCatalog`, then registry readiness | Fails closed before snapshotting. | No direct catalog call is fine at manager level; snapshot should keep reading registry after refresh. |
+
+## APIs that were obsolete after rule status moved (done — Stage 11)
+
+These catalog methods had no production caller except the old `RuleGVRResolver`, or no
+production caller at all. With rule status moved onto the registry (Stage 11) they are gone:
+
+| API | Old state | Fate |
+| --- | --- | --- |
+| `Entry` | No production caller found. | **Deleted.** |
+| `CatalogLookup`, `LookupGVK`, `LookupGVR` | No production caller found; leftover from mapper era. | **Deleted.** `typeset.Registry.ByGVK` is the live lookup surface. |
+| `GroupVersionDegraded` | No production caller found. | **Deleted.** A mapper-era leftover with no caller once rule status stopped probing the catalog. |
+| `DegradedGroupVersions` | Operator degraded/recovered log + catalog stats. | **Kept** — for the operator-facing degraded-group/version log line and the catalog gauges, *not* for rule status. |
+| `entriesForResource`, `entriesForGroup`, `entriesForGroupResource`, `allEntries` | Only `RuleGVRResolver`. | **Deleted.** |
+| `hasDegradedLookup` | Only `RuleGVRResolver`. | **Deleted** (not re-homed). Rule status reports only the followable types a rule watches; it raises no per-selector "discovery degraded" diagnostic — see "Rule status resolution" below. |
+| `byGVK`, `byResource`, `byGroupRes` indexes | Only supported the obsolete lookups/resolver. | **Deleted.** |
+
+After this cleanup the catalog keeps only its group/version-keyed raw scan (`byGroupVer`), the
+derived `byGVR` index it feeds to `typeset`, and group/version trust state.
+
+## What should not move
+
+### Discovery refresh
+
+`typeset` should not call `ServerGroupsAndResources()`. That would pull live
+cluster IO and Kubernetes discovery error handling into a package that is currently
+usable by:
+
+- the live watch manager,
+- the no-cluster manifest analyzer,
+- tests and snapshot fixtures,
+- the git writer's `typeset.Lookup`.
+
+That leaf shape is valuable. Keep discovery IO in `internal/watch`.
+
+### Partial discovery preservation
+
+The catalog currently preserves the last trusted entries for group/versions that
+discovery reports as failed, and separately marks those group/versions degraded.
+That is scan mechanics, not product followability. `typeset` should receive this as
+entry facts:
+
+```go
+typeset.Entry{
+ GVK: ...
+ GVR: ...
+ Degraded: true,
+}
+```
+
+Then `typeset` can decide `trusted -> discovery-degraded` or `retained`, but it
+does not need to know how Kubernetes discovery produced that state.
+
+### Refresh metrics and discovery logs
+
+Metrics like refresh duration, refresh changed/unchanged/error, and degraded
+group/version transitions belong near discovery refresh. They are about scanning
+the API server, not about type followability.
+
+## What should move
+
+### Rule status resolution — done (Stage 11)
+
+`ResolveWatchRuleResources` and `ResolveClusterWatchRuleResources` no longer use
+`RuleGVRResolver`; they report the same decision the actual watch/snapshot path uses,
+because they read the same surface.
+
+Before, two paths could disagree:
+
+```text
+actual watching: registry.Followable() -> match rule selectors -> TargetTypeSet -> informers/snapshots
+rule status: APIResourceCatalog -> RuleGVRResolver -> ResolveMiss -> status text
+```
+
+That split could lie — a rule could read "resolved" through `RuleGVRResolver` while the
+registry refused the same type (identity, origin, scale, sensitivity, a stricter verb
+requirement), or the reverse. Now both paths share one matcher:
+
+```text
+registry.Followable() -> matchFollowableRecords(rule selector) -> watched types
+```
+
+`matchFollowableRecords` (in `internal/watch/watched_type_resolver.go`) is the single
+rule-matching surface, used by both the per-GitTarget watched-type tables and rule status,
+so the status answer and the active informer/snapshot answer cannot drift.
+
+**Report only what is watched — no refusal taxonomy, no degraded diagnostic.** The status is
+deliberately minimal: a rule's `ResourcesResolved` condition reports catalog readiness and how
+many distinct followable types the rule currently watches (`"watching N resource type(s)"`),
+and nothing more. It does *not* explain why an individual selector matched nothing — absent,
+denied-by-policy, verb-poor, ambiguous, and discovery-degraded are all the same to a mirror,
+and the application does not surface those distinctions. The only unresolved (`False`) case is
+a catalog that has not yet observed discovery (`"API resource catalog is not ready"`).
+
+This **reverses** the earlier proposal in this section, which kept a per-selector
+`DiscoveryDegraded` diagnostic and therefore wanted `hasDegradedLookup` re-homed onto the
+status projection. That diagnostic is gone — `hasDegradedLookup` was *deleted, not re-homed*.
+The full machine-readable "why is this type not followed?" answer still lives on the registry
+record (`Manager.TypeRecords()`) for anyone who needs it; it is simply not projected per rule
+selector into operator status. The catalog's `DegradedGroupVersions()` stays, but only for the
+manager's operator-facing degraded/recovered **log** line and the catalog gauges.
+
+### Policy application inside catalog entries
+
+`APIResourceEntry` currently stores `Allowed` and `PolicyReason`. That is product
+policy, not raw Kubernetes discovery.
+
+Better boundary:
+
+```text
+DiscoveryCatalog.Entry
+ GVK
+ GVR
+ Namespaced
+ Verbs
+ Preferred
+ Subresource
+ Degraded
+
+watch/catalog_observe.go adapter
+ applies allowedResource(...)
+ applies SensitiveResourcePolicy
+ builds typeset.Entry
+
+typeset
+ evaluates policy/sensitivity/followability
+```
+
+This keeps the catalog raw and makes the policy boundary explicit.
+
+### Allowed/excluded catalog metrics
+
+`CatalogStats` currently counts `AllowedResources` and `ExcludedResources`.
+Those are no longer pure catalog facts once policy moves out of the raw entry.
+
+Suggested split:
+
+| Metric family | Owner | Examples |
+| --- | --- | --- |
+| Discovery scan metrics | `DiscoveryCatalog` / manager | refresh outcome, duration, generation, trusted/degraded group versions, served top-level resource count |
+| Type decision metrics | `typeset.Registry` / manager | known records, followable/live records, refused records by first failing requirement/reason |
+
+That split names what is actually being measured.
+
+### The registry's own change signal (the gate the table watches) — landed
+
+This was the boundary leak that mattered most in practice, because it caused a real
+bug. It is the one part of this proposal already implemented (Stage 10); the rest of
+this document is still forward-looking.
+
+The per-GitTarget `WatchedTypeTable` is re-projected only on a deliberate trigger — a
+rule-set change or a "did the type surface change?" signal — so the common no-change
+reconcile is a cheap compare rather than a rescan. The question is *which* signal
+answers "did the type surface change?".
+
+That signal **used to be `registry.Generation()`**, which is just the catalog's
+generation passed straight through:
+
+```go
+reg.Update(catalog.Observations(...), catalog.Generation())
+```
+
+So a consumer of the decision surface (the table) was actually gated on the **scan
+layer's** counter — the boundary inversion this document set out to remove: the table
+should depend on the registry, not on how Kubernetes discovery counts revisions.
+
+It was not only inelegant — it was **incorrect at the retention-grace boundary.** The
+grace is owned by the registry and is time-based, so a type can leave the live set
+*without any discovery change*:
+
+```text
+t0 discovery serves T catalog gen = N T followable
+t1 discovery drops T catalog gen = N+1 T retained (grace running) <- gen moved, table re-projects, keeps T
+t1..t60 discovery stable catalog gen = N+1 T still retained
+t61 grace elapses catalog gen = N+1 T dropped from Followable() <- gen did NOT move
+```
+
+At `t61` the followable set changed but the catalog generation did not, so a
+generation-gated table never re-projected: the dropped type lingered in the table, its
+informer kept listing a resource the server no longer served, and the target's
+mark-and-sweep snapshot failed closed against a phantom GVR. The old `watchedTypeStore`
+removal grace hid this by re-judging absence itself; once the grace moved into the
+registry, the gate had to watch a registry-owned signal instead.
+
+**Implemented: the registry exposes `Revision()` — the decision-surface analog of the
+catalog's `Refresh() (changed, generation)`.**
+
+```go
+// Update bumps an internal revision whenever the *followable membership* changes
+// (a type appears, drops after grace, or flips followable<->refused) or the backing
+// scan generation moves. It is the registry's "something a consumer cares about
+// changed" signal — independent of how discovery counts generations.
+func (r *Registry) Update(obs []Observation, generation uint64)
+func (r *Registry) Revision() uint64
+```
+
+The `WatchedTypeTable` gate is now `(registry.Revision(), rulesFingerprint)` instead of
+`(catalog generation, rulesFingerprint)`. The payoff, confirmed in practice:
+
+- **Correctness:** the revision bumps exactly when a retained type leaves the live set,
+ so the grace-drop re-projects the table and stops the phantom informer — no separate
+ watched-type-layer absence tracking required. (`internal/typeset/registry_test.go`
+ `TestRegistry_RevisionBumpsOnGraceDropAtStableGeneration` locks this in.)
+- **Clean boundary:** the table depends only on the decision surface. The catalog's
+ generation is now a pure scan-layer detail (still passed into `Update` as the
+ `ResolvedAt` stamp), and the registry exposes its own change-of-decision signal. Each
+ layer reports change in its own terms.
+- **No new cost:** the revision also bumps on generation change, so steady-state
+ re-projection frequency is unchanged from the generation-gated version; the grace case
+ only adds the occasional extra bump it was previously missing.
+
+The companion safety — `resolveSnapshotGVRs` failing closed while a watched type is
+`retained` (currently unserved, mid-grace) rather than streaming a phantom GVR — landed
+alongside it, re-expressing the old pending-removal fail-closed in the registry's
+verdict vocabulary.
+
+`Generation()` stays available (still useful as the `ResolvedAt` stamp and for
+diagnostics); `Revision()` is the thing the gate watches.
+
+## Proposed final shape
+
+### `internal/watch/discovery_catalog.go`
+
+Raw discovery cache:
+
+```go
+type DiscoveryCatalog struct {
+ byGroupVersion map[schema.GroupVersion][]DiscoveryEntry
+ groupVersion map[schema.GroupVersion]DiscoveryGroupVersionState
+ generation uint64
+ ready bool
+}
+
+type DiscoveryEntry struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+ Namespaced bool
+ Verbs []string
+ Preferred bool
+ Subresource bool
+ Degraded bool
+}
+```
+
+Public-ish surface:
+
+```go
+func (c *DiscoveryCatalog) Refresh(d apiResourceDiscovery) (changed bool, err error)
+func (c *DiscoveryCatalog) Ready() bool
+func (c *DiscoveryCatalog) Generation() uint64
+func (c *DiscoveryCatalog) Entries() []DiscoveryEntry
+func (c *DiscoveryCatalog) Stats() DiscoveryStats
+func (c *DiscoveryCatalog) DegradedGroupVersions() []schema.GroupVersion
+```
+
+No GVK/GVR lookup helpers. No resource selector expansion. No product policy.
+
+### `internal/watch/catalog_observe.go`
+
+Boundary adapter:
+
+```go
+func observationsFromDiscoveryCatalog(
+ catalog *DiscoveryCatalog,
+ sensitive types.SensitiveResourcePolicy,
+) []typeset.Observation
+```
+
+Responsibilities:
+
+- copy raw discovery entries,
+- apply default resource policy,
+- apply configured sensitive-resource policy,
+- call `typeset.ObservationsFromEntries`.
+
+### `internal/typeset`
+
+Decision surface:
+
+```go
+Registry.Update([]Observation, generation)
+Registry.All()
+Registry.Followable()
+Registry.ByGVK()
+Registry.Revision() // change-of-decision signal the TargetTypeSet gate watches
+```
+
+Potential additions for rule status:
+
+```go
+type Selector struct {
+ APIGroups []string
+ APIVersions []string
+ Resources []string
+ Scope Scope
+}
+
+func MatchRecords(records []TypeRecord, selector Selector) MatchResult
+```
+
+Whether this helper belongs in `typeset` or `internal/watch` depends on whether
+we want `typeset` to know WatchRule selector semantics. The conservative choice is
+to keep selector matching in `internal/watch` but make it consume only
+`typeset.TypeRecord` values.
+
+## Migration plan
+
+0. ✅ **Done (Stage 10).** Gave `Registry` a `Revision()` and gated the `WatchedTypeTable`
+ on it instead of the catalog generation. This was the smallest, highest-value step: it
+ fixed the retention-grace lingering-type bug and removed the table's dependency on the
+ scan counter. It was independent of the rule-status work below.
+1. ✅ **Done (Stage 11).** Moved WatchRule and ClusterWatchRule status onto registry-backed
+ matching (`matchFollowableRecords` over `registry.Followable()`). The status now reports
+ only what the rule watches — catalog readiness plus a followable-type count — and drops the
+ refusal taxonomy entirely. The degraded-group/version diagnostic was *deliberately dropped*,
+ not preserved: the application does not surface per-selector discovery-degraded reasons (see
+ "Rule status resolution" above). This reverses the earlier sub-proposal to re-home
+ `hasDegradedLookup`.
+2. ✅ **Done (Stage 11).** Deleted `RuleGVRResolver` (and the `ResolveMiss` reason vocabulary).
+3. ✅ **Done (Stage 11).** Deleted the catalog lookup/enumeration APIs and indexes that only
+ existed for the old resolver (`Entry`, `CatalogLookup`/`LookupGVK`/`LookupGVR`,
+ `GroupVersionDegraded`, `hasDegradedLookup`, `entriesFor*`/`allEntries`, and the
+ `byGVK`/`byResource`/`byGroupRes` indexes).
+4. Move `Allowed`/`PolicyReason` out of `APIResourceEntry` and into the observation
+ adapter.
+5. Split catalog stats into raw discovery stats and registry/type decision stats.
+6. Rename `APIResourceCatalog` to `DiscoveryCatalog` during the naming pass.
+
+## Recommendation
+
+Keep the boundary:
+
+```text
+DiscoveryCatalog: what Kubernetes discovery said and how trustworthy the scan is.
+typeset/typeinventory: what GitOps Reverser decides about each type.
+TargetTypeSet: what one GitTarget selected from the followable/live records.
+```
+
+Most callers should not move to `typeset`; they should move to the manager's
+registry-backed view. The one big exception is rule status: it should stop reading
+raw catalog entries and start reporting the same registry decisions that drive the
+actual watchers.
diff --git a/docs/design/manifest/version2/double-repo-detection.md b/docs/design/manifest/version2/double-repo-detection.md
new file mode 100644
index 00000000..88881183
--- /dev/null
+++ b/docs/design/manifest/version2/double-repo-detection.md
@@ -0,0 +1,494 @@
+Here is a strong prompt you can paste into your agent.
+
+You are working in the ConfigButler codebase.
+
+I want you to write a design document for a change around detecting duplicate Git destinations / Git upstreams.
+
+Context:
+ConfigButler has a concept called GitDestination. A GitDestination represents a Git upstream repository that ConfigButler can write to, read from, or otherwise use as a configured target. Users may configure the same repository multiple times through different URLs, for example over SSH and HTTPS:
+
+- git@github.com:ConfigButler/gitops-reverser.git
+- ssh://git@github.com/ConfigButler/gitops-reverser.git
+- https://github.com/ConfigButler/gitops-reverser
+- https://github.com/ConfigButler/gitops-reverser.git
+
+These can all refer to the same real repository. I want ConfigButler to detect these cases reliably where possible.
+
+Important insight:
+Do not assume that Git itself gives us a universal repository identity. It does not. A Git remote is basically an endpoint exposing objects and refs. There is no global built-in repository UUID that works across arbitrary Git servers.
+
+Also, do not treat the SHA of `main` or the default branch as a unique repository identifier. That only tells us something about current content. Different repositories can have the same default branch SHA, especially forks, mirrors, templates, empty repos, or recently copied repositories.
+
+The design should treat repository identity as a confidence-based classification problem, not a perfect equality check.
+
+Please inspect the existing codebase before writing the design. Look for:
+- The existing GitDestination CRD/schema.
+- Existing controllers/reconcilers touching GitDestination.
+- Existing status/conditions conventions.
+- Existing Git provider abstractions, Git authentication handling, URL parsing, cloning, ls-remote usage, or related packages.
+- Existing documentation style/design docs, if any.
+
+The task is to write a design document only. Do not implement the change yet.
+
+The design document should be written in Markdown and should be suitable for inclusion in the repository, for example under `docs/design/` or a similar location if one exists.
+
+The document should cover the following.
+
+# 1. Problem statement
+
+Explain the problem clearly:
+
+Users may configure the same Git repository multiple times using different remote URLs, protocols, credentials, ports, or provider-specific aliases.
+
+Examples:
+- SSH vs HTTPS for the same GitHub repo.
+- URLs with or without `.git`.
+- `git@host:owner/repo.git` vs `ssh://git@host/owner/repo.git`.
+- Canonical provider URLs vs vanity domains or redirects.
+- Self-hosted Git servers with non-standard ports.
+- Mirrors and forks that expose the same refs but are not the same repository identity.
+
+Explain why naive approaches are insufficient:
+- String equality of URLs misses common aliases.
+- DNS resolution is not enough because of CNAMEs, virtual hosting, redirects, SSH config, load balancers, and provider-specific routing.
+- Default branch SHA is only a content signal, not identity.
+- Full refs equality is stronger than default branch SHA, but still represents “same exposed content,” not necessarily “same repository.”
+- Provider APIs may expose authoritative IDs, but only for recognized providers and when credentials allow access.
+
+# 2. Goals
+
+The design should aim to:
+- Detect high-confidence duplicate GitDestinations.
+- Detect possible duplicates or mirrors without making unsafe claims.
+- Support both SSH and HTTPS remotes.
+- Work across common providers such as GitHub, GitLab, Gitea, and possibly Bitbucket where feasible.
+- Work reasonably for arbitrary/self-hosted Git servers.
+- Avoid false-positive hard failures where two distinct repositories merely share content.
+- Surface duplicate information clearly in status.
+- Keep the user in control for ambiguous cases.
+- Make the implementation incremental.
+
+# 3. Non-goals
+
+Explicitly state non-goals:
+- We are not trying to prove global Git repository identity for arbitrary Git remotes.
+- We are not relying solely on DNS canonicalization.
+- We are not rejecting GitDestinations purely because the default branch SHA matches.
+- We are not requiring every provider to have a resolver on day one.
+- We are not trying to understand all possible SSH client config aliases from the user’s local machine.
+- We are not cloning full repositories just for identity detection unless the existing system already does so for another reason.
+
+# 4. Identity model
+
+Design a confidence-based identity model.
+
+Suggested levels:
+
+- `Authoritative`
+ - Same provider-native repository/project ID.
+ - Example: GitHub repository ID/node ID, GitLab project ID, Gitea repository ID.
+ - This is the strongest signal.
+
+- `Strong`
+ - Same normalized canonical remote URL under provider-specific normalization rules.
+ - Example: GitHub SSH and HTTPS URLs normalize to the same host/owner/repo key.
+ - This is strong but less authoritative than provider ID.
+
+- `Medium`
+ - Same complete remote refs fingerprint.
+ - Computed from `git ls-remote` output over advertised refs.
+ - Indicates the remotes currently expose the same content/refs.
+ - Could mean same repo, mirror, fork, or copied repo.
+
+- `Weak`
+ - Same default branch name and default branch SHA.
+ - Useful as a clue only.
+ - Should not produce a hard duplicate classification by itself.
+
+- `None` / `Unknown`
+ - Insufficient data, inaccessible remote, unsupported provider, authentication failure, etc.
+
+The design should be careful with terms:
+- “same repository” should only be used for authoritative or very high-confidence cases.
+- “same content” or “possible duplicate” should be used for medium/weak signals.
+- Avoid claiming certainty when the system only has a fingerprint.
+
+# 5. Suggested status shape
+
+Propose a status structure for GitDestination or a related status object.
+
+Possible shape:
+
+```yaml
+status:
+ identity:
+ observedGeneration: 3
+ provider: github
+ providerRepositoryId: "123456789"
+ canonicalRemoteUrl: "https://github.com/ConfigButler/gitops-reverser.git"
+ normalizedRemoteKey: "github.com/configbutler/gitops-reverser"
+ defaultBranch: main
+ defaultBranchSha: "abc123..."
+ refsFingerprint: "sha256:..."
+ confidence: Authoritative
+ lastResolvedAt: "2026-06-05T10:00:00Z"
+
+ duplicateDetection:
+ duplicates:
+ - name: github-ssh
+ namespace: configbutler-system
+ reason: SameProviderRepositoryId
+ confidence: Authoritative
+ possibleDuplicates:
+ - name: mirror-over-https
+ namespace: configbutler-system
+ reason: SameRefsFingerprint
+ confidence: Medium
+
+This is only a suggested shape. Please inspect existing CRD/status conventions and propose a shape that fits the codebase.
+
+Also consider Kubernetes conditions, for example:
+
+IdentityResolved
+DuplicateDetected
+PossibleDuplicateDetected
+IdentityResolutionFailed
+
+The status should be machine-readable and human-readable.
+
+6. URL normalization
+
+Design URL parsing and normalization.
+
+Support at least these forms:
+
+git@github.com:owner/repo.git
+ssh://git@github.com/owner/repo.git
+ssh://git@github.com:22/owner/repo.git
+https://github.com/owner/repo
+https://github.com/owner/repo.git
+
+Normalization should likely include:
+
+Lower-casing known provider hostnames.
+Removing default ports where safe, for example SSH 22 and HTTPS 443.
+Removing trailing .git where provider semantics allow it.
+Normalizing SCP-like SSH syntax into URI-like components.
+Preserving non-default ports.
+Preserving path case for unknown/self-hosted providers unless a provider-specific rule says otherwise.
+Avoiding unsafe assumptions for arbitrary Git servers.
+
+Provider-specific normalization may be needed:
+
+GitHub owner/repo matching can be normalized more aggressively.
+GitLab/Gitea self-hosted instances may need more conservative rules.
+Unknown Git servers should use conservative normalization.
+
+Please discuss edge cases:
+
+Case sensitivity.
+Ports.
+Nested GitLab paths/groups.
+URL redirects.
+Vanity domains.
+SSH aliases.
+Credential/userinfo in HTTPS URLs.
+Different usernames in SSH URLs.
+Same host/path but different auth scopes.
+7. Provider-native identity resolvers
+
+Design an interface for provider identity resolution.
+
+Possible interface concept:
+
+type RepositoryIdentityResolver interface {
+ Supports(remote ParsedRemote) bool
+ Resolve(ctx context.Context, remote ParsedRemote, auth GitAuth) (*ResolvedRepositoryIdentity, error)
+}
+
+The exact interface should fit the existing codebase.
+
+Provider resolvers should attempt to return:
+
+Provider name.
+Provider repository/project ID.
+Canonical clone URL if available.
+Default branch if available.
+Visibility/permissions if useful.
+Provider-specific metadata needed for stable identity.
+
+Start with the providers that are realistic for the codebase. Likely:
+
+GitHub
+GitLab
+Gitea
+
+For each provider, discuss whether the repository ID is authoritative and how it can be fetched:
+
+Through provider API if credentials are available.
+Possibly through unauthenticated API for public repositories.
+Fallback to URL normalization and ls-remote when API access is unavailable.
+
+Do not overpromise. The design should explicitly say that provider identity resolution may be unavailable due to missing credentials, unsupported provider, permissions, or network errors.
+
+8. Git remote fingerprinting
+
+Design a fallback fingerprint based on git ls-remote.
+
+Possible data to collect:
+
+Symbolic HEAD, via git ls-remote --symref HEAD.
+Default branch name.
+Default branch SHA.
+Advertised refs, via git ls-remote .
+Branch refs.
+Tag refs.
+Optionally whether peeled tags are included and how they are normalized.
+
+Possible fingerprint:
+
+refsFingerprint = sha256(sorted(refname + "\x00" + sha + "\n"))
+
+The design should specify:
+
+Which refs are included.
+Whether to include tags.
+How to handle peeled annotated tags like refs/tags/v1.0^{}.
+How to handle hidden refs.
+How to handle authorization differences where different credentials expose different refs.
+How to handle empty repositories.
+How to handle remote failures.
+How often to refresh the fingerprint.
+Whether to debounce/retry.
+Whether the fingerprint should be used for status only or also for duplicate detection.
+
+Important:
+A refs fingerprint is not repository identity. It means “these remotes currently expose the same refs.” Treat this as a medium-confidence possible duplicate/mirror signal.
+
+9. Duplicate classification algorithm
+
+Propose an algorithm along these lines:
+
+Parse and normalize the configured remote URL.
+Build a conservative normalized remote key.
+Try provider-specific identity resolution.
+Run git ls-remote to determine default branch and refs fingerprint, if credentials and network allow.
+Store the resolved identity data in status.
+Compare this GitDestination with other GitDestinations in the same relevant scope.
+Classify matches:
+Same provider and same provider repo ID => duplicate, authoritative.
+Same normalized remote key => duplicate/probable duplicate, strong.
+Same full refs fingerprint => possible duplicate/mirror, medium.
+Same default branch SHA only => possible related repository, weak; probably do not report prominently unless useful.
+Set conditions/status accordingly.
+Do not block reconciliation for weak/medium matches unless the user explicitly enables a stricter policy.
+
+Please define the scope of comparison:
+
+Same namespace?
+Cluster-wide?
+Same tenant/workspace if ConfigButler has such a concept?
+Same GitProvider?
+Same credentials?
+Same GitTarget?
+
+Pick the safest default based on the codebase.
+
+10. User-facing behavior
+
+Design how users should experience this.
+
+Important:
+
+High-confidence duplicates may be surfaced as a warning or condition.
+Medium/weak matches should be advisory, not fatal.
+The user should be able to intentionally configure aliases/mirrors without fighting the controller.
+There may need to be an explicit override or grouping field.
+
+Consider a field like:
+
+spec:
+ identity:
+ allowDuplicateOf: some-other-destination
+
+or:
+
+spec:
+ identity:
+ aliasGroup: configbutler-main
+
+or:
+
+spec:
+ duplicatePolicy: WarnOnly | RejectAuthoritativeDuplicates | Ignore
+
+Do not introduce this unless it fits the project style. Discuss options and recommend one.
+
+Possible default:
+
+Always warn on authoritative duplicates.
+Do not reject by default.
+Allow future policy to reject authoritative duplicates through validation/admission or controller policy.
+Never reject on default-branch SHA alone.
+11. API and CRD changes
+
+Propose concrete CRD/schema changes.
+
+Include:
+
+New status fields.
+New conditions.
+Optional spec fields, if needed.
+Backward compatibility implications.
+Whether conversion webhooks are needed.
+Whether existing GitDestinations need migration.
+Whether existing status fields can be reused.
+
+Keep the spec minimal. Prefer status-first unless there is a strong reason for user-configurable behavior.
+
+12. Security considerations
+
+Discuss:
+
+Do not leak credentials in status.
+Strip username/password/token from URLs before storing canonical/normalized URLs.
+Be careful with SSH usernames.
+Provider APIs may reveal private repo metadata; only store safe fields.
+Refs may reveal branch names; status may expose branch names to users who can read the CR.
+Different credentials may see different refs, so identity can be auth-context-dependent.
+Avoid making network calls from admission webhooks if that would make writes slow or fragile.
+Prefer controller reconciliation for remote identity resolution.
+13. Reliability and performance
+
+Discuss:
+
+git ls-remote is network-bound and can fail.
+Provider APIs can rate-limit.
+DNS/HTTP/SSH can be flaky.
+Identity resolution should be retried with backoff.
+Results should be cached in status.
+Reconciliation should not hammer Git providers.
+The controller should use timeouts.
+Duplicate comparison should be efficient, possibly using indexes if controller-runtime supports it in this codebase.
+Fingerprints should only be refreshed when relevant inputs change, or on a sensible interval.
+14. Failure modes
+
+Include a section with failure modes:
+
+Remote unavailable.
+Authentication failed.
+Provider API unavailable.
+Provider API says repo not found but git ls-remote works.
+URL normalization fails.
+Empty repo.
+Default branch missing.
+Different credentials expose different refs.
+Same repo reachable through a vanity domain.
+Same content in two different repos.
+Mirror intentionally configured.
+Fork initially identical to upstream.
+Repo transferred/renamed.
+GitHub/GitLab redirects.
+Self-hosted provider with unusual path semantics.
+
+For each, describe expected behavior.
+
+15. Testing plan
+
+Design tests.
+
+Unit tests:
+
+URL parser and normalizer.
+SCP-style SSH parsing.
+HTTPS parsing.
+.git suffix behavior.
+ports.
+case sensitivity.
+provider-specific path normalization.
+refs fingerprint calculation.
+duplicate classification.
+
+Integration tests:
+
+Fake Git server or local bare repos.
+Same repo exposed via multiple URLs if feasible.
+Two repos with identical refs.
+Fork-like repo with same default branch SHA.
+Repos that diverge after initial fingerprint.
+Auth failure.
+Empty repo.
+
+Controller tests:
+
+Status updates.
+Conditions.
+Duplicate detection across multiple GitDestinations.
+Reconciliation retry behavior.
+No hard failure for weak/medium matches by default.
+
+E2E tests:
+
+Use existing e2e framework if present.
+Prefer local/self-contained Git server such as Gitea if the project already uses it.
+Test SSH and HTTPS remotes if possible.
+16. Rollout plan
+
+Propose an incremental rollout:
+
+Add URL parser/normalizer.
+Add status fields for normalized key and basic remote info.
+Add ls-remote based fingerprint.
+Add duplicate detection based on normalized key and fingerprint.
+Add provider resolvers for one provider first, likely GitHub or Gitea depending on existing e2e setup.
+Add policy/override fields only after real behavior is understood.
+17. Alternatives considered
+
+Discuss alternatives and why they are insufficient:
+
+Use only URL string equality.
+Use only normalized URL.
+Use only default branch SHA.
+Use only full refs fingerprint.
+Always clone the repo and inspect object database.
+Rely on DNS canonicalization.
+Rely only on provider APIs.
+Hard reject all apparent duplicates.
+18. Recommended decision
+
+End with a concrete recommendation.
+
+The recommendation should likely be:
+
+Implement a status-first identity system.
+Use provider-native repository IDs as authoritative where available.
+Use conservative URL normalization as a strong signal.
+Use git ls-remote refs fingerprint as a medium-confidence “same exposed content” signal.
+Use default branch SHA only as a weak diagnostic signal.
+Do not reject duplicates by default.
+Surface authoritative duplicates and possible duplicates clearly in status/conditions.
+Keep room for future policy enforcement.
+19. Open questions
+
+List open questions that need project-owner input, such as:
+
+Should duplicate detection be namespace-scoped or cluster-scoped?
+Should authoritative duplicates eventually be rejected?
+Should mirrors be explicitly modelled?
+Which providers should be supported first?
+Should identity resolution be tied to GitProvider credentials?
+Should users be able to set a manual identity/alias group?
+How much remote metadata is acceptable to expose in status?
+
+Output requirements:
+
+Produce a complete Markdown design document.
+Be specific and concrete.
+Do not invent existing code details. If something is unknown, say what needs to be inspected or verified.
+Prefer a design that can be implemented incrementally.
+Include YAML examples where useful.
+Include pseudocode for the classification algorithm.
+Use the terminology GitDestination unless the codebase uses a different exact name.
+Keep the design honest: arbitrary Git repository identity cannot be solved perfectly.
+
+I’d keep the prompt this opinionated. Otherwise an agent will often drift toward the tempting-but-wrong “compare URLs
\ No newline at end of file
diff --git a/docs/design/manifest/version2/dream.md b/docs/design/manifest/version2/dream.md
new file mode 100644
index 00000000..cfcaa96e
--- /dev/null
+++ b/docs/design/manifest/version2/dream.md
@@ -0,0 +1,24 @@
+I have a 'dream' on the initial GitTarget reconcile. It consists out of a few things:
+
+* I believe that we should have a more fixed GKV GKR lookup table per GitTarget, off course it should change, but it should be a consise step.
+* I really would like to split the reconcile into every bound GKV, so if we have 5 gkvs that we are tracking: we will have have 5 reconcile actions. One for every gkv. Once a reconicle is done the tracking is starting: that helps in multiple situations.
+ * We can do them one by one, having one reconile commit per type
+ * We can immediatly start tracking changes once the initial type is done
+ * If new types are added or removed from the GitTarget GKV table then we reconcile that sepeate, without needing a completely new reconicle
+ * We might be able to even do one 'reconcile' on the kubernetes side (one LIST or InitialSendEvents) and 'stream' that to all GitTargets that need it.
+
+There is also a technical improvement in the Kubernetes API that makes this more approachale: within a resource type you can now trust the RV to be increasing over time. This is really nice since this also would give the option to even pickup events from during the resync. Especially for longer syncs (bigger sets of resources) that would be a very nice property. I would look at it as merging two datastreams: the initial send (the fake 'added' events from the watch) and when thats finished, picking up the first event that has an higher RV. (you can pick up the earlier events but you should drop them off course).
+
+I see some consequenes:
+* More than one commit (which is fine and even more readable I guess?) And perhaps we can also use the normal mechanism to get quick reconciles into one (that we also do for multiple events).
+* We should be carefull now with multiple commits / writers (I guess that it still should go on the branchworkers queue so that it all is in order)
+* I'm now sketing a scenario with bigger amounts of resources: we might want to create a good e2e test for this as well, and then als add some metrics on that specific situation.
+* This gives a bit of breathing room for wobbly types in the Kubernetes API that are 'not reachable' or whatever: we now can just sync the types that are stable. And only have troubles with types that are not stable (which also is understandable and not the fault of gitops-reverser)
+* Perhaps: we can even drop the whole hash thing? Since we now can trust the RV to be correct? I guess that all that hashing of content is not really good for our CPU usage... Also the hashing for the whole group etc. I would hope that we can drop some complexity because of this.
+
+Visibility:
+* I would love it to have more overview of the actual data inside a GitTarget: which resource types do we exactly follow. Which exact CRD version is behind something, etc. It would be very good to have some API for that, not sure if we could push it to the status since it could become to big?
+* I would love to see the sync state and perhaps even a total synced counter per type. Not sure here: it's a lot of data, but it's also a lot of 'darkness' now for a new user if we don't provide anything.
+* Off course metrics are also a good place to start for anyone curious: and most admins/devs do that these days as well
+
+
diff --git a/docs/design/manifest/version2/gittarget-new-file-placement-rules.md b/docs/design/manifest/version2/gittarget-new-file-placement-rules.md
new file mode 100644
index 00000000..ef2a22f5
--- /dev/null
+++ b/docs/design/manifest/version2/gittarget-new-file-placement-rules.md
@@ -0,0 +1,741 @@
+# GitTarget new-file placement rules
+
+> Status: proposed
+> Captured: 2026-06-05
+> Related:
+> [gittarget-repository-validity-and-placement.md](gittarget-repository-validity-and-placement.md),
+> [current-manifest-support-review.md](../current-manifest-support-review.md),
+> [manifestedit-new-file-placement-spike.md](../manifestedit-new-file-placement-spike.md),
+> [reconcile-via-watchlist-mark-and-sweep.md](../reconcile-via-watchlist-mark-and-sweep.md),
+> [per-type-reconcile-and-streaming-tail.md](per-type-reconcile-and-streaming-tail.md)
+
+## Summary
+
+New-resource placement should become an explicit GitTarget-level policy. There
+are two viable shapes:
+
+- **Option A: ordered rule lists** (`sensitiveRules` / `normalRules`), evaluated
+ top to bottom.
+- **Option B: type maps plus defaults** (`sensitive.byType` / `normal.byType`),
+ using exact GVR keys such as `v1/secrets` and `apps/v1/deployments`.
+
+The current recommendation is to ship the nested type-map API first. It covers
+the common "this type goes here, everything else goes there" case with less
+surface area, while keeping ordered rules as a future extension if exact type
+lookups are too limiting.
+
+Existing manifests are still match-first: once a resource already has a document
+in Git, updates and deletes use that document's current location instead of
+re-running placement.
+
+This keeps the useful part of the older `newFilePath` proposal, but makes the
+per-type policy explicit:
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: GitTarget
+spec:
+ providerRef:
+ name: platform
+ branch: main
+ path: clusters/prod
+ placement:
+ sensitive:
+ byType:
+ v1/secrets: "{namespace}/secret-{name}.sops.yaml"
+ default: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normal:
+ byType:
+ v1/configmaps: "{namespace}/configmaps.yaml"
+ default: "all-else.yaml"
+```
+
+In this example:
+
+- sensitive resources land in one identity-complete SOPS file per resource;
+- ConfigMaps are grouped into `clusters/prod/configmaps.yaml`;
+- every other new resource is appended to `clusters/prod/all-else.yaml`.
+
+That is powerful enough to express layouts such as `namespace-{namespace}.yaml`,
+per-kind bundles, Secret-only SOPS paths, and the current canonical
+`group/version/resource/namespace/name.yaml` layout. Splitting sensitive and
+normal placement is what keeps this from becoming too sharp: a broad normal
+default cannot catch a Secret, and sensitive placement can have stricter
+uniqueness rules.
+
+The pushback: fully ordered rules may be more API than we need first. A type map
+is smaller, easier to validate, and still supports Secret-specific paths,
+ConfigMap bundles, and a catch-all default. Ordered rules remain the flexible
+escape hatch if users later need scope-wide or metadata-aware placement.
+
+## Current implementation, as reviewed
+
+The current `GitTargetSpec` has `providerRef`, `branch`, `path`, and optional
+`encryption`; it has no placement policy yet
+([api/v1alpha1/gittarget_types.go](../../../../api/v1alpha1/gittarget_types.go)).
+
+The writer already uses the materialized-model direction described in
+[current-manifest-support-review.md](../current-manifest-support-review.md):
+
+- steady-state writes scan the GitTarget subtree into a content-derived store,
+ then apply a commit-scoped plan
+ ([internal/git/plan_flush.go](../../../../internal/git/plan_flush.go));
+- resync uses the same content-derived upsert path plus mark-and-sweep for
+ managed orphans ([internal/git/resync_flush.go](../../../../internal/git/resync_flush.go));
+- existing resources are found by manifest identity, so moved files are updated
+ in place;
+- new resources still fall back to `ResourceIdentifier.ToGitPath()`, with
+ `.sops.yaml` added for sensitive resources
+ ([internal/types/identifier.go](../../../../internal/types/identifier.go),
+ [internal/git/git.go](../../../../internal/git/git.go)).
+
+So placement policy should replace only the final "resource has no document in
+Git, pick a path" step. It should not change content identity, acceptance,
+mark-and-sweep, or the rule that existing documents stay where they are.
+
+## Why GitTarget-level, not WatchRule-level
+
+Placement belongs on `GitTarget`.
+
+`WatchRule` and `ClusterWatchRule` select resources. They are allowed to overlap:
+two rules may select the same ConfigMap through different resource expressions,
+or a future rule may select by label while another selects by type. If placement
+lives on the selecting rule, a single resource can have two valid placements. The
+controller would then need a second conflict-resolution system whose only purpose
+is to decide which rule's placement won.
+
+A GitTarget owns exactly one repository folder. The folder layout is part of that
+ownership policy. The watched rules decide what enters the target; the target
+decides where a new entry goes.
+
+If per-rule placement is ever needed, it should be expressed as data available to
+the GitTarget placement matcher, not as the placement owner itself. For example,
+a future matched resource could carry `watchRuleNames` or `watchSource`, and a
+GitTarget rule could match on that. That keeps one ordered placement list.
+
+## Option A: ordered rule lists
+
+Prefer a new structured field instead of changing `spec.newFilePath` into a list.
+A list named `newFilePath` is no longer a file path; it is a policy. The clearer
+shape is:
+
+```yaml
+spec:
+ placement:
+ sensitiveRules:
+ - path: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normalRules:
+ - match:
+ apiGroups: [""]
+ resources: ["configmaps"]
+ path: "{namespace}/configmaps.yaml"
+ - path: "{groupPath}/{version}/{resource}/{namespace}/{name}.yaml"
+```
+
+Compatibility option:
+
+- no `spec.placement` means the current canonical placement;
+- a future single-string `spec.newFilePath` can be treated as one fallback rule if
+ it already exists by the time this lands;
+- do not expose both long-term. Pick one canonical surface in the CRD.
+
+Suggested Go shape:
+
+```go
+type GitTargetPlacementSpec struct {
+ SensitiveRules []GitTargetPlacementRule `json:"sensitiveRules,omitempty"`
+ NormalRules []GitTargetPlacementRule `json:"normalRules,omitempty"`
+}
+
+type GitTargetPlacementRule struct {
+ Match *GitTargetPlacementMatch `json:"match,omitempty"`
+ Path string `json:"path"`
+}
+
+type GitTargetPlacementMatch struct {
+ APIGroups []string `json:"apiGroups,omitempty"`
+ APIVersions []string `json:"apiVersions,omitempty"`
+ Resources []string `json:"resources,omitempty"`
+ Kinds []string `json:"kinds,omitempty"`
+ Namespaces []string `json:"namespaces,omitempty"`
+ Scope string `json:"scope,omitempty"` // Namespaced | Cluster
+}
+```
+
+Rules are deliberately simple:
+
+- the controller chooses `sensitiveRules` for sensitive resources and
+ `normalRules` for everything else;
+- rules are evaluated in list order;
+- fields inside one `match` are ANDed;
+- lists inside one field are ORed;
+- an omitted `match` matches everything;
+- each non-empty rule list must include a catch-all fallback rule;
+- omitted `placement` uses the built-in canonical fallback for both lists;
+- omitted `sensitiveRules` uses the built-in secure canonical SOPS fallback;
+- omitted `normalRules` uses the built-in canonical plaintext fallback;
+- an explicitly empty rule list is invalid.
+
+That gives the user top-to-bottom control without needing CEL, Go-template
+conditionals, or per-rule priorities.
+
+## Option B: type map plus defaults
+
+There is a smaller API shape worth considering before committing to ordered
+rules. Most placement needs are not "run a matcher"; they are "this type goes
+here, and everything else goes there." That can be expressed as exact type
+lookups plus defaults:
+
+```yaml
+spec:
+ placement:
+ sensitiveTypes:
+ v1/secrets: "{namespace}/secret-{name}.sops.yaml"
+ sensitiveDefault: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normalTypes:
+ v1/configmaps: "{namespace}/configmaps.yaml"
+ normalDefault: "all.yaml"
+```
+
+Names can improve, but the model is:
+
+- classify the resource as sensitive or normal;
+- build its resolved type key from GVR;
+- look for an exact entry in the matching type map;
+- otherwise use that class's default.
+
+The type key should be based on **API resource identity**, not manifest kind:
+
+| Resource | Type key |
+|---|---|
+| core Secret | `v1/secrets` |
+| core ConfigMap | `v1/configmaps` |
+| Deployment | `apps/v1/deployments` |
+| cert-manager Certificate | `cert-manager.io/v1/certificates` |
+
+That means plural resource names, not singular kind names. This matches the
+writer's `ResourceIdentifier` and the watch-rule resource model. It also avoids
+the question of whether `v1/Secret`, `v1/secret`, or `v1/secrets` is the "right"
+spelling.
+
+The type-map shape is less flexible than ordered rules, but that may be the
+point:
+
+- no rule ordering to understand;
+- no `match` object to validate;
+- no "does this broad rule accidentally catch too much?" concern;
+- exact type overrides are naturally unique;
+- defaults make the policy short.
+
+It also gives sensitive resources the same hard split as the rule-list API. A
+normal type map cannot catch a Secret. A sensitive type map cannot route plaintext
+resources. If ConfigMaps are intentionally added to the configured sensitive
+resource policy, they use `sensitiveTypes` / `sensitiveDefault`; otherwise they
+use `normalTypes` / `normalDefault`. The placement policy does not decide
+sensitivity.
+
+Suggested Go shape:
+
+```go
+type GitTargetPlacementSpec struct {
+ SensitiveTypes map[string]string `json:"sensitiveTypes,omitempty"`
+ SensitiveDefault string `json:"sensitiveDefault,omitempty"`
+ NormalTypes map[string]string `json:"normalTypes,omitempty"`
+ NormalDefault string `json:"normalDefault,omitempty"`
+}
+```
+
+An object wrapper may be better for future metadata:
+
+```yaml
+placement:
+ sensitive:
+ byType:
+ v1/secrets: "{namespace}/secret-{name}.sops.yaml"
+ default: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normal:
+ byType:
+ v1/configmaps: "{namespace}/configmaps.yaml"
+ default: "all.yaml"
+```
+
+```go
+type GitTargetPlacementSpec struct {
+ Sensitive GitTargetPlacementClass `json:"sensitive,omitempty"`
+ Normal GitTargetPlacementClass `json:"normal,omitempty"`
+}
+
+type GitTargetPlacementClass struct {
+ ByType map[string]string `json:"byType,omitempty"`
+ Default string `json:"default,omitempty"`
+}
+```
+
+This nested version is probably the cleaner type-map API. It keeps the
+sensitive/normal split obvious and leaves room for class-level fields later, such as
+`allowMultiDocument`, without inventing new top-level names.
+
+The validation rules are almost the same as for ordered rules:
+
+- omitted `placement.sensitive.default` uses the built-in secure canonical SOPS
+ fallback;
+- omitted `placement.normal.default` uses the built-in canonical plaintext
+ fallback;
+- every `byType` key must parse as a valid resolved type key;
+- every referenced type should be served and watched by the GitTarget, or at
+ least reported as unused policy;
+- sensitive paths must end in `.sops.yaml` or `.sops.yml`;
+- sensitive paths must be identity-complete, unless the type key itself narrows
+ to one namespaced or cluster-scoped type and the path contains the scope
+ identity;
+- normal paths may intentionally collide and append to plaintext multi-document
+ files.
+
+The main loss is expressiveness. A type map cannot say "all namespaced resources
+go to `namespace-{namespace}.yaml`, but cluster-scoped resources go to
+`cluster.yaml`" unless every type is listed. It also cannot match future
+metadata such as labels. If we do not need those patterns yet, this may be a
+better first API than ordered rules.
+
+My current preference is:
+
+1. ship the nested type-map API first;
+2. keep ordered rules as a future extension only if users hit the type-map limit;
+3. keep the same template renderer, SOPS validation, and append rules for both.
+
+## Sensitive placement and uniqueness
+
+Sensitive placement should be stricter than normal placement. A normal template
+may intentionally map many resources to one file because plaintext
+multi-document append is supported. A sensitive template must not do that in the
+first version.
+
+The guarantee should be structural:
+
+> Every accepted sensitive template must render an identity-complete path.
+
+Identity-complete means the rendered path cannot collide for two distinct
+sensitive resources in the GitTarget. There are two ways a template can prove
+that:
+
+1. The path contains the full API identity variables:
+ `{groupPath}`, `{version}`, `{resource}`, `{namespaceOrCluster}`, and `{name}`.
+2. The placement entry narrows to exactly one served resource type, and the path
+ contains the scope identity for that type:
+ `{namespace}` plus `{name}` for namespaced resources, or `{name}` for
+ cluster-scoped resources.
+
+For the type-map API, the `byType` key itself narrows to one type. For ordered
+rules, "narrows to exactly one served resource type" means the rule names one API
+group, one API version, and one resource, with no wildcard or omitted type field.
+
+This rule is intentionally conservative. A user might know that
+`{namespace}/secret-{name}.sops.yaml` is unique because they only watch core
+Secrets, but the controller can only rely on that if the match proves it:
+
+```yaml
+placement:
+ sensitiveRules:
+ - match:
+ apiGroups: [""]
+ apiVersions: ["v1"]
+ resources: ["secrets"]
+ path: "{namespace}/secret-{name}.sops.yaml"
+```
+
+If the match does not narrow to one type, use the full identity path:
+
+```yaml
+placement:
+ sensitiveRules:
+ - path: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+```
+
+Variable expansion must also be non-lossy for identity variables. Do not use a
+sanitizer that turns two legal Kubernetes names into the same path segment.
+Percent-encoding or another reversible path encoding is safer than lossy
+replacement for `{groupPath}`, `{version}`, `{resource}`, `{namespace}`,
+`{namespaceOrCluster}`, and `{name}`.
+
+## Template variables
+
+Templates should be small path templates, not a general programming language.
+Branching belongs in `match`; path rendering belongs in `path`.
+
+Use brace variables such as `{namespace}` rather than full Go templates. The
+current commit-message templates already use Go templates, but file paths need
+stricter validation and less expressive power. A dedicated path-template renderer
+can validate every variable and every rendered segment before the write happens.
+
+Recommended variables:
+
+| Variable | Meaning |
+|---|---|
+| `{group}` | API group, empty for core resources |
+| `{groupPath}` | API group as a path segment, omitted for core resources |
+| `{version}` | API version |
+| `{apiVersion}` | Kubernetes manifest `apiVersion` |
+| `{resource}` | plural resource name, for example `configmaps` |
+| `{kind}` | manifest kind, for example `ConfigMap` |
+| `{scope}` | `namespaced` or `cluster` |
+| `{namespace}` | metadata namespace, empty for cluster-scoped resources |
+| `{namespaceOrCluster}` | namespace, or `cluster` for cluster-scoped resources |
+| `{name}` | metadata name |
+| `{sensitiveSuffix}` | `.sops.yaml` in sensitive rules, `.yaml` in normal rules |
+
+With those variables, the built-in canonical normal layout is:
+
+```text
+{groupPath}/{version}/{resource}/{namespace}/{name}{sensitiveSuffix}
+```
+
+For a core `v1` ConfigMap named `app` in namespace `default`, empty path segments
+are removed, so the canonical result is:
+
+```text
+v1/configmaps/default/app.yaml
+```
+
+For an `apps/v1` Deployment:
+
+```text
+apps/v1/deployments/default/app.yaml
+```
+
+For a Secret:
+
+```text
+v1/secrets/default/app.sops.yaml
+```
+
+Optional future variables can expose selected object metadata:
+
+| Variable | Meaning |
+|---|---|
+| `{label:key}` | sanitized value of a metadata label |
+| `{annotation:key}` | sanitized value of a metadata annotation |
+
+Those are useful, but they should not be day-one unless there is a strong need.
+Labels and annotations can change. Placement is create-time and non-retroactive,
+so changing a label later would not move the file, but it can still surprise
+users who expected the path to track metadata.
+
+Do not expose arbitrary object fields such as `{spec.foo}` in the first version.
+That makes path policy depend on mutable, schema-specific content and pulls the
+placement layer into every CRD's structure.
+
+## Path validation
+
+The rendered path is always relative to `spec.path`. A rendered path must:
+
+- be non-empty;
+- be a clean relative path;
+- stay under the GitTarget path after cleaning;
+- not contain `..`, an absolute path, Windows drive prefixes, or empty final file
+ names;
+- end in `.yaml`, `.yml`, `.sops.yaml`, or `.sops.yml`;
+- use sanitized path segments for every variable expansion;
+- land inside the configured discovery scope.
+
+The discovery-scope rule matters if `discovery.recurse: false` also lands. A
+non-recursive GitTarget cannot create `namespaces/default/app.yaml`, because the
+next scan would intentionally ignore that child folder. Either the placement rule
+must render an immediate child file such as `default-app.yaml`, or the GitTarget
+must enable recursive discovery.
+
+Sensitive resources need one more invariant: if the selected resource is sensitive,
+the final path must be a SOPS path. Because sensitive resources use
+the sensitive placement class, the policy can validate this without guessing:
+
+- every sensitive template must render `.sops.yaml` or `.sops.yml`;
+- every sensitive template must be identity-complete;
+- if sensitive placement is omitted, the controller uses the built-in secure
+ canonical SOPS rule.
+
+A Secret rule that renders `secrets/{name}.yaml` should fail validation or
+reconciliation before any cleartext write is attempted.
+
+## Collision and append behavior
+
+Normal placement rules intentionally allow many resources to render to the same
+file:
+
+```yaml
+placement:
+ normalRules:
+ - match:
+ resources: ["configmaps"]
+ path: "configmaps.yaml"
+ - path: "all-else.yaml"
+```
+
+That means collision is not automatically an error. It is a request to create or
+append a multi-document YAML file.
+
+Plaintext rules:
+
+- if the rendered file does not exist, create it;
+- if several new plaintext resources in one plan render to the same path, write a
+ multi-document file in deterministic resource-identity order;
+- if the file already exists and is accepted managed KRM, append the new document
+ when doing so does not create a duplicate identity;
+- if the existing file is non-KRM YAML, invalid YAML, allowlisted auxiliary KRM,
+ outside scope, or otherwise refused by acceptance, do not append;
+- never partially manage a file. After append, every document in the file must be
+ managed by the GitTarget.
+
+Sensitive rules:
+
+- sensitive resources remain single-document files for the first version;
+- a sensitive rule that is not identity-complete is invalid;
+- a sensitive rule that still maps two resources to the same path is a placement
+ error;
+- a sensitive resource must not be appended to a plaintext multi-document file;
+- a plaintext resource must not be appended to a SOPS file.
+
+That is stricter than SOPS can theoretically support, but it keeps the current
+writer's invariant: encrypted documents are not patched in place and are handled
+through the re-encrypt path. Multi-document encrypted append can be a later
+explicit feature.
+
+## Repository acceptance and validity
+
+Placement policy feeds into the same acceptance model as the current manifest
+design. A GitTarget must not reconcile when its repository folder cannot be
+accepted as a fully managed projection.
+
+The content acceptance gate remains responsible for:
+
+- duplicate identities;
+- non-KRM YAML in managed files;
+- unwatched API-backed KRM;
+- watched KRM outside target scope;
+- mixed files that combine managed resources with retained allowlisted KRM.
+
+Placement adds policy acceptance:
+
+- the policy must be syntactically valid;
+- custom placement classes must have defaults, or use the built-in defaults;
+- every path template must reference only known variables;
+- rendered paths for the current desired snapshot must pass path validation;
+- sensitive resources must render to SOPS paths;
+- sensitive templates must be identity-complete;
+- sensitive collisions are refused;
+- plaintext collisions are allowed only when they produce an accepted managed
+ multi-document file.
+
+A useful status split:
+
+```text
+Validated
+PlacementPolicyValid
+RepositoryValid
+SnapshotSynced
+EventStreamLive
+Ready
+```
+
+`PlacementPolicyValid` catches invalid placement policy before any repository scan.
+`RepositoryValid` catches content and rendered-placement problems discovered
+against the checked-out tree and the desired snapshot.
+
+If we want fewer conditions, `PlacementPolicyValid` can be folded into
+`Validated`. The important behavior is the same: invalid placement policy blocks
+snapshot sync and live event processing.
+
+## Examples
+
+### Type map with default
+
+This is the likely first API shape:
+
+```yaml
+placement:
+ sensitive:
+ byType:
+ v1/secrets: "{namespace}/secret-{name}.sops.yaml"
+ default: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normal:
+ byType:
+ v1/configmaps: "{namespace}/configmaps.yaml"
+ default: "all.yaml"
+```
+
+The keys are plural resource keys, so use `v1/secrets` and `v1/configmaps`, not
+`v1/secret` or `v1/configmap`. A ConfigMap goes through `normal.byType` unless
+the cluster/operator configuration classifies ConfigMaps as sensitive; in that
+case it goes through `sensitive.byType` or `sensitive.default`.
+
+### Namespace bundle with ordered rules
+
+Group every namespaced resource into one file per namespace. Cluster-scoped
+resources get their own bundle.
+
+```yaml
+placement:
+ sensitiveRules:
+ - path: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normalRules:
+ - match:
+ scope: Namespaced
+ path: "namespace-{namespace}.yaml"
+ - path: "cluster.yaml"
+```
+
+This is compact and friendly to humans, but it creates large multi-document files.
+It is a good fit for small namespaces and a poor fit for clusters with hundreds
+of resources per namespace.
+
+### Secret isolation with ordered rules
+
+```yaml
+placement:
+ sensitiveRules:
+ - match:
+ apiGroups: [""]
+ apiVersions: ["v1"]
+ resources: ["secrets"]
+ path: "{namespace}/secrets/{name}.sops.yaml"
+ normalRules:
+ - path: "{groupPath}/{version}/{resource}/{namespace}/{name}.yaml"
+```
+
+This keeps sensitive resources one-per-file and leaves everything else in the
+current canonical layout.
+
+### ConfigMaps grouped with ordered rules
+
+```yaml
+placement:
+ sensitiveRules:
+ - path: "{groupPath}/{version}/{resource}/{namespaceOrCluster}/{name}.sops.yaml"
+ normalRules:
+ - match:
+ apiGroups: [""]
+ resources: ["configmaps"]
+ path: "{namespace}/configmaps.yaml"
+ - path: "{groupPath}/{version}/{resource}/{namespace}/{name}.yaml"
+```
+
+This is a reasonable middle ground: only the low-risk, plaintext resource type is
+bundled.
+
+### Broad normal default
+
+```yaml
+placement:
+ normalRules:
+ - path: "all.yaml"
+```
+
+This affects only normal resources. It is valid for plaintext resources but
+operationally heavy. Every edit touches one file, and a very large file becomes
+harder to review, merge, and re-render. Sensitive resources still use the
+built-in secure canonical fallback unless `sensitiveRules` is explicitly set.
+
+## Keeping it small
+
+The placement model can get too clever quickly. The first version should stay
+inside these limits:
+
+- GitTarget-level only;
+- separate sensitive and normal rule lists;
+- prefer exact type-map overrides plus defaults unless ordered matching proves
+ necessary;
+- when ordered matching exists, keep it first-match-wins only;
+- no CEL expressions;
+- no Go-template conditionals;
+- no arbitrary object-field variables;
+- no regex matching;
+- no template functions except safe path-segment sanitization;
+- no retroactive moves when rules change;
+- no sensitive multi-document files;
+- no per-resource status spam. Status should show bounded examples.
+
+This still gives enough flexibility for the use cases that motivated the idea:
+Secret-specific SOPS paths, ConfigMap bundles, namespace files, and a catch-all
+layout.
+
+## Implementation sketch
+
+1. Choose the API surface:
+ - preferred first version: nested type map (`placement.sensitive.byType`,
+ `placement.sensitive.default`, `placement.normal.byType`,
+ `placement.normal.default`);
+ - more flexible fallback: ordered `sensitiveRules` / `normalRules`.
+2. Add the CRD field:
+ - `GitTargetSpec.Placement *GitTargetPlacementSpec`
+ - the chosen nested type-map shape, or the ordered-rule shape
+ - policy/path validation that can be done statically.
+3. Introduce a placement policy interface in the writer/manifestreport layer:
+
+ ```go
+ type PlacementPolicy interface {
+ LocateNew(resource types.ResourceIdentifier, objectMeta PlacementObjectMeta) (ManifestLocation, error)
+ }
+ ```
+
+4. Keep the current canonical policy as the default implementation. With no
+ `spec.placement`, output must be byte-identical to today's
+ `ResourceIdentifier.ToGitPath()` behavior.
+5. Parse and validate path templates once per GitTarget reconcile. Sensitive
+ templates must be SOPS-suffixed and identity-complete. Type-map keys must
+ parse to exact GVR keys. Store compiled templates in the resolved target
+ metadata passed to the BranchWorker.
+6. Replace calls to `filePathForIdentifier` / `generateFilePath` for new
+ resources with `placement.LocateNew`.
+7. Leave existing-document paths unchanged. `applyUpsert` still checks the store
+ first and only calls placement when no document exists.
+8. Add plaintext append support for same-path creates:
+ - group new create actions by rendered path;
+ - sort documents by resource identity for deterministic output;
+ - write or append multi-document YAML only for accepted plaintext files.
+9. Add sensitive collision checks before rendering encrypted bytes; this is a
+ runtime backstop behind the static identity-completeness validation.
+10. Feed placement errors into GitTarget status and the scan/dry-run output.
+11. Update chart docs and examples after the API shape is settled.
+
+## Tests
+
+Unit tests:
+
+- default placement reproduces `ResourceIdentifier.ToGitPath()` exactly;
+- type-map keys parse as exact GVR keys, including core `v1/secrets` and grouped
+ `apps/v1/deployments`;
+- type-map default applies when no exact type entry exists;
+- ordered-rule option: first matching rule wins;
+- ordered-rule option: fallback rule catches resources not matched earlier;
+- path validation rejects absolute paths, `..`, empty names, bad suffixes, and
+ paths outside non-recursive discovery scope;
+- core group removes the empty `{groupPath}` segment;
+- sensitive resources require `.sops.yaml`;
+- omitted sensitive placement still uses the built-in secure canonical fallback;
+- sensitive templates that are not identity-complete are rejected;
+- plaintext same-path creates produce deterministic multi-document YAML;
+- sensitive same-path creates fail;
+- existing moved manifests are updated in place and do not re-run placement;
+- policy changes do not move existing files.
+
+Integration/e2e tests:
+
+- a GitTarget with ConfigMaps grouped into `configmaps.yaml` creates and updates
+ multiple ConfigMaps without duplicate files;
+- Secret placement writes `.sops.yaml` and never creates cleartext Secret YAML;
+- a namespace-bundle policy removes one document when the API resource is deleted
+ and deletes the file only after the last managed document is gone;
+- an invalid policy blocks `Ready` before live events are accepted;
+- an external push that adds a duplicate identity still makes
+ `RepositoryValid=False` and the controller does not guess which path to keep.
+
+## Open questions
+
+- For the ordered-rule option, should a custom rule list be required to end with
+ an explicit catch-all, or should the controller append the canonical fallback
+ implicitly? This document recommends explicit catch-all rules because they make
+ the user's layout complete on the page.
+- Should `{label:key}` and `{annotation:key}` ship in v1, or wait until somebody
+ has a concrete use case?
+- Should `discovery.recurse: false` survive the newer "whole folder ownership"
+ model, or should flat discovery be dropped before placement rules land?
+- Should placement rule matches include `watchRuleNames` later for users who want
+ rule-origin-aware placement without moving policy onto WatchRule?
diff --git a/docs/design/manifest/version2/gittarget-repository-validity-and-placement.md b/docs/design/manifest/version2/gittarget-repository-validity-and-placement.md
new file mode 100644
index 00000000..1453e3d2
--- /dev/null
+++ b/docs/design/manifest/version2/gittarget-repository-validity-and-placement.md
@@ -0,0 +1,305 @@
+# GitTarget repository validity and placement controls
+
+> Status: proposed
+> Related: [manifest-inventory-file-agnostic-placement.md](manifest-inventory-file-agnostic-placement.md),
+> [manifestedit-new-file-placement-spike.md](manifestedit-new-file-placement-spike.md),
+> [manifestedit-integration-readonly-reconcile.md](manifestedit-integration-readonly-reconcile.md)
+
+## Summary
+
+GitTarget startup should include a repository-content validity gate. A GitTarget
+is valid only when the configured branch/path contains at most one editable KRM
+document for each Kubernetes resource identity. If Git contains two manifests for
+the same resource, the API is still the source of truth, but Git no longer has a
+single safe destination for that truth. The GitTarget must not start, and a
+running GitTarget must transition back to invalid as soon as the branch scan sees
+the duplicate.
+
+This replaces the older "first occurrence wins, delete duplicate losers" idea in
+the file-agnostic placement vision. Duplicate KRM is not a prune candidate; it is
+an invalid repository state that requires a human or upstream Git process to
+choose the authoritative file.
+
+This same round should add two GitTarget placement controls:
+
+- `spec.discovery.recurse`: whether repository discovery scans only the immediate
+ GitTarget path or recursively scans child folders.
+- `spec.newFilePath`: a template that decides where newly-discovered API objects
+ are written when no existing Git document owns them. If multiple new resources
+ resolve to the same file path, the writer may create or append a multi-document
+ YAML file.
+
+## Desired API shape
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: GitTarget
+spec:
+ providerRef:
+ name: platform
+ branch: main
+ path: clusters/prod
+ discovery:
+ recurse: true
+ newFilePath: "{{ .GroupPath }}/{{ .Version }}/{{ .Resource }}/{{ .Namespace }}/{{ .Name }}.yaml"
+```
+
+`discovery.recurse` defaults to `true` to preserve the current inventory
+direction, which recursively scans YAML below the GitTarget path. Users with flat
+GitOps folders can set it to `false`; then only files directly under
+`spec.path` are discovered, and subdirectories are ignored for duplicate
+detection and repo-state snapshots.
+
+`newFilePath` replaces any enum-style choice for new-file placement. It is a
+single template evaluated relative to `spec.path`. The default renders the
+current canonical layout. Suggested template variables:
+
+| Variable | Meaning |
+|---|---|
+| `.Group` | API group, empty for core resources |
+| `.GroupPath` | API group as a path segment, omitted for core resources |
+| `.Version` | API version |
+| `.Resource` | plural resource name |
+| `.Kind` | manifest kind when available |
+| `.Namespace` | namespace, empty for cluster-scoped resources |
+| `.Name` | object name |
+
+Rules:
+
+- The rendered path must be relative, clean, and stay under `spec.path`.
+- Empty path segments are removed before joining.
+- Sensitive resources still use `.sops.yaml` unless `newFilePath` already
+ renders that suffix.
+- Existing-resource writes always follow the inventory location. `newFilePath`
+ applies only when Git has no document for the API object.
+
+## Multi-document creation
+
+`newFilePath` makes multi-document files a natural outcome instead of a separate
+mode enum. If two API objects with no existing Git location render to the same
+file, the writer appends the second object as another YAML document in that file:
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+```
+
+This is allowed only for plaintext manifests. Encrypted SOPS files remain
+single-document because the writer re-encrypts whole files and must not combine
+multiple secrets or secret-adjacent objects into one encrypted document stream.
+
+If an existing file at the rendered `newFilePath` contains valid KRM documents,
+the writer may append the new document when doing so does not create a duplicate
+identity. If the file is invalid YAML, non-editable, encrypted, or outside the
+discovery scope, the write is refused and surfaced as a repository validity
+problem instead of overwriting user content.
+
+## Repository validity gate
+
+Add a new condition:
+
+```text
+RepositoryValid
+```
+
+Suggested reasons:
+
+| Reason | Meaning |
+|---|---|
+| `OK` | Branch/path scanned and no blocking repository-content issues were found |
+| `DuplicateResourceManifests` | Two or more KRM documents target the same resource |
+| `InvalidRepositoryContent` | YAML or placement content is unsafe enough to block startup |
+| `ScanFailed` | The branch/path could not be fetched or scanned |
+| `NotStarted` | Blocked by an earlier gate |
+
+Gate order:
+
+1. `Validated`: provider, branch allow-list, GitTarget path conflicts.
+2. `EncryptionConfigured`.
+3. `RepositoryValid`: fetch/sync the branch and scan `spec.path` with the
+ configured recursion policy.
+4. `SnapshotSynced`.
+5. `EventStreamLive`.
+6. `Ready`.
+
+When `RepositoryValid=False`, set:
+
+- `SnapshotSynced=Unknown` with reason `Blocked`
+- `EventStreamLive=False` with reason `RepositoryInvalid`
+- `Ready=False` with reason `RepositoryInvalid`
+
+The GitTarget must not accept live events while invalid. If the target had
+already reached live processing and a later branch scan discovers duplicate KRM,
+the controller disables or unregisters that GitTarget's event stream and deletes
+its `FolderReconciler`. The branch worker may remain alive for other GitTargets
+on the same provider/branch.
+
+## Duplicate identity definition
+
+The validity scan should use the API-side resource identity wherever possible:
+
+```text
+group/version/resource/namespace/name
+```
+
+Manifest content gives `apiVersion`, `kind`, `metadata.namespace`, and
+`metadata.name`. The scanner maps GVK to GVR through the same discovery/catalog
+path used by WatchRule resolution. If mapping is unavailable, the scanner may use
+manifest identity as a fallback diagnostic, but a production duplicate block
+should be based on API identity so aliases or preferred-version details do not
+hide conflicts.
+
+Cluster-scoped resources use an empty namespace. Documents with no namespace for
+a namespaced resource are invalid unless an explicit future namespace-context
+feature supplies that namespace.
+
+Duplicate examples that must block:
+
+- `apps/v1 Deployment default/app` in `app.yaml` and `copy.yaml`
+- the same ConfigMap repeated twice inside one multi-document YAML file
+- one copy at the canonical path and one user-placed copy under `apps/app.yaml`
+
+Non-KRM YAML, empty YAML documents, and unwatched resource kinds do not
+participate in duplicate blocking unless they are otherwise invalid enough to
+block scanning.
+
+## Detecting external pushes
+
+Repository validity is not only a startup check. Every time the controller syncs
+or observes a newer remote branch tip for a GitTarget, it must rescan the
+configured path before keeping the target live.
+
+Implementation model:
+
+1. Fetch branch metadata for the GitTarget.
+2. If the remote HEAD changed since the last repository-valid scan, checkout the
+ new tree and rebuild inventory for `spec.path`.
+3. If duplicate identities are found, transition the GitTarget to
+ `RepositoryValid=False` in the same reconcile and stop its event stream.
+4. If a later push removes the duplicate, the next scan returns the GitTarget to
+ `RepositoryValid=True`, runs snapshot sync again, then resumes live events.
+
+"Immediately" means the next GitTarget reconcile after the remote change is
+observed. In e2e tests this should be driven by an explicit reconcile trigger or
+by waiting for the controller's branch metadata poll interval; if a Git webhook
+or provider callback is added later, it can shorten the same path without
+changing the state machine.
+
+## Implementation steps
+
+1. Extend the API:
+ - add `GitTargetSpec.Discovery *GitTargetDiscoverySpec`
+ - add `GitTargetDiscoverySpec.Recurse *bool` defaulting to `true`
+ - add `GitTargetSpec.NewFilePath string`
+ - add kubebuilder validation for relative path template output constraints
+ where possible; runtime validation still owns rendered paths.
+2. Extend manifest scanning:
+ - add `IndexDir(root, options)` or `IndexDirWithOptions`
+ - support recursive and flat scans
+ - expose duplicate groups, not only duplicate losers
+ - keep diagnostics bounded for status.
+3. Add repository validation to `BranchWorker`:
+ - fetch/prepare branch
+ - scan the GitTarget path
+ - return summary counts plus duplicate details.
+4. Add the `RepositoryValid` gate to `GitTargetReconciler`.
+5. Make `EventRouter`/`GitTargetEventStream` stoppable for invalid targets.
+6. Replace new-file placement enum usage with `newFilePath` rendering.
+7. Add append-to-existing-file support for same-path new resources, with
+ plaintext-only and no-duplicate guards.
+8. Update CRDs, samples, Helm chart values, and configuration docs.
+
+## E2E tests
+
+### Existing folder with duplicate KRM blocks startup
+
+Purpose: prove the simple invalid-at-start case.
+
+Setup:
+
+1. Create a fresh e2e repo/branch.
+2. Commit two plaintext YAML files under the future GitTarget path:
+
+ ```text
+ clusters/prod/app-a.yaml
+ clusters/prod/app-b.yaml
+ ```
+
+ Both files contain the same `v1/ConfigMap default/app`, with different data so
+ it is obvious they are separate copies.
+3. Create a `GitProvider`, `GitTarget`, and `WatchRule` for ConfigMaps. Set
+ `spec.path: clusters/prod` and leave `discovery.recurse` at the default.
+
+Expected assertions:
+
+- `GitTarget.status.conditions[RepositoryValid]` becomes `False`.
+- Reason is `DuplicateResourceManifests`.
+- The condition message names the duplicated resource and both locations, bounded
+ if there are many duplicates.
+- `Ready=False` with reason `RepositoryInvalid`.
+- `SnapshotSynced` is `Unknown` or `False` and did not create a write request.
+- `EventStreamLive=False` or `Unknown`; live API edits to `default/app` do not
+ produce a commit while invalid.
+
+### External push with duplicate KRM invalidates a live GitTarget
+
+Purpose: prove a valid running target becomes invalid after another actor pushes
+a duplicate manifest into the branch/path.
+
+Setup:
+
+1. Create a fresh e2e repo/branch with one valid manifest under
+ `clusters/prod/app.yaml`.
+2. Create `GitProvider`, `GitTarget`, and `WatchRule`.
+3. Wait until the GitTarget is `Ready=True`,
+ `RepositoryValid=True`, `SnapshotSynced=True`, and `EventStreamLive=True`.
+4. From an external clone or direct Gitea API helper, push
+ `clusters/prod/copy.yaml` containing the same `v1/ConfigMap default/app`.
+5. Trigger or wait for the GitTarget reconcile that observes the new remote HEAD.
+
+Expected assertions:
+
+- The GitTarget transitions from `Ready=True` to `Ready=False`.
+- `RepositoryValid=False` with reason `DuplicateResourceManifests`.
+- `EventStreamLive=False` with reason `RepositoryInvalid`, and the stream no
+ longer accepts live events for that GitTarget.
+- The controller does not auto-delete either duplicate file.
+- An API update to the ConfigMap while the GitTarget is invalid does not create a
+ Git commit.
+- After removing `copy.yaml` with another external push, the next reconcile makes
+ `RepositoryValid=True`; the target runs snapshot sync before returning to
+ `Ready=True`.
+
+### Discovery recursion coverage
+
+The duplicate tests should be extended with a table once `discovery.recurse`
+lands:
+
+| `recurse` | Duplicate location | Expected |
+|---|---|---|
+| `true` | `clusters/prod/app.yaml` and `clusters/prod/nested/app.yaml` | invalid |
+| `false` | `clusters/prod/app.yaml` and `clusters/prod/nested/app.yaml` | valid; nested file ignored |
+| `false` | `clusters/prod/app-a.yaml` and `clusters/prod/app-b.yaml` | invalid |
+
+This can be unit-tested first around the scanner and covered in e2e by one
+representative `recurse=false` GitTarget.
+
+## Open questions
+
+- Should invalid YAML block `RepositoryValid`, or only the subset that looks like
+ KRM and cannot be safely classified?
+- How much duplicate detail belongs in GitTarget status before it becomes too
+ large? A bounded summary plus first N examples is likely enough.
+- Should a branch push notification be added so external invalidating pushes are
+ observed faster than the normal reconcile/poll interval?
+- Should `newFilePath` support conditionals, or only simple variable expansion
+ plus path cleanup?
diff --git a/docs/design/manifest/version2/m12-bootstrap-decoupling-plan.md b/docs/design/manifest/version2/m12-bootstrap-decoupling-plan.md
new file mode 100644
index 00000000..69538603
--- /dev/null
+++ b/docs/design/manifest/version2/m12-bootstrap-decoupling-plan.md
@@ -0,0 +1,386 @@
+# M12 bootstrap-decoupling: typeset-owned per-type bootstrap
+
+> Status: replacement implementation plan, captured 2026-06-09. This supersedes the
+> earlier "whole-target snapshot plus per-type escape hatch" plan. Sibling design:
+> [type-lifecycle-events-and-wobble-settling.md](type-lifecycle-events-and-wobble-settling.md);
+> flake forensics:
+> [../../e2e-full-suite-flakiness-findings-2026-06.md](../../e2e-full-suite-flakiness-findings-2026-06.md).
+
+## Decision
+
+Make the typeset registry the runtime source of truth for type lifecycle, and make
+the unit of bootstrap work one selected type for one GitTarget.
+
+The old model is:
+
+```text
+GitTarget Ready waits for one whole-target snapshot
+ -> stream every selected type
+ -> join every bookmark
+ -> one full mark-and-sweep commit
+ -> then live events may flow
+```
+
+The new model is:
+
+```text
+typeset registry owns type verdict + lifecycle
+WatchedTypeTable projects registry records per GitTarget
+TargetTypeScheduler schedules missing per-type work
+ TypeActivated / selected type -> scoped reconcile
+ TypeRemoved / unselected type -> scoped sweep
+```
+
+No normal bootstrap path should need a whole-GitTarget snapshot. Whole-target
+snapshot code may stay temporarily as a repair/test helper, but it is no longer a
+runtime dependency.
+
+## Rollout Decision
+
+Land the immediate blocker as a small PR before the scheduler rewrite:
+
+```text
+PR 1: remove WaitForCacheSync from informer startup
+ -> focused e2e should go green with current whole-target machinery
+
+PR 2+: introduce the per-type scheduler behind the existing path
+ -> prove retry, apply-result feedback, and restart-safe sweep
+ -> then remove normal whole-target bootstrap/rule-change usage
+```
+
+This sequencing does not weaken the destination. It keeps the urgent e2e fix out of
+the blast radius of changing bootstrap, status, and convergence semantics at the
+same time.
+
+## What We Lose
+
+These components should be deleted from normal runtime, not patched. Delete them
+only after the replacement scheduler has the two hard correctness properties below:
+mirror-derived per-type sweep and retry/apply-result feedback.
+
+- `GitTargetConditionSnapshotSynced`: remove the condition and all "Blocked until
+ SnapshotSynced=True" status wiring. This can be a later cleanup: the first
+ scheduler slice may keep the condition as a transitional compatibility signal.
+- `GitTargetReconciler.evaluateSnapshotGate`: replace it with target activation:
+ ensure the worker, ensure/register the event stream, and ask the per-type scheduler
+ to reconcile the target's current selected type set.
+- `EventRouter.EmitResyncForGitDest` and `gatherAndEnqueueResync`: remove from the
+ GitTarget controller and WatchRule rule-change path. If a full-target repair command
+ is kept, move this behind an explicit repair-only API/name so production code cannot
+ drift back to it.
+- `Manager.snapshotTargetsNeedingDelivery`, `ruleSetSnapshotTarget`,
+ `lastDeliveredRuleSetHash`, and `pendingRuleSetHash`: replace the target-level
+ "effective plan hash needs a snapshot" cache with per-type sync state.
+- `Manager.emitSnapshotForRuleChange`: rule changes enqueue per-type reconciles and
+ sweeps; they do not enqueue a whole-target snapshot.
+- `StreamClusterSnapshotForGitDest` and `resolveSnapshotGVRs` as runtime bootstrap
+ machinery. Do not add `SweepSkip`; that creates another full-resync mode instead of
+ deleting the old one.
+- `git.ResyncRequest` whole-target mode in normal operation. `ScopeGVR` should become
+ the only resync mode used by bootstrap, rule changes, lifecycle recovery, and type
+ removal.
+- The informer cache-sync handoff assumption in `ReconcileForRuleChange`: no comments
+ or logic may rely on `WaitForCacheSync` proving that initial ADDED events were
+ buffered before a full snapshot.
+
+## PR 1: Root Cause Fix
+
+The deterministic e2e failure remains real and should be fixed immediately:
+`WatchRuleReconciler.reconcileWatchRuleViaTarget` calls
+`Manager.ReconcileForRuleChange` synchronously before status is persisted. That path
+starts informers and blocks in `startSingleInformer` on `cache.WaitForCacheSync`
+while holding `informersMu`. A fresh CRD informer can fail to list with "the server
+could not find the requested resource", so the WatchRule never writes conditions and
+the spec times out on `status.conditions not found`.
+
+Do this as the first code change:
+
+- In `internal/watch/manager.go`, remove synchronous `cache.WaitForCacheSync` from
+ `startSingleInformer`.
+- Start informer factories and return. Informer sync is background work; live events
+ flow when the informer is actually synced.
+- Update `ReconcileForRuleChange` comments so they no longer claim cache sync
+ guarantees the initial ADDED buffer.
+
+This fix is independent of the architecture rewrite, but it removes the immediate
+deadlock and makes the later per-type path easier to validate.
+
+Expected result: the focused wildcard e2e should pass with the current machinery
+still intact. The WatchRule writes status promptly; existing retry paths can gather
+and commit once the fresh CRD-backed type is actually served.
+
+## New Component: TargetTypeScheduler
+
+Add one scheduler in `internal/watch`, owned by `Manager`.
+
+Inputs:
+
+- the current `typeset.Registry`;
+- the current resident `WatchedTypeTable` values;
+- registered GitTarget event streams;
+- branch worker availability through `EventRouter`;
+- lifecycle events from `typeset.Registry.Subscribe`;
+- explicit target/rule-change kicks from GitTarget and WatchRule reconcilers.
+
+State:
+
+- per `(gitDest, gvr)` selected scope hash;
+- last successfully applied registry generation/revision for that type;
+- last action: `reconcile`, `sweep`, `skip-retained`, `skip-refused`;
+- last error and timestamp;
+- next retry time and retry count for transient failures;
+- in-flight flag so repeated kicks coalesce instead of stampeding the worker.
+
+Lifecycle events are wakeups, not durable truth. On every wakeup the scheduler reads
+the registry and target table again and computes the current required work. This keeps
+event delivery cheap and makes dropped buffered events recoverable without falling
+back to a whole-target snapshot.
+
+## Hard Requirements Before Replacement
+
+The scheduler may run beside the old path before these are done, but it must not
+replace whole-target bootstrap/rule-change delivery until both are true:
+
+1. **Mirror-derived per-type sweep.** Rule removals and settled `TypeRemoved` must
+ converge after manager restart. The scheduler cannot rely only on in-memory
+ "previously selected" state to know what to sweep. It must derive mirrored types
+ from the managed Git subtree/manifest store, then sweep only the affected
+ `(group, resource)` with `ScopeGVR`.
+2. **Retry plus apply-result feedback.** A per-type reconcile is not complete when it
+ is enqueued. The scheduler must learn whether the worker applied the scoped resync,
+ record success only from the `ResyncResult`, and periodically recompute/retry
+ transient failures such as API stream errors, worker-not-ready, or push conflicts.
+
+These two properties are what make the scheduler a convergence mechanism instead of
+an event callback. They are also what make content assertions pass, not just readiness
+assertions.
+
+## Scheduler Rules
+
+For every target with a live event stream:
+
+- If a selected type is `VerdictFollowable` and either it was never applied, its scope
+ changed, or the registry revision/generation advanced, enqueue
+ `EventRouter.EmitTypeReconcileForGitDest`.
+- If a selected type is `VerdictRetained`, enqueue nothing, keep the previous mirror,
+ and record `skip-retained` status. Retained means "do not stream, do not sweep".
+- If a previously selected type is no longer selected by rules, enqueue
+ `EventRouter.EmitTypeSweepForGitDest` for that GVR. After restart, "previously
+ selected" comes from the mirrored managed types, not only scheduler memory.
+- If the registry emits settled `TypeRemoved`, enqueue a type sweep for targets that
+ previously selected or currently mirror that GVR.
+- If a type is `VerdictRefused`, enqueue nothing and surface the reason. Permanent
+ refusal should never become a destructive sweep unless the type was previously
+ selected and the scheduler has an explicit previous-selection record to sweep.
+- If a scoped reconcile/sweep fails, keep the work item pending and retry from a
+ periodic recompute. Lifecycle events should speed up convergence, not be the only
+ recovery path.
+
+The scheduler must be idempotent: recomputing all target/type pairs after restart is
+valid. Missing in-memory state should err toward reconciling followable selected
+types, not sweeping unknown historical types.
+
+## Small Scheduler Slice
+
+The first scheduler PR should be intentionally small:
+
+- add the scheduler and recompute loop;
+- drive only scoped reconciles for selected `VerdictFollowable` types;
+- record worker apply results and retry failures;
+- leave whole-target bootstrap/rule-change delivery in place as a fallback;
+- avoid GitTarget status API churn except for internal/diagnostic fields needed by
+ tests.
+
+The second scheduler PR adds mirror-derived scoped sweep. Only after that should
+normal whole-target bootstrap and rule-change snapshots be removed.
+
+## GitTarget Controller
+
+Final target shape: replace the snapshot gate with target activation:
+
+```text
+Validated gate
+EncryptionConfigured gate
+ensure branch worker
+ensure/register GitTarget event stream
+schedule target's current selected types
+mark EventStreamLive based on stream registration
+mark Ready when target runtime is live and per-type work is accepted/observable
+```
+
+`Ready` should no longer mean "all selected types completed one global snapshot".
+That definition is what lets one unhealthy type block the whole target. The status
+should instead expose per-type progress and failures:
+
+- selected type count;
+- synced type count;
+- retained/wobbling type count;
+- failed type count with capped examples and reasons;
+- last successful type reconcile/sweep time.
+
+If the whole API catalog has never been observed (`registry.Ready()==false`), target
+activation should stay blocked. Once the registry is ready, unrelated type failures
+must not block target activation.
+
+Do not force this status/API cleanup into PR 1 or the first scheduler slice. Keeping
+`SnapshotSynced` as a transitional compatibility signal is acceptable while the
+scheduler proves convergence.
+
+## WatchRule and Rule Changes
+
+`ReconcileForRuleChange` should become:
+
+```text
+RefreshAPIResourceCatalog
+refreshWatchedTypeTables
+diff previous TargetTypeSet against current TargetTypeSet
+start/stop informers non-blockingly
+schedule per-type reconcile for added or scope-changed selected types
+schedule per-type sweep for removed selected types
+return so the WatchRule controller can write status
+```
+
+No whole-target snapshot is emitted for rule changes. A wildcard expansion across
+core and custom APIs becomes many independent type reconciles. If one CRD-backed type
+is not served yet, that type stays retained or fails its own reconcile; ConfigMaps and
+other healthy types still proceed.
+
+During the transition, rule changes may still run the old whole-target snapshot after
+non-blocking informer startup. The cutover happens only when scoped reconcile retry
+and mirror-derived scoped sweep are both validated.
+
+## Type Lifecycle Consumer
+
+Keep the registry subscription, but change the consumer from "directly do git work"
+to "wake the scheduler".
+
+Current direct behavior:
+
+```text
+TypeActivated -> fan out EmitTypeReconcileForGitDest
+TypeRemoved -> fan out EmitTypeSweepForGitDest
+```
+
+New behavior:
+
+```text
+TypeActivated -> scheduler.RecomputeGVR(gvr)
+TypeRecovered -> scheduler.RecomputeGVR(gvr)
+TypeWobbling -> scheduler.RecomputeGVR(gvr)
+TypeRemoved -> scheduler.RecomputeGVR(gvr)
+TypeRefused -> scheduler.RecomputeGVR(gvr)
+```
+
+The recompute step reads the latest registry verdict and target tables. This makes
+`TypeWobbling`, `TypeRecovered`, and `TypeRefused` useful without making every event
+handler re-derive type state.
+
+## Git Worker
+
+Keep the existing scoped machinery:
+
+- `EventRouter.EmitTypeReconcileForGitDest`;
+- `EventRouter.EmitTypeSweepForGitDest`;
+- `git.ResyncRequest.ScopeGVR`;
+- `manifestanalyzer.BuildScopedPlan`.
+
+Tighten the model so scoped resync is the runtime default. The whole-target branch in
+`resyncPlan` can remain only while repair/test callers still exist. The implementation
+should make it obvious when the old whole-target mode has no production callers left,
+then delete it.
+
+Do not add `SweepSkip`. It is the wrong abstraction: it preserves a global sweep and
+teaches it exceptions. Per-type sweep already gives the correct safety boundary.
+
+## Safety Invariants
+
+- Never sweep a type unless the work item is scoped to that type.
+- Never sweep a retained type. `VerdictRetained` means the previous mirror is held.
+- Never stream a retained type. It is not served/trusted right now.
+- Sweep on CRD/type disappearance only after the registry emits settled `TypeRemoved`
+ or after a rule diff proves this GitTarget no longer selects the type.
+- A failed per-type stream blocks only that type's reconcile. It must not prevent
+ other selected followable types from reconciling.
+- BranchWorker serialization remains the ordering guarantee for multiple type-scoped
+ commits touching the same file.
+
+## Implementation Sequence
+
+1. PR 1: remove `WaitForCacheSync` from informer startup and fix stale
+ comments/tests. Run the focused wildcard e2e and the required suite.
+2. Add `TargetTypeScheduler` with per-target/per-GVR sync state, periodic recompute,
+ apply-result feedback, and retry. Initially schedule scoped reconciles only.
+3. Change the lifecycle consumer to wake the scheduler instead of directly enqueueing
+ git work.
+4. Add mirror-derived per-type sweep: derive mirrored managed GVRs from the GitTarget
+ subtree/manifest store and sweep removed/unselected types with `ScopeGVR`.
+5. Rewrite rule-change delivery to diff target type sets and schedule scoped reconcile
+ or sweep. Keep the old whole-target snapshot fallback until tests prove scoped
+ convergence across restart and transient failures.
+6. Rewrite GitTarget activation to remove dependence on `evaluateSnapshotGate`.
+ `SnapshotSynced` may remain as a transitional condition until status is redesigned.
+7. Delete target-level snapshot delivery state: `snapshotTargetsNeedingDelivery`,
+ `ruleSetSnapshotTarget`, `lastDeliveredRuleSetHash`, `pendingRuleSetHash`, and
+ their tests.
+8. Remove production callers of `EmitResyncForGitDest` and
+ `StreamClusterSnapshotForGitDest`. Keep them only behind explicit repair/test names
+ until all tests are migrated.
+9. Update GitTarget status/tests to assert per-type progress; then delete
+ `SnapshotSynced`.
+10. Update design docs and flake findings to state that bootstrap is per-type and the
+ full snapshot path is no longer runtime architecture.
+
+## Files To Modify
+
+- `internal/watch/manager.go`: non-blocking informer start; rule-change path becomes
+ type-set diff + scheduler kick; remove target-level snapshot delivery state after
+ scoped convergence is proven.
+- `internal/watch/type_lifecycle.go`: lifecycle events wake scheduler; remove
+ `gitTargetSnapshotSynced` and direct fan-out functions.
+- `internal/watch/target_type_scheduler.go` (new): per-target/per-GVR scheduling and
+ sync-state owner.
+- `internal/watch/snapshot_stream.go`: keep `StreamSnapshotForType`; remove or demote
+ `StreamClusterSnapshotForGitDest` and `resolveSnapshotGVRs` from runtime paths.
+- `internal/watch/event_router.go`: keep scoped type reconcile/sweep; remove production
+ use of whole-target resync.
+- `internal/controller/gittarget_controller.go`: replace `evaluateSnapshotGate` with
+ target activation and scheduler kick; keep `SnapshotSynced` only as transitional
+ compatibility until status is redesigned.
+- `internal/git/types.go`, `internal/git/resync_flush.go`: keep `ScopeGVR`; delete
+ whole-target resync mode after repair/test callers are gone.
+- Tests under `internal/watch`, `internal/controller`, and `internal/git`: replace
+ whole-target snapshot expectations with scoped scheduling expectations.
+- Docs:
+ `docs/design/manifest/version2/type-lifecycle-events-and-wobble-settling.md` and
+ `docs/design/e2e-full-suite-flakiness-findings-2026-06.md`.
+
+## Verification
+
+Because this is a Go/runtime change, run the full validation sequence:
+
+1. `task fmt`
+2. `task generate`
+3. `task manifests` if GitTarget status/API docs change
+4. `task vet`
+5. `task lint`
+6. `task test`
+7. Check Docker with `docker info`
+8. `task test-e2e`
+
+Focused acceptance before the full e2e:
+
+```bash
+task prepare-e2e
+CTX=k3d-gitops-reverser-test-e2e \
+INSTALL_MODE=config-dir \
+NAMESPACE=gitops-reverser \
+E2E_AGE_KEY_FILE=.stamps/cluster/k3d-gitops-reverser-test-e2e/age-key.txt \
+go run github.com/onsi/ginkgo/v2/ginkgo \
+ --procs=1 \
+ --focus="should expand wildcard resources across core and custom namespaced APIs" \
+ ./test/e2e/
+```
+
+Expected outcome: WatchRule status is written quickly; healthy selected types commit
+independently; an unhealthy CRD-backed type cannot block ConfigMaps or other stable
+types; deletion is still only type-scoped.
diff --git a/docs/design/manifest/version2/per-type-reconcile-and-streaming-tail.md b/docs/design/manifest/version2/per-type-reconcile-and-streaming-tail.md
new file mode 100644
index 00000000..4e1085c7
--- /dev/null
+++ b/docs/design/manifest/version2/per-type-reconcile-and-streaming-tail.md
@@ -0,0 +1,684 @@
+# Per-Type Reconcile, the Streaming Tail, and Visibility
+
+> Status: design direction, captured 2026-06-05.
+> Origin: [dream.md](dream.md).
+> Related:
+> [reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md),
+> [current-manifest-support-review.md](current-manifest-support-review.md),
+> [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md),
+> [implementation-plan.md](implementation-plan.md).
+
+## What this document is
+
+M1–M8 built the materialized model and made the reconcile **correct**:
+content-derived identity end to end, a revision-pinned streaming snapshot, and a
+mark-and-sweep that refuses to act on a partial view. The implementation plan has
+exactly one milestone left — **M9, the cross-batch structure cache** — and it is
+pure optimization.
+
+This document designs what comes *after* the documented roadmap: the initial
+reconcile evolving from GitTarget-atomic to **per watched type**, the streaming
+tail that folds bootstrap and steady state into one connection, and the visibility
+surface that ends the "what does this GitTarget actually follow?" darkness. It
+carries a single foundational assumption (next section) that everything else
+obeys, proposes milestones **M10–M14**, and keeps the same level of detail as the
+rest of the corpus. The sequencing is a proposal to settle together.
+
+The work is five threads. Three of them are one architectural move — **make the
+unit of reconcile a watched type, not a whole GitTarget** — and two are the payoff
+that move unlocks: less machinery, more visibility.
+
+## Foundational assumption: the git folder mirrors the tracked cluster state
+
+**Git is the source of truth — the durable record an operator reads and GitOps
+consumes — and a GitTarget is a standing request from a user: *bring this git
+folder in line with these watched cluster resources, and keep it that way.*** The
+git folder's trustworthiness depends entirely on it holding **exactly** the
+currently-tracked resources: no stale extras, and nothing the cluster no longer
+serves. The cluster is authoritative for what a resource *contains*; the git folder
+must reflect, exactly, *which* resources are tracked. The instant a resource leaves
+the tracked set, the mirror is wrong until that resource leaves git too.
+
+This is not a new conviction — it is the same source-of-truth duty the materialized
+model already discharges by **dropping a watched resource the API no longer has**
+([current-manifest-support-review.md](current-manifest-support-review.md)). This
+document extends that duty to its logical edge: a resource also leaves the tracked
+set when its **type** does.
+
+The hard consequence, settled here and obeyed everywhere below:
+
+> **Untracking a type sweeps its KRM.** When a `WatchRule`/`ClusterWatchRule` is
+> adjusted to no longer follow a type, or a type is removed from the cluster API,
+> the only honest mirror is one without that type's documents. We remove all
+> involved KRM. This is a hard decision, not a policy knob.
+
+[The detailed mechanism and its one safety guard](#untracking-a-type-sweeps-its-krm)
+are below; it is stated here first because the rest of the design assumes it.
+
+## Worth designing now, not all worth building now
+
+The direction here is the right post-M8 architecture, but it must *not* all land in
+the M8 stabilization window. The threads have very different risk profiles, and the
+value of this design is in keeping them apart rather than treating them as one
+refactor:
+
+- **Low regret — do now/soon.** The watched-type table (Thread 1) and a basic
+ visibility summary (Thread 4). Both are worth doing **even if per-type reconcile
+ never lands**: the table stops re-resolving the whole type set in the hot path
+ and is the natural source for metrics/status; visibility ends the darkness a new
+ operator faces today. Neither changes reconcile behavior, so neither can regress
+ M8.
+- **The real win — after M8 is boring.** Per-type reconcile + per-type sweep (the
+ one move), which also carries the untracking sweep. High value (failure
+ isolation, understandable commits), moderate risk, and it lands *with today's
+ snapshot-close behavior* — **without** the merged streaming tail.
+- **A second-order rewrite — later.** The merged streaming tail (Thread 2). It is
+ **not** the natural next patch: it is a watch-subsystem rewrite that owns
+ reconnects, compaction / `410 Gone`, the aggregated-API fallback, fan-out
+ reference counting, and late joins. It is a milestone of its own.
+- **Only after measurement.** Dropping content hashing (Thread 3).
+
+The rest of this document designs all of it. The sequencing section maps these
+tiers onto milestones, low-regret first.
+
+## Where M8 left us
+
+The reconcile today is **GitTarget-atomic**. One GitTarget's initial reconcile
+(and every resync) is:
+
+```text
+StreamClusterSnapshotForGitDest(gitDest)
+ resolveSnapshotGVRs # re-resolve the whole watched type set, every time
+ joinSnapshotStreams # N streams (one per (GVR, namespace))
+ fold every ADDED
+ wait for EVERY type's initial-events-end bookmark
+ if ANY stream fails before its bookmark -> abort, return nothing
+ -> ClusterSnapshot{ Desired: , Revision: max bookmark RV }
+EnqueueResync(Desired) # one worker request
+ BuildPlan + apply + flush # ONE commit for the whole GitTarget
+```
+
+Grounded in code:
+
+- the gather and the join are
+ [`Manager.StreamClusterSnapshotForGitDest`](../../../internal/watch/snapshot_stream.go)
+ and `joinSnapshotStreams`; the all-or-nothing rule is the `firstErr`/`cancel()`
+ there;
+- the type set is re-resolved on every gather by `resolveSnapshotGVRs`
+ (`RefreshAPIResourceCatalog` + `ruleGVRResolver`);
+- the apply is the M8
+ [`BranchWorker.applyResyncToWorktree`](../../../internal/git/resync_flush.go),
+ one `BuildPlan` mark-and-sweep, one commit;
+- **steady state is a second, separate pipeline**: long-lived shared informers
+ ([`startInformersForGVRs`](../../../internal/watch/manager.go),
+ [`addHandlers`](../../../internal/watch/informers.go)) feed the
+ `GitTargetEventStream`, which **buffers** live events while the snapshot runs
+ (`BeginReconciliation` / `OnReconciliationComplete` in
+ [`event_router.go`](../../../internal/watch/event_router.go)) and flushes them
+ after;
+- **change detection is content-hash dedup**: `isDuplicateContent`
+ ([`informers.go`](../../../internal/watch/informers.go)) and
+ `computeEventHash` / `processedEventHashes`
+ ([`git_target_event_stream.go`](../../../internal/reconcile/git_target_event_stream.go))
+ both sha256 the sanitized YAML to drop status-only churn.
+
+Two structural costs fall out of "GitTarget-atomic":
+
+1. **One wobbly type blocks every stable type.** A CRD whose apiserver is
+ throttling, a half-installed aggregated API, a type mid-upgrade — any single
+ stream that cannot reach its bookmark aborts the *entire* GitTarget reconcile.
+ The stable 95% of the folder is held hostage by the unstable 5%. The M8 rule
+ ("fail loudly, never act on a partial view") is correct, but the blast radius is
+ wrong.
+2. **Bootstrap and steady state are two subsystems with a handover.** The snapshot
+ stream is opened, drained to its bookmark, and **thrown away**; live events come
+ from a different connection (the informers) and have to be buffered across the
+ handover.
+
+## The one move: reconcile per watched type
+
+Make the unit of reconcile a **`(GVK, GVR, scope)`** — one watched type — instead
+of a whole GitTarget. A GitTarget watching five types becomes five reconciles, each
+independent.
+
+This is not only an optimization; it **refines the consistency boundary** in a way
+that is strictly safer than today's, once the safety argument below holds.
+
+### Why a per-type sweep is safe (the load-bearing argument)
+
+The M8 design forbids sweeping on a partial mark, and is emphatic:
+
+> "Sweeping after type A's bookmark but before type B's would delete all of B's
+> manifests as phantom orphans."
+
+That sentence is true **only because today's sweep is computed over all members at
+once**. The per-type design changes the sweep set, and the prohibition dissolves:
+
+- The managed model's members partition cleanly **by GVK** — a `DocumentModel`
+ belongs to exactly one type. Type A's members and type B's members are disjoint
+ sets.
+- A **type-scoped sweep** computes `orphansₐ = membersₐ − streamedₐ` using only
+ type A's members and only type A's streamed identities. It can never name a type
+ B document, because a type B document is not in `membersₐ`.
+- Type A's `initial-events-end` bookmark is, by the same Kubernetes guarantee M8
+ relies on, the proof that type A's initial sync is **complete**. That is exactly
+ and only what a type A sweep needs.
+
+So the rule survives, scoped down: **no bookmark for type A, no sweep of type A —
+but type A's bookmark is sufficient to sweep type A.** Type B's troubles are type
+B's alone. This is the breathing room for wobbly types, and it is a *tightening* of
+the safety property — the abort blast radius shrinks from the GitTarget to the type
+— not a loosening.
+
+One case needs naming: a **multi-document file holding two types**
+(`a.yaml` = `[ConfigMap, Deployment]`). A `ConfigMap` sweep that drops document 0
+edits a file the `Deployment` still lives in. That is already safe by the M8 model:
+deletes are document-granular (`manifestedit.DeleteDocument`), the file is only
+removed when its *last* managed document goes, and positions are re-derived from
+live bytes at apply (`currentDocIndex`). A per-type sweep over a shared file only
+ever removes documents *of that type*, so it composes with M7/M8 unchanged. The
+only new requirement is that two type-scoped commits touching the same file are
+**serialized** — which they already are, because they ride the same BranchWorker
+queue (see "Ordering" below).
+
+### Per-type reconcile, sketched
+
+```mermaid
+flowchart TD
+ T[GitTarget watched-type table] --> A[type A reconcile]
+ T --> B[type B reconcile]
+ T --> C[type C reconcile]
+ A --> Aq[enqueue type-A plan on BranchWorker queue]
+ B --> Bq[enqueue type-B plan on BranchWorker queue]
+ C --> Cq[enqueue type-C plan on BranchWorker queue]
+ Aq --> Q[(BranchWorker queue — one writer, in order)]
+ Bq --> Q
+ Cq --> Q
+ Q --> Commit[coalesced commits: 1..N per window]
+```
+
+Each type's reconcile:
+
+```text
+open stream for (GVR, scope) with sendInitialEvents
+fold initial ADDED -> desiredₐ
+at the initial-events-end bookmark:
+ build (or reuse) the managed model for the GitTarget subtree
+ sweepₐ = membersₐ(model) − desiredₐ # type-scoped mark-and-sweep
+ plan = create/patch desiredₐ + drop sweepₐ
+ enqueue plan on the BranchWorker queue # ordering + coalescing
+mark type A "synced"; CLOSE the snapshot stream
+ steady state for type A continues via the existing informer pipeline
+ (folding bootstrap + tail into one stream is the LATER Thread 2 milestone)
+```
+
+Independence is the whole point:
+
+- **One commit per type**, trading one big commit for several readable ones, with
+ the existing commit window still free to coalesce several quick type reconciles
+ into one commit when they land together.
+- **A type starts tracking the moment its own sync finishes** — it does not wait
+ for its slowest sibling.
+- **Adding a type re-reconciles only that type**, not the whole GitTarget.
+ **Removing a type sweeps it** — see the next section.
+
+## Untracking a type sweeps its KRM
+
+The foundational assumption makes one behavior non-negotiable, and per-type
+reconcile makes it natural to implement. A type leaves a GitTarget's tracked set in
+exactly two ways, and both sweep:
+
+- **The rules stop following it.** A `WatchRule`/`ClusterWatchRule` is edited so
+ type X is no longer selected. This is an explicit, user-driven, unambiguous
+ signal. The user has said "stop mirroring X here," so X's documents must leave the
+ git folder. The type may still exist in the cluster — irrelevant; this GitTarget
+ no longer mirrors it.
+- **The cluster removes the type.** A CRD is uninstalled, or an API version is
+ retired, so the GVK no longer resolves to a served resource. The cluster can no
+ longer be the source for those documents, so a faithful mirror cannot keep them.
+
+Both reduce to **"type X is no longer tracked → drop every managed document of type
+X."** There is no desired set to diff against — the desired set for an untracked
+type is *empty by definition* — so this is a degenerate, especially cheap
+mark-and-sweep: enumerate `ByGVK[X]` in the managed model and emit a
+`PlanDropOrphan` for each, exactly the M3/M8 drop path, deleted by `RecordRef` so a
+moved manifest is still removed. It is **driven by the watched-type table diff**
+(Thread 1): when the resolved table loses an entry that the git folder still has
+documents for, that entry's documents are swept in one type-scoped commit.
+
+### The one safety guard: a trusted absence, never an unobservable one
+
+The rules-change trigger is safe on its own — it is a deliberate edit to a CR, not
+an inference. The **cluster-removed-the-type** trigger is where the corpus's
+fail-closed discipline is mandatory, because a destructive sweep driven by a wrong
+observation would delete a great deal of git content from a transient hiccup.
+
+So the type-removed sweep fires **only on a trusted absence**:
+
+- A GVK that resolves to `MappingUnserved` against a **ready, non-degraded** catalog
+ (`APIResourceCatalog` reports the type genuinely gone) is a trusted removal →
+ sweep.
+- `MappingCatalogUnavailable` / `MappingDiscoveryDegraded` — discovery could not be
+ observed — is **not** absence. It is fail-closed: hold, sweep nothing, retry when
+ the catalog is trustworthy again. This is the same rule the mapping layer and M6
+ `PlanDelete` already enforce ("never treat an unobservable surface as absence" —
+ [gvk-gvr-mapping-layer.md](gvk-gvr-mapping-layer.md), Failure Policy); the
+ type-level sweep inherits it verbatim.
+
+A momentarily-flaky apiserver therefore delays a sweep; it never causes a wrong one.
+A genuinely uninstalled CRD, confirmed by a healthy catalog, sweeps.
+
+### Where the hold lives: the watched-type store, not the catalog
+
+"Delays a sweep" above needs a home. The hold belongs in the **watched-type store
+(Thread 1), on the table publish path — not in `APIResourceCatalog`.** The distinction
+is foundational and worth stating sharply:
+
+- The **catalog is discovery truth**: *what the API server says it serves right now.*
+- The **watched-type table is action policy**: *what this GitTarget is allowed to act
+ on.*
+
+A hold is a policy decision about whether an observed absence is yet *actionable*. If we
+put it in the catalog — keeping stale `byGVR`/`byGVK` entries for a now-absent CRD for 60s
+— **discovery would lie**: validation, the mapping layer, the resolver, and any status
+check would all believe the type is still served, producing spurious `404`/`list failed`
+behaviour downstream from a shared primitive. The catalog must stay honest and surface
+removals immediately; it may *later* expose timestamps, but it must never *suppress* a
+removal. So the catalog reports the type gone the instant discovery does, and the
+watched-type store decides what to do about it.
+
+**Persistent absence in the watched-type store.** The store compares the table it last
+published against a freshly resolved *candidate* table on every refresh, and applies one
+rule per previously-published type the candidate no longer lists:
+
+- **A new type appears** → publish immediately (no hold on additions).
+- **The rules no longer select it** (checked against the *raw compiled rules*, not the
+ catalog) → remove immediately. This is explicit user intent and is never delayed.
+- **The catalog is unavailable/degraded for it** → retain indefinitely and mark the table
+ blocking; the gather already fails closed on the table's blocking misses. An
+ unobservable surface is never a trusted absence (the guard above, verbatim).
+- **The rules still select it but a healthy catalog says it is gone** → start (or
+ continue) a **pending removal**: retain the last-published `WatchedType`, keep its
+ informers alive, and **block the gather** until either the type reappears (clear the
+ pending, no behaviour change) or the absence persists past a grace window
+ (`removalGrace`, default 60s), at which point the removal is published and teardown +
+ sweep may proceed.
+
+Mechanically this adds a small amount of state to the store, all touched only under the
+existing publish lock: `pendingRemovals` keyed by `(GitTarget → typeKey → since/reason)`,
+where `typeKey` is the `(GVK, GVR, scope)` identity (the namespace/operation scope stays
+*inside* the retained `WatchedType`, not in the key, so a held type matches its candidate
+regardless of how its namespaces were last gathered); a `removalGrace` duration; and an
+injectable `now` for deterministic tests. The published `WatchedTypeTable` grows a
+`PendingRemovals []PendingRemoval` slice, and `resolveSnapshotGVRs` **returns an error
+while it is non-empty** — the explicit fail-closed that stops a reduced or empty snapshot
+from sweeping git during the wobble. Because the grace is time-based, the re-resolution
+gate (rules fingerprint + catalog generation) is bypassed while any removal is pending, so
+the grace can actually elapse on a quiet cluster.
+
+The rule-intent check (`rulesStillSelectWatchedType`) is what keeps an explicit untrack
+*immediate* while a CRD wobble is *held*: it matches the held type's group/version/resource
+and its cluster-wide-vs-namespaced shape against the raw rules — a cluster-wide type only
+counts as still-selected if a `ClusterWatchRule` selects it at the same scope; a
+per-namespace type only if a `WatchRule` in one of its gathered namespaces does.
+
+This is the clean fix for the `resources: 7 → 0 → 7` wobble that motivated it: a transient
+discovery dip retains the types, holds the gather, and clears the moment discovery
+recovers — the mirror is never swept on a view it could not trust. Edge logs
+(`watched type absent; holding removal`, `… reappeared; clearing pending removal`,
+`… absence persisted past grace; removing`) and the
+`gitopsreverser_watched_type_pending_removals` gauge make the hold observable. When per-type
+reconcile (M12) lands, the same pending state scopes the block and the eventual sweep to
+the individual type instead of the whole GitTarget gather.
+
+### This is not the adoption-time refusal — and does not contradict it
+
+[current-manifest-support-review.md](current-manifest-support-review.md) Decision #3
+*refuses* a GitTarget that, **at adoption**, contains API-backed KRM of a type it
+never watched: we never claimed it, so we will not silently prune content a human
+authored. That stays. The distinguishing question is **"was it ours?"**:
+
+- **Never-tracked KRM discovered while adopting a folder** → ambiguous, not ours →
+ **refuse** (Decision #3, unchanged).
+- **A type this GitTarget *was* tracking, now untracked** → unambiguously ours; we
+ materialized it, the user/cluster has withdrawn it → **sweep**.
+
+The two are complementary, not contradictory: refusal protects content we never
+claimed; the untracking sweep keeps the mirror honest for content that was always
+ours.
+
+## Thread 1: a resolved, deliberate watched-type table per GitTarget
+
+A GitTarget needs a first-class, resident table of the types it follows — resolved
+once, changed only on a deliberate trigger — rather than today's re-derivation of
+the set inside `resolveSnapshotGVRs` on **every** gather.
+
+Make the resolution a resident, per-GitTarget value:
+
+```text
+WatchedTypeTable {
+ GitTarget
+ Entries []WatchedType{
+ GVK, GVR, Namespaced, Scope(namespaces | cluster-wide),
+ CRDVersion / servedVersion, # exact version behind the GVK
+ ResolvedAt (catalog generation),
+ SyncState, LastSyncedRV, SyncedCount # filled by the per-type reconcile
+ }
+}
+```
+
+It is re-resolved on **deliberate triggers only**:
+
+- a GitTarget rule-set change (already the `ReconcileForRuleChange` path);
+- a catalog generation bump (`APIResourceCatalog.Generation()` already exists at
+ [api_resource_catalog.go:104](../../../internal/watch/api_resource_catalog.go#L104))
+ — a CRD installed/removed/upgraded changes what a GVK resolves to.
+
+The table is the spine the rest of the design hangs on:
+
+- **per-type reconcile iterates it** (one reconcile per entry);
+- **its diff drives the untracking sweep** — an entry that disappears (rule change)
+ or stops resolving against a trusted catalog (cluster removal) sweeps that type's
+ KRM;
+- **visibility surfaces it** — it is exactly the "what does this GitTarget follow?"
+ artifact, so the table and the overview are the same thing built once;
+- **it keys the shared cluster watch** by `(GVR, scope)`, so many GitTargets' tables
+ can point at one stream (Thread 2).
+
+## Thread 2: merge the initial send with the live tail (stream order, not RV math)
+
+After the initial send finishes, the same stream should keep flowing: pick up the
+live tail rather than handing off to a second connection. The mechanism for "is
+this event after the snapshot?" **must not be literal numeric `resourceVersion`
+comparison.** `resourceVersion` is opaque by Kubernetes contract — it is not
+promised to be a comparable integer across the versions we support, and code that
+does `rv > rv_b` relies on an implementation detail. Treat it as a **continuation
+token**, not a number.
+
+The robust framing is **stream order**: events delivered *after* the
+`initial-events-end` bookmark on the same watch are post-snapshot **by
+construction**, no comparison required. On reconnect, resume the watch *from* the
+last observed `resourceVersion` (the token), and on `410 Gone` re-list and re-run
+the type's sweep — ordinary Kubernetes watch semantics. (If a per-type monotonic-RV
+guarantee is later confirmed for every Kubernetes version we support, it can
+*reinforce* this story, but the design must never *depend* on integer comparison.)
+
+With that framing, a single stream per `(GVR, scope)` serves **both** the initial
+reconcile and steady state as one merged data stream:
+
+```text
+open ONE watch for (GVR, scope): sendInitialEvents=true, bookmarks=true
+phase 1 (initial send): fold synthetic ADDED -> desired set
+ bookmark: run the type-scoped mark-and-sweep, commit
+phase 2 (live tail): keep the SAME stream open
+ events AFTER the bookmark (by stream order) -> plan actions
+ on reconnect: resume the watch from the last observed
+ resourceVersion (continuation token)
+ on 410 Gone: re-list and re-run the type sweep
+```
+
+It collapses the two subsystems M8 left separate:
+
+- **No RECONCILING buffer / handover.** Today live events are buffered in
+ `GitTargetEventStream` while the snapshot runs and flushed after
+ (`BeginReconciliation`/`OnReconciliationComplete`). With one merged stream the
+ live tail simply *follows* the bookmark on the same connection — the handover
+ disappears, and so does the window where a long sync could miss or double-handle
+ an event. An event that lands during a slow initial send over a large resource
+ set is not lost and not buffered indefinitely — it is simply the first item on
+ the tail, after the bookmark.
+- **The informer pipeline for reconciled types is subsumed.**
+ [`startInformersForGVRs`](../../../internal/watch/manager.go) +
+ [`addHandlers`](../../../internal/watch/informers.go) exist to deliver live
+ events; a merged per-type stream delivers them itself. (The informer cache also
+ gives `DeletedFinalStateUnknown` handling and shared fan-out — see the fan-out
+ bullet — so this is a careful swap, not a delete.)
+- **Shared cluster watch, fanned out.** One stream per `(GVR, scope)` regardless of
+ how many GitTargets watch that type, with the watched-type tables (Thread 1) as
+ the fan-out routing. This generalizes the informers' current shared-factory model
+ (`informerFactories` keyed by namespace in
+ [manager.go](../../../internal/watch/manager.go)) to also carry the per-GitTarget
+ initial send.
+
+**The subtlety to respect:** stream order answers *"is this after the snapshot?"*
+It does **not** answer *"did the materialized content actually change?"* — a
+status-only update is delivered on the tail like any other event but sanitizes to
+identical YAML. That question is Thread 3.
+
+**Why this is a separate, later milestone — not folded into per-type reconcile.**
+Per-type reconcile (the move above) lands cleanly with **today's snapshot-close
+behavior**: gather to the bookmark, sweep, commit, close the stream, and let the
+existing informer pipeline carry steady state — exactly as M8 does, just scoped to
+one type. The merged tail is a strictly *bigger* change, and it owns the gnarly
+lifecycle the snapshot path gets to ignore precisely because it closes immediately:
+watch **reconnects**, **compaction / `410 Gone`** (the resume token expired → must
+re-list → re-sweep), the **aggregated-API LIST fallback** needing a tail story of
+its own, **fan-out reference counting** for a shared stream, and **a GitTarget
+joining an already-running stream** (fresh initial send, or reconcile off the
+shared cache?). These are real distributed-systems edges, so the tail is its own
+milestone, sequenced well after per-type reconcile is proven.
+
+## Thread 3: drop the content hashing — carefully
+
+The two content hashes today are:
+
+- `isDuplicateContent` ([informers.go](../../../internal/watch/informers.go)) —
+ drops status-only informer churn before it is routed;
+- `computeEventHash`/`processedEventHashes`
+ ([git_target_event_stream.go](../../../internal/reconcile/git_target_event_stream.go))
+ — drops a repeated identical event per identity.
+
+Both answer the **content-changed?** question, which stream order alone cannot (a
+status-only update is a real, ordered event). But there is already a *third* answer
+to that question, computed for free at the commit boundary: the writer's no-op
+detection (`manifestedit.Decide` → `EditNoChange`, and `manifestsAreSemanticallyEqual`
+in [plan_flush.go](../../../internal/git/plan_flush.go)). A status-only update that
+reaches the writer produces no commit today.
+
+So the hashes are **removable**, but removing them is a *trade*, not a free win:
+
+- **Removed:** a sha256-of-sanitized-YAML on every informer event (real CPU), plus
+ the `processedEventHashes` map and the dedup branch.
+- **Added:** more events flow to the BranchWorker, each costing a commit-boundary
+ parse + `Decide` compare for its one identity (cheap per event, coalesced per
+ window, but not zero) for changes that the hash would have dropped at the edge.
+
+The honest framing: **stream order becomes the freshness/ordering authority**
+(events past the bookmark are new; never re-handle a pre-bookmark one), and the
+**writer's existing no-op detection becomes the content-change authority.** The
+per-event hash is then redundant and can go — but this should land *only after* the
+merged stream (Thread 2) makes stream order authoritative, and should be
+**measured** on a high-churn type before and after, because the CPU saving depends
+on churn-rate vs. commit-rate. If a pathological high-churn type ever makes
+commit-boundary compares hurt, the cheap fallback is a per-`(identity)`
+**last-processed-token equality** check — "have we already handled this exact
+`resourceVersion` for this identity?", a string equality, not a hash and not an
+ordering comparison — which keeps the opaque-token contract intact and is still far
+cheaper than sha256.
+
+## Thread 4: visibility
+
+A new operator has no overview today of what a GitTarget actually follows. The
+watched-type table (Thread 1) is the data; the per-type reconcile fills in its live
+state. A *basic* summary (which types, their CRD versions, resolved-vs-failing
+counts) is available from the **table alone**, so it can ship right after Thread 1,
+before any reconcile change. The *richer* per-type sync counters fill in once
+per-type reconcile lands. Three surfaces, in increasing cost:
+
+1. **Metrics (start here).** Per `(GitTarget, GVK)` gauges/counters: synced object
+ count, last-synced revision, sync state, reconcile duration, drops. This is where
+ the bulk per-type data belongs — it is unbounded-friendly, it is what admins
+ already scrape, and the telemetry plumbing exists
+ ([`internal/telemetry`](../../../internal/telemetry), e.g. `ObjectsScannedTotal`,
+ `APICatalogGeneration`).
+2. **Bounded status summary.** Extend `GitTargetStatus`
+ ([gittarget_types.go:116](../../../api/v1alpha1/gittarget_types.go#L116), which
+ already has `Snapshot`/`Stats`) with a **capped** per-type roll-up: total types,
+ how many synced, the slowest/failing types, last sync time. Status carries the
+ *summary and the exceptions*; metrics carry the *full table*. Status must stay
+ small.
+3. **A queryable inventory (optional, later).** If the full table is genuinely
+ wanted in-cluster — "which exact CRD version is behind this type" per GitTarget —
+ a separate status-only resource (a `GitTargetInventory` CR, or a status
+ subresource list) keeps it out of the hot GitTarget object. Decide this only if
+ metrics + summary prove insufficient; it is the most expensive surface.
+
+The exact-CRD-version data (`servedVersion` per GVK) is already reachable through
+the catalog (`APIResourceEntry`), so it is a matter of *surfacing*, not
+*discovering*.
+
+## Ordering, commits, and the queue
+
+Multiple commits and multiple writers stay safe because they remain serialized on
+the BranchWorker queue. The BranchWorker is a single-goroutine event loop
+([branch_worker.go](../../../internal/git/branch_worker.go)); resyncs already ride
+it as `ResyncRequest`s (`EnqueueResync` → `handleResyncRequest`), serialized with
+live events. Per-type reconcile (and the untracking sweep) change *how many*
+requests land, not the ordering discipline: every type's plan is enqueued on the
+same queue, applied in arrival order, and the existing commit window coalesces
+co-arriving ones. No new concurrency is introduced on the write side — the fan-out
+concurrency is on the *read* (watch) side, which already runs many streams.
+
+## Consequences
+
+- **More commits, more readable.** The existing coalescing window keeps it from
+ becoming chatty: quick successive type reconciles still merge into one commit when
+ they land inside a window.
+- **Wobbly types stop poisoning stable ones.** The headline robustness win, and it
+ follows directly from the per-type sweep safety argument. A degraded type fails
+ *itself* (and is visible as such via Thread 4) while its siblings sync.
+- **Untracking is a first-class, observable event.** Removing a type from the rules,
+ or a CRD being uninstalled, produces a clear, type-scoped sweep commit (and a
+ visibility transition), not a silent drift. The fail-closed guard means an
+ unhealthy catalog delays that sweep rather than misfiring it.
+- **Big resource sets need their own e2e + metrics.** A cluster-wide CRD with
+ thousands of objects is the stress case for the initial send duration and the
+ live-tail merge. This wants a dedicated e2e (large synthetic set, assert per-type
+ commits + correct sweep + no lost tail event) and the Thread 4 metrics to observe
+ it.
+- **Drop hashing.** Viable after the merged tail, with the measured trade in Thread
+ 3.
+- **The cross-batch cache (deferred M9) is reshaped, not wasted.** Per-type
+ reconcile means smaller, type-scoped stores rebuilt more often; a structure cache
+ still helps but its key and granularity change (per `(checkout, GitTarget,
+ type?)`). M9 is therefore best built against the per-type batch shape, not
+ today's whole-folder one.
+
+## Proposed sequencing (to decide together)
+
+A proposal in the implementation-plan's idiom, ordered **low-regret first**.
+Numbers are provisional and reflect recommended implementation order, not just raw
+dependency.
+
+**Tier 1 — low regret, do now/soon (no reconcile change, cannot regress M8):**
+
+- **M10 — Watched-type table (+ basic per-GVK metrics).** Promote
+ `resolveSnapshotGVRs` into a resident, per-GitTarget `WatchedTypeTable`,
+ re-resolved on rule-change and catalog generation only, and emit basic
+ per-`(GitTarget, GVK)` metrics (resolved/failed, CRD version). *No reconcile
+ behavior change* — the existing gather reads the table instead of re-resolving
+ inline. **Done when:** the table is the single source of "what this GitTarget
+ watches," re-resolution is triggered (not per-gather), a catalog generation bump
+ re-resolves it, and basic metrics exist.
+
+ > **Status (2026-06-05): landed.** The table and resident store live in
+ > [`internal/watch/watched_type_table.go`](../../../internal/watch/watched_type_table.go)
+ > and [`watched_type_resolver.go`](../../../internal/watch/watched_type_resolver.go).
+ > All three former inline resolvers now read it: the snapshot
+ > (`resolveSnapshotGVRs`), the effective-plan hash (`currentRuleSetSnapshots`, kept
+ > byte-identical via `watchPlanFromTable`), and the informer set
+ > (`computeRequestedGVRs` + `getNamespacesForGVR`) — the latter removing the old
+ > pattern-match path, which also fixes the divergence where a WatchRule namespace
+ > shadowed a coexisting cluster-wide rule. Re-resolution is gated on a rules
+ > fingerprint and the catalog `Generation()`. Metrics: `gitopsreverser_watched_types`
+ > and `gitopsreverser_watched_type_conflicts` per GitTarget.
+ >
+ > **One settled simplification adopted here:** GVK↔GVR is treated as **1:1**. A GVK
+ > the rules resolve to more than one served resource is recorded as a `TypeConflict`
+ > and **not watched** ("fix your cluster"), rather than watched ambiguously in
+ > GVR-space. GVR→GVK is already single-valued in the catalog, so this only refuses a
+ > pathological cluster; it is what lets the table be GVK-keyed losslessly, aligning
+ > the watch side with the already-GVK-keyed managed model (`ManifestStore.ByGVK`) and
+ > giving M12's per-type sweep and M13's GVR→GVK delete mapping an unambiguous
+ > bijection to lean on.
+ >
+ > **Persistent absence also landed (2026-06-05).** The store's publish path now holds a
+ > still-selected type the catalog momentarily stops serving rather than dropping it on
+ > the spot — see [Where the hold lives](#where-the-hold-lives-the-watched-type-store-not-the-catalog).
+ > A candidate-vs-published diff each refresh classifies every disappeared type as
+ > immediate-removal (rules no longer select it), indefinite blocking retention (catalog
+ > degraded/unavailable), or a grace-held **pending removal** (rules still select it, a
+ > healthy catalog says it is gone). A pending removal retains the `WatchedType` (informers
+ > stay up) and makes `resolveSnapshotGVRs` fail closed via the new
+ > `WatchedTypeTable.PendingRemovals`, so a `resources: 7 → 0 → 7` discovery wobble no
+ > longer sweeps git. State and grace (`removalGrace`, default 60s, injectable `now`) live
+ > in `watchedTypeStore`; observability is the three edge logs plus
+ > `gitopsreverser_watched_type_pending_removals`.
+- **M11 — Visibility summary.** A *bounded* `GitTargetStatus` roll-up derived from
+ the table: total watched types, resolved-vs-failing counts, failing type names
+ (capped), CRD versions; metrics carry the full per-type detail. Depends only on
+ M10. **Done when:** an operator can see what a GitTarget follows and which types
+ are unhealthy, without bloating the object. (Per-type *sync* counters fill in once
+ M12 lands.)
+
+**Tier 2 — the real win, after M8 is boring:**
+
+- **M12 — Per-type reconcile + per-type sweep + untracking sweep, snapshot-close.**
+ Split the GitTarget-atomic gather/apply into per-`(GVR, scope)` reconciles with
+ type-scoped mark-and-sweep, each enqueued on the BranchWorker queue; a failing
+ type aborts only itself. A type the watched-type table diff drops (rule change, or
+ trusted cluster removal) sweeps all its KRM; an untrusted/degraded catalog holds.
+ **No merged tail** — gather to the bookmark, sweep, commit, close the stream, and
+ let the existing informer pipeline carry steady state, exactly as M8. **Done
+ when:** e2e shows a wobbly type does not block stable ones; per-type commits land;
+ the sweep never crosses type boundaries; multi-type files stay document-correct;
+ untracking a type sweeps its KRM, and a degraded catalog does not. **[runtime]**
+
+**Tier 3 — second-order, deliberately last:**
+
+- **M13 — Merged stream (initial send + live tail).** One stream per `(GVR, scope)`
+ carries the initial send through the bookmark and continues as the live tail (by
+ *stream order*, resuming from the continuation token on reconnect). Remove the
+ RECONCILING buffer/handover and retire the separate informer path for reconciled
+ types (preserving its fan-out and deleted-final-state handling). A watch-subsystem
+ rewrite with its own lifecycle edges — reconnect, `410 Gone`, aggregated-API
+ fallback, fan-out ref counting, late join. **Done when:** an event arriving during
+ a long initial send is applied exactly once via the tail; bootstrap and steady
+ state are one connection per type. **[runtime]**
+- **M14 — Drop content hashing — only after measurement.** Remove
+ `isDuplicateContent` / `processedEventHashes` once stream order + commit-time
+ no-op detection are the authorities; measure CPU on a high-churn type first; keep
+ the last-processed-token equality guard as the fallback if needed. **Done when:**
+ no regression on a status-churn workload; CPU measured before/after.
+
+**Recommended order:** M10 → M11 → M12 → M13 → M14. M10/M11 are the "do now/soon"
+tier and cannot regress M8 (no reconcile change). M12 is the headline win, taken
+*after* M8 stabilization, and is the only Tier-2 item. M13 and M14 are explicitly a
+later subsystem rewrite and a measured cleanup. M11 depends only on M10 and could
+run in parallel with M12, but ship the cheap visibility first. The deferred **M9
+cache** is best revisited *after* M12 settles the batch shape.
+
+## Open questions
+
+- **Cross-type consistency.** GitTarget-atomic gave one snapshot revision for the
+ whole folder. Per-type gives one revision *per type*. Is any consumer relying on a
+ single folder-wide consistent revision (status? a future audit join?)? If so, the
+ per-type revisions need a documented "max across types" interpretation, as
+ `maxResourceVersion` already hints.
+- **Fan-out lifecycle.** A shared `(GVR, scope)` stream feeding many GitTargets
+ needs reference-counted start/stop and a clear story for "a new GitTarget joins a
+ type already being streamed" (a fresh initial send, or reconcile off the shared
+ cache?). The informer factory already does shared lifecycle; the merged stream
+ must not regress it.
+- **Bound on concurrent streams.** A GitTarget (or a cluster) watching very many
+ types opens very many streams. Is there a concurrency cap, and how is "waiting for
+ N of M type syncs" surfaced (now naturally answered by Thread 4)?
+- **Aggregated apiservers.** The per-type LIST fallback
+ (`isStreamingWatchUnsupported` → `listInitialEvents`) must survive the merge: a
+ type that cannot stream gets a consistent LIST for its initial send and then —
+ what for its tail? (Fall back to an informer for just that type, or periodic
+ re-list.) This is the per-type analogue of M8's hardening #6.
+- **The destructive-sweep observability bar.** Untracking sweeps can be large
+ (uninstalling a CRD removes every document of that type). The behavior is settled;
+ what remains is making it *loud* — commit messages, metrics, and status that name
+ the swept type and count, so a large sweep is auditable after the fact.
diff --git a/docs/design/manifest/version2/scale-subresource-audit-rehydration.md b/docs/design/manifest/version2/scale-subresource-audit-rehydration.md
new file mode 100644
index 00000000..d1b992a3
--- /dev/null
+++ b/docs/design/manifest/version2/scale-subresource-audit-rehydration.md
@@ -0,0 +1,278 @@
+# Subresource Audit Resolution
+
+> Status: design proposal, captured 2026-06-08, revised 2026-06-08.
+>
+> Trigger: `kubectl scale deployment` updates the live Deployment through the
+> `deployments/scale` subresource, but the committed Deployment manifest is not
+> updated today.
+>
+> Scope update: see
+> [subresource-scope-reduction.md](subresource-scope-reduction.md). Future work
+> should narrow this from generic subresource translation to built-in and CRD
+> `/scale` only.
+
+## Problem
+
+Kubernetes subresources are API endpoints below a parent resource:
+
+```text
+deployments/scale
+deployments/status
+pods/exec
+customresources/status
+customresources/scale
+```
+
+Some subresources mutate desired parent state. Others are runtime commands,
+status writes, logs, streams, token requests, or proxy operations. GitOps
+Reverser must not treat all subresources as manifests.
+
+The concrete event is captured at
+[`internal/webhook/testdata/audit-events/deployment-scale-subresource.json`](../../../../internal/webhook/testdata/audit-events/deployment-scale-subresource.json).
+Its `objectRef` identifies the parent Deployment, but its `responseObject` is an
+`autoscaling/v1 Scale`, not an `apps/v1 Deployment`.
+
+The old ingress behavior dropped every event with `objectRef.subresource != ""`.
+That preserves safety, but misses author-preserving desired-state mutations such
+as scale.
+
+## Decision
+
+Represent supported mutating subresource events as **field-patch events** against
+the already committed parent manifest.
+
+For `deployments/scale`, the event becomes:
+
+```text
+Identifier: apps/v1 deployments scale-audit-capture/scale-audit-target
+Operation: UPDATE
+FieldPatch:
+ Source: deployments/scale
+ Assignments:
+ - {Path: [spec, replicas], Value: 3}
+UserInfo: {Username: system:admin}
+```
+
+The Git layer never writes the `Scale` response object and never fetches a full
+parent object as commit content.
+
+## Non-Negotiables
+
+- Commit values come from `responseObject.spec` in the audit event only.
+- Do not fall back to `requestObject.spec`; request bodies are pre-admission
+ intent, not confirmed accepted state.
+- Do not hydrate the parent object to build the commit body.
+- A live parent GET is allowed only as a **sanitized field-presence gate**.
+- Do not add `ParentKind` to `git.FieldPatch`; production resolves the parent by
+ GVR/resource identity in the writer.
+- Do not add subresources to `WatchedTypeTable`.
+- Do not allow subresource names in `WatchRule.resources`.
+- Do not write subresource response objects, such as `autoscaling/v1 Scale`, to
+ Git.
+- Do not mirror `status`, command, log, proxy, attach, exec, token, binding, or
+ finalize-style subresources.
+
+## Flow
+
+```text
+kube-apiserver audit event
+ -> webhook ingress
+ -> deny-list gate for hard-refused subresources
+ -> canonical Redis stream
+ -> audit consumer rule matching on parent GVR
+ -> translate responseObject.spec into candidate assignments
+ -> GET parent only for sanitized field-presence check
+ -> drop unless every assignment path exists in sanitized parent projection
+ -> git.Event{FieldPatch: ...}
+ -> GitTargetEventStream / BranchWorker
+ -> manifestedit.PatchFields on existing Git parent document
+ -> commit authored from the audit event user
+```
+
+## Field-Patch Event Shape
+
+`git.Event` gets a mutually exclusive field-patch payload:
+
+```go
+type Event struct {
+ Object *unstructured.Unstructured // full-object path
+ FieldPatch *FieldPatch // bounded field patch path
+
+ Identifier types.ResourceIdentifier // parent GVR + namespace/name
+ Operation string
+ UserInfo UserInfo
+ // ... Path, GitTargetName, ...
+}
+
+type FieldPatch struct {
+ Assignments []manifestedit.FieldAssignment
+ Source string // bounded label, e.g. "deployments/scale"
+}
+```
+
+`ParentKind` is intentionally absent. The audit consumer should not solve GVR to
+GVK for Git placement, and carrying Kind would create a half-retained optional
+path. The writer already has the live-catalog mapper and should locate the parent
+document by the same GVR/resource-identity path used for GVR-only deletes.
+
+## Translation Rules
+
+The generic translator is intentionally small:
+
+1. Use the parent identity from `objectRef`: group, version, resource, namespace,
+ and name.
+2. Ignore the subresource body's `apiVersion` and `kind`.
+3. Require `responseObject.spec`.
+4. Walk `responseObject.spec` to leaves and emit assignments rooted at parent
+ `spec`.
+5. Do not read `requestObject.spec`.
+6. Do not read `status`.
+7. Drop the event if no candidate assignments are produced.
+
+For scale, `responseObject.spec.replicas: 3` becomes `spec.replicas: 3`.
+
+## Sanitized Parent Gate
+
+After translation, the consumer GETs the current parent object and runs the same
+`sanitize.Sanitize` projection as the full-object path.
+
+The patch is allowed only if every candidate assignment path exists in the
+sanitized parent projection.
+
+This GET is a gate, not a source of commit values:
+
+- The fetched object is never routed to Git.
+- Fetched field values are never copied into assignments.
+- A stale or concurrent GET can cause a false drop or false allow of a path, but
+ it cannot attribute another user's value to this audit event.
+- Fields stripped by the sanitizer, such as Service `spec.clusterIP`, cannot pass.
+
+This keeps the "no guessing" rule while avoiding an allow-list for every future
+desired-state subresource: a new subresource must both carry explicit accepted
+values and point at fields that the sanitized parent manifest actually exposes.
+
+The gate is all-or-nothing. If any assignment path is absent from the sanitized
+parent projection, drop the whole subresource patch.
+
+## Writer Rules
+
+The writer applies field patches only to an existing managed parent document.
+
+- Resolve by parent GVR/resource identity through the same inventory used by
+ `manifestanalyzer.PlanDelete`.
+- Do not use `ParentKind`.
+- Do not create a parent from a partial patch.
+- Do not whole-replace from a partial patch.
+- Do not patch encrypted/non-editable parents in place.
+- Use `manifestedit.PatchFields`, which owns only the assigned paths and leaves
+ every other Git field untouched.
+
+If a Deployment in Git omits `spec.replicas`, a scale patch may still add it:
+the parent manifest exists, and the sanitized live Deployment exposes
+`spec.replicas` after the scale.
+
+## Denied Subresources
+
+Hard-denied before Redis:
+
+| Pattern | Reason |
+| --- | --- |
+| `*/status` | Observed state, not desired manifest state. |
+| `pods/exec` | Runtime command stream. |
+| `pods/attach` | Runtime stream. |
+| `pods/portforward` | Runtime stream. |
+| `*/proxy` for known proxy resources | Proxy request, not manifest state. |
+| `pods/log` | Log retrieval. |
+| `pods/eviction` | Operational eviction. |
+| `bindings`, `pods/binding` | Scheduler/runtime placement. |
+| `*/finalize` | Lifecycle control. |
+| `*/approval` | Workflow decision. |
+| `*/token` | Credential/token request. |
+
+> Superseded: the broad hard-deny taxonomy above and the sanitized parent gate
+> below were the generic-subresource design. The shipped behavior is scale-only —
+> see [subresource-scope-reduction.md](subresource-scope-reduction.md). Only
+> `/scale` reaches the consumer now; everything else is dropped at webhook ingress.
+
+## Metrics
+
+Keep outcomes bounded by group/version/resource/verb/subresource/outcome. Never
+label by object name or request URI.
+
+The shipped consumer outcomes are scale-specific (see the scope-reduction doc):
+
+- `routed_scale_subresource`: built-in scale translated and routed.
+- `dropped_non_scale_subresource`: subresource is not `scale`.
+- `dropped_scale_missing_response_replicas`: scale response lacks
+ `responseObject.spec.replicas`.
+- `dropped_scale_path_unresolved`: no known parent replica path (CRD / aggregated API).
+- `unmatched`: no parent-GVR rule matched.
+- `subresource_patch_no_parent` / `subresource_patch_unsafe`: writer-side outcomes,
+ logged with those `reason` strings (not yet counters).
+
+There should be no `rehydrated_*` or `fallback_*` outcomes.
+
+## TODO Checklist
+
+- [x] Build `manifestedit.PatchFields` field-patch primitive.
+- [x] Add field-patch transport through `git.Event`.
+- [x] Teach `GitTargetEventStream` to forward field-patch events.
+- [x] Teach `BranchWorker` to apply field patches to existing manifests.
+- [x] Forward non-denied mutating subresources through webhook ingress.
+- [x] Add consumer translation from subresource event to field-patch event.
+- [x] Remove `FieldPatch.ParentKind` from `internal/git/types.go`.
+- [x] Remove writer `ParentKind` / `ByManifestIdentity` fast path; resolve field
+ patches by GVR/resource identity only.
+- [x] Update writer tests to use the production GVR/resource-identity path.
+- [x] Require `responseObject.spec` in `internal/queue/subresource_translate.go`.
+- [x] Drop request-only subresource events; no `requestObject.spec` fallback.
+- [x] Update translator tests: response-only succeeds, request-only drops.
+- [x] **Superseded by the scope reduction** — the generic translator and the
+ sanitized live parent projection gate were replaced by a scale-only translator
+ (`translateScaleToAssignments`). Today it is keyed on
+ `auditutil.BuiltinScaleReplicasPath`; the target shape moves those built-in
+ paths into the same API resource scale fact that CRD scale will use. The gate,
+ its tests, and `dropped_subresource_field_not_in_parent` were removed; the
+ scale-specific outcomes are in the Metrics section above. See
+ [subresource-scope-reduction.md](subresource-scope-reduction.md).
+- [ ] Add the writer outcome counters `subresource_patch_no_parent` /
+ `subresource_patch_unsafe` (today logged with those exact `reason` strings, not yet
+ counters).
+- [ ] Move built-in scale paths into the shared API resource scale fact, so
+ built-ins and future CRD scale paths use the same translator input.
+- [ ] Add CRD scale path remap from `specReplicasPath`. Until then a CRD scale is
+ **safely dropped** (`dropped_scale_path_unresolved`), never miswritten.
+- [ ] Run `task lint`.
+- [ ] Run `task test`.
+- [ ] Check Docker with `docker info`, then run `task test-e2e`.
+
+## Current State
+
+The scope reduction has landed (see
+[subresource-scope-reduction.md](subresource-scope-reduction.md)). The shipped
+behavior is scale-only, and the generic translator plus the sanitized parent gate
+are gone:
+
+- `FieldPatch.ParentKind` is removed. The writer resolves a field patch's parent
+ solely by its objectRef GVR through the same resource-identity inventory the
+ GVR-only delete uses (`manifestanalyzer.PlanDelete`); the patch is then applied
+ with the parent document's own committed Kind.
+- The webhook forwards only `*/scale` mutating events (`IsScaleSubresource`); every
+ other subresource is dropped before Redis.
+- The consumer translator (`translateScaleToAssignments`) reads only
+ `responseObject.spec.replicas` and currently resolves the parent replica path
+ from built-in policy (`auditutil.BuiltinScaleReplicasPath`). The target shape is
+ to move that built-in path registry into the API resource scale fact, so
+ built-ins and CRDs are handled through the same input model. A request-only event
+ drops, and a scale whose parent path is unknown (CRD / aggregated API) drops as
+ `dropped_scale_path_unresolved` — never defaulted to `.spec.replicas`.
+- The sanitized live parent projection gate has been **removed**: with scale-only
+ support the accepted value comes straight from the standardized Scale response, so
+ no live-parent GET is needed to authorize the patch.
+
+Still narrower-than-final:
+
+- The writer `subresource_patch_no_parent` / `subresource_patch_unsafe` outcomes are
+ logged with those `reason` strings, not yet counters.
+- CRD scale-path remap from `specReplicasPath` is not implemented. CRD scale is
+ safely dropped (`dropped_scale_path_unresolved`), never miswritten.
diff --git a/docs/design/manifest/version2/subresource-scope-reduction.md b/docs/design/manifest/version2/subresource-scope-reduction.md
new file mode 100644
index 00000000..fe5aaf28
--- /dev/null
+++ b/docs/design/manifest/version2/subresource-scope-reduction.md
@@ -0,0 +1,237 @@
+# Subresource Scope Reduction
+
+> Status: improvement proposal, captured 2026-06-08.
+>
+> Amends:
+> [api-catalog-watched-type-architecture.md](api-catalog-watched-type-architecture.md)
+> and
+> [scale-subresource-audit-rehydration.md](scale-subresource-audit-rehydration.md).
+>
+> Trigger: after reviewing Kubernetes subresource facts and the recorded
+> `deployments/scale` audit event, the previous generic-subresource direction is
+> broader than GitOps Reverser needs.
+
+## Decision
+
+For the first simplification pass, support only Kubernetes `/scale` subresources
+whose parent replica path is known. Built-in Kubernetes types get that path from
+a built-in scale pointer registry; CRDs eventually get the same fields from CRD
+`spec.versions[*].subresources.scale`.
+
+Ignore every other subresource for now.
+
+Do not add CRD scale support until the richer API resource object exists. CRD
+scale needs that object to carry the parent `specReplicasPath`; until then, a CRD
+scale event must fail clearly as "scale path unresolved" rather than guessing
+`.spec.replicas`.
+
+Do not add special aggregated-API handling. Aggregated API subresources should
+fall through the same unsupported path as any other non-built-in subresource. If
+an aggregated API exposes `/scale`, it will not have a known parent replica path
+in the current API resource model and should be dropped with the same "scale path
+unresolved" outcome.
+
+## Why This Is Enough
+
+The recorded Deployment scale event proves the useful case:
+
+- the request is addressed to `deployments/scale`;
+- the audit `responseObject` is `autoscaling/v1 Scale`, not `apps/v1 Deployment`;
+- the parent Deployment is still the object that changed;
+- the accepted desired value is `responseObject.spec.replicas`;
+- the `Scale.metadata.resourceVersion` matched the normal parent Deployment read
+ in the local capture.
+
+That is a narrow, valuable GitOps case: a subresource writes parent desired
+state, and Kubernetes exposes the accepted value in a standardized response.
+
+Most other subresources do not have that shape. They are observed state, runtime
+streams, derived reads, credentials, lifecycle control, proxying, or imperative
+actions. Treating them as generic parent `spec` patches invites accidental
+mirroring of behavior that is not durable desired state.
+
+## Scope
+
+### In Scope
+
+- `*/scale` on scalable resources whose parent path is known.
+- Built-in scalable resources whose paths come from the built-in scale pointer
+ registry, such as Deployments, StatefulSets, ReplicaSets, and
+ ReplicationControllers.
+- Metrics for dropped subresources.
+- Parent-resource WatchRule matching. Rules continue to name `deployments`, not
+ `deployments/scale`.
+- Field-patch writes into existing parent manifests.
+
+### Out Of Scope
+
+- Generic translation of arbitrary `responseObject.spec` trees.
+- Subresources in `WatchRule.resources`.
+- Subresources in `WatchedTypeTable`.
+- Writing subresource response objects to Git.
+- CRD scale support until CRD scale pointers are carried on the API resource
+ object.
+- Aggregated API subresource support.
+- Status, exec, attach, portforward, log, proxy, eviction, binding, finalize,
+ approval, token, restart, console, VNC, or similar operation subresources.
+
+## Architecture Tune-Back
+
+The API catalog should remain a raw discovery cache for served resources. It may
+retain raw subresource entries because Kubernetes discovery reports them, but
+GitOps Reverser should not build an operational subresource type system on top of
+them.
+
+The resolved type surface, if kept, should resolve parent resources only:
+
+- exact GVK to parent GVR;
+- exact GVR to parent type facts;
+- top-level resource ambiguity and disallowed policy;
+- subresource-only matches refused as a planning miss.
+
+`WatchedTypeTable` stays GitTarget-local and parent-GVR-only. A watched type is
+the object we list, watch, snapshot, and write as a manifest. A subresource is an
+audit-event modifier on that parent, not a watched type.
+
+This means we can drop the broad direction where every subresource gets a generic
+translation opportunity guarded by sanitized parent field presence. Scale support
+should be explicit: if the parent replica path is not known, the event is dropped.
+
+## Scale Translation
+
+Replace generic subresource translation with a scale-specific translator.
+
+Input requirements:
+
+- `objectRef.subresource == "scale"`;
+- verb maps to a mutating operation;
+- `responseObject.spec.replicas` exists;
+- parent GVR matches at least one WatchRule or ClusterWatchRule;
+- parent replica path is known by the API resource object.
+
+Translation:
+
+```text
+autoscaling/v1 Scale responseObject.spec.replicas
+ -> parent manifest assignment at resolved replicas path
+```
+
+For built-in scalable resources, the API resource object should be populated from
+the built-in scale pointer registry, so the translator sees the same shape it
+will later see for CRDs:
+
+```text
+responseObject.spec.replicas -> parent .spec.replicas
+```
+
+For CRDs and aggregated APIs in the first pass:
+
+```text
+no known parent replica path -> dropped_scale_path_unresolved
+```
+
+Do not read:
+
+- `requestObject.spec`;
+- `responseObject.status`;
+- arbitrary leaves under `responseObject.spec`;
+- the subresource body's `apiVersion` or `kind` as parent identity.
+
+## CRD Scale Deferred
+
+Do not add a separate CRD scale path index in this pass.
+
+The desired future shape is a richer API resource object that can answer:
+
+```text
+parent GVR -> scale parent replica path
+```
+
+For a built-in Deployment this answer should come from the built-in scale pointer
+registry:
+
+```text
+apps/v1 deployments -> ["spec", "replicas"]
+```
+
+For a CRD with scale enabled, the future answer should come from:
+
+```text
+spec.versions[*].subresources.scale.specReplicasPath
+```
+
+Until that API resource object exists, CRD scale events are not supported. They
+must not be special-cased through a parallel CRD informer, and they must not
+default to `.spec.replicas`.
+
+## Metrics
+
+Keep labels bounded:
+
+```text
+source, group, version, resource, subresource, verb, outcome
+```
+
+Do not label by name, namespace, UID, request URI, or backend identity.
+
+Recommended outcomes:
+
+| Outcome | Meaning |
+| --- | --- |
+| `routed_scale_subresource` | Scale translated and routed with a known parent path. |
+| `dropped_non_scale_subresource` | Subresource is not `scale` and is not supported. |
+| `dropped_scale_missing_response_replicas` | Scale response lacks `responseObject.spec.replicas`. |
+| `dropped_scale_path_unresolved` | No known parent replica path exists for this resource. |
+| `scale_patch_no_parent` | Parent manifest absent from Git. |
+| `scale_patch_unsafe` | Parent manifest exists but cannot be patched safely. |
+
+`audit_events_received_total` can continue to expose the raw `subresource` label,
+but the explicit dropped outcomes are what make ignored subresources visible.
+
+## Implementation Plan
+
+1. Replace `translateSubresourceToAssignments` with a scale-only translator.
+2. Keep `manifestedit.PatchFields` and `git.FieldPatch`; they are the right write
+ primitive for Scale.
+3. Remove the sanitized parent field-presence gate from subresource routing.
+4. Add built-in scale paths through the same API resource field that CRD scale
+ will use later; do not scatter built-in path conditionals through the
+ translator.
+5. Do not add CRD scale path indexing yet.
+6. Do not add aggregated API classification for subresource handling.
+7. Change webhook ingress to forward only candidate Scale events; drop and metric
+ non-scale subresources.
+8. Change consumer metrics from generic subresource outcomes to scale-specific
+ outcomes.
+9. Add tests:
+ - built-in Deployment scale routes to `spec.replicas`;
+ - built-in Deployment scale path is read from the API resource scale fact, not
+ a translator-local conditional;
+ - CRD scale is dropped with `dropped_scale_path_unresolved`;
+ - generic `responseObject.spec.foo` subresource is dropped;
+ - aggregated API scale is dropped with `dropped_scale_path_unresolved`;
+ - `WatchRule.resources: ["deployments/scale"]` remains rejected.
+
+## Complexity To Remove
+
+- Generic "walk every leaf of `responseObject.spec`" subresource translation.
+- The idea that sanitized parent field presence is enough to authorize unknown
+ subresources.
+- The sanitized parent field-presence gate for subresource routing.
+- A parallel CRD scale-path index.
+- Translator-local built-in scale conditionals once the built-in registry
+ populates the shared API resource scale fact.
+- Aggregated API subresource classification.
+- Any future work to add subresource entries to watched-type planning.
+- Any resolved type-surface API that treats subresources as first-class selected
+ types.
+- Broad hard-deny taxonomy as the main safety mechanism. With scale-only support,
+ the primary rule is simpler: only `scale` can route; everything else drops.
+
+## Revised Principle
+
+Subresources are not a new manifest surface for GitOps Reverser.
+
+They are ignored by default. `/scale` is the single exception because Kubernetes
+standardizes it as a view that writes parent desired replica state. Even `/scale`
+routes only when the parent replica path is known.
diff --git a/docs/design/manifest/version2/type-followability-implementation.md b/docs/design/manifest/version2/type-followability-implementation.md
new file mode 100644
index 00000000..686705b5
--- /dev/null
+++ b/docs/design/manifest/version2/type-followability-implementation.md
@@ -0,0 +1,411 @@
+# Type followability — implementation log
+
+> Companion to [type-followability.md](type-followability.md). This file is the
+> running, append-only record of what the implementation actually changed, in the
+> order it changed it. The design doc says what we want; this says what we did.
+> Naming follow-up: [type-followability-naming-proposal.md](type-followability-naming-proposal.md).
+> Catalog/typeset boundary follow-up:
+> [discovery-catalog-typeset-boundary.md](discovery-catalog-typeset-boundary.md).
+
+## Goal
+
+Collapse the scattered type-handling logic (catalog scan + GVK→GVR mapper + rule
+GVR resolver + watched-type table/resolver + a hardcoded scale switch) into the
+three-layer model of the design:
+
+```
+Scan ─▶ Observation ─▶ TypeRegistry (the single decision surface) ─▶ TargetView
+```
+
+The heart is one `TypeRecord` carrying one `Followability` (verdict + summary +
+funnel-ordered checks), with a single reason-code vocabulary used everywhere a
+type is turned away.
+
+## Strategy
+
+Land it in green-keeping increments — `task test` (>90% coverage) and
+`task test-e2e` must stay green at every committed step. New canonical model lives
+in a fresh leaf package `internal/typeset` (no Kubernetes client deps, just
+apimachinery `schema`), so both the live cluster path (`internal/watch`) and the
+no-cluster analyzer path can share one decision surface.
+
+## Change list
+
+### Stage 1 — `internal/typeset` model + funnel evaluator (additive)
+
+- New package `internal/typeset`. Pure data model + the funnel that turns an
+ `Observation` (raw per-type facts) plus policy into a `Followability`.
+- Files:
+ - `model.go` — `Identity`, `Scope`, `Origin`, `OriginKind`, `Confidence`,
+ `TypeRecord`, `Followability`, `Check`, `Verdict`, `Requirement`, `Result`,
+ `Reason`, `Subresources`, `StatusFact`, `ScaleBinding`. `TypeRecord.Followable()`.
+ - `scale.go` — the built-in scale registry (`BuiltinScale`), the single source
+ of `/scale` parent-replica facts for built-ins (folds the old
+ `auditutil.BuiltinScaleReplicasPath`).
+ - `funnel.go` — `Evaluate(Observation, Policy) Followability`: the funnel-order
+ requirement checks and the mechanical verdict derivation.
+- Reason vocabulary (kebab-case, stable): `not-served`, `subresource-only`,
+ `discovery-degraded`, `catalog-unavailable`, `absence-expired`, `gvk-not-unique`,
+ `gvr-not-unique`, `scope-unknown`, `missing-verb`, `origin-unknown`,
+ `denied-by-policy`, `sensitive-unsupported`, `scale-path-unresolved`.
+
+- Tests: `model_test.go`, `funnel_test.go`, `scale_test.go` — table-driven, 95.2%
+ package coverage. The funnel test mutates one field of a baseline followable
+ Deployment observation per case and asserts verdict + summary + the failing check.
+
+### Stage 2 — `typeset.Registry` decision surface (additive)
+
+- `registry.go` — `Registry`: one `TypeRecord` per known type, the lookups
+ (`ByGVK`, `ByGVR`, `Followable`, `All`, `Ready`, `Generation`), and the live-set
+ `RemovalGrace` (fixed 60s). `Update(observations, generation)` replaces the set and
+ applies the grace: additions are immediate; a previously-live type that stops being
+ observed is re-judged `retained` within the grace and dropped once it elapses. The
+ clock is injectable (`newRegistry`) so the grace is deterministic in tests.
+- Identity ambiguity: a GVK served by >1 GVR keeps one refused record per resource
+ (each carries `gvk-not-unique`); `ByGVK` returns the deterministic first by GVR.
+- Tests: `registry_test.go` — grace retain→drop, reappearance restarts the grace,
+ refused (never-live) types drop immediately, ambiguous-GVK refusal. 97% coverage.
+
+### Stage 4 — single source of built-in scale facts
+
+- `auditutil.BuiltinScaleReplicasPath` is now a thin `[]string` adapter over
+ `typeset.BuiltinScale` + `typeset.SplitFieldPath`. The hardcoded apps/core switch
+ moved into `typeset.BuiltinScale`, so the followability registry (origin/scale
+ enrichment) and the audit consumer (`internal/queue` scale write) read one binding
+ and can never drift. Callers and their tests are unchanged (path still
+ `["spec","replicas"]`, still a fresh owned slice).
+
+### Stage 5 — live Scan → Observation → Registry pipeline in the Manager
+
+- `internal/watch/catalog_observe.go` — `APIResourceCatalog.Observations()` projects
+ the catalog scan into one `typeset.Observation` per served top-level type: identity
+ (GVK/GVR/scope), discovery verbs, preferred, trust state, resource policy
+ (`Allowed`/`PolicyReason` → `denied-by-policy`), GVK identity uniqueness, the core
+ Secret sensitivity flag, and folded subresource facts (`/status` presence, `/scale`
+ binding from `typeset.BuiltinScale`). Origin is a group-shape heuristic (builtin vs.
+ crd, confidence `inferred`) that never returns `unknown` for a served type.
+- `internal/watch/manager_catalog.go` — the Manager holds a `*typeset.Registry`
+ (`typeRegistryInstance`), `refreshTypeRegistry()` republishes it from the scan after
+ every `RefreshAPIResourceCatalog`, and `FollowableTypeRecords()` / `TypeRecords()`
+ expose the live set and the full inventory. The "API resource catalog ready" log
+ line now reports `followableTypes` / `knownTypes`, so the pipeline is exercised on
+ the real cluster during e2e.
+- Tests: `catalog_observe_test.go` — followable built-in (Deployment, with a usable
+ scale binding), followable CRD, policy-denied built-in (Pod), missing-verb built-in
+ (Node), sensitive-but-supported (Secret), subresources excluded from the registry,
+ ambiguous-GVK refusal, and the Manager refresh populating the registry at the
+ catalog generation.
+
+### Stage 6 — CatalogMapper deleted; the live mapper is registry-backed
+
+The user asked to remove `CatalogMapper` outright and accept the behavior change,
+simplifying where a choice is forced (no detailed per-GitTarget report for a
+misconfigured cluster type — yet).
+
+- **Deleted** `internal/watch/catalog_mapper.go` (the `CatalogMapper` struct, the old
+ `Manager.Mapper()`, and the `mappingEntries`/`verbSlice`/`lookupState` helpers).
+- **New** `internal/watch/registry_mapper.go` — `registryMapper` implements
+ `mapping.ResourceMapper` over `typeset.Registry`. `Manager.Mapper()` returns it.
+ `GVRForGVK` now answers from the single decision surface:
+ - known + followable/retained → `Resolved` (GVR, scope→Namespaced, verbs, preferred);
+ - known + refused → first-failing requirement maps to a status: `identity` →
+ `Ambiguous`, `policy` → `Disallowed`, anything else → `Unserved` (the simplification
+ — the analyzer already lumps the non-policy refusals into one "unresolved" issue,
+ so collapsing verb/scope/origin/scale refusals to `Unserved` loses no decision);
+ - unknown kind → `CatalogUnavailable` (registry not ready), `DiscoveryDegraded` (its
+ group/version is degraded), else `Unserved`. Trust state still comes from the
+ catalog via the new `APIResourceCatalog.GroupVersionDegraded`.
+- `refreshTypeRegistry` now publishes only once `catalog.Ready()`, so an unready
+ catalog keeps the registry (and therefore the mapper) reporting `CatalogUnavailable`
+ rather than an empty-but-ready scan.
+- The mapper reduction in `internal/mapping` is untouched — the no-cluster analyzer
+ still uses the static-snapshot/structure-only implementations. Only the *live*
+ implementation moved onto the registry.
+- Tests: `catalog_mapper_test.go` → `registry_mapper_test.go` (Resolved, Unserved,
+ CatalogUnavailable, Disallowed, **Ambiguous** — the new global identity rule —
+ DiscoveryDegraded, context-cancelled, nil-registry, and Manager hand-out).
+
+**Verb requirement relaxed (deliberate deviation from the design doc).** The doc lists
+`verbs` as get/list/watch/**patch**. Once the registry backs the *live* mapper, a
+patch requirement would refuse read-only-but-mirrorable types (and broke the
+list/watch-only test fixtures). GitOps Reverser mirrors cluster→Git, which is a read
+path, so [`requiredVerbs`](../../../../internal/typeset/funnel.go) is now
+**get/list/watch**. The one write-back (a `/scale` replica assignment) is gated on the
+scale subresource's own verbs via the `scale` requirement, not on the parent carrying
+`patch`. This also keeps the funnel coherent with the live watch resolver (which only
+ever required list+watch).
+
+### Stage 7 — `internal/mapping` deleted; the registry is the only Lookup
+
+The whole `mapping.ResourceMapper` / `mapping.Result` contract is gone. It had two
+jobs: a status reporter (its 8-way vocabulary — not needed, the registry answers one
+followability question) and a *source abstraction* that let the offline analyzer and
+the live worker share one engine. `typeset` now does the second job too, so the
+package was deleted.
+
+- **`typeset.Lookup`** ([lookup.go](../../../../internal/typeset/lookup.go)) — the
+ minimal surface every consumer reads: `Ready() bool` + `ByGVK(gvk) (TypeRecord,
+ bool)`. `*typeset.Registry` satisfies it. The three former mapper modes are now
+ three registry constructors: live (`Manager.TypeRegistry()`), snapshot
+ (`NewSnapshotRegistry`, for fixtures/CLI), and structure-only (an un-`Update`d
+ `NewRegistry()`, whose `Ready()==false` is exactly "no API source — don't judge").
+- **`typeset.ObservationsFromEntries`** ([observe.go](../../../../internal/typeset/observe.go))
+ — the scan reduction (identity uniqueness, origin, scale, sensitivity, policy) moved
+ out of `internal/watch` into `typeset`, operating on a neutral `typeset.Entry`. The
+ catalog converts its discovery entries to `Entry`; the snapshot builds `Entry`
+ fixtures. One reduction, two sources.
+- **`internal/manifestanalyzer`** now consumes a `typeset.Lookup`. `DocumentModel.
+ Mapping` is a 3-value `MappingOutcome` (`Followable` / `NotFollowable` /
+ `NoSource`) instead of `mapping.Status`. Acceptance collapses every not-followable
+ cause into one `IssueUnresolvedKRM` refusal (the `IssueUnwatchedAPIKRM` policy
+ distinction is dropped — the user asked not to over-report *why* a type is refused).
+ `hasAPISource` / `plan.go` follow the 3-state.
+- **`internal/git`** worker threads a `typeset.Lookup` (the field/method keep the
+ `mapper`/`SetMapper` names); `cmd/main.go` injects `watchMgr.TypeRegistry()`.
+- **`internal/watch`** gained `Manager.logTypeRefusals` — the single central place
+ refusals are logged (one V(1) line per refused type, edge-triggered by GVK+summary
+ so a stable refusal logs once). The full machine-readable "why" stays on the
+ registry record (`TypeRecords()`), not in logs.
+- **Deleted:** `internal/mapping/` (mapper.go, static_snapshot.go, structure_only.go
+ + tests). All fixtures across `manifestanalyzer` and `git` tests moved to
+ `typeset.Snapshot`/`typeset.Entry`; a snapshot entry with no Verbs is assumed
+ followable-verbed (a fixture convenience), and the status-table tests collapsed to
+ the 3 outcomes.
+
+### Stage 8 — close the GVR→Kind direction of the bijection (review follow-up)
+
+A review noted `gvr-not-unique` was modelled but never produced: `observationFromEntry`
+hardcoded `GVRUnique: true`. Verdict: not a real-world bug (a GVR is
+group/version/**resource**, and discovery keeps a resource name unique per
+group/version, so `GVR → Kind` is structurally 1:1 from a live cluster — and the
+catalog dedupes `byGVR` before observations are even built), but a genuine
+model-consistency gap: the funnel advertised a `gvr-not-unique` reason and
+`GVRUnique`/`GVRConflictDetail` fields that were dead, and a non-discovery `Lookup`
+source (a snapshot fixture) could be handed a duplicate GVR and would silently pick a
+winner instead of refusing both.
+
+Fix in [observe.go](../../../../internal/typeset/observe.go): identity uniqueness is
+now computed in **both** directions from the entries — `distinctGVRsByGVK` (GVK→
+distinct resources, `gvk-not-unique`) and `distinctGVKsByGVR` (GVR→distinct kinds,
+`gvr-not-unique`), both keyed on distinct values so an exact duplicate (same GVR+GVK)
+collapses rather than being mistaken for a conflict. The live path is unchanged
+(catalog `byGVR` is deduped, so `GVRUnique` stays true — correct, real discovery is
+1:1); the snapshot/non-discovery path now refuses a GVR served by two Kinds with
+`gvr-not-unique`. Regression tests: `TestObservationsFromEntries_AmbiguousGVR` and
+`_ExactDuplicateIsNotAConflict`.
+
+### Stage 9 — sensitivity is a policy input on the Entry, not inferred (review follow-up)
+
+A review asked: shouldn't `Sensitive` be modelled on `typeset.Entry` already, since it
+is known at startup? It was right. `typeset` hardcoded `coreSecret(group, resource)`,
+which **ignored the operator-configured `SensitiveResourcePolicy`** (the flag-driven
+"additional sensitive resources") — so `TypeRecord.Sensitive` was wrong for anything
+sensitive beyond core Secrets. (Nothing reads `TypeRecord.Sensitive` yet, so this was a
+model-correctness fix, not a behavior change.)
+
+- `typeset.Entry` gained a `Sensitive bool` input (next to `Allowed`/`PolicyReason`);
+ `observationFromEntry` now reads `e.Sensitive` and the hardcoded `coreSecret` helper
+ is gone. `typeset` no longer knows the word "secrets" — sensitivity is policy, applied
+ by the entry builder.
+- `APIResourceCatalog.Observations(sensitive types.SensitiveResourcePolicy)` applies the
+ configured policy per entry (`sensitive.IsSensitive(group, resource)`), exactly as the
+ allow/deny policy is applied. The watch `Manager` gained a `SensitiveResources` field,
+ set from `cfg.sensitiveResources` in `cmd/main.go` (the same value the worker already
+ gets); `refreshTypeRegistry` passes it. The zero value still treats core Secrets as
+ sensitive.
+- Test: `TestObservations_AppliesConfiguredSensitivePolicy` proves an operator-marked
+ type (configmaps) comes back `Sensitive`, core Secrets stay sensitive, and an unlisted
+ type does not.
+
+### Stage 10 — `WatchedTypeTable` is a registry projection; the duplicate grace is gone
+
+The last duplicated decision surface. The table used to run its **own** identity/conflict
+check and its **own** 60-second removal grace (`watchedTypeStore.pendingRemovals`), in
+parallel with the registry's. The user asked to delete the duplication outright, keep less
+code, and accept losing the per-GitTarget conflict/miss reporting.
+
+- **`WatchedTypeTable` is now a subset of the typeset.** `resolveWatchedTypeTables` reads
+ `registry.Followable()` once and projects each GitTarget's WatchRules/ClusterWatchRules
+ onto it via `matchFollowableRecords` (the same group/version/resource/scope + preferred-
+ version + omitted-apiGroups ambiguity semantics as `RuleGVRResolver`, but over the
+ already-followable records, so a refused type — `gvk-not-unique`, `denied-by-policy`,
+ verb-poor — simply never matches). `buildWatchedTypeTable` is a pure fold of matched
+ records into `WatchedType` + `NamespaceOps`; it does no catalog lookup and makes no
+ decision.
+- **The 60-second grace lives only in the registry now.** Because selection reads
+ `registry.Followable()`, a type that briefly leaves discovery stays `retained` (and so
+ stays in the table, the informer set, and the snapshot) until the registry's grace
+ elapses — then it drops from `Followable()` and the table on the next refresh. No table
+ re-judges absence. **Deleted** `watchedTypeStore.{pendingRemovals,removalGrace,now}`,
+ `applyPersistentAbsence`, `holdAbsentTypes`, `pendingRemoval`/`PendingRemoval`,
+ `logReappearances`/`logAbsence`, `rulesStillSelectWatchedType` + the rule-vs-type
+ matchers, and `hasPendingRemovals`.
+- **Identity/conflict reporting dropped.** `TypeConflict`/`WatchedTypeTable.Conflicts`,
+ `logTypeConflicts`, and the local conflict detection are gone — the registry decides
+ identity uniqueness globally, and an ambiguous kind is just absent from the table. The
+ `gitopsreverser_watched_type_conflicts` and `_pending_removals` gauges were removed.
+- **The change-gate is kept, keyed on the registry's own `Revision()`.** An early cut
+ dropped the gate and reprojected (and rebuilt the registry) on every
+ `refreshWatchedTypeTables`; under parallel e2e that starved the controllers (every
+ GitProvider/GitTarget/WatchRule reconcile timed out waiting for status). The gate is
+ restored and keyed on `(registry.Revision(), rulesFingerprint)` — **not** the catalog
+ generation. `Revision()` bumps when the followable membership changes (incl. a grace drop
+ at a stable generation, when the catalog generation does **not** move) or the scan
+ generation moves, so a grace-expired type leaves the table promptly — its phantom informer
+ stops and its target's snapshots recover, with no separate watched-type-layer absence
+ tracking. The heavy scan→registry rebuild stays where it always was — once per
+ `RefreshAPIResourceCatalog`; `refreshWatchedTypeTables` only rebuilds the registry lazily
+ the first time (for unit tests that drive it directly) and otherwise just reprojects on a
+ real change. `refreshMu` still serializes resolve-and-publish. See
+ [discovery-catalog-typeset-boundary.md](discovery-catalog-typeset-boundary.md) for the
+ boundary rationale (the table depends on the decision surface, not the scan counter).
+- **Snapshot fail-closed re-expressed on the registry verdict.** `WatchedTypeTable.{Misses,
+ BlockingMisses}` were dropped; `resolveSnapshotGVRs` now fails closed on two registry
+ signals: (1) `registry.Ready()` is false (the API surface has not been observed yet), and
+ (2) any watched type is currently `retained` (followable under the grace but not served
+ right now — a discovery wobble). The retained case is the old pending-removal fail-closed
+ re-expressed in the registry's vocabulary: streaming a retained-but-unserved type would
+ fail, and sweeping the reduced view would delete a still-valid mirror, so the gather aborts
+ until the type is served again or the grace elapses and the `Revision()` bump re-projects it
+ out of the table. (Deliberate detail loss: the old discovery-degraded-while-still-selected
+ abort is folded into the same grace/retained path.)
+- `RuleGVRResolver` is unchanged and still backs the WatchRule/ClusterWatchRule controller
+ *status* feedback (`ResolveWatchRuleResources`); only the table stopped using it.
+ **(Superseded in Stage 11 — the controller status moved onto the registry too and
+ `RuleGVRResolver` was deleted.)**
+- Tests: `watched_type_table_test.go` now covers the pure fold + `matchFollowableRecords`
+ (scope filter, wildcard, preferred-version collapse); `watched_type_resolver_test.go`
+ covers the registry-driven resolution incl. an ambiguous-GVK exclusion;
+ `watched_type_metrics_test.go` keeps only the `watched_types` gauge;
+ `watched_type_pending_removal_test.go` was deleted (the grace is covered by
+ `internal/typeset/registry_test.go`); a new `resolveSnapshotGVRs` test covers the
+ registry-not-ready fail-closed.
+
+### Stage 11 — rule status is a registry projection; `RuleGVRResolver` and the dead catalog APIs are gone
+
+The last consumer of the old catalog-mechanics path. WatchRule/ClusterWatchRule controller
+*status* was still computed by `RuleGVRResolver` over raw `APIResourceCatalog` entries, in
+parallel with — and able to disagree with — the registry-backed set the watchers actually
+follow. The user's direction: the application does not get to worry about *why* a type is or
+isn't followed; status should report only what is watched, and the duplicated resolver +
+catalog lookups should go.
+
+- **Status reports only what is watched.** `ResolveWatchRuleResources` /
+ `ResolveClusterWatchRuleResources` now match each rule's selectors with
+ `matchFollowableRecords` over `registry.Followable()` — the *same* matcher the per-GitTarget
+ watched-type tables use, so status and watching cannot drift. The `ResourcesResolved`
+ condition is `True` whenever the catalog is ready, with message `"watching N resource
+ type(s)"` (N = distinct followable types the rule selects); the only `False` case is an
+ unobserved catalog (`"API resource catalog is not ready"`).
+- **No refusal taxonomy, no degraded diagnostic (deliberate).** The whole `ResolveMiss`
+ vocabulary (`NotServed`/`Ambiguous`/`Disallowed`/`CatalogUnavailable`/`DiscoveryDegraded`)
+ and the per-selector "discovery degraded" signal are dropped: absent, denied-by-policy,
+ verb-poor, ambiguous, and discovery-degraded are all the same to a mirror. The full
+ machine-readable "why" still lives on the registry record (`Manager.TypeRecords()`); it is
+ just not projected into operator status. **This reverses** the
+ [boundary doc](discovery-catalog-typeset-boundary.md)'s earlier "keep the
+ degraded-group/version diagnostic / re-home `hasDegradedLookup`" sub-proposal — that doc was
+ updated to match.
+- **Deleted.** `internal/watch/rule_gvr_resolver.go` (the `RuleGVRResolver` struct, the
+ `ResolveMiss`/`ResolveMissReason` vocabulary, and all the catalog-candidate helpers);
+ `FormatResolveMisses`/`formatResolutionStatus`/`ruleSelectorsContainWildcard` and the
+ now-orphaned `uniqueStrings`. On the catalog: `Entry`, `CatalogLookup`/`LookupGVK`/
+ `LookupGVR`, `GroupVersionDegraded`/`degradedForGroupVersionLocked`, `entriesForResource`/
+ `entriesForGroup`/`entriesForGroupResource`/`allEntries`, `hasDegradedLookup`, the
+ `byGVK`/`byResource`/`byGroupRes` indexes (and their rebuild/sort), `APIResourceEntry.
+ Supports`, and the `cloneAPIResourceEntries`/`cloneAPIResourceEntry`/`mapKeys` helpers.
+ `rebuildIndexesLocked` became `rebuildGVRIndexLocked` (the catalog keeps only the one raw
+ `byGVR` index it feeds to `typeset`, plus `byGroupVer` + group/version trust state).
+- **Kept.** `APIResourceCatalog.DegradedGroupVersions()` — but only for the manager's
+ operator-facing degraded/recovered **log** line (`logCatalogTransitions`) and the catalog
+ gauges, no longer for rule status. `matchesScope` moved to `watched_type_resolver.go` and
+ `dedupeGVRs` to `gvr.go` (their surviving consumers).
+- Tests: `rule_gvr_resolver_test.go` and `api_resource_catalog_lookup_test.go` deleted; new
+ `rule_status_test.go` covers the followable-match count, the unmatched-but-ready case (still
+ resolved — the app does not flag it), the not-ready fail-closed, and a wildcard ClusterWatchRule;
+ `api_resource_catalog_test.go`'s newly-served and degraded-preservation tests now read the raw
+ scan (`byGVR`) instead of `Entry`/the resolver. The wildcard e2e status assertion changed from
+ `"wildcard expanded to"` to `"watching "`.
+
+## Files touched
+
+New:
+
+- `internal/typeset/model.go`, `funnel.go`, `scale.go`, `registry.go`, `observe.go`,
+ `lookup.go` (+ tests).
+- `internal/watch/catalog_observe.go` (+ `catalog_observe_test.go`).
+- `docs/design/manifest/version2/type-followability-implementation.md` (this log).
+
+Deleted:
+
+- `internal/mapping/` (mapper.go, static_snapshot.go, structure_only.go + tests) — the
+ registry is now the only `Lookup`.
+- `internal/watch/catalog_mapper.go` and `registry_mapper.go` (+ tests) — the worker
+ reads `Manager.TypeRegistry()` directly.
+
+Modified:
+
+- `internal/auditutil/subresource_policy.go` — `BuiltinScaleReplicasPath` now adapts
+ `typeset.BuiltinScale`.
+- `internal/watch/manager.go` — `typeRegistry` + `typeRefusalsLogged` fields.
+- `internal/watch/manager_catalog.go` — `typeRegistryInstance`, `refreshTypeRegistry`
+ (gated on `catalog.Ready()`), `TypeRegistry`, `FollowableTypeRecords`, `TypeRecords`,
+ `logTypeRefusals`, ready-line `followableTypes`/`knownTypes`.
+- `internal/watch/api_resource_catalog.go` — added `GroupVersionDegraded`.
+- `internal/typeset/funnel.go` — `requiredVerbs` relaxed to get/list/watch.
+- `internal/manifestanalyzer/` — `store.go` (`MappingOutcome`, `typeset.Lookup`
+ signatures, `resolveMapping`/`resolvedIdentity`), `acceptance.go`, `plan.go`,
+ `scan.go`, `analyzer.go` migrated off `mapping`.
+- `internal/git/worker_manager.go`, `branch_worker.go`, `plan_flush.go` — `mapper`
+ field/param is now `typeset.Lookup`.
+- `cmd/main.go` — `SetMapper(watchMgr.TypeRegistry())`.
+
+Stage 10:
+
+- `internal/typeset/registry.go` — added `Revision()` (the change-of-decision signal) +
+ `followableKeysLocked`/`sameKeySet`; `Update` bumps it on a followable-membership change
+ or generation move.
+- `internal/watch/watched_type_table.go` — `WatchedTypeTable` is now a registry projection
+ (`watchSelection`/`buildWatchedTypeTable`/`watchedTypeFromRecord`); dropped
+ `TypeConflict`, `PendingRemoval`, `Misses`, `BlockingMisses`.
+- `internal/watch/watched_type_resolver.go` — registry-driven `resolveWatchedTypeTables` +
+ `matchFollowableRecords` (group/version/resource/scope + preferred-version + ambiguity);
+ deleted the whole `watchedTypeStore` removal-grace machinery; gate keyed on
+ `(registry.Revision(), rulesFingerprint)`.
+- `internal/watch/snapshot_stream.go` — `resolveSnapshotGVRs` fails closed on
+ `!registry.Ready()` or any `retained` watched type (`retainedWatchedTypes`/`gvkListSummary`).
+- `internal/watch/gvr.go`, `manager.go` — `ComputeRequestedGVRs` drops misses; removed the
+ unplanned-resources log and `blockingSnapshotMisses`.
+- `internal/telemetry/exporter.go` — removed `WatchedTypeConflicts`/`WatchedTypePendingRemovals`.
+- **Deleted** `internal/watch/watched_type_pending_removal_test.go` (grace covered by
+ `registry_test.go`); `RuleGVRResolver` kept for controller status only.
+- `docs/design/manifest/version2/discovery-catalog-typeset-boundary.md` — added the
+ registry change-signal proposal.
+
+## Validation
+
+- `task fmt` / `task generate` / `task manifests` — clean, no generated diffs (no API
+ types changed).
+- `task vet` / `task lint` — clean (the lint pass fixed: cyclop on `reasonPhrase`,
+ gochecknoglobals on the verb list, gocognit on the scale test, nonamedreturns on
+ `splitSubresource`, unparam on `newRegistry`, and the `exhaustive` map/switch checks
+ — resolved with a phrase map + a boolean helper).
+- `task test` — all packages pass; `internal/typeset` 98.4%, `internal/watch` 81.7%
+ (whole-package; the new code paths are covered).
+- `task test-e2e` — **44 Passed, 0 Failed, 8 Skipped — Test Suite Passed**, re-run
+ green at each behavior-changing step: the registry as additive inventory (Stage 5),
+ the live mapper switched onto it (Stage 6), `internal/mapping` deleted with the worker
+ + analyzer reading the registry directly (Stage 7), the gvr-not-unique (Stage 8) and
+ sensitivity-policy (Stage 9) follow-ups, and the `WatchedTypeTable`-as-registry-
+ projection migration (Stage 10). The worker resolves manifest GVKs through the registry
+ on the real k3d cluster, so e2e exercises the whole pipeline end to end.
+- **Stage 10 e2e note (lesson learned):** the suite must be run on a *fresh* k3d cluster.
+ Re-running it repeatedly on a long-lived cluster (≈2h, ~9 manager rollouts, accumulated
+ CRD/gitea/etcd state) produced spurious `status.conditions not found` controller-throughput
+ timeouts on the heavier specs (17/11 → 23/9 → 19/9 across reuses) that were **not** code
+ failures — `task clean-cluster` then `task test-e2e` returned a clean 44/0/8. The fixes
+ that did matter for Stage 10 (found via those runs) were: keeping the re-projection
+ change-gate (an early gate-less cut rebuilt the registry on every hot-path refresh and
+ starved the controllers), and re-expressing the snapshot fail-closed on the registry's
+ `retained` verdict + the `Revision()` gate (so a grace-held deleted type stops being
+ streamed and is dropped from the table promptly).
diff --git a/docs/design/manifest/version2/type-followability-naming-proposal.md b/docs/design/manifest/version2/type-followability-naming-proposal.md
new file mode 100644
index 00000000..a45d732b
--- /dev/null
+++ b/docs/design/manifest/version2/type-followability-naming-proposal.md
@@ -0,0 +1,276 @@
+# Type followability naming proposal
+
+> Status: proposal
+>
+> Companion to [type-followability.md](type-followability.md) and
+> [type-followability-implementation.md](type-followability-implementation.md).
+> Discovery boundary follow-up:
+> [discovery-catalog-typeset-boundary.md](discovery-catalog-typeset-boundary.md).
+
+## Problem
+
+The implementation has mostly collapsed the old split between API discovery,
+mapping, rule resolution, and watched-type tables into one shared type decision
+surface. The current names still carry some of the old shape:
+
+- `typeset` sounds like a set, but the package now owns records, evaluation,
+ live-set retention, lookup, and snapshot construction helpers.
+- `WatchedTypeTable` sounds like a resolved lookup table, but it is now a
+ per-GitTarget projection of globally followable records through WatchRules.
+- `funnel.go` describes the order of checks, not the thing the file owns.
+- `model.go` is generic; it hides the fact that the file defines the public
+ vocabulary and record shape.
+
+The names should make the three different scopes obvious:
+
+1. **All known cluster types**: every type the registry can explain.
+2. **Followable/live cluster types**: the globally safe/actionable subset.
+3. **Per-GitTarget watched type set**: the subset selected by that target's
+ WatchRules and ClusterWatchRules, including namespace and operation scope.
+
+## Recommended vocabulary
+
+Use `followable`, `live`, and `target` instead of `valid`.
+
+`valid` is not precise enough. A type can be a valid Kubernetes resource while
+GitOps Reverser refuses it because policy denies it, identity is ambiguous, or
+the required verbs are absent. `followable` names the product decision; `live`
+names the operational set that remains actionable during the retention grace.
+
+| Concept | Recommended name | Why |
+| --- | --- | --- |
+| Every known/explainable type | `allTypeRecords` / `TypeRecords()` | Includes refused and unknown records, so `all` is honest. |
+| Globally actionable type set | `followableTypeRecords` / `FollowableTypeRecords()` | Matches the existing `Followability` decision vocabulary. |
+| Operational live set | `liveTypeRecords` / `LiveTypeRecords()` | Good if we want `retained` to feel first-class: followable + retained. |
+| Per-GitTarget selected set | `TargetTypeSet` | It is really a set per target, limited by watch rules. |
+| One member of that set | `TargetType` or `WatchedType` | Keep `WatchedType` if we want continuity with current call sites. |
+| Rule match before folding | `typeSelection` or `watchSelection` | A temporary selected record plus namespace/ops. |
+
+Preferred public shape:
+
+```text
+TypeRegistry
+ AllRecords() -> []TypeRecord
+ FollowableRecords() -> []TypeRecord
+ LiveRecords() -> []TypeRecord (optional alias if retained should be explicit)
+
+TargetTypeSetStore
+ TargetTypeSet(gitDest) -> TargetTypeSet
+ AllTargetTypeSets() -> []TargetTypeSet
+
+TargetTypeSet
+ GitTarget
+ Destination
+ Types []WatchedType
+ ResolvedAt
+```
+
+If we keep only one accessor for the actionable global subset, prefer
+`FollowableRecords()`. If the retained state starts appearing in status/UI, add
+`LiveRecords()` as a clearer alias and document it as "followable or retained".
+
+## Package name options
+
+### Recommended: `internal/typeinventory`
+
+`typeinventory` says the package owns a durable inventory of Kubernetes resource
+types, not merely a set. It fits all current responsibilities: records, decisions,
+lookup, live-set retention, and snapshot-backed registries.
+
+Suggested package-level names:
+
+- `typeinventory.Record` instead of `typeset.TypeRecord`
+- `typeinventory.Registry`
+- `typeinventory.Observation`
+- `typeinventory.SnapshotRegistry`
+- `typeinventory.Lookup`
+
+Pros:
+
+- Clearer than `typeset` for non-set responsibilities.
+- Avoids collision with `APIResourceCatalog`; this is not raw discovery.
+- Works for both live cluster and no-cluster analyzer paths.
+
+Cons:
+
+- Longer import name.
+- "Inventory" sounds descriptive rather than decisional unless paired with
+ `Followability`.
+
+### Alternative: `internal/typecatalog`
+
+Good if we want the package to read as the canonical type catalog, with decisions
+included.
+
+Pros:
+
+- Very easy to understand.
+- Pairs naturally with `TypeCatalog`, `TypeRecord`, `TypeLookup`.
+
+Cons:
+
+- We already have `APIResourceCatalog` for raw discovery. Two catalogs can blur the
+ boundary unless names become `DiscoveryCatalog` and `TypeCatalog`.
+
+### Alternative: `internal/typesurface`
+
+Good if we want to emphasize "the cluster API surface as GitOps Reverser sees it".
+
+Pros:
+
+- Captures observed, degraded, and retained API surface well.
+- Less generic than `typeset`.
+
+Cons:
+
+- Slightly abstract.
+- "Surface" does not immediately say it contains a registry and decisions.
+
+### Alternative: keep `internal/typeset`
+
+This is acceptable only if we rename the per-GitTarget object away from
+`WatchedTypeTable`. Then `typeset` can mean "the global set of type records".
+
+Pros:
+
+- Smallest code churn.
+- The user's intuition is right: there is a real set here.
+
+Cons:
+
+- The package has more than set semantics now.
+- It remains easy to confuse the global type set with the per-GitTarget selected
+ set.
+
+## File name options inside the package
+
+Recommended file layout if the package becomes `typeinventory`:
+
+| Current file | Recommended file | Why |
+| --- | --- | --- |
+| `model.go` | `record.go` | Defines `TypeRecord`, identity, origin, scope, and subresource facts. |
+| `funnel.go` | `followability.go` | Defines `Observation`, checks, verdict derivation, and summaries. |
+| `registry.go` | `registry.go` | Already exact. |
+| `observe.go` | `observations.go` | Plural because it builds observations from entries. |
+| `lookup.go` | `lookup.go` | Already exact. |
+| `scale.go` | `scale_binding.go` | Names the actual domain object, not just the feature. |
+
+If `followability.go` becomes too broad, split it:
+
+- `observation.go`: `Observation` and raw facts.
+- `checks.go`: requirement check functions.
+- `verdict.go`: verdict derivation and summary rendering.
+
+That split is only worth it if the file grows; today one `followability.go` is
+probably clearer.
+
+## Watch-layer rename proposal
+
+The watch package should stop saying "table" for the per-target projection.
+
+| Current name | Proposed name | Notes |
+| --- | --- | --- |
+| `WatchedTypeTable` | `TargetTypeSet` | The user's suggested "typeset, one per GitTarget" is exactly this object. |
+| `watchedTypeStore` | `targetTypeSetStore` | Stores the published per-target projections. |
+| `refreshWatchedTypeTables` | `refreshTargetTypeSets` | Reprojects registry records through rules. |
+| `resolveWatchedTypeTables` | `buildTargetTypeSets` | It no longer resolves followability; the registry already did. |
+| `buildWatchedTypeTable` | `buildTargetTypeSet` | Pure fold of selected records. |
+| `watchedTypeTableForGitDest` | `targetTypeSetForGitDest` | Reads one set. |
+| `allWatchedTypeTables` | `allTargetTypeSets` | Reads every set. |
+| `residentWatchedTypeTables` | `residentTargetTypeSets` | Published in-memory view. |
+| `WatchedType` | keep or rename to `TargetType` | Keep if we want continuity; rename if we want the full model to be crisp. |
+
+Recommended package/file names:
+
+- `target_type_set.go`: `TargetTypeSet`, `WatchedType`, folding helpers.
+- `target_type_sets.go`: store, refresh, projection from rules.
+- `target_type_set_test.go` / `target_type_sets_test.go`.
+
+If we keep `WatchedType`, the relationship reads well:
+
+```text
+TypeRegistry.FollowableRecords()
+ -> TargetTypeSet.Types []WatchedType
+```
+
+The `WatchedType` name is still useful because the value has namespace/operation
+scope and is consumed by informers/snapshots. The set itself should carry the
+target-specific name.
+
+## Naming schemes
+
+### Scheme A: explicit and conservative
+
+- Package: `internal/typeinventory`
+- Global registry: `TypeRegistry`
+- Global complete set: `AllRecords`
+- Global actionable set: `FollowableRecords`
+- Per-target set: `TargetTypeSet`
+- Per-target member: `WatchedType`
+- Evaluation file: `followability.go`
+- Record file: `record.go`
+
+This is the recommended scheme. It keeps `Followability` as the product term and
+uses `TargetTypeSet` for the user's "typeset, one per GitTarget" idea.
+
+### Scheme B: shorter, set-oriented
+
+- Package: `internal/typeset`
+- Global registry: `Registry`
+- Global complete set: `All`
+- Global actionable set: `Followable`
+- Per-target set: `TargetTypeSet`
+- Per-target member: `WatchedType`
+- Evaluation file: `followability.go`
+- Record file: `record.go`
+
+This minimizes churn. It works if `TargetTypeSet` replaces `WatchedTypeTable`,
+because the two levels are no longer both called some form of watched type table.
+
+### Scheme C: catalog-oriented
+
+- Package: `internal/typecatalog`
+- Global registry: `Catalog`
+- Global complete set: `AllRecords`
+- Global actionable set: `LiveRecords`
+- Per-target set: `TargetWatchSet`
+- Per-target member: `WatchType`
+- Evaluation file: `decision.go`
+- Record file: `record.go`
+
+This is readable, but it risks confusion with `APIResourceCatalog`. Choose it only
+if the raw discovery catalog is renamed to `DiscoveryCatalog`.
+
+## Suggested migration order
+
+1. Rename `WatchedTypeTable` to `TargetTypeSet` first. This gives the biggest
+ clarity win with the least package churn.
+2. Rename `model.go` to `record.go` and `funnel.go` to `followability.go`.
+ These are file-only changes and should be easy to review.
+3. Decide whether `internal/typeset` is good enough after the target rename. If it
+ still feels overloaded, rename the package to `internal/typeinventory`.
+4. Only then consider accessor changes like `All()` -> `AllRecords()` and
+ `Followable()` -> `FollowableRecords()`; API churn is easier once the domain
+ names are settled.
+
+## Recommendation
+
+Adopt Scheme A unless minimizing churn is more important than clarity:
+
+```text
+internal/typeinventory
+ record.go
+ followability.go
+ registry.go
+ observations.go
+ lookup.go
+ scale_binding.go
+
+internal/watch
+ target_type_set.go
+ target_type_sets.go
+```
+
+Use `TargetTypeSet` for the per-GitTarget object. Avoid `validTypes`; use
+`allTypeRecords`, `followableTypeRecords`, and optionally `liveTypeRecords` when
+the retained state needs to be explicit.
diff --git a/docs/design/manifest/version2/type-followability.md b/docs/design/manifest/version2/type-followability.md
new file mode 100644
index 00000000..8c616449
--- /dev/null
+++ b/docs/design/manifest/version2/type-followability.md
@@ -0,0 +1,364 @@
+# Type followability model
+
+> Status: greenfield proposal, captured 2026-06-08
+>
+> Supersedes the layered model in
+> [api-catalog-watched-type-architecture.md](api-catalog-watched-type-architecture.md).
+> That doc grounds in today's packages and ends up with seven layers and two
+> parallel reports (followability requirements + a health report). This one starts
+> from the question we actually want answered and works backward.
+
+## The one question
+
+For every resource type the cluster serves, GitOps Reverser needs exactly one
+answer:
+
+> **Is this type followable, and if not, what is the single reason it is not?**
+
+"Followable" means safe enough for the product to mirror, watch, snapshot, route
+audit events for, and potentially sweep from Git. Everything else — the health
+level, the status conditions, the "why is this ignored?" diagnostic — is a
+rendering of that one answer. There is no separate health report.
+
+This matters because the layered model invented condition names that do not map to
+a real decision. A `Watch` condition that is `False` does not tell an operator
+anything actionable; the type is simply **not followable because it is missing a
+required verb**. Naming the check after the failure ("Watch: False") is less clear
+than naming it after the requirement and letting the reason speak ("not followable
+— missing required verb: patch"). So the model has one list of named requirement
+checks, and the failing check *is* the explanation.
+
+## Fewer layers
+
+Three things, not seven. Two are cluster-global; one is per-GitTarget.
+
+```text
+cluster ─ scan ─▶ Observation raw per-type facts: discovery + CRD +
+ (one per served type) APIService evidence + built-in registry
+ │
+ ▼
+ TypeRegistry the single decision surface:
+ (policy + identity observation + policy -> Followability
+ + 60s grace baked in) one record per known type, lookups,
+ │ the live set
+ ┌───────────────┼─────────────────┐
+ ▼ ▼ ▼
+ mapper live set TargetView (per GitTarget)
+ ByGVK/ByGVR Followable() folds followable records into
+ (no separate namespace/operation scope; never
+ mapper object) recomputes followability
+```
+
+- **Scan** gathers raw facts and produces an `Observation` per served type. It
+ joins discovery with `CustomResourceDefinition` and `APIService` evidence and the
+ built-in scale registry. It makes no product decisions.
+- **`TypeRegistry`** is the only place that turns observations + policy into a
+ followability verdict. It owns identity rules, the deny/sensitive policy, the live
+ set, and the 60-second removal grace. Every consumer reads from it.
+- **`TargetView`** is a GitTarget's projection of *already-followable* records into
+ namespaces and operations. It never recomputes followability.
+
+The mapper is not a layer — it is `TypeRegistry.ByGVK`. WatchRule expansion is not a
+layer — it is a function that builds a `TargetView` by asking the registry.
+
+## Identity is 1:1, in both directions
+
+A type is followable only when its identity is a closed bijection:
+
+- exactly one **GVR** serves a given **GVK**, and
+- that GVR resolves back to exactly one **Kind**.
+
+The round trip must close: `GVK → GVR → GVK` returns the original GVK and
+`GVR → GVK → GVR` returns the original GVR. If either direction forks, the type is
+refused (`gvk-not-unique` or `gvr-not-unique`) and never enters the live set.
+
+This is strict on purpose. A served-but-ambiguous identity is where silent
+mis-mirroring, wrong-parent writes, and confusing sweeps come from, and it is almost
+always a symptom of an unhealthy cluster: duplicate CRDs claiming a kind, an
+aggregated API shadowing a built-in group/version, or two resources sharing a kind
+across versions. GitOps Reverser does not guess past it. It refuses the type, names
+the conflict, and lets the operator fix it. **Keep your cluster healthy** is a
+precondition the product relies on, not something it works around.
+
+## The followability output
+
+Every known type carries one `Followability` value: a verdict, a one-line summary,
+and the full list of requirement checks in funnel order. This single list replaces
+both the old "requirements" table and the old "health conditions" table.
+
+### Verdicts
+
+| Verdict | Meaning | Old health equivalent |
+| --- | --- | --- |
+| `followable` | every required check passed; in the live set | healthy |
+| `retained` | a transient check (`served`/`trusted`) is failing now, but the 60s grace has not elapsed; still treated as live | degraded |
+| `refused` | a permanent check failed; will not be followed | refused |
+| `unknown` | the registry could not assess the type (catalog unavailable) | unknown |
+
+### Requirements
+
+Each check has a stable kebab-case name and a single reason code on failure. The
+reason code is the one vocabulary used everywhere a type is turned away — lookup
+results, the live-set report, and operator status — so "why isn't this picked up?"
+always has the same machine-readable answer.
+
+| Requirement | Passes when | Fails with |
+| --- | --- | --- |
+| `served` | discovery serves this as a top-level resource (not a subresource) | `not-served`, `subresource-only` |
+| `trusted` | the backing group/version came from trusted, non-degraded discovery | `discovery-degraded`, `catalog-unavailable` |
+| `stable` | the type is not mid-disappearance, or is inside the removal grace | `absence-expired` |
+| `identity` | GVK ↔ GVR is 1:1 in both directions | `gvk-not-unique`, `gvr-not-unique` |
+| `scope` | the type is known namespaced or cluster-scoped | `scope-unknown` |
+| `verbs` | discovery advertises `get`, `list`, `watch`, `patch` (detail names the missing verb) | `missing-verb` |
+| `origin` | classified `builtin`, `crd`, or `aggregated` with evidence | `origin-unknown` |
+| `policy` | product policy permits mirroring this type | `denied-by-policy` |
+| `sensitivity` | not sensitive, or sensitivity has supported encryption/write handling | `sensitive-unsupported` |
+| `scale` | scale is unused, or its parent replica path is known (not guessed) | `scale-path-unresolved` |
+
+Verdict derivation is mechanical:
+
+- all required checks `pass` → `followable`;
+- only `served`/`trusted` fail and the absence is younger than 60s → `retained`;
+- `catalog-unavailable` (the whole catalog is down) → `unknown`;
+- any other failed check → `refused`, summarized by the first failing check.
+
+### Examples
+
+Followable built-in:
+
+```yaml
+gvk: apps/v1 Deployment
+gvr: apps/v1 deployments
+scope: Namespaced
+origin: { kind: builtin, confidence: inferred }
+verdict: followable
+summary: followable
+checks:
+ - { requirement: served, result: pass }
+ - { requirement: trusted, result: pass }
+ - { requirement: stable, result: pass }
+ - { requirement: identity, result: pass }
+ - { requirement: scope, result: pass }
+ - { requirement: verbs, result: pass }
+ - { requirement: origin, result: pass }
+ - { requirement: policy, result: pass }
+ - { requirement: sensitivity, result: pass }
+ - { requirement: scale, result: pass }
+subresources:
+ scale: { source: builtin-registry, specReplicasPath: .spec.replicas, usable: true }
+```
+
+Refused for a missing verb — the case the old `Watch` condition described badly:
+
+```yaml
+gvk: metrics.k8s.io/v1beta1 PodMetrics
+gvr: metrics.k8s.io/v1beta1 pods
+origin: { kind: aggregated, confidence: observed, evidence: v1beta1.metrics.k8s.io }
+verdict: refused
+summary: not followable — missing required verb: watch, patch
+checks:
+ - { requirement: served, result: pass }
+ - { requirement: trusted, result: pass }
+ - { requirement: identity, result: pass }
+ - { requirement: verbs, result: fail, reason: missing-verb, detail: "watch, patch" }
+```
+
+Refused for ambiguous identity:
+
+```yaml
+gvk: example.com/v1 Widget
+verdict: refused
+summary: not followable — GVK served by two GVRs
+checks:
+ - { requirement: identity, result: fail, reason: gvk-not-unique, detail: "widgets, widgetz" }
+```
+
+## Interfaces
+
+The record is the unit everything passes around. It answers "can I act on this?"
+and "why not?" in one object, so the safe path and the diagnostic path are the same
+call.
+
+```go
+// Identity is the one true name of a type. For a followable type the GVK <-> GVR
+// bijection is closed, so these two always round-trip.
+type Identity struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+ Scope Scope // Namespaced | ClusterScoped | Unknown
+}
+
+type Origin struct {
+ Kind OriginKind // builtin | crd | aggregated | unknown
+ Confidence Confidence // observed | inferred | unknown
+ Evidence string // bounded, e.g. crontabs.stable.example.com
+}
+
+type TypeRecord struct {
+ Identity Identity
+ Origin Origin
+ Preferred bool
+ Verbs []string
+ Subresources Subresources
+ Sensitive bool
+
+ Followability Followability
+ Generation uint64
+}
+
+type Followability struct {
+ Verdict Verdict // followable | retained | refused | unknown
+ Summary string // one line, e.g. "not followable — missing required verb: patch"
+ Checks []Check // every requirement, funnel order
+}
+
+type Check struct {
+ Requirement Requirement // served, trusted, stable, identity, scope, verbs, origin, policy, sensitivity, scale
+ Result Result // pass | fail | skip | unknown
+ Reason Reason // empty on pass; otherwise the single reason code
+ Detail string // bounded human detail, e.g. "patch"
+}
+
+// Followable is the safe-path helper. Most callers never inspect Verdict directly.
+func (r TypeRecord) Followable() bool {
+ return r.Followability.Verdict == VerdictFollowable ||
+ r.Followability.Verdict == VerdictRetained
+}
+```
+
+The registry is small. Two lookups, two lists. The lookups always return the full
+record — including the verdict and every check — so a caller never needs a second
+"inspect" call to render the reason.
+
+```go
+type TypeRegistry interface {
+ Ready() bool
+ Generation() uint64
+
+ // Always return the full record. The bool reports whether the type is known to
+ // the registry at all; callers gate behavior on record.Followable().
+ ByGVK(ctx context.Context, gvk schema.GroupVersionKind) (TypeRecord, bool, error)
+ ByGVR(ctx context.Context, gvr schema.GroupVersionResource) (TypeRecord, bool, error)
+
+ // Followable returns only verdict in {followable, retained}. All returns every
+ // known type for inventory and "why not" views.
+ Followable(ctx context.Context) ([]TypeRecord, error)
+ All(ctx context.Context) ([]TypeRecord, error)
+}
+```
+
+Per-GitTarget projection consumes records; it does not recompute them. A rule that
+matches nothing followable returns the refused record so the operator sees why.
+
+```go
+type TargetView struct {
+ Target TargetID
+ Types []FollowedType
+}
+
+type FollowedType struct {
+ Record TypeRecord // copied from the registry, never recomputed
+ NamespaceOps map[string]OperationSet
+}
+
+func BuildTargetView(
+ reg TypeRegistry, rules []WatchRule,
+) (TargetView, []Rejection, error)
+
+type Rejection struct {
+ Rule WatchRule
+ Record TypeRecord // the refused/ambiguous record the rule resolved to
+}
+```
+
+## Subresource and scale facts
+
+Subresources are folded into the parent record, not followed as their own types. The
+only subresource fact the writer needs is where a `/scale` mutation lands on the
+parent's desired state.
+
+```go
+type Subresources struct {
+ Status StatusFact
+ Scale ScaleBinding
+}
+
+type ScaleBinding struct {
+ Enabled bool
+ Source string // discovery | crd | builtin-registry | aggregated | unknown
+ ResponseGVK schema.GroupVersionKind // normally autoscaling/v1 Scale
+
+ SpecReplicasPath string
+ StatusReplicasPath string
+ SelectorPath string
+ SelectorKind string // serialized-string | label-selector | unknown
+
+ // Usable is true only when a /scale audit event can be mapped back to a durable
+ // parent field. False feeds the `scale` requirement's `scale-path-unresolved`.
+ Usable bool
+}
+```
+
+`SpecReplicasPath` comes from `CustomResourceDefinition` `spec.versions[*].subresources.scale`
+for CRDs and from a small built-in registry for built-ins, so both look identical to
+the writer. The CRD JSONPath rules live in
+[../../../facts/subresources.md](../../../facts/subresources.md) and are not repeated
+here. The scale write path depends only on `SpecReplicasPath`; selector facts are for
+reporting. The `scale` requirement is `skip` when scale is unused and only `fail`s
+(`scale-path-unresolved`) when a needed parent path is missing.
+
+## Live set and the 60-second grace
+
+Additions are fast; removals are slow. A newly served type that passes every
+requirement enters the live set immediately. A previously live type that fails its
+next `served`/`trusted` observation is held as `retained` for a fixed **60 seconds**
+before it leaves the live set as `refused`/`unknown`. The grace is product safety,
+not tuning, so it is not configurable: it stops a short discovery blink from turning
+into a large Git sweep.
+
+While retained, `ByGVK`/`ByGVR` still return the record and `Followable()` is still
+true, so planning, snapshots, informers, and writer identity keep working. The
+verdict reads `retained` and the failing check explains why. Once the grace expires,
+the type drops from `Followable()` and a deliberate sweep can act on a stable
+absence.
+
+## What each consumer asks
+
+Every consumer reads `TypeRecord`; they differ only in which fields they use and
+whether they need the non-followable ones.
+
+| Consumer | Reads | Needs refused records? |
+| --- | --- | --- |
+| Manifest mapper | `Identity`, `Followable()` | no |
+| WatchRule → `TargetView` | `Identity`, verbs, scope, `Followable()` | yes, as `Rejection` |
+| Snapshot / informer | `Identity`, scope, `retained` state | no |
+| Audit consumer | parent `Identity`, `Subresources.Scale.SpecReplicasPath` | no |
+| Git writer | `Sensitive`, `Origin`, `Subresources.Scale` | no |
+| CLI / status / GUI | the whole record, including `refused` | yes, via `All()` |
+
+The `/scale` case is the pressure test: the audit consumer calls `ByGVR` for the
+parent, checks `Followable()`, and reads `Subresources.Scale.SpecReplicasPath`. If
+that path is empty the `scale` check is `fail` and the translator refuses rather than
+guessing `.spec.replicas`.
+
+## Relationship to today's code
+
+Greenfield names, but they land on existing packages:
+
+- **Scan / `Observation`** ← today's `APIResourceCatalog` refresh plus the new CRD /
+ `APIService` enrichment.
+- **`TypeRegistry`** ← the resolved surface that does not exist yet; absorbs
+ `CatalogMapper` (becomes `ByGVK`), the GVK/GVR ambiguity policy, the sensitive /
+ deny policy, and the live-set hysteresis currently scattered across
+ `RuleGVRResolver` and `WatchedTypeTable`.
+- **`TargetView`** ← today's `WatchedTypeTable`, stripped of identity, ambiguity, and
+ removal-grace logic now owned by the registry.
+
+## References
+
+- [api-catalog-watched-type-architecture.md](api-catalog-watched-type-architecture.md)
+- [catalog-mapper-vs-watched-type-table.md](catalog-mapper-vs-watched-type-table.md)
+- [subresource-scope-reduction.md](subresource-scope-reduction.md)
+- [gvk-gvr-mapping-layer.md](../gvk-gvr-mapping-layer.md)
+- [resource-types.md](../../../facts/resource-types.md)
+- [subresources.md](../../../facts/subresources.md)
diff --git a/docs/design/manifest/version2/type-lifecycle-events-and-wobble-settling.md b/docs/design/manifest/version2/type-lifecycle-events-and-wobble-settling.md
new file mode 100644
index 00000000..e4a67e4f
--- /dev/null
+++ b/docs/design/manifest/version2/type-lifecycle-events-and-wobble-settling.md
@@ -0,0 +1,228 @@
+# Type lifecycle events, the wobble-settle phase, and consolidation
+
+> Status: design direction, captured 2026-06-09. **Implemented 2026-06-09 (M12 first
+> slices):** Proposals 1–2 in [internal/typeset/lifecycle.go](../../../../internal/typeset/lifecycle.go)
+> (`LifecycleEvent`/`Observer`/`Subscribe`, `SettleWindow`, settle + flap coalescing in
+> `Registry.Update`); M12 per-type reconcile/sweep via
+> `manifestanalyzer.BuildScopedPlan`, the `ScopeGVR` resync request, the registry
+> subscription + drain goroutine in [internal/watch/type_lifecycle.go](../../../../internal/watch/type_lifecycle.go),
+> and `EventRouter.EmitTypeReconcile/SweepForGitDest`. Proposal 3's consolidation is the
+> shared `Manager.typeWobbling` predicate. The per-type path is gated on `SnapshotSynced`,
+> so bootstrap is unchanged; `Unknown` granularity and bootstrap-decoupling remain open.
+> Origin: the per-type reconcile track ([dream.md](dream.md),
+> [per-type-reconcile-and-streaming-tail.md](per-type-reconcile-and-streaming-tail.md))
+> and the [e2e flakiness findings](../../e2e-full-suite-flakiness-findings-2026-06.md).
+> Related:
+> [discovery-catalog-typeset-boundary.md](discovery-catalog-typeset-boundary.md),
+> [type-followability.md](type-followability.md),
+> [catalog-mapper-vs-watched-type-table.md](catalog-mapper-vs-watched-type-table.md),
+> [api-catalog-watched-type-architecture.md](api-catalog-watched-type-architecture.md).
+
+## Why now
+
+Two things came together. First, **M10 landed**: the typeset registry is now the
+single decision surface for "what does this GitTarget follow, and is each type
+usable right now?", projected per-GitTarget by the resident `WatchedTypeTable`.
+That central, checking source was the right move and this design keeps it.
+
+Second, **M12 — per-type reconcile + per-type sweep — is the next real product
+move**, and tracing the e2e flakiness made its hard edge concrete: the unit of
+work becomes a *type*, and a type's health **changes over time** (a CRD installs,
+a group's discovery wobbles, a CRD is deleted). A naive per-type reconcile that
+acts on whatever verdict it reads *at that instant* would:
+
+- fire its first reconcile mid-wobble and then immediately have to undo it, or
+- treat a transient unserved blink as a removal and sweep the type's KRM.
+
+The registry already refuses to do the destructive half of that (the
+`RemovalGrace` + `VerdictRetained` fail-closed). What it does **not** yet give us
+is a clean, single signal of *transitions* — "this type just became unhealthy",
+"this type became healthy again", "this type is now really gone" — that a per-type
+reconcile can subscribe to instead of polling and re-deriving. And the "is this
+type usable?" judgment is currently re-derived in **three** layers, which is the
+simplification this document is also chasing.
+
+## What we already have (the foundation — keep it)
+
+The typeset registry (`internal/typeset/registry.go`,
+[type-followability.md](type-followability.md)) is the single owner of type
+identity and the live set. Per known type it computes one `TypeRecord` with a
+`Verdict`:
+
+| Verdict | Meaning |
+|---|---|
+| `Followable` | every check passed; in the live set |
+| `Retained` | a *transient* check (served/trusted) fails now, but the type is held through `RemovalGrace` |
+| `Refused` | a *permanent* check failed; never followed |
+| `Unknown` | the registry could not assess it (catalog unavailable) |
+
+…with a single machine-readable `Reason` for a failure (`not-served`,
+`discovery-degraded`, `absence-expired`, `gvk-not-unique`, `missing-verb`, …), a
+fixed `RemovalGrace` (60s — product safety, not tuning), a `Generation` (the scan
+the records came from), and a `Revision` — a *change-of-decision* counter that
+bumps whenever followable membership or the generation moves.
+
+M10 projects this per-GitTarget into the `WatchedTypeTable`, re-resolved on a rules
+fingerprint or a catalog `Generation()` change, and already holds a type the
+catalog momentarily stops serving rather than dropping it (the pending-removal
+fail-closed in `resolveSnapshotGVRs`). GVK↔GVR is treated as **1:1** (a
+multi-resource GVK is a refused `TypeConflict`), giving the per-type work an
+unambiguous key.
+
+None of that changes here. This design adds a transition layer on top of it and
+then deletes the duplication it makes redundant.
+
+## The gap: rich state, no transitions
+
+Everything above is **state you pull**. A consumer reads the current verdict and
+learns "something changed" only from the coarse `Revision` bump; to find out *what*
+changed it diffs its own previous projection against the new one. The M10
+watched-type store already does exactly this — "a candidate-vs-published diff each
+refresh classifies every disappeared type as immediate-removal / indefinite
+blocking retention / grace-held pending removal." That diff-and-classify is a
+**per-consumer re-derivation of transitions the registry already knows**, because
+the registry is the thing that moved the type from one verdict to another.
+
+For M12 this is the wrong shape. A per-type reconcile is *triggered by a
+transition* ("became followable" → reconcile it; "absence-expired" → sweep it). If
+each consumer re-derives transitions by diffing tables, we have re-implemented the
+same edge detection in the watched-type store, the snapshot gate, the informer
+lifecycle, and (soon) the per-type reconciler — four places that must agree.
+
+## Proposal 1 — the registry emits per-type lifecycle transitions
+
+Make the registry the **single emitter** of typed transition events, computed where
+it already compares prior vs new verdict. No new verdict vocabulary — the events
+are transitions *between the existing verdicts*:
+
+| Event | Verdict transition | What it means / who acts |
+|---|---|---|
+| `TypeActivated` | `*` → `Followable` (settled — see Proposal 2) | type is healthy and stable; M12 schedules its (re)reconcile |
+| `TypeWobbling` | `Followable` → `Retained` | transient unserved; **do not** sweep, postpone the type's reconcile, keep informers up |
+| `TypeRecovered` | `Retained` → `Followable` | back; resume (collapses into `TypeActivated` after settle) |
+| `TypeRemoved` | `Retained` → `Refused`(`absence-expired`) | grace elapsed, genuinely gone; M12's **per-type untracking sweep** for *this type only* |
+| `TypeRefused` | `*` → `Refused`(permanent reason) | never watch; drop informers; surface in status |
+
+Each event carries `(GVK, GVR, from, to, Reason, Generation, at)`. The registry
+already holds per-entry timestamps for the grace clock, so it has everything to
+emit these as a side effect of the verdict recompute it already runs. Delivery
+shape is an open question (§Open questions) — a subscribe/observer callback invoked
+under the registry's existing single-updater discipline is the leading option; the
+`Revision` counter stays as the cheap "did anything change" gate for bulk consumers
+that don't want per-type granularity.
+
+The point is not "add an event bus." It is: **the transition is computed once, by
+the component that owns the decision, and named** — so every consumer reacts to the
+same edge instead of re-detecting it.
+
+## Proposal 2 — the wobble-settle phase (debounce activation)
+
+Separate two timers that are easy to conflate:
+
+- **`RemovalGrace` (existing, 60s) governs REMOVAL.** How long a vanished type is
+ *held* before its deletion is honored. It exists so a discovery blink never
+ sweeps git. It is deliberately long and is product safety.
+- **The settle window (new, short — a few seconds) governs ACTIVATION.** How long a
+ type must be *stably* `Followable` before its per-type reconcile is allowed to
+ fire. It exists so a flapping or just-appeared type does not drive a per-type
+ reconcile (and a potential snapshot/sweep) on a state that is about to change
+ again.
+
+`TypeActivated` is emitted only after the type has been continuously `Followable`
+for the settle window. A `Followable`→`Retained`→`Followable` flap inside the
+window emits **no** `TypeActivated` churn — the reconcile waits for stability. This
+is the concrete answer to "should we build a wait-for-the-wobble phase that
+prevents the wobble from breaking the first type-specific reconcile": **yes, and it
+belongs in the registry, not in each consumer**, so the debounce is implemented and
+tested once and M12 simply consumes a stable signal.
+
+A first per-type reconcile therefore only ever runs against a type that has been
+stably healthy; a sweep only ever runs on a settled `TypeRemoved`. The wobble can
+no longer break either.
+
+## Proposal 3 — the consolidation this unlocks (the cleanup)
+
+The headline payoff is deletion, not addition. "Is this type usable, and what just
+changed?" is currently answered in three layers that each re-derive a slice of it:
+
+1. **Catalog** (`api_resource_catalog.go`): marks group/versions degraded, keeps
+ last-known on partial discovery.
+2. **Registry**: turns that into `Verdict` + `Reason` + the grace.
+3. **Watched-type store** (M10): a candidate-vs-published diff that re-classifies
+ every disappeared type into immediate-removal / indefinite-blocking /
+ pending-removal.
+
+Layer 1 and 2 are the right boundary and stay (see
+[discovery-catalog-typeset-boundary.md](discovery-catalog-typeset-boundary.md)).
+**Layer 3's re-classification is the duplication to remove**: once the registry
+emits transitions, the watched-type store stops diffing and re-classifying — it
+*subscribes* to its types' transitions and updates membership directly. Concretely,
+this design expects to collapse:
+
+- the candidate-vs-published **diff/classify** step → replaced by reacting to
+ `TypeWobbling` / `TypeRemoved` / `TypeActivated`;
+- `resolveSnapshotGVRs`'s ad-hoc `retainedWatchedTypes` fail-closed scan → replaced
+ by a single "are all my types reconcile-eligible?" read off the lifecycle, with
+ the *same* fail-closed result;
+- the scattered consumers that gate rebuilds on the coarse `Revision` bump and then
+ re-derive *what* changed → replaced by the named transition;
+- the bespoke pending-removal timers in the watched-type store, folded back onto the
+ registry's one grace clock (the store should not own a second copy of the grace).
+
+Net: one owner of "type health over time," one grace clock, one settle clock, one
+place that names a transition — and three downstream re-derivations deleted. That is
+the "serious simplification" worth taking *with* this change rather than after it,
+because adding events without removing the diffs would be strictly worse.
+
+## How M11 and M12 consume it
+
+- **M11 (visibility):** the bounded `GitTargetStatus` roll-up becomes a projection
+ of current lifecycle states + last-transition `Reason`s (which it wanted anyway):
+ watched/resolved/failing counts, capped failing-type names with their reason, CRD
+ versions. No separate computation.
+- **M12 (per-type reconcile + sweep):** driven directly by the events.
+ `TypeActivated` → reconcile that type's snapshot into git; `TypeRemoved` → sweep
+ *only that type's* documents. The settle phase gates the first reconcile; the
+ per-type sweep stays type-scoped and fires only on a settled removal — preserving
+ the anti-sweep invariant the global mark-and-sweep guards today. The "one slow or
+ unhealthy type blocks the whole GitTarget at bootstrap" failure mode disappears
+ because each type activates independently.
+
+## Safety invariants (unchanged)
+
+- **Never sweep on a partial/reduced view.** A type is swept only on a *settled*
+ `TypeRemoved` (`absence-expired`), never on `Retained`/`Unknown`/wobble.
+- **`RemovalGrace` still gates deletion.** The settle window only gates activation;
+ it never shortens removal.
+- **GVK↔GVR is 1:1.** A multi-resource GVK stays a refused `TypeConflict`.
+- **`Unknown` (catalog unavailable) is global, not per-type.** When the registry
+ cannot assess the surface at all, no per-type reconcile should proceed — this stays
+ a fail-closed whole-surface gate (§Open questions refines it).
+
+## Sequencing and open questions
+
+This is the substrate M12 should be built **on**, so it lands as M12's first slice
+(the event + settle layer) before the per-type sweep, not as a separate milestone.
+M11 can read the lifecycle states even before the events exist (it only needs
+current state), so it is not blocked.
+
+Open questions to settle before coding:
+
+1. **Delivery shape.** Observer callback under the registry's single-updater lock,
+ a buffered channel drained by a consumer goroutine, or a "transitions since
+ revision N" pull API? The callback keeps the one-writer discipline; a channel
+ risks reordering vs the `Generation`.
+2. **Settle window value and scope.** One global value, or per-type/per-kind? Start
+ with one small fixed constant (mirror `RemovalGrace`'s "safety, not tuning"
+ stance) and revisit only with evidence.
+3. **Flap coalescing.** Rapid `Followable`↔`Retained` flapping should coalesce to
+ at most one pending `TypeActivated`; define the coalescing rule explicitly so two
+ consumers cannot disagree on how many events fired.
+4. **`Unknown` granularity.** Is "catalog unavailable" always whole-surface, or can
+ it be scoped to the degraded group/version so unrelated types keep reconciling?
+ (This is the same question the e2e cascade raised: one group's discovery failure
+ should not stall unrelated types.)
+5. **Idempotency for restart.** On controller restart there is no "prior verdict" to
+ diff from; define the cold-start emission (treat first observation as the initial
+ state, emit `TypeActivated` only after the settle window, never a spurious
+ `TypeRemoved`).
diff --git a/docs/design/watchrule-wildcard-and-resolution-semantics.md b/docs/design/watchrule-wildcard-and-resolution-semantics.md
index 6b4fe8a2..3f91f005 100644
--- a/docs/design/watchrule-wildcard-and-resolution-semantics.md
+++ b/docs/design/watchrule-wildcard-and-resolution-semantics.md
@@ -129,3 +129,14 @@ This distinction matters for the state model in
[rule-set-snapshot-discovery-lag-fix.md](../finished/rule-set-snapshot-discovery-lag-fix.md):
only the transient family should drive "retry next cycle"; the wildcard family
will not resolve on its own.
+
+### Same policy applies one step later, at list time
+
+The `NotServed` skip above happens during *resolution* (a type absent from the
+catalog never enters the watch set). The identical condition can also surface one
+step later, during the authoritative `list` in `RequestClusterState`: a type that
+was served at resolve time can have its CRD/APIService removed before the list
+runs, which returns `NotFound`. For consistency that list-time `NotFound` is
+handled the same way — the GVR is skipped, not treated as a partial-view abort.
+Every *other* list error (a served type we could not read) still aborts. See the
+[Item D decision](../future/watchrule-wildcard-support-plan.md#item-d-decision-notfound-skips-everything-else-aborts).
diff --git a/docs/facts/generated-name-support.md b/docs/facts/generated-name-support.md
new file mode 100644
index 00000000..336c2361
--- /dev/null
+++ b/docs/facts/generated-name-support.md
@@ -0,0 +1,179 @@
+# Kubernetes `generateName` and audit identity facts
+
+This note records the Kubernetes API behavior relevant to resources created
+with `metadata.generateName`, especially when the create request is consumed
+from Kubernetes audit events.
+
+## Resource naming
+
+- `metadata.name` is the persisted object name. For namespaced resources, it is
+ unique within the tuple `(apiGroup, resource, namespace)`. It cannot be
+ changed after creation.
+- `metadata.generateName` is an optional server-side name prefix. The API
+ server uses it only when `metadata.name` is not provided.
+- When `generateName` is used, the server appends a generated suffix and returns
+ the allocated object name in `metadata.name`.
+- The supplied `generateName` prefix follows the same validation rules as
+ `metadata.name`. Kubernetes may truncate the prefix to leave room for the
+ generated suffix.
+- Name generation can still collide. Kubernetes returns HTTP `409 Conflict` if
+ it cannot allocate a unique name. Kubernetes v1.31 and later make up to eight
+ allocation attempts before returning `409`.
+- `metadata.uid` is generated by the API server on successful creation. Unlike
+ `metadata.name`, it distinguishes a deleted object from a later object created
+ with the same name.
+
+## Create request shape
+
+A client that wants the server to allocate the name sends a collection `POST`
+with `metadata.generateName` and without `metadata.name`:
+
+```text
+POST /apis/configbutler.ai/v1alpha1/namespaces/voter-production/commitrequests
+```
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: CommitRequest
+metadata:
+ namespace: voter-production
+ generateName: coffee-save-
+spec:
+ gitTargetRef:
+ name: voter-demo
+ message: Lower espresso price
+```
+
+The successful API response contains the final server-allocated identity:
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: CommitRequest
+metadata:
+ namespace: voter-production
+ generateName: coffee-save-
+ name: coffee-save-8tw8m
+ uid: 31434c94-34ab-4f02-a1c0-83e6a3d5cb2b
+spec:
+ gitTargetRef:
+ name: voter-demo
+ message: Lower espresso price
+```
+
+The concrete suffix is not part of the API contract. Clients must read the
+created object, the create response, a watch event, or an audit `responseObject`
+to learn the allocated name.
+
+## Audit event fields
+
+Kubernetes audit events use the `audit.k8s.io/v1` `Event` type.
+
+- `objectRef` identifies the object or collection the request targeted. It does
+ not apply to list-type or non-resource requests.
+- `requestObject` is the request body as received by the API server. It is
+ recorded only at audit level `Request` or `RequestResponse`.
+- `responseObject` is the response body returned by the API server. It is
+ recorded only at audit level `RequestResponse`.
+- `ResponseComplete` is the audit stage where the response body has been
+ completed.
+
+For a successful `generateName` create, the request targets the collection URL
+and the client did not submit the final name. Therefore `objectRef.name` must be
+treated as optional. The allocated name is available in
+`responseObject.metadata.name` when the audit policy records the event at
+`RequestResponse`.
+
+Example audit event shape:
+
+```json
+{
+ "apiVersion": "audit.k8s.io/v1",
+ "kind": "Event",
+ "level": "RequestResponse",
+ "stage": "ResponseComplete",
+ "verb": "create",
+ "requestURI": "/apis/configbutler.ai/v1alpha1/namespaces/voter-production/commitrequests",
+ "objectRef": {
+ "apiGroup": "configbutler.ai",
+ "apiVersion": "v1alpha1",
+ "resource": "commitrequests",
+ "namespace": "voter-production"
+ },
+ "requestObject": {
+ "apiVersion": "configbutler.ai/v1alpha1",
+ "kind": "CommitRequest",
+ "metadata": {
+ "namespace": "voter-production",
+ "generateName": "coffee-save-"
+ }
+ },
+ "responseObject": {
+ "apiVersion": "configbutler.ai/v1alpha1",
+ "kind": "CommitRequest",
+ "metadata": {
+ "namespace": "voter-production",
+ "generateName": "coffee-save-",
+ "name": "coffee-save-8tw8m",
+ "uid": "31434c94-34ab-4f02-a1c0-83e6a3d5cb2b"
+ }
+ }
+}
+```
+
+## Identity resolution rule
+
+Audit consumers that need the persisted object identity should resolve
+`namespace`, `name`, and `uid` in this order:
+
+1. Start with `objectRef.namespace`, `objectRef.name`, and `objectRef.uid`.
+2. For create, update, and patch operations, backfill missing fields from
+ `responseObject.metadata`.
+3. For delete operations, backfill missing fields from `requestObject.metadata`.
+4. Use the other audit body as a fallback only when the preferred body is absent
+ or incomplete.
+5. Never overwrite a field that was already present on `objectRef`.
+
+This keeps the URL-level audit reference authoritative when it is complete, but
+still handles server-named objects whose final identity only appears in the
+response body.
+
+## `CommitRequest` example
+
+`CommitRequest` objects are intentionally suitable for generated names because
+each object is a one-shot save signal:
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: CommitRequest
+metadata:
+ namespace: voter-production
+ generateName: save-
+spec:
+ gitTargetRef:
+ name: voter-demo
+ message: Save current voter configuration
+```
+
+After creation, the client or audit consumer should act on the allocated name,
+for example:
+
+```text
+save-vmv5d
+```
+
+not on the prefix:
+
+```text
+save-
+```
+
+## References
+
+- Kubernetes ObjectMeta API reference:
+
+- Kubernetes object names and IDs:
+
+- Kubernetes audit configuration API reference:
+
+- Kubernetes auditing guide:
+
diff --git a/docs/facts/resource-types.md b/docs/facts/resource-types.md
new file mode 100644
index 00000000..77800512
--- /dev/null
+++ b/docs/facts/resource-types.md
@@ -0,0 +1,303 @@
+schemaVersion: 1
+kind: KubernetesResourceTypeFacts
+title: Kubernetes resource type read API facts
+lastChecked: "2026-06-08"
+
+scope:
+ description: >
+ Facts about discovering, reading, listing, watching, and content-negotiating
+ Kubernetes API resources through the raw HTTP API.
+ perspective: raw-kubernetes-http-api
+ excludes:
+ - Client-library helper abstractions.
+ - kubectl output aggregation except where it differs from API behavior.
+
+sources:
+ - name: Kubernetes API overview
+ url: https://kubernetes.io/docs/concepts/overview/kubernetes-api/
+ facts:
+ - Discovery API contents.
+ - Aggregated and unaggregated discovery endpoints.
+ - OpenAPI v3 schema publication.
+ - name: Kubernetes API concepts
+ url: https://kubernetes.io/docs/reference/using-api/api-concepts/
+ facts:
+ - Resource URI shapes.
+ - get, list, and watch read semantics.
+ - YAML, Table, and PartialObjectMetadata media negotiation.
+ - Chunked list semantics.
+ - List-watch and streaming-list behavior.
+
+facts:
+ readPrimitives:
+ - name: discovery
+ kubernetesVerb: null
+ httpMethod: GET
+ purpose: Discover which API groups, versions, resources, scopes, and verbs the cluster serves.
+ - name: get
+ kubernetesVerb: get
+ httpMethod: GET
+ purpose: Retrieve one resource instance.
+ - name: list
+ kubernetesVerb: list
+ httpMethod: GET
+ purpose: Retrieve a collection of resource instances.
+ - name: watch
+ kubernetesVerb: watch
+ httpMethod: GET
+ purpose: Stream changes for a resource or collection from a resourceVersion.
+ - name: contentNegotiatedRead
+ kubernetesVerb: get-or-list
+ httpMethod: GET
+ purpose: Retrieve the same logical object or collection using another supported representation.
+
+ discovery:
+ summary: Discovery answers what resources exist and what operations they support.
+ aggregated:
+ featureState: Kubernetes v1.30 stable, enabled by default
+ endpoints:
+ - /api
+ - /apis
+ acceptHeader: application/json;v=v2;g=apidiscovery.k8s.io;as=APIGroupDiscoveryList
+ returns:
+ - resource names
+ - cluster or namespace scope
+ - endpoint URLs
+ - supported verbs
+ - alternative names
+ - group, version, and kind
+ fact: Aggregated discovery publishes all cluster resources through /api and /apis.
+ unaggregated:
+ rootEndpoints:
+ - /api
+ - /apis
+ groupVersionEndpoints:
+ - /api/v1
+ - /apis/apps/v1
+ - /apis/batch/v1
+ - /apis/example.com/v1
+ fact: Unaggregated discovery requires a separate request for each served group-version.
+ schemaDiscovery:
+ endpoints:
+ - /openapi/v3
+ - /openapi/v3/apis/apps/v1?hash=...
+ fact: Discovery is a short resource summary; OpenAPI is the schema-level API description.
+
+ resourceUris:
+ coreGroup:
+ basePath: /api/v1
+ fact: Core resources use /api and omit the API group path segment.
+ examples:
+ - /api/v1/pods
+ - /api/v1/namespaces/default/pods/nginx
+ - /api/v1/nodes/worker-1
+ namedGroups:
+ basePathPattern: /apis/{group}/{version}
+ fact: Named API groups use /apis/{group}/{version}.
+ examples:
+ - /apis/apps/v1/deployments
+ - /apis/apps/v1/namespaces/default/deployments/nginx
+ - /apis/example.com/v1/widgets
+ clusterScoped:
+ collectionPattern: /apis/{group}/{version}/{resource}
+ instancePattern: /apis/{group}/{version}/{resource}/{name}
+ examples:
+ - /api/v1/nodes
+ - /api/v1/nodes/worker-1
+ - /apis/rbac.authorization.k8s.io/v1/clusterroles/admin
+ namespaceScoped:
+ allNamespacesCollectionPattern: /apis/{group}/{version}/{resource}
+ namespaceCollectionPattern: /apis/{group}/{version}/namespaces/{namespace}/{resource}
+ instancePattern: /apis/{group}/{version}/namespaces/{namespace}/{resource}/{name}
+ examples:
+ - /api/v1/pods
+ - /api/v1/namespaces/default/pods
+ - /api/v1/namespaces/default/pods/nginx
+ - /apis/apps/v1/deployments
+ - /apis/apps/v1/namespaces/default/deployments
+ - /apis/apps/v1/namespaces/default/deployments/nginx
+ fact: >
+ For a namespaced resource type, the collection path without
+ /namespaces/{namespace} lists instances across all namespaces; it does
+ not make the resource cluster-scoped.
+
+ get:
+ fact: A get request returns one resource instance.
+ examples:
+ - request: GET /api/v1/namespaces/default/pods/nginx
+ responseKind: Pod
+ - request: GET /apis/apps/v1/namespaces/default/deployments/nginx
+ responseKind: Deployment
+ - request: GET /api/v1/nodes/worker-1
+ responseKind: Node
+ responseShape:
+ requiredTopLevelFields:
+ - apiVersion
+ - kind
+ - metadata
+ commonTopLevelFields:
+ - spec
+ - status
+
+ list:
+ fact: A list request returns a collection kind for one resource type.
+ examples:
+ - request: GET /api/v1/pods
+ responseKind: PodList
+ scope: all namespaces
+ - request: GET /api/v1/namespaces/default/pods
+ responseKind: PodList
+ scope: namespace default
+ - request: GET /apis/apps/v1/deployments
+ responseKind: DeploymentList
+ scope: all namespaces
+ - request: GET /apis/example.com/v1/widgets
+ responseKind: WidgetList
+ scope: depends on resource discovery scope
+ responseShape:
+ metadata:
+ includes:
+ - resourceVersion
+ - continue
+ - remainingItemCount
+ items: Contains resource instances of the listed type.
+ collectionKindFacts:
+ - Kubernetes defines concrete collection kinds such as PodList, ServiceList, and DeploymentList.
+ - "`kind: List` is a kubectl or client-side aggregation shape, not a universal API response kind."
+
+ chunkedLists:
+ featureState: Kubernetes v1.29 stable, enabled by default
+ fact: List responses can be paged with limit and continue while preserving one consistent snapshot.
+ requestSequence:
+ - GET /api/v1/pods?limit=500
+ - GET /api/v1/pods?limit=500&continue=ENCODED_CONTINUE_TOKEN
+ guarantees:
+ - The collection resourceVersion remains constant across pages for the same list operation.
+ - Items created, updated, or deleted after that resourceVersion are not included in later pages.
+ - A client can finish the paged list and then watch from the collection resourceVersion.
+ failureMode:
+ status: 410 Gone
+ cause: The continue token expired before the client finished the paged list.
+ handling: Start the list again or omit the limit parameter.
+
+ watch:
+ fact: A watch streams changes after the requested resourceVersion.
+ classicPattern:
+ - step: list
+ request: GET /api/v1/namespaces/default/pods
+ result: Read metadata.resourceVersion from the PodList.
+ - step: watch
+ request: GET /api/v1/namespaces/default/pods?watch=1&resourceVersion=10245
+ result: Stream events after resourceVersion 10245.
+ eventTypes:
+ - ADDED
+ - MODIFIED
+ - DELETED
+ - BOOKMARK
+ bookmarkFacts:
+ - BOOKMARK events carry resourceVersion progress without a full object.
+ - Clients request bookmarks with allowWatchBookmarks=true.
+ - Clients must not assume bookmarks are returned at a specific interval.
+ - Clients must not assume the API server will send bookmarks even when requested.
+ compactionFailure:
+ status: 410 Gone
+ cause: The requested historical resourceVersion is no longer available.
+ handling: Clear the local cache, perform a fresh get or list, and restart the watch.
+
+ streamingLists:
+ featureState: Kubernetes v1.34 beta, enabled by default
+ fact: A watch can send synthetic initial ADDED events for current state before normal watch events.
+ request: >
+ GET /api/v1/namespaces/default/pods?watch=1&sendInitialEvents=true&
+ allowWatchBookmarks=true&resourceVersion=&resourceVersionMatch=NotOlderThan
+ requirements:
+ - sendInitialEvents=true
+ - resourceVersionMatch=NotOlderThan
+ sequence:
+ - Synthetic ADDED events for current objects.
+ - BOOKMARK event when requested and initial state is synced.
+ - Normal watch events after the synced resourceVersion.
+
+ subresources:
+ fact: A subresource is a separate API surface below a parent resource path.
+ pathPatterns:
+ clusterScoped: /apis/{group}/{version}/{resource}/{name}/{subresource}
+ namespaceScoped: /apis/{group}/{version}/namespaces/{namespace}/{resource}/{name}/{subresource}
+ examples:
+ - GET /api/v1/namespaces/default/pods/nginx/log
+ - GET /api/v1/namespaces/default/pods/nginx/status
+ - GET /apis/apps/v1/namespaces/default/deployments/nginx/status
+ - GET /apis/apps/v1/namespaces/default/deployments/nginx/scale
+ behaviorFacts:
+ - Supported verbs differ by subresource and parent resource type.
+ - A subresource can have a different response shape from the parent resource.
+ - It is not possible to access subresources across multiple parent resources with one generic subresource call.
+
+ mediaNegotiation:
+ fact: GET requests can use the Accept header to ask for alternate representations.
+ defaultJson:
+ request: GET /api/v1/pods
+ acceptHeader: application/json
+ responseKind: PodList
+ yaml:
+ request: GET /api/v1/pods
+ acceptHeader: application/yaml
+ responseKind: PodList
+ fact: Kubernetes supports application/yaml for requests and responses.
+ table:
+ request: GET /api/v1/pods
+ acceptHeader: application/json;as=Table;g=meta.k8s.io;v=v1
+ responseKind: Table
+ usefulFor:
+ - generic UIs
+ - kubectl-like tabular displays
+ fallbackHeader: application/json;as=Table;g=meta.k8s.io;v=v1, application/json
+ fallbackFact: Servers that do not support Table can return 406 unless another media type is accepted.
+ partialObjectMetadata:
+ singleObject:
+ request: GET /api/v1/namespaces/default/pods/nginx
+ acceptHeader: application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1
+ responseKind: PartialObjectMetadata
+ collection:
+ request: GET /api/v1/pods
+ acceptHeader: application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1
+ responseKind: PartialObjectMetadataList
+ usefulFor:
+ - existence checks
+ - metadata indexes
+ - garbage collection
+ - inventory tools that do not need spec or status
+ fallbackHeader: >
+ application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1,
+ application/json;q=0.9
+ fallbackFact: Aggregated APIs and extensions may not support partial metadata responses.
+
+ genericClientFlow:
+ - step: discover
+ requests:
+ - GET /api
+ - GET /apis
+ - GET /openapi/v3
+ result: Know resource names, scopes, verbs, and optionally schemas.
+ - step: inventory
+ requests:
+ - GET /apis/{group}/{version}/{resource}
+ - GET /apis/{group}/{version}/namespaces/{namespace}/{resource}
+ result: List the resource collection, usually in chunks for large clusters.
+ - step: readOne
+ request: GET .../{resource}/{name}
+ result: Fetch a single resource instance.
+ - step: continuousSync
+ pattern: LIST, record collection resourceVersion, then WATCH from that resourceVersion.
+ result: Maintain a local view without missing later changes.
+ - step: lighterReads
+ headers:
+ - application/json;as=Table;g=meta.k8s.io;v=v1, application/json
+ - application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1, application/json;q=0.9
+ result: Use server-side tabular or metadata-only responses when supported.
+
+ practicalModel:
+ - Kubernetes read operations are discover, get, list, and watch.
+ - Kubernetes commonly uses HTTP GET for get, list, and watch, but classifies them as different API verbs.
+ - resourceVersion ties list and watch together for continuous synchronization.
+ - The same API machinery applies to built-ins, CRDs, and many aggregated API resources when the server supports the same verbs and representations.
diff --git a/docs/facts/resource-versions.md b/docs/facts/resource-versions.md
new file mode 100644
index 00000000..75445a36
--- /dev/null
+++ b/docs/facts/resource-versions.md
@@ -0,0 +1,78 @@
+# Fact: Kubernetes resourceVersion semantics
+
+Reference: [Kubernetes API concepts — resource versions](https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions)
+
+This is a standing reference for how GitOps Reverser may and may not rely on
+`metadata.resourceVersion` (RV). It captures the API contract and the
+reliability improvements available on recent clusters. Design docs (for example
+[HA and GitTarget distribution](../future/ha-gittarget-distribution-plan.md))
+link here instead of restating the rules.
+
+## The contract (always true)
+
+- **RV is opaque.** Clients must treat it as an opaque string. Do not parse it,
+ do arithmetic on it, or assume it is numeric.
+- **RV is scoped to a single resource history.** It is only meaningful within one
+ group/resource. Two RVs from the *same* group/resource (for example two
+ `apps/deployments`) can be ordered when served by kube-apiserver; RVs from
+ *different* resource types (for example `apps/deployments` vs
+ `apps/replicasets`) **must not** be compared or collated, even within the same
+ API group.
+- **Extension/aggregated API servers.** Numeric ordering is only safe when both
+ RV strings parse as decimal numbers. Otherwise fall back to equality-only
+ comparison.
+- **Gaps are normal.** RV is not a promise of contiguous per-type integers. A
+ client cannot prove "RV 123 is missing, wait for it." Bounded reorder windows
+ plus idempotent replay and snapshot/reconcile correction are the safe pattern,
+ not waiting for a specific RV to appear.
+
+## List / watch parameter semantics
+
+- **LIST, no `resourceVersion`** → current collection (consistent; see below).
+- **LIST with `resourceVersionMatch=NotOlderThan`** (the default when only
+ `resourceVersion` is set) → data at least as fresh as the given RV.
+- **LIST with `resourceVersionMatch=Exact`** → exactly that RV, or `410 Gone` if
+ it has been compacted.
+- **WATCH with `resourceVersion=R`** → streams changes after `R` for that
+ resource. This is the resume point a client persists.
+- **Watch bookmarks** (`BOOKMARK` events) → periodic RV progress markers on a
+ long watch, so a client can keep its persisted RV recent without a real change
+ occurring. Use them to reduce how often the `410` fallback fires.
+
+## What changed: consistent reads from the watch cache
+
+`ConsistentListFromCache` graduated to **GA in Kubernetes 1.34** and is stable in
+**1.35+**. A consistent LIST now returns a trustworthy collection
+`resourceVersion` **cheaply from the watch cache**, instead of forcing a quorum
+read against etcd.
+
+Practical effect: you can now lean on RV-based **watermarks** and **watch resume**
+more than older client guidance allowed. A consistent LIST gives a precise
+collection RV `R`; any later event with a higher comparable RV (same
+group/resource) is newer than that snapshot, and anything at or below `R` is
+already reflected. This makes the "snapshot at `R`, then apply events with
+RV > `R`" pattern reliable and inexpensive.
+
+This does **not** relax the contract above: RV is still opaque, still
+per-group-resource, and still not collatable across resource types. The
+improvement is about the *cost and consistency of obtaining a watermark / resume
+point*, not about cross-resource ordering.
+
+## `410 Gone` (compaction)
+
+A persisted RV can age past the API server's compaction horizon. A request that
+uses it (resume watch, or `resourceVersionMatch=Exact`) then returns `410 Gone`.
+The required handling is to **relist from current state and reconcile**, not to
+fail hard. Keeping the persisted RV recent via watch bookmarks reduces how often
+this happens.
+
+## How GitOps Reverser applies this
+
+- Committed YAML never carries RV — it is stripped during sanitization, so no-op
+ updates do not produce spurious diffs.
+- RV (with `metadata.uid`) is the natural mutation-identity / dedup key inside the
+ pipeline and queues; content hashes are a secondary idempotency guard.
+- Ordering-sensitive Git writes stay serialized per branch write shard; RV is an
+ ordering hint within a group/resource, never a cross-type global clock.
+- Snapshot/reconcile is the correction path whenever audit ordering or delivery
+ leaves the derived Git view uncertain.
diff --git a/docs/facts/subresources.md b/docs/facts/subresources.md
new file mode 100644
index 00000000..a963f351
--- /dev/null
+++ b/docs/facts/subresources.md
@@ -0,0 +1,483 @@
+# Kubernetes Subresources
+
+A factual reference on what subresources are in the Kubernetes API, which ones
+exist, how they behave, and how `configbutler.ai` resources use them.
+
+Primary source for the CRD facts below:
+
+
+## Two ways to extend the API
+
+Kubernetes has two extension mechanisms. They look similar from the outside but
+differ in what they can do.
+
+**CustomResourceDefinitions (CRDs)** let you add new resource types served by the
+main kube-apiserver:
+
+```text
+/apis/configbutler.ai/v1alpha1/namespaces/default/gittargets/example
+```
+
+A CRD supports exactly two built-in subresources, and no others:
+
+```text
+.../gittargets/example/status
+.../gittargets/example/scale
+```
+
+The CRD documentation is explicit that custom resources support `/status` and
+`/scale`, enabled in the CRD definition. CRDs cannot define arbitrary
+subresources such as `/diff`, `/render`, `/logs`, `/restart`, or `/console`.
+
+---
+
+Scale subresource
+When the scale subresource is enabled, the /scale subresource for the custom resource is exposed. The autoscaling/v1.Scale object is sent as the payload for /scale.
+
+To enable the scale subresource, the following fields are defined in the CustomResourceDefinition.
+
+specReplicasPath defines the JSONPath inside of a custom resource that corresponds to scale.spec.replicas.
+
+It is a required value.
+Only JSONPaths under .spec and with the dot notation are allowed.
+If there is no value under the specReplicasPath in the custom resource, the /scale subresource will return an error on GET.
+statusReplicasPath defines the JSONPath inside of a custom resource that corresponds to scale.status.replicas.
+
+It is a required value.
+Only JSONPaths under .status and with the dot notation are allowed.
+If there is no value under the statusReplicasPath in the custom resource, the status replica value in the /scale subresource will default to 0.
+labelSelectorPath defines the JSONPath inside of a custom resource that corresponds to Scale.Status.Selector.
+
+It is an optional value.
+It must be set to work with HPA and VPA.
+Only JSONPaths under .status or .spec and with the dot notation are allowed.
+If there is no value under the labelSelectorPath in the custom resource, the status selector value in the /scale subresource will default to the empty string.
+The field pointed by this JSON path must be a string field (not a complex selector struct) which contains a serialized label selector in string form.
+In the following example, both status and scale subresources are enabled.
+
+Save the CustomResourceDefinition to resourcedefinition.yaml:
+
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: crontabs.stable.example.com
+spec:
+ group: stable.example.com
+ versions:
+ - name: v1
+ served: true
+ storage: true
+ schema:
+ openAPIV3Schema:
+ type: object
+ properties:
+ spec:
+ type: object
+ properties:
+ cronSpec:
+ type: string
+ image:
+ type: string
+ replicas:
+ type: integer
+ status:
+ type: object
+ properties:
+ replicas:
+ type: integer
+ labelSelector:
+ type: string
+ # subresources describes the subresources for custom resources.
+ subresources:
+ # status enables the status subresource.
+ status: {}
+ # scale enables the scale subresource.
+ scale:
+ # specReplicasPath defines the JSONPath inside of a custom resource that corresponds to Scale.Spec.Replicas.
+ specReplicasPath: .spec.replicas
+ # statusReplicasPath defines the JSONPath inside of a custom resource that corresponds to Scale.Status.Replicas.
+ statusReplicasPath: .status.replicas
+ # labelSelectorPath defines the JSONPath inside of a custom resource that corresponds to Scale.Status.Selector.
+ labelSelectorPath: .status.labelSelector
+ scope: Namespaced
+ names:
+ plural: crontabs
+ singular: crontab
+ kind: CronTab
+ shortNames:
+ - ct
+
+---
+
+
+**Aggregated API servers** are needed for arbitrary subresources. The Kubernetes
+aggregation layer lets you register an `APIService` that claims an API path; the
+main kube-apiserver then proxies requests for that API group to your own API
+server, which can implement any subresource and any behavior.
+
+| | CRD | Aggregated API server |
+| --- | --- | --- |
+| New resource types | yes | yes |
+| Subresources available | `/status`, `/scale` only | any |
+| Custom API behavior | no | yes |
+| Operational cost | low (served by kube-apiserver) | high (you run an API server) |
+
+## What a subresource is
+
+A subresource is not a child object. It is a **separate API surface** attached to
+the same resource. Each subresource can have different verbs, different
+request/response shapes, and different RBAC permissions.
+
+For a Pod, all of these address the same object but mean different things:
+
+```text
+/api/v1/namespaces/default/pods/nginx
+/api/v1/namespaces/default/pods/nginx/status
+/api/v1/namespaces/default/pods/nginx/log
+/api/v1/namespaces/default/pods/nginx/exec
+```
+
+RBAC treats subresources separately, using `resource/subresource` slash notation.
+Granting access to a Pod's logs is distinct from granting access to the Pod:
+
+```yaml
+rules:
+ - apiGroups: [""]
+ resources:
+ - pods
+ - pods/log
+ verbs: ["get"]
+```
+
+So a subresource is a separate contract and a separate security boundary, not
+just a different URL for the same data.
+
+## CRD subresource: `/status`
+
+The `/status` subresource splits a resource into user-owned desired state
+(`spec`) and controller-owned observed state (`status`):
+
+```yaml
+spec:
+ # what the user wants
+status:
+ # what the controller has observed or done
+```
+
+When the status subresource is enabled, the API enforces this split
+(per the CRD documentation):
+
+- `PUT`/`POST`/`PATCH` to the main resource endpoint **ignore changes to the
+ `status` stanza**.
+- `PUT` to the `/status` endpoint **ignores changes to everything except
+ `status`**.
+- `.metadata.generation` is incremented for all changes **except** changes to
+ `.metadata` or `.status`. So a status-only write does not bump the generation;
+ a spec change does.
+
+In a kubebuilder/controller-runtime project the subresource is enabled with a
+marker on the type:
+
+```go
+// +kubebuilder:subresource:status
+```
+
+Every `configbutler.ai` CRD enables `/status`: `GitProvider`, `GitTarget`,
+`WatchRule`, `ClusterWatchRule`, and `CommitRequest`. A real `GitTarget`:
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: GitTarget
+metadata:
+ name: platform
+ namespace: default
+spec:
+ providerRef:
+ group: configbutler.ai
+ kind: GitProvider
+ name: platform-config
+ branch: main
+ path: clusters/dev
+status:
+ observedGeneration: 4
+ lastReconcileTime: "2026-06-08T10:00:00Z"
+ lastCommit: abc123def
+ conditions:
+ - type: Ready
+ status: "True"
+ reason: CommitPushed
+```
+
+The user owns `spec`; the controller owns `status`. `observedGeneration` is what
+lets a reader tell whether the reported conditions describe the current `spec` or
+a stale one — a `Ready: "True"` condition is only trustworthy when
+`status.observedGeneration` equals `metadata.generation`.
+
+## CRD subresource: `/scale`
+
+`/scale` is a standardized scaling interface. When enabled, the `/scale`
+endpoint exposes an `autoscaling/v1.Scale` view over the resource. The CRD
+declares which JSONPaths back the Scale object:
+
+```yaml
+subresources:
+ scale:
+ specReplicasPath: .spec.replicas # -> scale.spec.replicas (required, under .spec)
+ statusReplicasPath: .status.replicas # -> scale.status.replicas (required, under .status)
+ labelSelectorPath: .status.selector # -> scale.status.selector (optional; required for HPA/VPA)
+```
+
+The `/scale` endpoint sends and receives an `autoscaling/v1.Scale` payload, not
+the full resource. This is what makes a custom resource work with the standard
+tooling:
+
+```bash
+kubectl scale customsets.apps.example.com/foo --replicas=5
+```
+
+and with HorizontalPodAutoscaler / VerticalPodAutoscaler consumers. It is a
+scaling contract specifically — it is only meaningful for resources that
+represent a replicated, scalable workload, for example:
+
+```yaml
+apiVersion: apps.example.com/v1
+kind: CustomSet
+spec:
+ replicas: 3
+status:
+ replicas: 2
+ selector: app=my-custom-set
+```
+
+No `configbutler.ai` resource enables `/scale`: `GitProvider`, `GitTarget`,
+`WatchRule`, `ClusterWatchRule`, and `CommitRequest` are not scalable workloads,
+so a replica count has no meaning for them.
+
+## Audit logs and the `/scale` subresource
+
+Because a subresource is a separate API surface, a write to it produces a
+*separate* audit event — and that has a direct consequence for anything that
+reconstructs object state from the Kubernetes audit stream, as gitops-reverser
+does.
+
+When a user runs:
+
+```bash
+kubectl scale deployment/scale-audit-target --replicas=3
+```
+
+kubectl does **not** patch the main Deployment endpoint or send a full
+Deployment object. It issues a `PATCH` against the Deployment's `scale`
+subresource. The apiserver persists that as a real parent Deployment
+`.spec.replicas` change, but it records exactly one audit event for the request
+— keyed to the subresource and carrying an `autoscaling/v1.Scale` payload, not a
+Deployment. See
+[deployment-scale-subresource.json](../../internal/webhook/testdata/audit-events/deployment-scale-subresource.json)
+(abridged):
+
+```jsonc
+{
+ "verb": "patch",
+ "requestURI": ".../deployments/scale-audit-target/scale",
+ "objectRef": {
+ "resource": "deployments", // the parent resource...
+ "subresource": "scale" // ...addressed via its scale subresource
+ },
+ "requestObject": { "spec": { "replicas": 3 } },
+ "responseObject": {
+ "kind": "Scale",
+ "apiVersion": "autoscaling/v1",
+ "metadata": {
+ "resourceVersion": "6977"
+ },
+ "spec": { "replicas": 3 },
+ "status": { "replicas": 0, "selector": "app=scale-audit-target" }
+ }
+}
+```
+
+In this capture, a normal Deployment read after the scale showed the parent
+Deployment at the same `metadata.resourceVersion` (`6977`) with
+`.spec.replicas: 3`. That is the key local proof for gitops-reverser: the
+`Scale` response is not a separate durable object to commit, but it does expose
+the accepted desired-state change that was just persisted onto the parent
+Deployment.
+
+**The important fact: no "normal", complete PATCH/UPDATE audit event against the
+Deployment itself ever arrives.** The apiserver does update the Deployment's
+`.spec.replicas` internally, but in the audit stream that change is visible
+*only* through the scale subresource event above. A consumer that watches the
+parent resource and ignores subresource events (filtering on
+`objectRef.subresource == ""`) silently misses every `kubectl scale` and every
+HPA-driven replica change — the desired state of the workload would drift from
+what is captured in Git with no event to explain it.
+
+That is exactly why gitops-reverser supports the scale subresource explicitly. It
+recognizes the `subresource: "scale"` event, reads the desired count from the
+`Scale` payload's `spec.replicas` (here `3`) — **not** `status.replicas`, which
+is the *observed* count and is `0` in this capture — and translates it into a
+field patch on the parent Deployment's `.spec.replicas`, so the scaling change is
+materialized into Git like any other update.
+
+## Built-in subresources
+
+The built-in Kubernetes API uses a richer set of subresources than CRDs can.
+Pods expose the most:
+
+```text
+pods/status # controlled mutation of observed state
+pods/resize # in-place resource resize
+pods/ephemeralcontainers # add debug containers, not allowed at create time
+pods/log # derived read representation
+pods/exec # streaming/interactive
+pods/attach # streaming/interactive
+pods/portforward # streaming/interactive
+pods/proxy # proxy
+pods/eviction # policy-aware action
+```
+
+Ephemeral containers illustrate why a field gets its own subresource: they
+cannot be set on a normal Pod create/update and must be added through the
+`pods/ephemeralcontainers` subresource.
+
+The Pod eviction endpoint is a policy-aware action — it accepts an `Eviction`
+object, not a full Pod, and applies PodDisruptionBudget policy:
+
+```http
+POST /api/v1/namespaces/{namespace}/pods/{name}/eviction
+```
+
+Services expose a proxy subresource that forwards through the apiserver toward
+service endpoints:
+
+```text
+/api/v1/namespaces/default/services/my-service/proxy
+/api/v1/namespaces/default/services/my-service/proxy/{path}
+```
+
+The common thread: Kubernetes uses a subresource when the operation is not a
+normal CRUD operation on the parent object.
+
+## Aggregated API server subresources
+
+Arbitrary subresources require an aggregated API server.
+
+**KubeVirt** is a clear example. Alongside its VM resources it serves a separate
+subresource API group, `subresources.kubevirt.io`, for operations that cannot be
+modeled as declarative object updates — console access, VNC, restart, and
+similar. RBAC for these is granted on the subresource:
+
+```yaml
+rules:
+ - apiGroups:
+ - subresources.kubevirt.io
+ resources:
+ - virtualmachines/console
+ - virtualmachines/vnc
+ verbs:
+ - get
+```
+
+Concrete operations from that API group:
+
+```http
+GET /apis/subresources.kubevirt.io/v1/namespaces/{ns}/virtualmachineinstances/{name}/console
+GET /apis/subresources.kubevirt.io/v1/namespaces/{ns}/virtualmachineinstances/{name}/vnc
+PUT /apis/subresources.kubevirt.io/v1/namespaces/{ns}/virtualmachines/{name}/restart
+```
+
+**metrics-server** also uses aggregation, but differently: it serves an entire
+extra API (`metrics.k8s.io`) through the aggregation layer rather than adding a
+`resource/subresource` to an existing type.
+
+**sample-apiserver** is the reference implementation for building an extension
+API server on the `k8s.io/apiserver` library. Its own README notes that CRDs are
+simpler when all you need is a new resource type.
+
+`configbutler.ai` does not run an aggregated API server and exposes no custom
+subresources; everything is served by the main kube-apiserver as CRDs.
+
+## Subresource categories
+
+Across built-in APIs and CRDs, subresources fall into a few categories.
+
+| Category | Mechanism | Examples |
+| --- | --- | --- |
+| Controller-owned observed state | `/status` | `pods/status`, `deployments/status`, `gittargets/status` |
+| Standardized cross-type view | `/scale` | `deployments/scale`, `statefulsets/scale` |
+| Streaming / interactive | built-in / aggregated API | `pods/exec`, `pods/attach`, `virtualmachineinstances/console` |
+| Alternate read representation | built-in / aggregated API | `pods/log` |
+| Policy-aware action | built-in / aggregated API | `pods/eviction`, `virtualmachines/restart` |
+| Special controlled mutation | built-in | `pods/ephemeralcontainers`, `pods/resize` |
+
+A few of these are only available on built-in types or through an aggregated API
+server; a plain CRD cannot add them.
+
+## Request resources vs. imperative subresources
+
+For an imperative operation, Kubernetes API design has two options: a custom
+subresource (requires an aggregated API server), or a **request resource** — a
+normal CRD that represents "please do X", carrying its outcome in `status`.
+
+`configbutler.ai` uses the request-resource pattern with **`CommitRequest`**.
+Instead of a hypothetical `POST /gittargets/{name}/commit` subresource, creating
+a `CommitRequest` object finalizes the open commit window for the referenced
+`GitTarget` and reports the result back in status:
+
+```yaml
+apiVersion: configbutler.ai/v1alpha1
+kind: CommitRequest
+metadata:
+ name: save-now
+ namespace: default
+spec:
+ gitTargetRef:
+ name: platform
+ message: "Manual save"
+status:
+ phase: Committed # WaitingForAuditEvent | Committed | NoOpenWindow | Failed
+ branch: main
+ sha: abc123def
+```
+
+Because it is an ordinary resource, a request CRD automatically gets
+`kubectl get`/`describe`/`wait`, watch support, status conditions/phases, RBAC,
+an audit trail, and durable retry/reconciliation — none of which a one-shot
+subresource call provides. This is why it is the GitOps-friendly way to model an
+action: the request itself is a durable, inspectable object.
+
+## Choosing a mechanism
+
+| Need | Mechanism |
+| --- | --- |
+| Durable declarative intent | CRD |
+| Controller reports observed state | CRD + `/status` |
+| HPA / `kubectl scale` compatibility | CRD + `/scale` |
+| One-off declarative job/request | Request CRD (e.g. `CommitRequest`) |
+| Derived synchronous or streaming output | Aggregated API subresource |
+| Proxy / tunnel / console / session | Aggregated API subresource |
+| Imperative action | Request CRD, or aggregated API subresource if it must be synchronous/streaming |
+
+The deciding question is whether the thing is **state** or an **operation**:
+
+- Desired state → a resource (`spec`).
+- Observed state → `status`.
+- A live operation, stream, derived view, or policy-aware action → a subresource
+ (built-in or aggregated API).
+
+## Summary
+
+The pattern Kubernetes follows:
+
+| Intent | Mechanism |
+| --- | --- |
+| Normal state | resource |
+| Controller-owned observation | `/status` |
+| Common cross-type protocol | `/scale` |
+| Live / dynamic / interactive behavior | subresource (aggregated API for custom types) |
+| Arbitrary imperative operation | request resource, or aggregated API subresource |
+
+`configbutler.ai` stays entirely within CRDs: every type is served by the main
+kube-apiserver, every type uses `/status`, none use `/scale`, and imperative
+actions are modeled as request resources (`CommitRequest`) rather than custom
+subresources.
diff --git a/docs/finished/design-rule-change-snapshot-trigger.md b/docs/finished/design-rule-change-snapshot-trigger.md
index fc05fed3..e3947b6b 100644
--- a/docs/finished/design-rule-change-snapshot-trigger.md
+++ b/docs/finished/design-rule-change-snapshot-trigger.md
@@ -5,7 +5,7 @@
> Implemented: 2026-05-21
> Tests that anchor this work:
> - [internal/watch/rule_change_snapshot_test.go](../../internal/watch/rule_change_snapshot_test.go) — four unit tests
-> - [test/e2e/e2e_test.go](../../test/e2e/e2e_test.go) — `It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", Label("smoke"), ...)`
+> - [test/e2e/e2e_test.go](../../test/e2e/e2e_test.go) — `It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", ...)`
## Implementation summary
@@ -32,7 +32,7 @@ Validation at implementation time:
- `go test ./internal/watch -run 'TestReconcileForRuleChange' -count=1`
- `task lint`
- `task test`
-- `task test-e2e` with the new backfill spec included in the smoke set
+- `task test-e2e` with the new backfill spec included in the full e2e suite
## The problem in one sentence
@@ -206,7 +206,7 @@ go green:
- `TestReconcileForRuleChange_RestartLikeBootstrap_NoSnapshotDrops`
The e2e spec
-`It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", Label("smoke"), ...)`
+`It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", ...)`
passes against a real k3d cluster.
The `EventRouter.SnapshotDeliveryDrops()` counter stays at 0 in steady state under a representative load test (creating and editing rules, restarting the controller).
diff --git a/docs/finished/gittarget-isolation-on-rule-change.md b/docs/finished/gittarget-isolation-on-rule-change.md
index e905ffe3..c37d1087 100644
--- a/docs/finished/gittarget-isolation-on-rule-change.md
+++ b/docs/finished/gittarget-isolation-on-rule-change.md
@@ -358,7 +358,7 @@ regression:
- assert target A still receives the expected event commit, not a snapshot commit
This should be smaller and more intentional than relying on incidental
-cross-spec timing in the full smoke suite.
+cross-spec timing in the full e2e suite.
## Non-Goals
diff --git a/docs/future/ha-gittarget-distribution-plan.md b/docs/future/ha-gittarget-distribution-plan.md
new file mode 100644
index 00000000..70df34d4
--- /dev/null
+++ b/docs/future/ha-gittarget-distribution-plan.md
@@ -0,0 +1,449 @@
+# High Availability and GitTarget Distribution Plan
+
+Status: **proposed** (not started)
+
+## Goal
+
+Make GitOps Reverser run safely with multiple pods while preserving the most
+important write invariant:
+
+> At any moment, only one pod should own work for a given Git branch, and every
+> push must still be protected by remote-branch compare-and-swap semantics.
+
+The cluster's Kubernetes state should be observable from multiple
+`gitops-reverser` pods, and `GitTarget`s should be distributable across those
+pods. The write path must still serialize every write that can touch the same
+branch. The safety model is at-least-once delivery plus idempotent replay, not
+exactly-once processing.
+
+## Current Shape
+
+The current implementation is intentionally single-active:
+
+- [AuditConsumer](../../internal/queue/redis_audit_consumer.go) uses Redis
+ consumer groups, but `NeedLeaderElection()` returns `true`, so only the leader
+ drains the canonical audit stream.
+- [WorkerManager](../../internal/git/worker_manager.go) also participates in
+ leader election. It creates in-process [BranchWorker](../../internal/git/branch_worker.go)
+ instances keyed by `(GitProvider namespace, GitProvider name, branch)`.
+- [BranchWorker](../../internal/git/branch_worker.go) serializes commits and
+ pushes for that branch key inside one pod. This protects the branch locally,
+ but does not by itself protect a multi-pod deployment.
+- [GitTargetEventStream](../../internal/reconcile/git_target_event_stream.go)
+ buffers live events while snapshots are reconciling and deduplicates events
+ per target, but its state is in memory and pod-local.
+- [watch.Manager](../../internal/watch/manager.go) is also leader-elected, so
+ discovery, informer lifecycle, and snapshot emission happen on one active pod.
+
+This means Redis/Valkey already helps with ingress durability and future
+failover, but the Git write ownership boundary is still process-local.
+
+## Audit Ingress Fan-In
+
+All pods can safely serve the audit webhook and append to the same canonical
+audit stream, as long as webhook handling stays a **producer-only** path:
+
+- each pod receives `/audit-webhook` and `/audit-webhook-additional` traffic;
+- each pod performs request decode, validation, audit body joining, and
+ canonical event preparation;
+- each pod appends accepted events to the shared Redis/Valkey stream with
+ [RedisAuditQueue](../../internal/queue/redis_audit_queue.go);
+- no ingress pod routes directly to a local `BranchWorker`.
+
+Redis stream append is atomic across producers, so multiple pods can `XADD` to
+the same stream. The Redis-backed audit joiner is also compatible with multiple
+ingress pods because body parking, decision claims, commit, and release all use
+shared Redis keys keyed by audit ID.
+
+This does not create a new perfect ordering guarantee. The canonical stream
+orders events by enqueue time, not by Kubernetes resource version or by a global
+API-server sequence. With multiple API servers, webhook retries, load-balanced
+HTTP requests, and optional additional audit sources, Kubernetes audit delivery
+can already arrive slightly out of order. Multiple ingress pods can make that
+arrival-order reality more visible, but they are not the fundamental source of
+it.
+
+The design response should be:
+
+- treat the canonical audit stream as an at-least-once ingress log, not an
+ exactly-once ordered history;
+- preserve event metadata such as deterministic event id, audit ID, stage
+ timestamp, user, object reference, operation, and object resource version when
+ available;
+- keep ordering-sensitive Git writes serialized later by branch write shard;
+- make replay and duplicate handling idempotent in the shard writer;
+- use snapshot/reconcile as the correction path when audit ordering or delivery
+ produces an uncertain derived Git view.
+
+So the answer is "yes, all pods can push audit events to the same queue," but
+that only solves ingress availability. It does not by itself make consumers,
+branch workers, or snapshots active/active-safe.
+
+## Resource-Type Sequencing Queues
+
+An optional layer between audit ingress and branch-shard writes is a set of
+small **resource-type queues**:
+
+```text
+ResourceTypeQueue = API group + resource type
+```
+
+This is deliberately narrower than "API group." Kubernetes `resourceVersion`
+ordering is only meaningful for objects from the same API group and resource
+type when served by kube-apiserver, per the Kubernetes
+[resource versions](https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions)
+rules. For example, two `apps/deployments` resource versions can be ordered, but
+`apps/deployments` and `apps/replicasets` cannot be ordered just because both
+are in the `apps` group. For extension API servers, numeric ordering is only
+safe when both resource version strings parse as decimal numbers; otherwise
+equality-only comparison is the safe fallback.
+
+The useful shape is:
+
+1. Audit ingress appends to the canonical stream, or directly to a
+ resource-type stream after lightweight GVR extraction.
+2. A resource-type sequencer consumes events for one group/resource.
+3. It holds a short reorder window, sorts comparable arrived events by
+ `metadata.resourceVersion`, and coalesces stale updates for the same
+ `(namespace, name)`.
+4. It fans the resulting event to every active `GitTarget` whose `WatchRule` or
+ `ClusterWatchRule` matches that resource type.
+5. The fan-out still writes to branch-shard queues, where Git ordering and
+ branch ownership are enforced.
+
+This can improve local ordering and reduce redundant writes for noisy resource
+types. It also gives a clean fan-out point: one Kubernetes mutation can be
+sequenced once, then delivered to many target-specific branch queues.
+
+The reorder window must stay bounded. Resource versions are orderable, but they
+are not a promise of contiguous per-type integers that lets a client prove "RV
+123 is missing, so wait until it arrives." Gaps may be normal. The sequencer can
+delay briefly to let near-simultaneous out-of-order deliveries settle; after the
+window expires, it should emit what it has and rely on idempotent replay plus
+snapshot/reconcile for correction.
+
+Queue key choice should probably be **group/resource** rather than full GVR.
+Different served versions of the same group/resource represent the same
+underlying objects, while `WatchRule` planning and routing can still retain the
+observed API version in the event payload. If implementation convenience starts
+with GVR queues, the design should still normalize high-water marks and
+deduplication at the group/resource level where Kubernetes comparison rules
+apply.
+
+This layer is not a prerequisite for branch-safe HA. It is a later refinement if
+audit ordering noise or fan-out cost becomes visible in practice.
+
+## Desired Model
+
+Move from "one leader owns everything" to "many pods may ingest and route, but
+each write shard has exactly one owner."
+
+The first durable shard should be a **branch write shard**:
+
+```text
+BranchWriteShard = canonical Git remote identity + branch
+```
+
+Using only `GitProvider namespace/name + branch` is not sufficient forever,
+because two `GitProvider` objects may point at the same repository and branch.
+That collision is already tracked in [TODO.md](../TODO.md). HA should resolve
+that at the same time by normalizing branch ownership around the remote identity
+that actually receives the push.
+
+Each `GitTarget` maps to exactly one branch write shard, while one branch write
+shard may serve many `GitTarget`s that write to different paths.
+
+`GitTarget`s can still be spread across pods. The distribution rule is that
+their **write owner** is selected by branch shard, not by target name alone. A
+pod may own many branch shards, and a branch shard may own many targets.
+
+## Why Not One Queue Per GitTarget First?
+
+A per-`GitTarget` queue is attractive because it isolates target backlogs and
+matches the user's mental model. It is not sufficient as the first HA primitive:
+two targets can write different paths on the same branch, and two independent
+target queues could then produce two independent push loops.
+
+The safer shape is:
+
+- route events with target identity preserved;
+- partition durable write queues by branch write shard;
+- optionally add per-target subqueues or priority lanes inside the shard later;
+- let exactly one branch owner coalesce, commit, and push all target events for
+ that branch.
+
+That gives the desired spread across pods without violating the branch-push
+invariant.
+
+## Delivery and Fencing Model
+
+The durable write path should assume **at-least-once** delivery:
+
+- Redis/Valkey streams retain work until it is acknowledged after the Git write
+ path reaches a durable terminal point.
+- Crash recovery may replay already-seen events.
+- Replayed events must be safe through deterministic event ids, content hashes,
+ current remote state, and existing `PushAtomic` conflict handling.
+
+The branch-owner lease is an ownership and coordination mechanism, not the final
+correctness fence for Git itself. Git remotes do not provide a native fencing
+token that can reject a push because a Redis or Kubernetes lease changed while
+the network operation was in flight. A lease check immediately before `git push`
+would still be a time-of-check/time-of-use race.
+
+Therefore the true write fence remains the remote ref compare-and-swap performed
+by [PushAtomic](../../internal/git/git_atomic_push.go): a push is valid only if
+the remote branch is still at the expected root. The lease prevents duplicate
+work and keeps one intended owner per branch shard; `PushAtomic` protects the
+remote branch if a stale owner races during failover or a slow push.
+
+## First Work Item: Queue-Based Branch Ownership
+
+This is the first HA milestone because it moves branch work onto a durable
+handoff before informer or snapshot work is spread across pods. HA-0 and HA-1
+do **not** by themselves unlock multiple active writer pods; they prepare the
+write path while it is still leader-elected. HA-2 is the phase that makes
+active writer distribution safe.
+
+### Phase HA-0: Make Same-Branch Ownership Explicit
+
+Introduce a small abstraction around the current branch key:
+
+- compute a canonical `BranchWriteShardID` from resolved `GitProvider` remote
+ identity and branch;
+- keep the current `BranchWorker` behavior, but key worker creation by
+ `BranchWriteShardID`;
+- expose the computed shard in logs, metrics, and possibly `GitTarget.status`;
+- detect multiple `GitProvider`s that resolve to the same remote + branch and
+ make them converge onto the same shard;
+- reject or clearly degrade configurations where two `GitTarget`s would write
+ overlapping paths on the same shard.
+
+Overlapping-path detection should start as controller reconciliation logic that
+sets a degraded status condition before registering the target with the shard.
+Admission-time validation is useful for simple literal paths, but it cannot be
+the only guard if future target paths become templated or otherwise dynamic.
+Runtime conflict detection in the branch owner should remain a defense-in-depth
+check before committing a batch.
+
+This can still run single-pod. The purpose is to name the invariant in code
+before distributing it.
+
+### Phase HA-1: Per-Shard Redis Work Queues
+
+Split the current single audit-consumer-to-local-worker handoff into durable
+per-shard queues:
+
+1. The canonical audit stream remains the ingress queue from kube-apiserver.
+2. The active consumer reads audit events, matches them against rules, sanitizes
+ the object, and produces one `git.Event` per matched `GitTarget`.
+3. Instead of directly calling the local `GitTargetEventStream` / `BranchWorker`,
+ the router appends each write event to the Redis stream for that target's
+ `BranchWriteShardID`.
+4. The still-leader-elected branch owner consumes that shard stream and owns the
+ in-memory `GitTargetEventStream`s plus the single `BranchWorker` for the
+ shard.
+
+HA-1 is a compatibility and durability step, not active/active writing. It can
+coexist with the current direct handoff behind a feature flag or versioned
+runtime mode: existing installs keep direct in-process routing, while the new
+mode writes to `gitopsreverser.write.shard.v1.*` streams. The `v1` stream name
+is intentionally versioned so payload changes can be introduced without
+silently confusing old consumers.
+
+Once HA-2 adds shard leases, any active consumer may read audit events, match
+them against rules, sanitize the object, and append the resulting write events
+to the appropriate shard queue. A branch owner pod then consumes that shard
+stream and owns the in-memory `GitTargetEventStream`s plus the single
+`BranchWorker` for the shard.
+
+Suggested stream shape:
+
+```text
+gitopsreverser.write.shard.v1.{shardID}
+```
+
+Payload should include enough context to route without re-reading mutable CRDs:
+target namespace/name, target path, provider identity, branch, resource
+identifier, operation, user, timestamp, sanitized object payload or tombstone,
+and a deterministic event id for deduplication.
+
+This preserves event ordering per branch shard. Once HA-2 enables multiple
+active consumers, the same partitioning also keeps ordering stable even if
+several pods ingest audit events. It lets a remote outage stall only the
+affected shard queue.
+
+### Phase HA-2: Lease Branch Shards
+
+Add ownership leases for branch write shards:
+
+- each shard has one owner pod and a short renewable lease;
+- only the lease holder may consume that shard's write stream or attempt to push
+ that branch;
+- when ownership changes, the new owner claims pending Redis messages, rebuilds
+ any required local clone state from the remote branch, and resumes;
+- shutdown drains or hands off without acknowledging work that has not reached
+ Git.
+
+This is the point where the deployment can safely run multiple active writer
+pods. The Redis consumer group alone is not enough: consumer groups prevent two
+pods from receiving the same queue entry, but they do not prevent two different
+local branch workers from pushing to the same branch after independent routing.
+The lease backend choice affects operational behavior and failover latency, but
+not the final Git safety property: stale owners must still lose to the remote
+ref compare-and-swap.
+
+### Phase HA-3: Durable Per-Target Stream State
+
+Move the correctness state currently held by `GitTargetEventStream` out of the
+pod:
+
+- reconciliation state (`RECONCILING` vs `LIVE_PROCESSING`);
+- buffered live events during snapshot/reconcile;
+- processed content hashes;
+- pending snapshot/reconcile delivery markers.
+
+This can be stored in Redis first. Some low-churn status markers may also belong
+in `GitTarget.status`, but the hot dedup and buffering path should not depend on
+Kubernetes status writes.
+
+Where the snapshot path needs a freshness boundary, prefer a collection
+`resourceVersion` watermark over open-ended content-hash buffering: a snapshot is
+authoritative as of the collection RV it was listed at, so only live events newer
+than that RV (for the same group/resource) need to be replayed on top, and
+anything at or below it is already reflected. Content hashes then become a
+secondary idempotency guard rather than the primary buffering mechanism. See
+HA-4 for the resume side of the same watermark.
+
+This makes branch-owner failover safe during an in-flight snapshot, not merely
+during ordinary live-event processing.
+
+## Replicating Kubernetes State Across Pods
+
+After branch ownership is safe, the Kubernetes observation side can evolve in
+two layers.
+
+### Phase HA-4: Active/Passive Snapshot State
+
+Keep one active `watch.Manager`, but persist enough state that a newly elected
+leader can resume instead of starting from an empty in-memory contract:
+
+- pending and last-delivered rule-set hashes;
+- per-target snapshot delivery status;
+- tracked GVR completeness and degraded discovery state;
+- last-seen resource hashes or resource versions used for deduplication.
+
+Persisting the per-`(group/resource[, namespace])` collection `resourceVersion`
+lets a newly elected leader **resume the watch from that point** instead of
+relisting cold, and gives snapshot reconciliation a precise watermark rather than
+relying only on content hashes. Kubernetes
+[consistent reads from the watch cache](https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions)
+graduated to GA in 1.34 and is stable in 1.35+, so a consistent LIST now returns
+a trustworthy collection `resourceVersion` cheaply from cache instead of forcing
+a quorum read against etcd. HA can therefore lean on RV-based watermarks and
+watch resume more than earlier client guidance allowed. Two constraints still
+hold and must be coded for: RV remains comparable only within one group/resource
+(never collated across resource types), and a persisted RV that has aged past the
+API server's compaction horizon returns `410 Gone` and must fall back to a fresh
+relist.
+
+This matches the low-risk path already sketched in
+[design-snapshot-engine-evolution.md](design-snapshot-engine-evolution.md#34-multi-pod--ha).
+
+### Phase HA-5: Active/Active Tracking Shards
+
+Only after the state model is durable, shard tracking across pods by
+`(GVR, namespace)` or another explicit tracked-set key. Each tracking shard has
+its own lease, informer lifecycle, and completeness state.
+
+Snapshots then become a fan-in problem: a single `GitTarget` may need state from
+many tracking shards. Snapshot emission should therefore be a queued operation
+that waits for all required shards to be synced before producing authoritative
+absence/deletion facts.
+
+## Relationship To WatchRule Wildcards
+
+[WatchRule wildcard support](watchrule-wildcard-support-plan.md) increases the
+number of GVRs a single target may watch. That stresses informer scale and
+snapshot completeness, but it should not be the first distributed-systems
+problem solved.
+
+Recommended ordering:
+
+1. Ship HA-0/HA-1 branch-shard queueing first, so wildcard events can be routed
+ to a durable per-branch stream instead of a local in-process worker.
+2. Then implement wildcard resolver expansion and status visibility.
+3. Gate "wildcard support is done" on snapshot robustness, especially per-GVR
+ list failure handling.
+4. Only later shard the watch/tracking engine across pods.
+
+This lets wildcard work proceed without accidentally creating a world where
+multiple pods can push the same branch.
+
+## Failure Cases To Design For
+
+- **Pod dies after dequeue, before push.** Redis pending-entry reclaim must make
+ the event visible to the new shard owner.
+- **Pod dies after local commit, before push.** The new owner rebuilds from the
+ remote and replays retained/pending writes; local commits are disposable.
+- **Pod dies after push, before ACK.** Replayed events must be idempotent via
+ deterministic event ids, content hashes, and remote-state replay.
+- **Lease expires during slow push.** The stale owner may complete wasted work,
+ but the remote ref compare-and-swap must prevent it from overwriting a newer
+ owner. The owner should keep renewing during long operations to reduce churn,
+ but renewal is not the Git fence.
+- **Graceful handoff during rolling deploy.** The old owner should stop reading
+ new shard work, finish or abandon in-flight work without premature ACKs, and
+ let the new owner resume from the shard stream without duplicate divergent
+ commits.
+- **Remote branch moves externally.** Existing `PushAtomic` conflict handling
+ still applies, but replay must draw from the shard queue/state rather than only
+ local memory.
+- **Two providers point to the same branch.** They must map to one
+ `BranchWriteShardID`; otherwise HA is unsafe even if each provider-local
+ worker is serialized.
+- **Persisted resource version too old (HTTP 410 Gone).** A resume `resourceVersion`
+ that has aged past the API server compaction horizon must trigger a fresh relist
+ and reconcile rather than a hard failure. Watch bookmarks should be used to keep
+ the persisted RV recent and reduce how often this fallback fires.
+
+## Acceptance Criteria
+
+- Multiple pods can receive audit webhook traffic and append canonical audit
+ events.
+- Official and additional audit payloads may land on different ingress pods and
+ still produce one canonical event decision per audit ID.
+- Multiple pods can route matched events to write-shard queues.
+- For a given canonical remote + branch, exactly one pod owns the branch worker
+ and pushes at a time.
+- Killing the owner pod during queued, committed-but-unpushed, and post-push
+ windows does not lose events or produce duplicate divergent commits.
+- Rolling deploys and voluntary lease handoff do not lose events or produce
+ duplicate divergent commits.
+- Independent branch shards continue processing when one remote or branch is
+ slow, broken, or rate-limited.
+- The README can remove the blanket "HA is not supported yet" statement only
+ after branch-shard ownership and failover are covered by e2e tests.
+
+## Open Decisions
+
+- What is the canonical remote identity: normalized URL, provider UID plus URL,
+ resolved host/repo pair, or an explicit `GitProvider.status.remoteID`?
+- Should per-shard queues be created lazily per active shard, or should Redis
+ store all write events in one stream with `shardID` fields and consumer-group
+ partitioning? Redis Cluster topology is a factor: stream-per-shard can
+ distribute load naturally, while a single stream is simpler but can become a
+ hotspot.
+- Should Redis or Kubernetes `Lease` objects own branch-shard coordination?
+ Either way, the lease is advisory for Git correctness; the remote ref
+ compare-and-swap is the push fence.
+- How much of `GitTargetEventStream` state belongs in Redis versus
+ `GitTarget.status`?
+- Should active/active audit consumers perform full rule matching, or should a
+ central matcher fan out to shard queues first?
+- Do shard writers need a per-resource monotonicity guard using resource version
+ or timestamp, or is snapshot/reconcile correction enough for rare out-of-order
+ audit delivery?
+- Is a resource-type sequencing layer worth the extra streams and dynamic CRD
+ lifecycle handling, or should branch-shard queues absorb audit events directly
+ until ordering noise becomes a measured problem?
diff --git a/docs/future/watchrule-wildcard-support-plan.md b/docs/future/watchrule-wildcard-support-plan.md
index 19d5eb49..1c96fe0e 100644
--- a/docs/future/watchrule-wildcard-support-plan.md
+++ b/docs/future/watchrule-wildcard-support-plan.md
@@ -25,12 +25,16 @@ A + B + C, status surfacing F-partial).
**Not done (the "not fully happy flow" caveat):**
-- **Phase 2 / item D — snapshot robustness.** Abort-on-any-`list`-error is
- retained intentionally (and now pinned by
- `TestSnapshotWildcardResourceAbortsOnAnyListError`). At real cluster scale a
- broad `["*"]` target lists hundreds of GVRs, so a single transient `list`
- failure can abort the whole target snapshot. This is the gating item for
- declaring wildcard support truly "done".
+- **Phase 2 / item D — snapshot robustness.** *Partially decided (see
+ [Item D decision](#item-d-decision-notfound-skips-everything-else-aborts)
+ below).* A `list` that returns **NotFound** (the type is no longer served) now
+ skips that GVR instead of aborting the whole snapshot; every other `list` error
+ still aborts (pinned by `TestSnapshotWildcardResourceAbortsOnAnyListError` and
+ `TestSnapshotAbortsOnListError`). This closes the CRD-churn race that wedged
+ wildcard targets. The remaining, larger half of D — resilience to *transient*
+ failures on *served* types at hundreds-of-GVR scale (retry/partial-snapshot
+ strategy) — is still open and is the gating item for declaring wildcard support
+ truly "done".
- **Phase 3 — guardrails.** No informer-count cap/observability and no CRD-burst
debounce yet.
- **HA.** Single-pod only by design; the branch-shard prerequisite in
@@ -168,15 +172,53 @@ A wildcard target lists *every* allowed GVR. Two existing behaviors get stressed
- **Partial-view abort.** `RequestClusterState` aborts the whole target snapshot
if any single GVR `list` fails
- ([manager.go:596-628](../../internal/watch/manager.go#L596-L628)) — correct
- today because a missing list looks like deletions. Across hundreds of GVRs the
- probability that *one* fails is much higher, so a wildcard target could rarely
- produce a complete snapshot. Likely needs a per-GVR resilience strategy that
- still distinguishes "failed to list" from "genuinely empty" without mirroring
- spurious deletions. This is the hardest correctness question.
+ ([manager.go](../../internal/watch/manager.go)) — correct for a *served* type
+ that we could not read, because a missing list looks like deletions. The
+ **NotFound** case (type no longer served) is now split out and skipped (see
+ [Item D decision](#item-d-decision-notfound-skips-everything-else-aborts)).
+ Across hundreds of GVRs the probability that *one served type* fails transiently
+ is still higher, so a wildcard target could rarely produce a complete snapshot.
+ Resolving that needs a per-GVR resilience strategy (retry/backoff or an explicit
+ partial-snapshot mode) that still distinguishes "failed to list a served type"
+ from "genuinely empty" without mirroring spurious deletions. This is the hardest
+ correctness question and the still-open remainder of D.
- **Informer scale / memory.** One informer per GVR across the whole surface is a
real resource cost; worth a soft cap or opt-in guardrail.
+#### Item D decision: NotFound skips, everything else aborts
+
+**Decided (2026-06-04).** The "one GVR failed to list" question splits cleanly on
+the *kind* of failure:
+
+- **`NotFound` → skip that GVR, keep snapshotting.** A `list` returning NotFound
+ means the type is no longer served — its CRD or aggregated APIService was
+ removed between catalog resolution and the list. This is the **same condition
+ the resolver already treats as non-blocking** (`ResolveMissNotServed` is omitted
+ from `blockingSnapshotMisses`): a type absent from the catalog at resolve time is
+ silently skipped today. A type that vanishes in the narrow resolve→list race is
+ the identical situation discovered one step later, so it must be handled
+ identically. A no-longer-served type has no live resources to mistake for
+ deletions, so skipping it cannot mirror a spurious delete.
+- **Any other error → abort the whole snapshot (unchanged).** A timeout, 5xx, or
+ connection failure on a type that *is* served means we could not read resources
+ that may well exist. Treating that as "empty" would wipe their mirrored files,
+ so the partial-view abort stays.
+
+This is provenance-independent — it applies whether the GVR came from a wildcard
+or a named rule — because the resolver's existing `NotServed` skip is already
+provenance-independent. It needs no per-GVR "is this wildcard?" tracking. It is
+deliberately the *smaller* half of D: it fixes the CRD-churn race (a deleted CRD
+elsewhere in the cluster no longer wedges every wildcard target) without yet
+solving resilience to transient failures on served types at scale, which remains
+open above.
+
+Implemented in `GetClusterStateForGitDest`
+([manager.go](../../internal/watch/manager.go)); pinned by
+`TestSnapshotSkipsTypeNoLongerServed` (skip path) alongside the unchanged
+`TestSnapshotAbortsOnListError` / `TestSnapshotWildcardResourceAbortsOnAnyListError`
+(abort path). Origin: this race was the dominant cause of the red `E2E (full)` runs
+analysed in [wildcard-ci-failure-findings.md](../wildcard-ci-failure-findings.md).
+
### E. Plan-hash churn — **small mechanically, a policy choice**
A wildcard target's effective-watch-plan hash changes on every CRD install/delete
@@ -213,9 +255,14 @@ to depth 1, so bursts are partly absorbed. **Probably acceptable as-is for now.*
## Open decisions
- Phase 0: fix docs, or add rejecting validation, or both? (Both is cleanest.)
-- D: what is the correct "one GVR failed to list" behavior for a wildcard target
+- D: ~~what is the correct "one GVR failed to list" behavior for a wildcard target
— skip that GVR for this cycle (risk: looks like deletions) vs abort the whole
- target (risk: wildcard targets rarely snapshot)? This needs its own mini-design.
+ target (risk: wildcard targets rarely snapshot)?~~ **Split and partly decided:**
+ a **NotFound** (type no longer served) skips that GVR; every other error still
+ aborts — see
+ [Item D decision](#item-d-decision-notfound-skips-everything-else-aborts). The
+ transient-failure-on-a-served-type half (retry vs partial snapshot at scale) is
+ still open.
- Should "watch everything" remain policy-filtered by the default denylist, or
should wildcard rules be able to opt into the excluded noisy kinds?
- Is the plan-hash churn for wildcard targets acceptable for v1, or is debounce a
diff --git a/docs/serious-bug/cozystack-bugreport.md b/docs/serious-bug/cozystack-bugreport.md
deleted file mode 100644
index 8d05c6b2..00000000
--- a/docs/serious-bug/cozystack-bugreport.md
+++ /dev/null
@@ -1,41 +0,0 @@
-Title: core.cozystack.io REST storage does not emit k8s.io/initial-events-end bookmark for SendInitialEvents=true watches
-
-Summary
-
-The aggregated apiserver's resources in group core.cozystack.io (tenantsecrets, tenantmodules, tenantnamespaces) do not honor the SendInitialEvents field of ListOptions. Watch clients that opt into the WatchList / streaming-list protocol (kube-apiserver ≥ 1.27, client-go WatchListClient feature gate, on by default since client-go v1.35) never receive the required k8s.io/initial-events-end bookmark, so reflectors stay stuck in their initial-stream phase and log hasn't received required bookmark event marking the end of initial events stream every ~10s. The apps.cozystack.io/Application storage already implements this contract correctly — the three core resources do not.
-
-Reproduction
-
-Any client running client-go ≥ v0.30 with the default WatchListClient=true informing over tenantsecrets, tenantmodules, or tenantnamespaces:
-
-
-Warning: event bookmark expired err="reflector.go:343: hasn't received required bookmark event marking the end of initial events stream, received last event 59.998946102s ago"
-The warning fires on every reflector restart and continuously while the cache is unsynced from the WatchList perspective.
-
-Evidence (commit 1810263)
-
-Reference implementation that does it correctly:
-pkg/registry/apps/application/rest.go:684-807 — reads options.SendInitialEvents, tracks lastResourceVersion, emits a watch.Bookmark with metadata.annotations["k8s.io/initial-events-end"] = "true" once initial ADDED events have been delivered, and even handles the "underlying watcher closes during initial snapshot" edge case.
-
-Missing in:
-
-pkg/registry/core/tenantsecret/rest.go:454 — Watch() ignores opts.SendInitialEvents and only forwards bookmark events from the backing corev1.Secret watcher; those carry no k8s.io/initial-events-end annotation.
-pkg/registry/core/tenantmodule/rest.go:311 — same pattern, forwards bookmarks from the backing helmv2.HelmRelease watcher.
-pkg/registry/core/tenantnamespace/rest.go:158 — same pattern, forwards bookmarks from the backing corev1.Namespace watcher.
-A grep for SendInitialEvents or initial-events-end across pkg/registry/core/ returns zero hits.
-
-Proposed fix
-
-Lift the sendInitialEventsEndBookmark helper and its caller sites from application/rest.go into the three core resources, adapting the bookmark object to the resource's own TypeMeta. The mechanical shape per file:
-
-Near the top of Watch(): sendInitialEvents := opts.SendInitialEvents != nil && *opts.SendInitialEvents.
-Track lastResourceVersion on every translated event from the backing watcher.
-After delivering the initial Added events, emit a watch.Bookmark whose object is an empty TenantSecret / TenantModule / TenantNamespace carrying the right TypeMeta, ResourceVersion=lastResourceVersion, and annotations["k8s.io/initial-events-end"]="true".
-Handle "underlying watcher closes before initial snapshot is complete" — emit the bookmark on the way out, same as application/rest.go:816-817.
-Acceptance criteria
-
-A test alongside rest_watch_test.go for each of the three resources asserting:
-When SendInitialEvents=true, all existing objects are emitted as ADDED.
-A Bookmark event follows with annotations["k8s.io/initial-events-end"] == "true" and a non-empty ResourceVersion.
-Subsequent live events are delivered after the bookmark.
-An informer using client-go's WatchListClient feature reaches HasSynced=true against these resources without the missing-bookmark warning.
diff --git a/docs/tasks/generated-name-support.md b/docs/tasks/generated-name-support.md
deleted file mode 100644
index 35f304e6..00000000
--- a/docs/tasks/generated-name-support.md
+++ /dev/null
@@ -1,341 +0,0 @@
-# `CommitRequest` generated-name audit handling
-
-`CommitRequest` audit handling fails for resources created with
-`metadata.generateName`.
-
-## Severity
-
-Medium-high. The watched resource change is still committed eventually through
-the normal commit window, but the explicit `CommitRequest` finalize signal is
-lost. That means:
-
-- user-provided save messages are ignored;
-- "save now" does not save now;
-- `CommitRequest.status.phase` stays at `WaitingForAuditEvent`;
-- the resulting Git commit may use the fallback grouped message and timing.
-
-This is visible in the voter demo because the auth-service creates
-`CommitRequest` objects with `metadata.generateName`.
-
-## Environment
-
-- `gitops-reverser` chart/app: `0.26.1`
-- Image observed live: `ghcr.io/configbutler/gitops-reverser:0.26.1`
-- Cluster: Kubernetes `v1.36.0` on Talos / Cozystack
-- Audit policy: `RequestResponse` for mutating verbs (`create`, `update`,
- `patch`, `delete`, `deletecollection`)
-- GitOps Reverser audit endpoint is receiving events successfully
-- Affected resource:
- - API group: `configbutler.ai`
- - version: `v1alpha1`
- - resource: `commitrequests`
-
-## Symptom
-
-Creating a `CommitRequest` with `metadata.generateName` leaves the object stuck
-in:
-
-```text
-WaitingForAuditEvent
-```
-
-Live objects:
-
-```text
-NAMESPACE NAME GITTARGET PHASE
-voter-production coffee-save-4dnln voter-demo WaitingForAuditEvent
-voter-production coffee-save-8tw8m voter-demo WaitingForAuditEvent
-```
-
-The controller log shows the audit consumer did see a `CommitRequest` create
-event, but it tried to read a resource with an empty name:
-
-```text
-Failed to read CommitRequest; skipping
-commitRequest="voter-production/"
-error="resource name may not be empty"
-```
-
-The same log stream also confirms impersonated audit events are reaching the
-controller:
-
-```text
-First impersonated audit event observed
-authUser="system:serviceaccount:voter-production:auth-service"
-impersonatedUser="Simon2"
-```
-
-So the issue is not "no audit event arrived"; the event arrived, but the
-`CommitRequest` identity was resolved incorrectly.
-
-## Kubernetes behavior behind this
-
-`generateName` is a Kubernetes server-side name generation mechanism. A client
-creates an object with:
-
-```yaml
-metadata:
- generateName: coffee-save-
-```
-
-and sends a collection `POST` to:
-
-```text
-/apis/configbutler.ai/v1alpha1/namespaces/voter-production/commitrequests
-```
-
-The API server allocates the final name, for example:
-
-```text
-coffee-save-8tw8m
-```
-
-For create audit events on collection endpoints, `audit.Event.objectRef` may
-identify the collection (`namespace`, `resource`, `apiGroup`, `apiVersion`)
-without carrying the final generated `name`. The generated name is available in
-the audit body at `responseObject.metadata.name` when the audit level is
-`RequestResponse`.
-
-That means code consuming audit events must not assume
-`event.ObjectRef.Name != ""` for all successful create events. The audit
-`objectRef` is still the first source of identity when it is populated, because
-it is the URL-level reference Kubernetes attached to the event. For
-server-named resources, any missing identity fields must be backfilled from the
-audit bodies:
-
-1. start with `objectRef.{namespace,name,uid}`;
-2. for create/update/patch, fill missing fields from
- `responseObject.metadata.{namespace,name,uid}`;
-3. for delete, fill missing fields from
- `requestObject.metadata.{namespace,name,uid}`;
-4. fall back to the other audit body only if the preferred body is absent or
- incomplete.
-
-## Why this is broader than `CommitRequest`
-
-The normal resource-write path in `gitops-reverser` already has similar
-behavior:
-
-- `routeAuditEvent` starts with `ref.Name`;
-- `extractObject` selects the audit body;
-- `backfillSanitizedIdentity` fills missing object identity from the body or
- from `objectRef`.
-
-There is even a unit test for this generic path:
-
-```text
-TestProcessMessage_UsesRequestObjectIdentityWhenObjectRefNameMissing
-```
-
-However, `CommitRequest` is handled as a special control-plane signal before
-the generic resource extraction path:
-
-```go
-if c.isCommitRequestCreate(auditEvent) {
- c.handleCommitRequest(ctx, log, auditEvent)
- c.ackMessage(ctx, msg.ID)
- return
-}
-```
-
-`handleCommitRequest` currently does this:
-
-```go
-ref := event.ObjectRef
-log = log.WithValues("commitRequest", ref.Namespace+"/"+ref.Name)
-
-err := c.apiReader.Get(ctx, client.ObjectKey{
- Namespace: ref.Namespace,
- Name: ref.Name,
-}, &commitRequest)
-```
-
-For a `generateName` create event where `objectRef.name == ""`, this becomes:
-
-```text
-client.ObjectKey{Namespace: "voter-production", Name: ""}
-```
-
-and the signal is skipped permanently after the audit message is ACKed.
-
-## Expected behavior
-
-For a successful `CommitRequest` create audit event:
-
-1. Resolve the object key from `objectRef` when available.
-2. If `objectRef.name` is empty, parse `responseObject` and use
- `metadata.name`.
-3. If namespace is also missing, backfill `metadata.namespace` from the body.
-4. Carry `metadata.uid` from the resolved identity into the existing
- stale-object protection.
-5. Fetch the resolved `CommitRequest`.
-6. Finalize the matching open GitTarget window.
-7. Write terminal status (`Committed`, `NoOpenWindow`, or `Failed`).
-
-`metadata.generateName` should work because the public sample for
-`CommitRequest` recommends it:
-
-```yaml
-apiVersion: configbutler.ai/v1alpha1
-kind: CommitRequest
-metadata:
- generateName: save-
-```
-
-## Actual behavior
-
-`handleCommitRequest` uses `event.ObjectRef.Name` directly. When that value is
-empty, it logs `resource name may not be empty`, ACKs the audit message, and
-leaves the persisted `CommitRequest` in `WaitingForAuditEvent`.
-
-## Reproduction
-
-1. Configure a `GitProvider`, `GitTarget`, and `WatchRule`.
-2. Make a watched write that opens a commit window.
-3. Create a `CommitRequest` with `metadata.generateName`:
-
- ```yaml
- apiVersion: configbutler.ai/v1alpha1
- kind: CommitRequest
- metadata:
- generateName: save-
- namespace: voter-production
- spec:
- gitTargetRef:
- name: voter-demo
- message: "Lower espresso price"
- ```
-
-4. Observe that the created object gets a generated name:
-
- ```text
- save-abcde
- ```
-
-5. Observe the controller log:
-
- ```text
- Failed to read CommitRequest; skipping
- commitRequest="voter-production/"
- error="resource name may not be empty"
- ```
-
-6. Observe `.status.phase` remains `WaitingForAuditEvent`.
-
-## Suggested implementation
-
-Add a common audit identity helper and use it consistently for every event path,
-including special control resources:
-
-```go
-type AuditObjectIdentity struct {
- Namespace string
- Name string
- UID types.UID
-}
-
-func IdentityFromAuditEvent(event auditv1.Event, op configv1alpha1.OperationType) AuditObjectIdentity {
- // Start from event.ObjectRef.
- // Backfill only missing namespace/name/uid fields from the preferred body.
- // For non-delete operations the preferred body is responseObject.
- // For delete operations the preferred body is requestObject.
- // Fall back to the other body if needed.
-}
-```
-
-For non-delete operations, prefer `responseObject` before `requestObject`.
-For delete operations, prefer `requestObject` before `responseObject`, matching
-the existing object extraction semantics.
-
-Then update `handleCommitRequest` to use that helper instead of reading
-`event.ObjectRef.Name` directly.
-
-Minimal targeted fix:
-
-```go
-key := commitRequestObjectKeyFromAuditEvent(event)
-if key.Namespace == "" || key.Name == "" {
- log.Info("CommitRequest audit event did not identify an object; skipping")
- return
-}
-
-if err := c.apiReader.Get(ctx, key, &commitRequest); err != nil {
- ...
-}
-```
-
-where `commitRequestObjectKeyFromAuditEvent` backfills missing namespace, name,
-and UID from `event.ResponseObject.Raw` and then `event.RequestObject.Raw`.
-`handleCommitRequest` should use the resolved UID when checking whether the
-fetched object still matches the audit event; an empty resolved UID remains a
-match for compatibility with bodyless or lower-detail audit events.
-
-## Test coverage to add
-
-Add a unit test for the special path:
-
-```text
-TestHandleCommitRequest_UsesResponseObjectNameWhenObjectRefNameEmpty
-```
-
-Shape:
-
-- create a fake `CommitRequest` named `save-generated`;
-- create an audit event with:
- - `verb=create`
- - `stage=ResponseComplete`
- - `objectRef.resource=commitrequests`
- - `objectRef.namespace=team-a`
- - `objectRef.name=""`
- - `responseObject.metadata.name=save-generated`
-- assert `FinalizeGitTargetWindow` is called;
-- assert the `CommitRequest` reaches `Committed`.
-
-Also add helper-level coverage for identity resolution:
-
-- objectRef namespace/name/uid still wins when present;
-- missing `objectRef.name` is backfilled from `responseObject.metadata.name`;
-- missing `objectRef.uid` is backfilled from `responseObject.metadata.uid`;
-- missing response identity falls back to `requestObject.metadata`;
-- delete operations prefer request identity before response identity;
-- an event with no resolvable namespace or name is skipped without finalizing;
-- a resolved UID mismatch is treated as a stale event and does not finalize.
-
-Also consider an integration/e2e variant that creates the `CommitRequest` with
-`generateName`, not explicit `metadata.name`. The current e2e test uses a fixed
-name and therefore misses this bug.
-
-## Proposed acceptance criteria
-
-- `CommitRequest` created with explicit `metadata.name` still works.
-- `CommitRequest` created with `metadata.generateName` reaches a terminal
- phase after its create audit event.
-- The Git commit uses `spec.message` from the generated-name `CommitRequest`.
-- The audit consumer does not log `resource name may not be empty` for
- successful generated-name creates.
-- The shared audit identity helper is used by all paths that need object
- identity, or there is a documented reason for any exception.
-
-## Appendix: related hardening item
-
-During the same investigation, Valkey was also unstable:
-
-```text
-restartCount: 9
-lastState.terminated.exitCode: 137
-RDB memory usage when created: 6124.68 Mb
-```
-
-The chart default `queue.redis.maxLen=0` leaves the hot audit stream unbounded.
-That is not the root cause of the empty-name bug, but it can cause missed audit
-events and long queue outages during restarts. Production/demo installs should
-set a bounded stream length, for example:
-
-```yaml
-queue:
- redis:
- maxLen: 10000
-```
-
-This is out of scope for the generated-name fix, but should be tracked as a
-separate production hardening item.
diff --git a/docs/wildcard-ci-failure-findings.md b/docs/wildcard-ci-failure-findings.md
new file mode 100644
index 00000000..fbc65f60
--- /dev/null
+++ b/docs/wildcard-ci-failure-findings.md
@@ -0,0 +1,293 @@
+# Wildcard re-add — CI failure findings
+
+Investigation of the red `main` builds following commit
+`8e1b3ab` ("fix: readds support for wildcards"). Two **distinct** failure
+signatures showed up across two consecutive `main` runs. They have different
+root causes and should be tracked separately.
+
+| | Run A | Run B |
+|---|---|---|
+| CI run | [`26848671083`](https://github.com/ConfigButler/gitops-reverser/actions/runs/26848671083) | [`26850118478`](https://github.com/ConfigButler/gitops-reverser/actions/runs/26850118478) |
+| Commit | `8e1b3ab` — *fix: readds support for wildcards* | `0945809` — *chore(main): release gitops-reverser 0.27.1 (#162)* |
+| Failed job | E2E (full) | E2E (full) |
+| Result | 1 failed | 5 failed / 14 skipped |
+| Signature | GitTarget isolation regression | Controller-availability cascade (WatchRules never reach `Ready`) |
+| Determinism | **Deterministic** — reproduced locally | Looks like a timing/bootstrap race — not yet confirmed deterministic |
+| Wildcard *expansion* test itself | **passed** | failed (collateral of the cascade) |
+
+Context: every `main` build before `8e1b3ab` was green; `8e1b3ab` is the first
+red one. The wildcard *feature* (expansion across core + custom namespaced APIs)
+works in both runs where the controller was healthy — what broke is elsewhere.
+
+---
+
+## Failure A — GitTarget isolation regression (deterministic)
+
+### Symptom
+
+Spec: **`Manager GitTarget Isolation › keeps target A's commits as events while
+target B's rules churn`** — [test/e2e/gittarget_isolation_e2e_test.go:143](../test/e2e/gittarget_isolation_e2e_test.go#L143).
+
+```
+Timed out after 30s.
+target A's commit for iso-cm-N must be a [CREATE] event commit
+Expected : to contain substring : [CREATE]
+```
+
+The controller log at the moment of failure shows what target A committed
+instead of a `[CREATE]` event commit:
+
+```
+git commit created messageKind=snapshot events=1 message="reconcile: sync 1 resources"
+```
+
+Target A was dragged into **rule-change snapshot mode** even though only
+target B's rules changed.
+
+### Reproduced locally
+
+`task test-e2e` on a fresh local k3d cluster — same single failure as CI:
+
+```
+45 Passed | 1 Failed | 0 Pending | 2 Skipped (46 of 48 specs, 14m21s)
+Summarizing 1 Failure:
+ [FAIL] Manager GitTarget Isolation
+ keeps target A's commits as events while target B's rules churn
+ test/e2e/gittarget_isolation_e2e_test.go:143
+```
+
+Extra data point: **CI failed on iteration 1** (target B *removes* `services`
+from its watch set), **local failed on iteration 0** (target B *adds* `services`).
+Different iteration, identical defect → it is **not** add-vs-remove specific.
+*Any* change to target B's effective watch plan churns the **global informer
+set** and forces every target to snapshot.
+
+### Root cause (hypothesis)
+
+This is exactly the coupling that
+[docs/finished/gittarget-isolation-on-rule-change.md](finished/gittarget-isolation-on-rule-change.md)
+claimed was removed. That design says snapshot selection must be driven purely
+by a **per-target effective-watch-plan hash** (resolved GVR + scope + unioned
+operations + destination), with the global `added`/`removed` `force` flag gone.
+
+The wildcard re-add reintroduced the cross-target coupling. Two candidate
+mechanisms, both in `internal/watch`:
+
+1. The global GVR add/remove delta is once again reaching
+ `snapshotTargetsNeedingDelivery` for targets whose own plan did not change
+ (the old `force` path), **or**
+2. `currentRuleSetSnapshots()`
+ ([internal/watch/manager.go:1233](../internal/watch/manager.go#L1233)) now
+ produces an **unstable per-target plan hash** when the catalog / informer set
+ churns — e.g. a plain `"configmaps"` rule resolving to a different GVR set
+ across catalog refreshes because of the new wildcard resolution paths.
+
+The wildcard commit's changes to the resolver
+([internal/watch/rule_gvr_resolver.go](../internal/watch/rule_gvr_resolver.go) —
+`resourceCandidates`, `wildcardResourceCandidates`, `choosePreferredVersions`,
+`ambiguityMiss`) are the most likely source of (2): they change how candidates
+and preferred versions are chosen, which feeds the resolved GVR set that the
+per-target hash is computed over.
+
+### Where to look
+
+- [internal/watch/manager.go:739](../internal/watch/manager.go#L739) — `ReconcileForRuleChange`
+- [internal/watch/manager.go:1177](../internal/watch/manager.go#L1177) — `snapshotTargetsNeedingDelivery`
+- [internal/watch/manager.go:1233](../internal/watch/manager.go#L1233) — `currentRuleSetSnapshots` (per-target plan hash)
+- [internal/watch/rule_gvr_resolver.go](../internal/watch/rule_gvr_resolver.go) — resolution changes from `8e1b3ab`
+
+Suggested first step: assert the per-target plan hash for a target whose own
+rules are unchanged is **stable** across an unrelated target's rule change
+(unit-level), then trace whether a global delta still reaches snapshot
+selection.
+
+---
+
+## Failure B — controller-availability cascade (timing/bootstrap)
+
+### Symptom
+
+5 specs failed, **all with the same assertion** at
+[test/e2e/e2e_test.go:194](../test/e2e/e2e_test.go#L194) inside `verifyResourceStatus`:
+
+```
+Timed out after 90s.
+status.conditions not found
+Expected : false to be true
+```
+
+i.e. a `WatchRule`/`ClusterWatchRule` was created but **never got any status
+written** within 90s — it never reached `Ready` because nothing reconciled it.
+
+| Failing spec | Resource stuck without status |
+|---|---|
+| Manager WatchRule … *should expand wildcard resources …* | `watchrule/watchrule-wildcard-expansion-test` |
+| Commit Signing … *snapshot commit with custom template* | `watchrule/signing-snapshot-wr` |
+| Commit Request `[BeforeAll]` … | `watchrule/commit-request-watchrule-*` |
+| Bi Directional … *avoid a commit loop* | `watchrule/bi-watchrule-*` |
+| Restart Snapshot Safety … *git mirror intact on restart* | `clusterwatchrule/restart-snapshot-wildcard` |
+
+The `[BeforeAll]` failure in *Commit Request* is what skips ~14 specs (an
+ordered container whose `BeforeAll` fails skips all its specs).
+
+### Root cause (evidence points to controller unavailability, not assertion logic)
+
+The early specs **passed** — `Manager Controller Basics` (run successfully,
+service exposed, metrics serving, audit webhook events) and several
+`Manager CRD Lifecycle` specs all went green at ~21:58. So the controller
+(`gitops-reverser-6969c78bb5-bwj9p`) was healthy and reconciling at the start.
+
+Then a **second controller pod replaced the first** mid-run, and the replacement
+got stuck on a TLS-bootstrap mount race:
+
+```
+Killing pod/gitops-reverser-6969c78bb5-bwj9p Stopping container manager
+SuccessfulCreate replicaset/gitops-reverser-6969c78bb5 Created pod: ...-kzpj5
+Warning FailedMount pod/gitops-reverser-6969c78bb5-... \
+ MountVolume.SetUp failed for volume "audit-webhook-certs": secret "audit-server-cert" not found
+```
+
+While the replacement pod could not mount `audit-server-cert` (cert-manager had
+not issued/published it yet), the controller was **not reconciling WatchRules**.
+Every spec that created a fresh WatchRule during that window timed out at 90s
+waiting for `status.conditions`. The same `audit-server-cert` / audit-root-ca
+bootstrap warnings also appear (benignly, self-healing) in Run A's event dump,
+so this is a pre-existing startup race that simply landed badly in Run B.
+
+Note: the controller log in this run is **flooded** with a benign, unrelated
+retry — `Failed to build pending write; dropping open window … get GitProvider:
+… "gitprovider-normal" not found` (a branch worker retrying against an
+already-cleaned-up namespace). It is noise, not the cause.
+
+### Why this is *not* (clearly) the wildcard code
+
+- The failing specs are a mix — some involve wildcards/CRDs, some don't
+ (signing, commit-request). They are unified only by *timing* (all created
+ WatchRules during the unavailable window), not by a shared code path.
+- The mechanism is "controller wasn't running/ready," not "reconcile produced
+ the wrong result."
+- The wildcard expansion spec passed in Run A.
+
+It is *possible* the wildcard work lengthens initial reconcile/discovery (a fresh
+wildcard plan expands across all served GVRs), widening the vulnerable window —
+but that is unconfirmed.
+
+### Where to look / next step
+
+- Confirm determinism: re-run E2E (full) on the same commit. If Run B's cascade
+ does **not** reproduce, it is an infrastructure/bootstrap flake to harden, not
+ a logic bug.
+- Controller startup ordering vs `audit-server-cert`: the manager should not be
+ killed/rolled while the cert secret is unpublished, or should tolerate the
+ mount gap without dropping reconciliation. See the audit TLS design:
+ [docs/design/audit-webhook-tls-design.md](design/audit-webhook-tls-design.md).
+- Investigate **why a second controller pod was created mid-run** in Run B
+ (rollout / eviction / cert rotation) — that replacement is the trigger.
+
+---
+
+## Failure C — local only: disk-pressure node taints (environment, not code)
+
+Hit while reproducing Failure A locally. Not a product bug — recorded so the
+next person does not lose time to it.
+
+### Symptom
+
+`task prepare-e2e` failed at the `portforward-ensure` step:
+
+```
+⏳ Waiting for Prometheus pod to be ready...
+error: timed out waiting for the condition on pods/prometheus-prometheus-shared-e2e-0
+❌ Prometheus pod failed to become ready
+NAME READY STATUS RESTARTS AGE
+prometheus-prometheus-shared-e2e-0 0/2 Pending 0 4m11s
+```
+
+The controller pods were `Pending`/`Error` too, with:
+
+```
+FailedScheduling 0/4 nodes are available: 4 node(s) had untolerated taint(s).
+```
+
+### Root cause
+
+All four k3d nodes carried `node.kubernetes.io/disk-pressure:NoSchedule`:
+
+```
+NAME TAINTS
+...-agent-0 [.../disk-pressure ...]
+...-server-0 [.../disk-pressure ...]
+```
+
+The host Docker overlay was **94% full** (`231G / 248G`), tripping kubelet's
+`imagefs`/`nodefs` eviction threshold. `docker system df` showed the hog:
+~169 GB reclaimable images and ~25 GB build cache.
+
+### Fix / workaround
+
+```
+docker builder prune -f # reclaimed ~25 GB build cache
+docker image prune -f # dangling images
+```
+
+Disk dropped 94% → 82% → 33% free, the `disk-pressure` taints cleared on their
+own, the cluster recovered, and `task test-e2e` then ran to completion
+(surfacing Failure A). A leftover second cluster (`audit-pass-through-e2e`) was
+also consuming disk and was a contributing factor.
+
+### Takeaways
+
+- Local full-suite runs need real disk headroom. The failure does **not**
+ present as "disk full" — it shows up as `Pending` pods and
+ `FailedScheduling … untolerated taint(s)`, which is easy to misread as a
+ scheduling/resource-request problem.
+- If pods won't schedule, check `kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints`
+ for `disk-pressure` before anything else, and `df -h /` + `docker system df`.
+- Tear down stale k3d clusters between runs; they hold image/volume disk.
+
+---
+
+## Update (2026-06-04) — Failure D: snapshot abort on a vanished CRD (fixed)
+
+A later red `E2E (full)` run (on branch `poc/manifestedit`,
+[run 26935363495](https://github.com/ConfigButler/gitops-reverser/actions/runs/26935363495))
+reproduced the same `status.conditions not found` cascade as Failure B, but the
+controller logs pinned a **distinct, deterministic root cause** rather than a
+bootstrap race:
+
+```
+aborting cluster snapshot for .../watchrule-wildcard-expansion-test-dest:
+failed to list bi-directional.e2e.example.com/v1, Resource=icecreamorders:
+the server could not find the requested resource
+"snapshot replay did not complete, leaving target pending"
+```
+
+Three e2e specs each register an `icecreamorders` CRD under a *different* API
+group (`bi-directional.…`, `crd-lifecycle.…`, `wildcard-watchrule.…`). Under
+`--procs=4` they create and tear down those CRDs concurrently. A wildcard target's
+snapshot enumerates all of them; when one CRD is gone mid-flight the `list`
+returns NotFound, the **whole snapshot aborted** (the partial-view guard), the
+GitTarget was left pending, its WatchRule never reached `Ready`, and the 90s
+`verifyStatus` timed out — cascading to unrelated specs sharing the controller.
+
+This is the CRD-churn half of **Phase 2 / item D** in
+[watchrule-wildcard-support-plan.md](future/watchrule-wildcard-support-plan.md).
+**Fixed** by skipping a `NotFound` (no-longer-served type) during the snapshot
+list while still aborting on every other list error — see the
+[Item D decision](future/watchrule-wildcard-support-plan.md#item-d-decision-notfound-skips-everything-else-aborts).
+Not related to the manifest-editing work on that branch.
+
+## Summary
+
+- **Failure A is the real, deterministic regression from the wildcard re-add** —
+ GitTarget isolation no longer holds; an unrelated target's rule change forces
+ other targets into snapshot commits. Reproduced locally. Fix lives in
+ `internal/watch` snapshot-selection / per-target plan hash.
+- **Failure B is a controller-availability cascade** triggered by a mid-run
+ controller pod replacement blocked on the `audit-server-cert` mount. It is a
+ bootstrap/timing problem (pre-existing race), not an assertion-logic bug, and
+ should be confirmed with a re-run before assuming it is caused by the wildcard
+ change.
+- The wildcard **expansion feature itself is not implicated** in either failure.
+- **Failure C is local-environment only** — disk-pressure node taints from a full
+ host Docker overlay. Not a product bug; clear disk before running the suite.
diff --git a/internal/auditutil/subresource_policy.go b/internal/auditutil/subresource_policy.go
new file mode 100644
index 00000000..76d1f222
--- /dev/null
+++ b/internal/auditutil/subresource_policy.go
@@ -0,0 +1,57 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package auditutil
+
+import "github.com/ConfigButler/gitops-reverser/internal/typeset"
+
+// Subresource handling policy. GitOps Reverser mirrors exactly one Kubernetes
+// subresource: the built-in /scale. It is the single case where a subresource writes
+// the parent's desired state (the accepted replica count) AND Kubernetes exposes that
+// value in a standardized response object. Every other subresource is observed state, a
+// runtime stream, a credential, lifecycle control, a proxy, or an imperative action, so
+// it is ignored. The rule is deliberately narrow: only `scale` can route, and only when
+// the parent's replica path is known by built-in policy. See
+// docs/design/manifest/version2/subresource-scope-reduction.md.
+
+// IsScaleSubresource reports whether subresource is the built-in scale subresource. The
+// webhook forwards only scale subresource events; every other subresource is dropped
+// before Redis. A CRD or aggregated-API scale still passes this gate — it is the
+// consumer that drops it for an unresolved parent replica path.
+func IsScaleSubresource(subresource string) bool {
+ return subresource == "scale"
+}
+
+// BuiltinScaleReplicasPath returns the parent replica field path for a currently
+// served built-in scalable resource identified by API group and plural resource.
+// ok is false when the resource is not a known built-in scalable type — for
+// example a CRD or aggregated API resource — in which case the scale event must
+// be dropped, never defaulted to .spec.replicas.
+//
+// It is a thin []string adapter over the single source of built-in scale facts,
+// typeset.BuiltinScale: the followability registry and the audit consumer read the
+// same binding, so the parent write path can never drift between them. typeset.
+// SplitFieldPath allocates a fresh slice each call, so the returned path is owned by
+// the caller (it may be mutated without corrupting the shared registry).
+func BuiltinScaleReplicasPath(group, resource string) ([]string, bool) {
+ binding, ok := typeset.BuiltinScale(group, resource)
+ if !ok {
+ return nil, false
+ }
+ return typeset.SplitFieldPath(binding.SpecReplicasPath), true
+}
diff --git a/internal/auditutil/subresource_policy_test.go b/internal/auditutil/subresource_policy_test.go
new file mode 100644
index 00000000..c0ab0523
--- /dev/null
+++ b/internal/auditutil/subresource_policy_test.go
@@ -0,0 +1,81 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package auditutil
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsScaleSubresource(t *testing.T) {
+ tests := []struct {
+ name string
+ subresource string
+ isScale bool
+ }{
+ {"scale is the one supported subresource", "scale", true},
+ {"status is not scale", "status", false},
+ {"exec is not scale", "exec", false},
+ {"empty subresource is not scale", "", false},
+ {"an arbitrary subresource is not scale", "throttle", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.isScale, IsScaleSubresource(tt.subresource))
+ })
+ }
+}
+
+func TestBuiltinScaleReplicasPath(t *testing.T) {
+ tests := []struct {
+ name string
+ group string
+ resource string
+ wantPath []string
+ wantOK bool
+ }{
+ {"apps deployments", "apps", "deployments", []string{"spec", "replicas"}, true},
+ {"apps statefulsets", "apps", "statefulsets", []string{"spec", "replicas"}, true},
+ {"apps replicasets", "apps", "replicasets", []string{"spec", "replicas"}, true},
+ {"core replicationcontrollers", "", "replicationcontrollers", []string{"spec", "replicas"}, true},
+ {"a CRD scalable type has no known path", "example.com", "widgets", nil, false},
+ {"an aggregated API scalable type has no known path", "metrics.k8s.io", "things", nil, false},
+ {"deployments outside the apps group are not built-in", "extensions", "deployments", nil, false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ path, ok := BuiltinScaleReplicasPath(tt.group, tt.resource)
+ assert.Equal(t, tt.wantOK, ok)
+ assert.Equal(t, tt.wantPath, path)
+ })
+ }
+}
+
+// The returned path is a copy: mutating it must not corrupt the shared policy for the
+// next caller.
+func TestBuiltinScaleReplicasPath_ReturnsCopy(t *testing.T) {
+ first, ok := BuiltinScaleReplicasPath("apps", "deployments")
+ assert.True(t, ok)
+ first[0] = "mutated"
+
+ second, ok := BuiltinScaleReplicasPath("apps", "deployments")
+ assert.True(t, ok)
+ assert.Equal(t, []string{"spec", "replicas"}, second, "the shared policy must be immutable")
+}
diff --git a/internal/controller/gitprovider_immutability_test.go b/internal/controller/gitprovider_immutability_test.go
new file mode 100644
index 00000000..f5e2b760
--- /dev/null
+++ b/internal/controller/gitprovider_immutability_test.go
@@ -0,0 +1,75 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+
+ configbutleraiv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+)
+
+// A GitProvider's repository URL is the destination identity its GitTargets materialize
+// into, so it is immutable (delete + recreate to repoint). Everything else is
+// operational and must stay mutable — especially allowedBranches: widening or narrowing
+// the writable set is a routine change that must not force tearing down every GitTarget.
+var _ = Describe("GitProvider URL Immutability", func() {
+ const (
+ timeout = time.Second * 10
+ interval = time.Millisecond * 250
+ )
+
+ It("rejects a change to the URL but allows changing allowedBranches", func() {
+ ctx := context.Background()
+ key := types.NamespacedName{Name: "immutable-provider", Namespace: "default"}
+
+ provider := &configbutleraiv1alpha1.GitProvider{
+ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace},
+ Spec: configbutleraiv1alpha1.GitProviderSpec{
+ URL: "https://example.com/repo.git",
+ AllowedBranches: []string{"main"},
+ },
+ }
+ Expect(k8sClient.Create(ctx, provider)).Should(Succeed())
+ DeferCleanup(func() { _ = k8sClient.Delete(ctx, provider) })
+
+ // allowedBranches is operational and mutable: widening the set succeeds.
+ Eventually(func(g Gomega) {
+ current := &configbutleraiv1alpha1.GitProvider{}
+ g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed())
+ current.Spec.AllowedBranches = []string{"main", "develop"}
+ g.Expect(k8sClient.Update(ctx, current)).To(Succeed())
+ }, timeout, interval).Should(Succeed())
+
+ // The URL is the destination identity and is immutable.
+ Eventually(func(g Gomega) {
+ current := &configbutleraiv1alpha1.GitProvider{}
+ g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed())
+ current.Spec.URL = "https://example.com/other.git"
+ err := k8sClient.Update(ctx, current)
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring("spec.url is immutable"))
+ }, timeout, interval).Should(Succeed())
+ })
+})
diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go
index 3cb0af08..e53f81be 100644
--- a/internal/controller/gittarget_controller.go
+++ b/internal/controller/gittarget_controller.go
@@ -194,7 +194,7 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
return ctrl.Result{RequeueAfter: encryptionRequeueAfter}, nil
}
- stream, snapshotState, snapshotMessage, snapshotRequeueAfter, snapshotErr := r.evaluateSnapshotGate(
+ stream, snapshotState, snapshotMessage, snapshotErr := r.evaluateSnapshotGate(
ctx,
&target,
providerNS,
@@ -250,7 +250,7 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
if err := r.updateStatusWithRetry(ctx, &target); err != nil {
return ctrl.Result{}, err
}
- return ctrl.Result{RequeueAfter: snapshotRequeueAfter}, nil
+ return ctrl.Result{RequeueAfter: RequeueShortInterval}, nil
}
if snapshotState == metav1.ConditionTrue {
r.setCondition(
@@ -303,7 +303,11 @@ func (r *GitTargetReconciler) evaluateValidatedGate(
return false, fmt.Sprintf("Validated gate failed: %s", reason), result, nil
}
- if conflict, conflictMsg, conflictReason, conflictResult := r.checkForConflicts(ctx, target, providerNS); conflict {
+ conflict, conflictMsg, conflictReason, conflictResult, conflictErr := r.checkForConflicts(ctx, target, providerNS)
+ if conflictErr != nil {
+ return false, "", nil, conflictErr
+ }
+ if conflict {
r.setCondition(target, GitTargetConditionValidated, metav1.ConditionFalse, conflictReason, conflictMsg)
return false, fmt.Sprintf("Validated gate failed: %s", conflictReason), &conflictResult, nil
}
@@ -369,14 +373,14 @@ func (r *GitTargetReconciler) evaluateSnapshotGate(
target *configbutleraiv1alpha1.GitTarget,
providerNS string,
log logr.Logger,
-) (*reconcile.GitTargetEventStream, metav1.ConditionStatus, string, time.Duration, error) {
- if r.EventRouter == nil || r.EventRouter.ReconcilerManager == nil {
+) (*reconcile.GitTargetEventStream, metav1.ConditionStatus, string, error) {
+ if r.EventRouter == nil {
now := metav1.Now()
if target.Status.Snapshot == nil {
target.Status.Snapshot = &configbutleraiv1alpha1.GitTargetSnapshotStatus{}
}
target.Status.Snapshot.LastCompletedTime = &now
- return nil, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil
+ return nil, metav1.ConditionTrue, MsgSnapshotCompleted, nil
}
// Initial snapshot only. Re-snapshots on rule changes are triggered by the
@@ -386,36 +390,32 @@ func (r *GitTargetReconciler) evaluateSnapshotGate(
if isConditionTrue(target.Status.Conditions, GitTargetConditionSnapshotSynced) {
stream, err := r.ensureEventStream(target, providerNS, log)
if err != nil {
- return nil, metav1.ConditionFalse, "", 0, err
+ return nil, metav1.ConditionFalse, "", err
}
- gitDest := types.NewResourceReference(target.Name, target.Namespace)
- r.EventRouter.ReconcilerManager.CreateReconciler(ctx, gitDest, stream)
- return stream, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil
+ return stream, metav1.ConditionTrue, MsgSnapshotCompleted, nil
}
stream, err := r.ensureEventStream(target, providerNS, log)
if err != nil {
- return nil, metav1.ConditionFalse, "", 0, err
+ return nil, metav1.ConditionFalse, "", err
}
- // Enter buffering state before starting reconciliation so that live events
- // arriving during the snapshot sync are queued and not interleaved.
+ // Enter buffering state before the resync so live events arriving during the
+ // snapshot are queued, not interleaved with the mark-and-sweep commit. The
+ // EventStreamLive gate flushes them once the stream goes live.
stream.BeginReconciliation()
+ // One synchronous, content-derived streaming-snapshot resync (M8): the worker
+ // gathers the GitTarget's complete watched set, materialises it (create / update),
+ // and mark-and-sweeps managed documents the cluster no longer has. It blocks until
+ // the resync commit lands, so the snapshot is complete before live events flush.
gitDest := types.NewResourceReference(target.Name, target.Namespace)
- reconciler := r.EventRouter.ReconcilerManager.CreateReconciler(ctx, gitDest, stream)
- if err := reconciler.StartReconciliation(ctx); err != nil {
- return stream, metav1.ConditionFalse, "", 0, fmt.Errorf(
- "failed to start initial snapshot reconciliation: %w",
- err,
- )
- }
-
- if !reconciler.HasBothStates() {
- return stream, metav1.ConditionFalse, "Initial snapshot reconciliation in progress", RequeueShortInterval, nil
+ stats, err := r.EventRouter.EmitResyncForGitDest(ctx, gitDest)
+ if err != nil {
+ return stream, metav1.ConditionFalse, "", fmt.Errorf(
+ "failed to run initial snapshot resync: %w", err)
}
- stats := reconciler.GetLastSnapshotStats()
now := metav1.Now()
if target.Status.Snapshot == nil {
target.Status.Snapshot = &configbutleraiv1alpha1.GitTargetSnapshotStatus{}
@@ -427,7 +427,7 @@ func (r *GitTargetReconciler) evaluateSnapshotGate(
Deleted: clampIntToInt32(stats.Deleted),
}
- return stream, metav1.ConditionTrue, MsgSnapshotCompleted, 0, nil
+ return stream, metav1.ConditionTrue, MsgSnapshotCompleted, nil
}
func (r *GitTargetReconciler) evaluateEventStreamGate(
@@ -621,10 +621,17 @@ func (r *GitTargetReconciler) checkForConflicts(
ctx context.Context,
target *configbutleraiv1alpha1.GitTarget,
providerNS string,
-) (bool, string, string, ctrl.Result) {
+) (bool, string, string, ctrl.Result, error) {
+ // A path the writer would reject (absolute, backslashes, ".." traversal) owns
+ // nothing, so it must neither block others nor be blocked here — its own write
+ // path fails it. Only well-formed paths participate in overlap detection.
+ if !git.IsValidTargetPath(target.Spec.Path) {
+ return false, "", "", ctrl.Result{}, nil
+ }
+
var allTargets configbutleraiv1alpha1.GitTargetList
if err := r.List(ctx, &allTargets); err != nil {
- return false, "", "", ctrl.Result{}
+ return false, "", "", ctrl.Result{}, fmt.Errorf("list GitTargets for conflict validation: %w", err)
}
for i := range allTargets.Items {
@@ -635,9 +642,19 @@ func (r *GitTargetReconciler) checkForConflicts(
if existing.Namespace != providerNS || existing.Spec.ProviderRef.Name != target.Spec.ProviderRef.Name {
continue
}
- if existing.Spec.Branch == target.Spec.Branch && existing.Spec.Path == target.Spec.Path {
- if target.CreationTimestamp.After(existing.CreationTimestamp.Time) {
- msg := fmt.Sprintf(
+ if existing.Spec.Branch != target.Spec.Branch ||
+ !git.IsValidTargetPath(existing.Spec.Path) ||
+ !gitTargetPathsOverlap(target.Spec.Path, existing.Spec.Path) {
+ continue
+ }
+ // Two GitTargets on the same provider+branch whose paths are equal or
+ // nested fight over which documents each one owns. The later-created
+ // target loses (ties broken deterministically by identity) so every
+ // materialized folder keeps exactly one owner.
+ if gitTargetLosesConflict(target, existing) {
+ var msg string
+ if normalizeGitTargetPath(target.Spec.Path) == normalizeGitTargetPath(existing.Spec.Path) {
+ msg = fmt.Sprintf(
"Conflict detected. Another GitTarget '%s/%s' (created at %s) is already using GitProvider '%s/%s', branch '%s', path '%s'. This GitTarget was created later and will not be processed.",
existing.Namespace,
existing.Name,
@@ -647,12 +664,24 @@ func (r *GitTargetReconciler) checkForConflicts(
target.Spec.Branch,
target.Spec.Path,
)
- return true, msg, GitTargetReasonTargetConflict, ctrl.Result{RequeueAfter: RequeueShortInterval}
+ } else {
+ msg = fmt.Sprintf(
+ "Conflict detected. This GitTarget's path '%s' overlaps the path '%s' of GitTarget '%s/%s' (created at %s) on GitProvider '%s/%s', branch '%s' — one path nests inside the other (sibling paths are allowed). This GitTarget was created later and will not be processed.",
+ target.Spec.Path,
+ existing.Spec.Path,
+ existing.Namespace,
+ existing.Name,
+ existing.CreationTimestamp.Format(time.RFC3339),
+ providerNS,
+ target.Spec.ProviderRef.Name,
+ target.Spec.Branch,
+ )
}
+ return true, msg, GitTargetReasonTargetConflict, ctrl.Result{RequeueAfter: RequeueShortInterval}, nil
}
}
- return false, "", "", ctrl.Result{}
+ return false, "", "", ctrl.Result{}, nil
}
func (r *GitTargetReconciler) ensureEncryptionSecret(
@@ -850,9 +879,6 @@ func (r *GitTargetReconciler) cleanupDeletedGitTarget(
gitDest := types.NewResourceReference(namespacedName.Name, namespacedName.Namespace)
r.EventRouter.UnregisterGitTargetEventStream(gitDest)
- if r.EventRouter.ReconcilerManager != nil {
- _ = r.EventRouter.ReconcilerManager.DeleteReconciler(gitDest)
- }
log.V(1).Info("Cleaned up in-memory state for deleted GitTarget", "gitDest", gitDest.String())
}
diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go
index 00e7e4c5..29b81f4e 100644
--- a/internal/controller/gittarget_controller_test.go
+++ b/internal/controller/gittarget_controller_test.go
@@ -616,6 +616,125 @@ var _ = Describe("GitTarget Controller Security", func() {
Expect(k8sClient.Delete(ctx, firstTarget)).Should(Succeed())
Expect(k8sClient.Delete(ctx, gitProvider)).Should(Succeed())
})
+
+ It("Should detect conflicts when one path nests inside another", func() {
+ ctx := context.Background()
+
+ // Create a GitProvider
+ gitProvider := &configbutleraiv1alpha1.GitProvider{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-provider-nested",
+ Namespace: "default",
+ },
+ Spec: configbutleraiv1alpha1.GitProviderSpec{
+ URL: "https://github.com/test-org/test-repo.git",
+ AllowedBranches: []string{"main"},
+ SecretRef: &configbutleraiv1alpha1.LocalSecretReference{
+ Name: "test-secret",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed())
+
+ // First GitTarget owns the parent folder "team" (winner - created first)
+ firstTarget := &configbutleraiv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "first-target-nested",
+ Namespace: "default",
+ },
+ Spec: configbutleraiv1alpha1.GitTargetSpec{
+ ProviderRef: configbutleraiv1alpha1.GitProviderReference{
+ Name: "test-provider-nested",
+ Kind: "GitProvider",
+ },
+ Branch: "main",
+ Path: "team",
+ },
+ }
+ Expect(k8sClient.Create(ctx, firstTarget)).Should(Succeed())
+
+ firstTargetKey := types.NamespacedName{Name: "first-target-nested", Namespace: "default"}
+ Eventually(func() bool {
+ var target configbutleraiv1alpha1.GitTarget
+ if err := k8sClient.Get(ctx, firstTargetKey, &target); err != nil {
+ return false
+ }
+ for _, condition := range target.Status.Conditions {
+ if condition.Type == GitTargetReasonReady {
+ return true
+ }
+ }
+ return false
+ }, timeout, interval).Should(BeTrue())
+
+ // Kubernetes creationTimestamp has second-level precision; ensure the
+ // nested target is created strictly later so it is the loser.
+ time.Sleep(1100 * time.Millisecond)
+
+ // Second GitTarget nests under the first ("team/app") - must conflict.
+ secondTarget := &configbutleraiv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "second-target-nested",
+ Namespace: "default",
+ },
+ Spec: configbutleraiv1alpha1.GitTargetSpec{
+ ProviderRef: configbutleraiv1alpha1.GitProviderReference{
+ Name: "test-provider-nested",
+ Kind: "GitProvider",
+ },
+ Branch: "main",
+ Path: "team/app",
+ },
+ }
+ Expect(k8sClient.Create(ctx, secondTarget)).Should(Succeed())
+
+ secondTargetKey := types.NamespacedName{Name: "second-target-nested", Namespace: "default"}
+ Eventually(func() bool {
+ var target configbutleraiv1alpha1.GitTarget
+ if err := k8sClient.Get(ctx, secondTargetKey, &target); err != nil {
+ return false
+ }
+ for _, condition := range target.Status.Conditions {
+ if condition.Type == GitTargetConditionValidated &&
+ condition.Reason == GitTargetReasonTargetConflict {
+ return true
+ }
+ }
+ return false
+ }, timeout, interval).Should(BeTrue())
+
+ var secondReconciledTarget configbutleraiv1alpha1.GitTarget
+ Expect(k8sClient.Get(ctx, secondTargetKey, &secondReconciledTarget)).Should(Succeed())
+
+ var readyCondition *metav1.Condition
+ for i, condition := range secondReconciledTarget.Status.Conditions {
+ if condition.Type == GitTargetReasonReady {
+ readyCondition = &secondReconciledTarget.Status.Conditions[i]
+ break
+ }
+ }
+ Expect(readyCondition).NotTo(BeNil())
+ Expect(readyCondition.Status).To(Equal(metav1.ConditionFalse))
+ Expect(readyCondition.Reason).To(Equal(GitTargetReadyReasonValidationFailed))
+
+ var validatedCondition *metav1.Condition
+ for i, condition := range secondReconciledTarget.Status.Conditions {
+ if condition.Type == GitTargetConditionValidated {
+ validatedCondition = &secondReconciledTarget.Status.Conditions[i]
+ break
+ }
+ }
+ Expect(validatedCondition).NotTo(BeNil())
+ Expect(validatedCondition.Reason).To(Equal(GitTargetReasonTargetConflict))
+ Expect(validatedCondition.Message).To(ContainSubstring("overlaps"))
+ Expect(validatedCondition.Message).To(ContainSubstring("first-target-nested"))
+ Expect(validatedCondition.Message).To(ContainSubstring("created later"))
+
+ // Cleanup
+ Expect(k8sClient.Delete(ctx, secondTarget)).Should(Succeed())
+ Expect(k8sClient.Delete(ctx, firstTarget)).Should(Succeed())
+ Expect(k8sClient.Delete(ctx, gitProvider)).Should(Succeed())
+ })
})
Context("When encryption secret auto-generation is configured", func() {
diff --git a/internal/controller/gittarget_controller_unit_test.go b/internal/controller/gittarget_controller_unit_test.go
index 31597177..78207f5e 100644
--- a/internal/controller/gittarget_controller_unit_test.go
+++ b/internal/controller/gittarget_controller_unit_test.go
@@ -20,12 +20,18 @@ package controller
import (
"context"
+ "errors"
"testing"
"github.com/go-logr/logr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
configbutleraiv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
)
@@ -110,7 +116,7 @@ func TestIsConditionTrue(t *testing.T) {
// TestEvaluateSnapshotGate_SkipsWhenSnapshotSynced verifies that when a GitTarget
// already has SnapshotSynced=True, evaluateSnapshotGate short-circuits and returns
-// ConditionTrue without calling StartReconciliation.
+// ConditionTrue without running another resync.
//
// This is the regression guard for Bug 2: an unrelated event (e.g. Flux touching the
// encryption secret) must not trigger a second cluster snapshot after the first has
@@ -126,14 +132,13 @@ func TestEvaluateSnapshotGate_SkipsWhenSnapshotSynced(t *testing.T) {
target := makeGitTargetWithCondition(GitTargetConditionSnapshotSynced, metav1.ConditionTrue)
- stream, state, msg, requeue, err := reconciler.evaluateSnapshotGate(
+ stream, state, msg, err := reconciler.evaluateSnapshotGate(
context.TODO(), target, "gitops-reverser", noopLogger(),
)
require.NoError(t, err)
assert.Equal(t, metav1.ConditionTrue, state, "gate should report SnapshotSynced=True")
assert.NotEmpty(t, msg)
- assert.Zero(t, requeue, "no requeue needed once snapshot is complete")
assert.Nil(t, stream, "stream is nil when EventRouter is nil")
}
@@ -151,10 +156,55 @@ func TestEvaluateSnapshotGate_RunsWhenSnapshotNotSynced(t *testing.T) {
// the one that fired.
target := makeGitTargetWithCondition(GitTargetConditionValidated, metav1.ConditionTrue)
- _, state, _, _, err := reconciler.evaluateSnapshotGate(
+ _, state, _, err := reconciler.evaluateSnapshotGate(
context.TODO(), target, "gitops-reverser", noopLogger(),
)
require.NoError(t, err)
assert.Equal(t, metav1.ConditionTrue, state)
}
+
+// TestCheckForConflicts_ListErrorFailsClosed verifies the topology guard never
+// silently accepts a GitTarget when it cannot list peer targets. The non-overlap
+// invariant is a precondition for the destructive manifest writer, so cache/API
+// failures must requeue reconciliation rather than pass validation.
+func TestCheckForConflicts_ListErrorFailsClosed(t *testing.T) {
+ client := newGitTargetListErrorClient(t)
+ reconciler := &GitTargetReconciler{Client: client}
+ target := &configbutleraiv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: "default"},
+ Spec: configbutleraiv1alpha1.GitTargetSpec{
+ ProviderRef: configbutleraiv1alpha1.GitProviderReference{Name: "provider-a", Kind: "GitProvider"},
+ Branch: "main",
+ Path: "apps",
+ },
+ }
+
+ conflict, _, _, _, err := reconciler.checkForConflicts(context.Background(), target, target.Namespace)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "list GitTargets for conflict validation")
+ assert.False(t, conflict)
+}
+
+func newGitTargetListErrorClient(t *testing.T) ctrlclient.Client {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ require.NoError(t, clientgoscheme.AddToScheme(scheme))
+ require.NoError(t, configbutleraiv1alpha1.AddToScheme(scheme))
+
+ return fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithInterceptorFuncs(interceptor.Funcs{
+ List: func(
+ context.Context,
+ ctrlclient.WithWatch,
+ ctrlclient.ObjectList,
+ ...ctrlclient.ListOption,
+ ) error {
+ return errors.New("simulated GitTarget list failure")
+ },
+ }).
+ Build()
+}
diff --git a/internal/controller/gittarget_immutability_test.go b/internal/controller/gittarget_immutability_test.go
new file mode 100644
index 00000000..8dbf4580
--- /dev/null
+++ b/internal/controller/gittarget_immutability_test.go
@@ -0,0 +1,126 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "context"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+
+ configbutleraiv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+)
+
+// A GitTarget's destination — providerRef, branch, path — is immutable: changing where
+// it materializes would orphan the old materialization, so the API server rejects the
+// change (CEL transition rules) and a relocation is a delete + recreate. This replaces
+// the alternative of reconciling a destination move (which would need a generation-aware
+// snapshot gate and worker rebinding); making it immutable removes that whole class of
+// bug instead of handling it.
+var _ = Describe("GitTarget Destination Immutability", func() {
+ const (
+ timeout = time.Second * 10
+ interval = time.Millisecond * 250
+ )
+
+ It("rejects changes to providerRef, branch, and path but allows a no-op update", func() {
+ ctx := context.Background()
+ key := types.NamespacedName{Name: "immutable-target", Namespace: "default"}
+
+ gitTarget := &configbutleraiv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace},
+ Spec: configbutleraiv1alpha1.GitTargetSpec{
+ ProviderRef: configbutleraiv1alpha1.GitProviderReference{Name: "prov-a", Kind: "GitProvider"},
+ Branch: "main",
+ Path: "apps",
+ },
+ }
+ Expect(k8sClient.Create(ctx, gitTarget)).Should(Succeed())
+ DeferCleanup(func() { _ = k8sClient.Delete(ctx, gitTarget) })
+
+ // A no-op update (the controller keeps writing status; re-applying an unchanged
+ // spec must still be allowed) succeeds.
+ Eventually(func(g Gomega) {
+ current := &configbutleraiv1alpha1.GitTarget{}
+ g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed())
+ g.Expect(k8sClient.Update(ctx, current)).To(Succeed())
+ }, timeout, interval).Should(Succeed())
+
+ // Each destination field is immutable. Eventually loops past any optimistic-lock
+ // conflict from a concurrent status write so the assertion lands on the real
+ // immutability rejection, not a transient 409.
+ expectImmutable := func(mutate func(*configbutleraiv1alpha1.GitTarget), wantMsg string) {
+ Eventually(func(g Gomega) {
+ current := &configbutleraiv1alpha1.GitTarget{}
+ g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed())
+ mutate(current)
+ err := k8sClient.Update(ctx, current)
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(wantMsg))
+ }, timeout, interval).Should(Succeed())
+ }
+
+ expectImmutable(func(gt *configbutleraiv1alpha1.GitTarget) {
+ gt.Spec.Path = "moved"
+ }, "spec.path is immutable")
+ expectImmutable(func(gt *configbutleraiv1alpha1.GitTarget) {
+ gt.Spec.Branch = "develop"
+ }, "spec.branch is immutable")
+ expectImmutable(func(gt *configbutleraiv1alpha1.GitTarget) {
+ gt.Spec.ProviderRef.Name = "prov-b"
+ }, "spec.providerRef is immutable")
+ })
+
+ It("requires a non-empty path: rejects an omitted or empty path but allows an explicit \".\" root", func() {
+ ctx := context.Background()
+ key := types.NamespacedName{Name: "root-policy-target", Namespace: "default"}
+
+ base := &configbutleraiv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace},
+ Spec: configbutleraiv1alpha1.GitTargetSpec{
+ ProviderRef: configbutleraiv1alpha1.GitProviderReference{Name: "prov-a", Kind: "GitProvider"},
+ Branch: "main",
+ },
+ }
+
+ // Omitting the path is rejected: with no default, a GitTarget can never silently
+ // write to the repository root.
+ omitted := base.DeepCopy()
+ Expect(k8sClient.Create(ctx, omitted)).ShouldNot(Succeed())
+
+ // An explicit empty string is rejected too: "" is too easy to leave blank by
+ // accident to count as a deliberate root choice.
+ empty := base.DeepCopy()
+ empty.Spec.Path = ""
+ Expect(k8sClient.Create(ctx, empty)).ShouldNot(Succeed())
+
+ // "." is the deliberate, allowed way to target the repository root.
+ root := base.DeepCopy()
+ root.Spec.Path = "."
+ Expect(k8sClient.Create(ctx, root)).Should(Succeed())
+ DeferCleanup(func() { _ = k8sClient.Delete(ctx, root) })
+
+ stored := &configbutleraiv1alpha1.GitTarget{}
+ Expect(k8sClient.Get(ctx, key, stored)).To(Succeed())
+ Expect(stored.Spec.Path).To(Equal("."), "an explicit \".\" must be stored as the root path")
+ })
+})
diff --git a/internal/controller/gittarget_path_overlap.go b/internal/controller/gittarget_path_overlap.go
new file mode 100644
index 00000000..92074892
--- /dev/null
+++ b/internal/controller/gittarget_path_overlap.go
@@ -0,0 +1,90 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "path"
+ "strings"
+
+ configbutleraiv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+)
+
+// normalizeGitTargetPath canonicalizes a GitTarget spec.path into a clean,
+// slash-rooted form so two paths can be compared segment-by-segment. Git paths
+// are always slash-separated, so this uses path (not filepath, which is
+// OS-specific). The repository root — an empty path, ".", or "/" — normalizes to
+// "/". Leading/trailing slashes, "." segments, and redundant separators are
+// removed (e.g. "a/b/", "/a/b", and "a/./b" all become "/a/b").
+func normalizeGitTargetPath(p string) string {
+ return path.Clean("/" + strings.TrimSpace(p))
+}
+
+// gitTargetPathIsAncestor reports whether ancestor strictly contains descendant
+// in the folder tree (ancestor is a proper prefix on a segment boundary). The
+// repository root "/" contains every other path. Equal paths are not ancestors
+// of one another — callers test equality separately. Both arguments must already
+// be normalized via normalizeGitTargetPath.
+func gitTargetPathIsAncestor(ancestor, descendant string) bool {
+ if ancestor == descendant {
+ return false
+ }
+ if ancestor == "/" {
+ // Root contains everything except itself (handled above).
+ return true
+ }
+ // Segment-boundary prefix: "/a" contains "/a/b" but not "/ab".
+ return strings.HasPrefix(descendant, ancestor+"/")
+}
+
+// gitTargetPathsOverlap reports whether two GitTarget paths fight over the same
+// folder subtree: they are equal, or one nests inside the other. Sibling folders
+// (e.g. "/a" and "/b") do not overlap. This enforces the "GitTargets never
+// overlap" invariant from the manifest materialization design — every
+// materialized folder must have exactly one owner.
+func gitTargetPathsOverlap(a, b string) bool {
+ na := normalizeGitTargetPath(a)
+ nb := normalizeGitTargetPath(b)
+ if na == nb {
+ return true
+ }
+ return gitTargetPathIsAncestor(na, nb) || gitTargetPathIsAncestor(nb, na)
+}
+
+// gitTargetLosesConflict reports whether target should lose an overlap conflict
+// against existing. The later-created target loses so the earlier owner keeps its
+// folder. When both carry the same creationTimestamp (the API server stamps at
+// second precision, so concurrent applies can tie) the loser is chosen
+// deterministically by identity — otherwise neither would lose and both could go
+// Ready over the same subtree. Both targets share a namespace here, so the
+// namespace/name key is unique and stable across every reconcile.
+func gitTargetLosesConflict(target, existing *configbutleraiv1alpha1.GitTarget) bool {
+ switch {
+ case target.CreationTimestamp.Time.After(existing.CreationTimestamp.Time):
+ return true
+ case target.CreationTimestamp.Time.Equal(existing.CreationTimestamp.Time):
+ return gitTargetIdentityKey(target) > gitTargetIdentityKey(existing)
+ default:
+ return false
+ }
+}
+
+// gitTargetIdentityKey returns a stable, unique ordering key for a GitTarget.
+func gitTargetIdentityKey(t *configbutleraiv1alpha1.GitTarget) string {
+ return t.Namespace + "/" + t.Name
+}
diff --git a/internal/controller/gittarget_path_overlap_test.go b/internal/controller/gittarget_path_overlap_test.go
new file mode 100644
index 00000000..edcbbddd
--- /dev/null
+++ b/internal/controller/gittarget_path_overlap_test.go
@@ -0,0 +1,172 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "testing"
+ "time"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ configbutleraiv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+)
+
+func TestNormalizeGitTargetPath(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"empty is root", "", "/"},
+ {"dot is root", ".", "/"},
+ {"slash is root", "/", "/"},
+ {"whitespace is root", " ", "/"},
+ {"leading slash stripped to canonical", "/a/b", "/a/b"},
+ {"trailing slash removed", "a/b/", "/a/b"},
+ {"no leading slash", "a/b", "/a/b"},
+ {"dot segment removed", "a/./b", "/a/b"},
+ {"redundant separators collapsed", "a//b", "/a/b"},
+ {"surrounding whitespace trimmed", " a/b ", "/a/b"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := normalizeGitTargetPath(tt.in); got != tt.want {
+ t.Errorf("normalizeGitTargetPath(%q) = %q, want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGitTargetPathIsAncestor(t *testing.T) {
+ tests := []struct {
+ name string
+ ancestor string
+ descendant string
+ want bool
+ }{
+ {"equal is not ancestor", "/a", "/a", false},
+ {"root contains child", "/", "/a", true},
+ {"root contains deep child", "/", "/a/b/c", true},
+ {"root is not ancestor of root", "/", "/", false},
+ {"direct parent", "/a", "/a/b", true},
+ {"grandparent", "/a", "/a/b/c", true},
+ {"sibling is not ancestor", "/a", "/b", false},
+ {"prefix without segment boundary", "/a", "/ab", false},
+ {"descendant is not ancestor of ancestor", "/a/b", "/a", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := gitTargetPathIsAncestor(tt.ancestor, tt.descendant); got != tt.want {
+ t.Errorf("gitTargetPathIsAncestor(%q, %q) = %v, want %v",
+ tt.ancestor, tt.descendant, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGitTargetPathsOverlap(t *testing.T) {
+ tests := []struct {
+ name string
+ a string
+ b string
+ want bool
+ }{
+ {"identical", "a/b", "a/b", true},
+ {"identical after normalization", "a/b/", "/a/b", true},
+ {"both root overlap", "", ".", true},
+ {"root overlaps any path", "", "team/app", true},
+ {"any path overlaps root (reversed)", "team/app", "/", true},
+ {"parent nests child", "team", "team/app", true},
+ {"child nests parent (reversed)", "team/app", "team", true},
+ {"deep nesting", "a", "a/b/c/d", true},
+ {"siblings do not overlap", "a", "b", false},
+ {"sibling subfolders do not overlap", "team/a", "team/b", false},
+ {"prefix without boundary does not overlap", "team/app", "team/app-staging", false},
+ {"disjoint trees", "infra/network", "apps/web", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := gitTargetPathsOverlap(tt.a, tt.b); got != tt.want {
+ t.Errorf("gitTargetPathsOverlap(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
+ }
+ // Overlap is symmetric.
+ if got := gitTargetPathsOverlap(tt.b, tt.a); got != tt.want {
+ t.Errorf("gitTargetPathsOverlap(%q, %q) [reversed] = %v, want %v", tt.b, tt.a, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGitTargetLosesConflict(t *testing.T) {
+ base := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
+ mk := func(name string, created time.Time) *configbutleraiv1alpha1.GitTarget {
+ return &configbutleraiv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: "default",
+ Name: name,
+ CreationTimestamp: metav1.NewTime(created),
+ },
+ }
+ }
+
+ tests := []struct {
+ name string
+ target *configbutleraiv1alpha1.GitTarget
+ existing *configbutleraiv1alpha1.GitTarget
+ want bool
+ }{
+ {
+ name: "later target loses",
+ target: mk("b", base.Add(time.Second)),
+ existing: mk("a", base),
+ want: true,
+ },
+ {
+ name: "earlier target wins",
+ target: mk("a", base),
+ existing: mk("b", base.Add(time.Second)),
+ want: false,
+ },
+ {
+ name: "tie broken by identity: higher key loses",
+ target: mk("b", base),
+ existing: mk("a", base),
+ want: true,
+ },
+ {
+ name: "tie broken by identity: lower key wins",
+ target: mk("a", base),
+ existing: mk("b", base),
+ want: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := gitTargetLosesConflict(tt.target, tt.existing); got != tt.want {
+ t.Errorf("gitTargetLosesConflict() = %v, want %v", got, tt.want)
+ }
+ // Exactly one of an overlapping pair must lose — never both, never
+ // neither — when their identities differ.
+ reverse := gitTargetLosesConflict(tt.existing, tt.target)
+ if got := gitTargetLosesConflict(tt.target, tt.existing); got == reverse {
+ t.Errorf("both/neither lose: target=%v existing=%v", got, reverse)
+ }
+ })
+ }
+}
diff --git a/internal/events/events.go b/internal/events/events.go
deleted file mode 100644
index 3cea4874..00000000
--- a/internal/events/events.go
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// Package events provides event types and interfaces for the GitOps Reverser.
-package events
-
-import (
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-
- "github.com/ConfigButler/gitops-reverser/internal/types"
-)
-
-// ClusterStateEvent reports cluster resources for a specific GitDestination.
-type ClusterStateEvent struct {
- // GitDestination reference (for routing)
- GitDest types.ResourceReference
-
- // Resources currently in cluster for this GitDestination
- Resources []types.ResourceIdentifier
-
- // Objects maps ResourceIdentifier.Key() to the full sanitized cluster object.
- // Populated during initial snapshot reconciliation so that CREATE and
- // RECONCILE_RESOURCE events can be hydrated with real payloads.
- Objects map[string]unstructured.Unstructured
-}
-
-// RepoStateEvent reports what Kubernetes resources exist in a Git repository for a GitDestination.
-type RepoStateEvent struct {
- // GitDestination reference
- GitDest types.ResourceReference
-
- // Resources found in Git (parsed from YAML files)
- Resources []types.ResourceIdentifier
-}
-
-// ControlEventType represents types of control events.
-type ControlEventType string
-
-const (
- // RequestClusterState requests cluster snapshot from WatchManager.
- RequestClusterState ControlEventType = "REQUEST_CLUSTER_STATE"
- // RequestRepoState triggers RepoStateEvent emission for specific GitDestination.
- RequestRepoState ControlEventType = "REQUEST_REPO_STATE"
- // ReconcileResource is a reminder event for individual resources that exist in both cluster and Git.
- ReconcileResource ControlEventType = "RECONCILE_RESOURCE"
-)
-
-// ControlEvent represents control events for coordination between components.
-type ControlEvent struct {
- Type ControlEventType
-
- // GitDestination reference
- GitDest types.ResourceReference
-
- // Optional resource context for ReconcileResource events
- Resource *types.ResourceIdentifier
-}
-
-// ControlEventEmitter emits control events for orchestration.
-type ControlEventEmitter interface {
- EmitControlEvent(event ControlEvent) error
-}
-
-// EventEmitter interface for emitting reconciliation events.
-type EventEmitter interface {
- EmitCreateEvent(resource types.ResourceIdentifier) error
- EmitDeleteEvent(resource types.ResourceIdentifier) error
- EmitReconcileResourceEvent(resource types.ResourceIdentifier) error
-}
diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go
index 79fff2d0..64af82ac 100644
--- a/internal/git/branch_worker.go
+++ b/internal/git/branch_worker.go
@@ -24,7 +24,6 @@ import (
"encoding/hex"
"errors"
"fmt"
- "os"
"path/filepath"
"strings"
"sync"
@@ -44,6 +43,7 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/sanitize"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
itypes "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
)
const (
@@ -90,6 +90,10 @@ type BranchWorker struct {
Client client.Client
Log logr.Logger
contentWriter *contentWriter
+ // mapper resolves manifest GVKs into resource identities while building the
+ // GitTarget inventory. A nil mapper keeps the writer structure-only, so
+ // object-less deletes have no resource index to target.
+ mapper typeset.Lookup
// Event processing
eventQueue chan WorkItem
@@ -282,6 +286,29 @@ func (w *BranchWorker) EnqueueFinalize(signal *FinalizeSignal) {
}
}
+// EnqueueResync adds a resync request to this worker's queue. Like a finalize
+// signal it rides the same queue as resource events, so it is applied in order with
+// live events: a resync enqueued during the snapshot window lands before the buffered
+// live events that follow it. If the queue is full the request is dropped and its
+// caller is notified immediately via the result channel.
+func (w *BranchWorker) EnqueueResync(request *ResyncRequest) {
+ if request == nil {
+ return
+ }
+ w.inflightItems.Add(1)
+ select {
+ case w.eventQueue <- WorkItem{Resync: request}:
+ w.Log.V(1).Info("Resync request enqueued",
+ "resources", len(request.Desired),
+ "gitTarget", request.GitTargetNamespace+"/"+request.GitTargetName)
+ default:
+ w.inflightItems.Add(-1)
+ w.Log.Error(nil, "Event queue full, resync request dropped",
+ "gitTarget", request.GitTargetNamespace+"/"+request.GitTargetName)
+ request.reply(ResyncResult{Err: ErrFinalizeQueueFull})
+ }
+}
+
func (w *BranchWorker) enqueueRequest(request *WriteRequest) {
if request == nil {
return
@@ -337,26 +364,6 @@ func (w *BranchWorker) recordQueueDepth() {
))
}
-// ListResourcesInPath returns resource identifiers found in a Git folder.
-// This is a synchronous service method called by EventRouter.
-func (w *BranchWorker) ListResourcesInPath(path string) ([]itypes.ResourceIdentifier, error) {
- w.repoMu.Lock()
- defer w.repoMu.Unlock()
-
- // Ensure repository is initialized and up-to-date
- if err := w.ensureRepositoryInitialized(w.ctx); err != nil {
- return nil, fmt.Errorf("failed to initialize repository: %w", err)
- }
-
- provider, err := w.getGitProvider(w.ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitProvider: %w", err)
- }
- repoPath := w.repoPathForRemote(provider.Spec.URL)
-
- return w.listResourceIdentifiersInPath(repoPath, path)
-}
-
// EnsurePathBootstrapped prepares bootstrap templates locally for a path.
// Existing files are preserved, and only missing template files are added.
// The files are staged in the local worktree but never committed or pushed here.
@@ -486,54 +493,6 @@ func (w *BranchWorker) bootstrapPathIfNeeded(
return nil
}
-// listResourceIdentifiersInPath lists resource identifiers in a specific path.
-func (w *BranchWorker) listResourceIdentifiersInPath(
- repoPath, path string,
-) ([]itypes.ResourceIdentifier, error) {
- var resources []itypes.ResourceIdentifier
-
- basePath := repoPath
- if path != "" {
- basePath = filepath.Join(repoPath, path)
- }
-
- err := filepath.Walk(basePath, func(walkPath string, info os.FileInfo, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if info.IsDir() {
- return nil
- }
-
- relPath, relErr := filepath.Rel(basePath, walkPath)
- if relErr != nil {
- return relErr
- }
- relPath = filepath.ToSlash(relPath)
-
- // Skip marker files
- if strings.Contains(relPath, ".configbutler") {
- return nil
- }
-
- // Process YAML files
- ext := filepath.Ext(relPath)
- if strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") {
- if id, ok := parseIdentifierFromPath(relPath); ok {
- resources = append(resources, id)
- }
- }
-
- return nil
- })
-
- if err != nil && !os.IsNotExist(err) {
- return nil, err
- }
-
- return resources, nil
-}
-
// processEvents is the main event processing loop.
//
// The loop owns one live commit-shaped event window. A commit-window timer
@@ -644,6 +603,11 @@ func (l *branchWorkerEventLoop) handleQueueItem(item WorkItem) {
return
}
+ if item.Resync != nil {
+ l.handleResyncRequest(item.Resync)
+ return
+ }
+
if item.Request == nil {
return
}
@@ -1381,4 +1345,4 @@ func buildBootstrapOptions(encryptionConfig *ResolvedEncryptionConfig) pathBoots
}
}
-// parseIdentifierFromPath and getAuthFromSecret are defined in helpers.go
+// getAuthFromSecret is defined in helpers.go
diff --git a/internal/git/branch_worker_split_test.go b/internal/git/branch_worker_split_test.go
index 0a2df362..22f2ac19 100644
--- a/internal/git/branch_worker_split_test.go
+++ b/internal/git/branch_worker_split_test.go
@@ -43,6 +43,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
itypes "github.com/ConfigButler/gitops-reverser/internal/types"
)
@@ -976,7 +977,7 @@ func TestEventLoop_AtomicRequest_RespectsCooldownAndUsesNormalPushPath(t *testin
loop.stopTimers()
}
-func TestEventLoop_ListResourcesDuringCooldownPreservesDeferredEventCommits(t *testing.T) {
+func TestEventLoop_DeferredEventCommitsAndAtomicDuringCooldownPushTogether(t *testing.T) {
worker, serverRepo, _ := setupCommitPushSplitWorker(t)
createPlainGitTarget(t, worker, "target-a", "iso-a")
createPlainGitTarget(t, worker, "target-b", "iso-b")
@@ -996,18 +997,10 @@ func TestEventLoop_ListResourcesDuringCooldownPreservesDeferredEventCommits(t *t
Events: []Event{configMapTargetEvent("live-b", "bob", "target-b")},
CommitMode: CommitModePerEvent,
}})
- require.Len(t, loop.pendingWrites, 2)
+ require.Len(t, loop.pendingWrites, 2,
+ "deferred event commits during cooldown are retained as local commits, not lost")
loop.syncQueueDepthMetric()
- resources, err := worker.ListResourcesInPath("iso-b")
- require.NoError(t, err)
- resourceNames := make([]string, 0, len(resources))
- for _, resource := range resources {
- resourceNames = append(resourceNames, resource.Name)
- }
- assert.Contains(t, resourceNames, "live-b",
- "repo-state reads during cooldown must see retained local commits instead of resetting to remote")
-
loop.handleQueueItem(WorkItem{Request: &WriteRequest{
Events: []Event{configMapEvent("snapshot-only", "reconciler", "")},
CommitMode: CommitModeAtomic,
@@ -1053,3 +1046,113 @@ func TestEventLoop_AtomicPushFailure_DoesNotAdvanceCooldownOrLosePendingWrite(t
assert.True(t, loop.lastPushAt.IsZero(), "failed atomic push must not advance cooldown state")
assert.Len(t, loop.pendingWrites, 1, "failed atomic push must retain pending work for retry")
}
+
+// TestResync_WorkerAppliesMarkAndSweepAndCommits drives a resync through the worker
+// queue end to end: a managed ConfigMap is seeded under the GitTarget path, then a
+// resync whose desired set replaces it with a different resource creates the new one
+// and sweeps the orphaned one — committing once and replying with the plan's stats.
+func TestResync_WorkerAppliesMarkAndSweepAndCommits(t *testing.T) {
+ worker, serverRepo, _ := setupCommitPushSplitWorker(t)
+ worker.mapper = configMapMapper()
+ createPlainGitTarget(t, worker, "target-a", "live")
+
+ initialRef, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true)
+ require.NoError(t, err)
+ initialHash := initialRef.Hash()
+
+ loop := newBranchWorkerEventLoop(worker, 0)
+ loop.lastPushAt = time.Now()
+
+ // Seed a managed ConfigMap under the GitTarget path with a normal per-event commit.
+ loop.handleQueueItem(WorkItem{Request: &WriteRequest{
+ Events: []Event{configMapTargetEvent("drop-me", "alice", "target-a")},
+ CommitMode: CommitModePerEvent,
+ }})
+ require.Len(t, loop.pendingWrites, 1)
+
+ // Resync: the cluster now has "keep" and no longer has "drop-me".
+ req := &ResyncRequest{
+ Desired: []manifestanalyzer.DesiredResource{desiredCM("keep", "blue")},
+ Revision: "42",
+ GitTargetName: "target-a",
+ GitTargetNamespace: "default",
+ Result: make(chan ResyncResult, 1),
+ }
+ loop.handleQueueItem(WorkItem{Resync: req})
+
+ result := <-req.Result
+ require.NoError(t, result.Err)
+ assert.Equal(t, 1, result.Stats.Created, "the new cluster resource is created")
+ assert.Equal(t, 1, result.Stats.Deleted, "the orphaned managed resource is swept")
+ require.Len(t, loop.pendingWrites, 2, "the resync is retained as a second local commit")
+
+ loop.pushPending()
+
+ finalRef, err := serverRepo.Reference(plumbing.NewBranchReferenceName("main"), true)
+ require.NoError(t, err)
+ commits := commitsAfterHash(t, serverRepo, finalRef.Hash(), initialHash)
+ require.Len(t, commits, 2)
+ assert.Equal(t, "[CREATE] v1/configmaps/drop-me", commits[0].Message)
+ assert.Equal(t, "reconcile: sync 2 resources", commits[1].Message,
+ "the resync commit counts the create and the managed drop")
+
+ loop.stopTimers()
+}
+
+// TestResync_WorkerNoopDoesNotRetainOrPush guards the regression where an empty initial
+// snapshot (no rules yet) still pushed: a no-op resync must not be retained or advance
+// the push cooldown, or it would delay the next real snapshot's push past its window.
+func TestResync_WorkerNoopDoesNotRetainOrPush(t *testing.T) {
+ worker, _, _ := setupCommitPushSplitWorker(t)
+ worker.mapper = configMapMapper()
+ createPlainGitTarget(t, worker, "target-a", "live")
+
+ loop := newBranchWorkerEventLoop(worker, 0)
+ // The worktree under live/ is empty and the desired set is empty: nothing to do.
+ req := &ResyncRequest{
+ GitTargetName: "target-a",
+ GitTargetNamespace: "default",
+ Result: make(chan ResyncResult, 1),
+ }
+ loop.handleQueueItem(WorkItem{Resync: req})
+
+ result := <-req.Result
+ require.NoError(t, result.Err)
+ assert.Zero(t, result.Stats.Created)
+ assert.Zero(t, result.Stats.Deleted)
+ assert.Empty(t, loop.pendingWrites, "a no-op resync must not be retained")
+ assert.True(t, loop.lastPushAt.IsZero(), "a no-op resync must not advance the push cooldown")
+
+ loop.stopTimers()
+}
+
+// TestResync_WorkerEmptyDesiredSweepsManagedResource proves the authoritative empty
+// snapshot: a resync with no desired resources sweeps every managed document and
+// commits the deletion.
+func TestResync_WorkerEmptyDesiredSweepsManagedResource(t *testing.T) {
+ worker, _, _ := setupCommitPushSplitWorker(t)
+ worker.mapper = configMapMapper()
+ createPlainGitTarget(t, worker, "target-a", "live")
+
+ loop := newBranchWorkerEventLoop(worker, 0)
+ loop.lastPushAt = time.Now()
+ loop.handleQueueItem(WorkItem{Request: &WriteRequest{
+ Events: []Event{configMapTargetEvent("orphan", "alice", "target-a")},
+ CommitMode: CommitModePerEvent,
+ }})
+ require.Len(t, loop.pendingWrites, 1)
+
+ req := &ResyncRequest{
+ GitTargetName: "target-a",
+ GitTargetNamespace: "default",
+ Result: make(chan ResyncResult, 1),
+ }
+ loop.handleQueueItem(WorkItem{Resync: req})
+
+ result := <-req.Result
+ require.NoError(t, result.Err)
+ assert.Equal(t, 1, result.Stats.Deleted, "an empty desired set sweeps the managed resource")
+ assert.Zero(t, result.Stats.Created)
+
+ loop.stopTimers()
+}
diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go
index 9b812805..085841c3 100644
--- a/internal/git/branch_worker_test.go
+++ b/internal/git/branch_worker_test.go
@@ -45,24 +45,6 @@ import (
itypes "github.com/ConfigButler/gitops-reverser/internal/types"
)
-func setupBranchWorkerTest() (*BranchWorker, func()) {
- scheme := runtime.NewScheme()
- _ = clientgoscheme.AddToScheme(scheme)
- _ = configv1alpha1.AddToScheme(scheme)
- client := fake.NewClientBuilder().WithScheme(scheme).Build()
- log := logr.Discard()
-
- worker := NewBranchWorker(client, log, "test-repo", "gitops-system", "main", nil, 0)
-
- cleanup := func() {
- if worker.started {
- worker.Stop()
- }
- }
-
- return worker, cleanup
-}
-
func TestRepoCacheKey_DeterministicAndDistinct(t *testing.T) {
a := repoCacheKey("https://example.com/foo.git")
b := repoCacheKey("https://example.com/foo.git")
@@ -74,130 +56,6 @@ func TestRepoCacheKey_DeterministicAndDistinct(t *testing.T) {
require.Equal(t, a, d, "cache key should ignore surrounding whitespace")
}
-// TestListResourcesInPath_BasicFunctionality verifies ListResourcesInPath can be called.
-func TestListResourcesInPath_BasicFunctionality(t *testing.T) {
- worker, cleanup := setupBranchWorkerTest()
- defer cleanup()
-
- // This test verifies the method can be called without panicking
- // In a real scenario, this would require setting up a Git repository
- // For now, we just ensure the method signature and basic flow work
- _, err := worker.ListResourcesInPath("apps")
-
- // We expect an error since no GitProvider exists in the fake client
- // But the important thing is that the method doesn't panic
- if err == nil {
- t.Error("Expected error due to missing GitProvider, but got nil")
- }
-}
-
-// TestListResourcesInPath_WithGitProvider verifies resources are listed correctly.
-func TestListResourcesInPath_WithGitProvider(t *testing.T) {
- worker, cleanup := setupBranchWorkerTest()
- defer cleanup()
-
- // Create a GitProvider in the fake client
- repoConfig := &configv1alpha1.GitProvider{
- Spec: configv1alpha1.GitProviderSpec{
- URL: "https://github.com/test/repo.git",
- AllowedBranches: []string{"main"},
- },
- }
- repoConfig.Name = "test-repo"
- repoConfig.Namespace = "gitops-system"
-
- err := worker.Client.Create(context.Background(), repoConfig)
- if err != nil {
- t.Fatalf("Failed to create GitProvider: %v", err)
- }
-
- // Call ListResourcesInPath - with new abstraction, initialization succeeds
- // but listing resources will return empty list for fake repo
- resources, err := worker.ListResourcesInPath("apps")
-
- // With the new abstraction, we expect success but empty resource list
- if err != nil {
- t.Logf("Got expected error during fetch: %v", err)
- } else {
- // If no error (abstraction handles it gracefully), verify empty list
- assert.Empty(t, resources, "Should return empty list for fake repository")
- }
-}
-
-// TestListResourcesInPath_DifferentPaths verifies different paths are handled.
-func TestListResourcesInPath_DifferentPaths(t *testing.T) {
- worker, cleanup := setupBranchWorkerTest()
- defer cleanup()
-
- // Create a GitProvider in the fake client
- repoConfig := &configv1alpha1.GitProvider{
- Spec: configv1alpha1.GitProviderSpec{
- URL: "https://github.com/test/repo.git",
- AllowedBranches: []string{"main"},
- },
- }
- repoConfig.Name = "test-repo"
- repoConfig.Namespace = "gitops-system"
-
- err := worker.Client.Create(context.Background(), repoConfig)
- if err != nil {
- t.Fatalf("Failed to create GitProvider: %v", err)
- }
-
- // Test different paths - with new abstraction, method handles them gracefully
- paths := []string{"apps", "infra", "", "clusters/prod"}
-
- for _, path := range paths {
- resources, err := worker.ListResourcesInPath(path)
-
- // With new abstraction, we either get an error during fetch or empty list
- if err != nil {
- t.Logf("Got expected error for path %q: %v", path, err)
- } else {
- // Method succeeded - verify it returns empty list for fake repo
- assert.Empty(t, resources, "Should return empty list for path %q", path)
- }
- }
-}
-
-func TestListResourceIdentifiersInPath_PathPrefixParsesAsCoreGroup(t *testing.T) {
- worker, cleanup := setupBranchWorkerTest()
- defer cleanup()
-
- repoPath := t.TempDir()
- targetPath := "live-cluster"
-
- resourcePath := filepath.Join(repoPath, targetPath, "v1", "configmaps", "ns1", "oeps3.yaml")
- require.NoError(t, os.MkdirAll(filepath.Dir(resourcePath), 0o755))
- require.NoError(t, os.WriteFile(resourcePath, []byte("apiVersion: v1\nkind: ConfigMap\n"), 0o600))
-
- markerPath := filepath.Join(repoPath, targetPath, ".configbutler")
- require.NoError(t, os.WriteFile(markerPath, []byte("marker"), 0o600))
-
- resources, err := worker.listResourceIdentifiersInPath(repoPath, targetPath)
- require.NoError(t, err)
- require.Len(t, resources, 1, "marker files should be ignored")
-
- assert.Empty(t, resources[0].Group)
- assert.Equal(t, "v1", resources[0].Version)
- assert.Equal(t, "configmaps", resources[0].Resource)
- assert.Equal(t, "ns1", resources[0].Namespace)
- assert.Equal(t, "oeps3", resources[0].Name)
-}
-
-// TestListResourcesInPath_MissingGitProvider verifies proper error when GitProvider is missing.
-func TestListResourcesInPath_MissingGitProvider(t *testing.T) {
- worker, cleanup := setupBranchWorkerTest()
- defer cleanup()
-
- // Don't create GitProvider - should fail
- _, err := worker.ListResourcesInPath("apps")
-
- if err == nil {
- t.Error("Expected error when GitProvider is missing, but got nil")
- }
-}
-
// TestBranchWorker_EmptyRepository tests that BranchWorker properly handles empty repositories
// that have no commits yet. This is a critical scenario for bootstrapping new repositories.
func TestBranchWorker_EmptyRepository(t *testing.T) {
@@ -239,17 +97,6 @@ func TestBranchWorker_EmptyRepository(t *testing.T) {
assert.False(t, exists, "Branch should not exist remotely for empty repository")
assert.Empty(t, sha, "SHA should be empty while branch is unborn")
assert.False(t, fetchTime.IsZero(), "Fetch time should be set")
-
- // Test ListResourcesInPath - should work with empty repo
- resources, err := worker.ListResourcesInPath("")
- require.NoError(t, err, "ListResourcesInPath should succeed with empty repository")
- assert.Empty(t, resources, "Should return empty resources list for empty repository")
-
- // Verify metadata was updated after ListResourcesInPath
- exists2, sha2, fetchTime2 := worker.GetBranchMetadata()
- assert.False(t, exists2, "Branch should remain unborn after listing")
- assert.Empty(t, sha2, "SHA should remain empty after listing")
- assert.False(t, fetchTime2.Before(fetchTime), "Fetch time should not move backwards")
}
// TestBranchWorker_IdentityFields verifies worker identity is set correctly.
diff --git a/internal/git/commit.go b/internal/git/commit.go
index 4f940a3f..3085e0fa 100644
--- a/internal/git/commit.go
+++ b/internal/git/commit.go
@@ -71,6 +71,20 @@ func renderSnapshotCommitMessage(
)
}
+// renderResyncCommitMessage renders the snapshot commit message for a resync from the
+// provider's SnapshotTemplate, so a resync honours a custom snapshot template exactly
+// as the old atomic snapshot did. count is the number of resources in the snapshot.
+func renderResyncCommitMessage(count int, gitTarget string, config CommitConfig) (string, error) {
+ return renderCommitTemplate(
+ "snapshot",
+ config.Message.SnapshotTemplate,
+ SnapshotCommitMessageData{
+ Count: count,
+ GitTarget: gitTarget,
+ },
+ )
+}
+
func renderGroupCommitMessage(pendingWrite PendingWrite, config CommitConfig) (string, error) {
return renderCommitTemplate(
"group",
diff --git a/internal/git/commit_executor.go b/internal/git/commit_executor.go
index 127ab824..508cedb1 100644
--- a/internal/git/commit_executor.go
+++ b/internal/git/commit_executor.go
@@ -108,6 +108,8 @@ func (w *BranchWorker) executePendingWrite(
pendingWrite PendingWrite,
) (int, error) {
switch pendingWrite.Kind {
+ case PendingWriteResync:
+ return w.executeResyncPendingWrite(ctx, repo, worktree, pendingWrite)
case PendingWriteCommit, PendingWriteAtomic:
default:
return 0, fmt.Errorf("unsupported pending write kind %q", pendingWrite.Kind)
@@ -162,17 +164,26 @@ func (w *BranchWorker) applyPendingWriteEvents(
worktree *gogit.Worktree,
events []Event,
) (bool, error) {
- anyChanges := false
+ // Stage path-scoped bootstrap files first, before any resource write, exactly as
+ // the per-event path did.
for _, event := range events {
if err := ensureBootstrapTemplateInPath(repo, sanitizePath(event.Path), event.BootstrapOptions); err != nil {
return false, err
}
+ }
- changesApplied, err := applyEventToWorktree(ctx, w.contentWriter, worktree, event)
+ // Plan-then-flush each GitTarget subtree once: build the structure model, resolve
+ // every event to a single-identity action, apply to hydrated file buffers, and
+ // flush dirty/deleted files. A grouped window is single-target, so this is usually
+ // one base path.
+ byBase := groupEventsByBase(events)
+ anyChanges := false
+ for _, base := range sortedBaseKeys(byBase) {
+ changed, err := w.flushEventsToWorktree(ctx, worktree, base, byBase[base])
if err != nil {
return false, err
}
- if changesApplied {
+ if changed {
anyChanges = true
}
}
diff --git a/internal/git/fieldpatch_flush_test.go b/internal/git/fieldpatch_flush_test.go
new file mode 100644
index 00000000..e1f76ceb
--- /dev/null
+++ b/internal/git/fieldpatch_flush_test.go
@@ -0,0 +1,165 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ gogit "github.com/go-git/go-git/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// deploymentScalePatch builds a deployments/scale-shaped field-patch event: no
+// object, just a spec.replicas assignment against a parent Deployment GVR identity.
+// It carries no parent Kind: the writer resolves the parent document from the GVR.
+func deploymentScalePatch(name string, replicas int64) Event {
+ return Event{
+ FieldPatch: &FieldPatch{
+ Assignments: []manifestedit.FieldAssignment{
+ {Path: []string{"spec", "replicas"}, Value: replicas},
+ },
+ Source: "deployments/scale",
+ },
+ Identifier: types.ResourceIdentifier{
+ Group: "apps", Version: "v1", Resource: "deployments", Namespace: "default", Name: name,
+ },
+ Operation: "UPDATE",
+ }
+}
+
+// deploymentsMapper resolves apps/v1 Deployment <-> deployments so the writer can
+// locate a field patch's parent by its objectRef GVR through the resource-identity
+// inventory (the production resolution path; the consumer never sends a Kind).
+func deploymentsMapper() typeset.Lookup {
+ return typeset.NewSnapshotRegistry(typeset.Snapshot{
+ Entries: []typeset.Entry{{
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
+ Namespaced: true,
+ Allowed: true,
+ }},
+ })
+}
+
+// applyScalePatch folds field-patch events over the worktree through a worker whose
+// mapper resolves the deployments GVR, exercising the production GVR-only resolution.
+func applyScalePatch(t *testing.T, writer *contentWriter, worktree *gogit.Worktree, events ...Event) bool {
+ t.Helper()
+ w := &BranchWorker{contentWriter: writer, mapper: deploymentsMapper()}
+ changed, err := w.flushEventsToWorktree(context.Background(), worktree, "", events)
+ require.NoError(t, err)
+ return changed
+}
+
+// A field-patch event scales an existing Deployment manifest in place: only
+// spec.replicas changes, and the hand-authored comments, the selector, and the
+// container spec all survive. The manifest is seeded off its canonical path to also
+// prove it is located by content identity, not by path.
+func TestPlanFlush_FieldPatchUpdatesExistingManifest(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ rel := "infra/web.yaml"
+ seeded := "# web tier — replicas owned by GitOps\n" +
+ "apiVersion: apps/v1\n" +
+ "kind: Deployment\n" +
+ "metadata:\n name: web\n namespace: default\n labels:\n app: web\n" +
+ "spec:\n replicas: 1 # current scale\n" +
+ " selector:\n matchLabels:\n app: web\n" +
+ " template:\n metadata:\n labels:\n app: web\n" +
+ " spec:\n containers:\n - name: web\n image: nginx:1.25\n"
+ full := seedPlacedManifest(t, worktree, rel, seeded)
+
+ changed := applyScalePatch(t, writer, worktree, deploymentScalePatch("web", 3))
+ require.True(t, changed, "scaling an existing manifest must write")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ out := string(got)
+ assert.Contains(t, out, "replicas: 3", "the scaled value is applied")
+ assert.NotContains(t, out, "replicas: 1")
+ assert.Contains(t, out, "# current scale", "the inline comment survives the patch")
+ assert.Contains(t, out, "# web tier — replicas owned by GitOps", "the header comment survives")
+ assert.Contains(t, out, "image: nginx:1.25", "unrelated fields survive")
+ assert.Contains(t, out, "matchLabels", "the selector survives")
+}
+
+// The production path: a translator-emitted field patch carries no parent Kind, so the
+// writer must resolve the parent Deployment from its objectRef GVR through the
+// mapper-built resource index — the same resolution the GVR-only delete uses — and
+// patch only spec.replicas. The manifest is seeded off its canonical path to prove the
+// resolution is content-derived (resource identity), not path-derived.
+func TestPlanFlush_FieldPatchResolvesParentByGVR(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ rel := "infra/web.yaml"
+ seeded := "apiVersion: apps/v1\nkind: Deployment\n" +
+ "metadata:\n name: web\n namespace: default\n" +
+ "spec:\n replicas: 1\n paused: false\n"
+ full := seedPlacedManifest(t, worktree, rel, seeded)
+
+ changed := applyScalePatch(t, writer, worktree, deploymentScalePatch("web", 4))
+ require.True(t, changed, "the parent must be resolved by GVR and patched")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "replicas: 4", "the scaled value is applied")
+ assert.Contains(t, string(got), "paused: false", "an unrelated field is preserved")
+}
+
+// A field patch whose parent is not present in Git fabricates nothing: there is no
+// creation path, because guessing every unaudited field would be worse than the drop.
+func TestPlanFlush_FieldPatchWithNoParentIsNoOp(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ changed := applyScalePatch(t, writer, worktree, deploymentScalePatch("ghost", 3))
+ assert.False(t, changed, "a field patch with no parent in Git writes nothing")
+}
+
+// An encrypted parent is never patched in place: that would drop the SOPS metadata and
+// write cleartext. The document is left intact and nothing is committed.
+func TestPlanFlush_FieldPatchEncryptedParentIsSkipped(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ rel := "infra/web.yaml"
+ seeded := "apiVersion: apps/v1\nkind: Deployment\n" +
+ "metadata:\n name: web\n namespace: default\n" +
+ "spec:\n replicas: 1\n" +
+ "sops:\n mac: ENC[placeholder]\n"
+ full := seedPlacedManifest(t, worktree, rel, seeded)
+
+ changed := applyScalePatch(t, writer, worktree, deploymentScalePatch("web", 3))
+ assert.False(t, changed, "an encrypted parent is never patched in place")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "sops:", "the encrypted document is left intact")
+ assert.NotContains(t, string(got), "replicas: 3")
+}
diff --git a/internal/git/git.go b/internal/git/git.go
index 930b2e9b..cee4b451 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -41,6 +41,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/yaml"
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
"github.com/ConfigButler/gitops-reverser/internal/sanitize"
"github.com/ConfigButler/gitops-reverser/internal/types"
)
@@ -301,6 +302,20 @@ func sanitizePath(base string) string {
return cleaned
}
+// IsValidTargetPath reports whether p is a path the writer can safely materialize
+// into: the repository root (empty or "."), or a clean relative path. Paths the
+// writer rejects as unsafe — absolute (leading "/"), Windows separators, or ".."
+// traversal — are invalid and can own nothing. It mirrors sanitizePath, the
+// write-path guard, so the overlap/admission check and the writer agree on what a
+// target legitimately owns.
+func IsValidTargetPath(p string) bool {
+ trimmed := strings.TrimSpace(p)
+ if trimmed == "" || trimmed == "." {
+ return true // repository root
+ }
+ return sanitizePath(trimmed) != ""
+}
+
// tryOpenExistingRepo attempts to open and validate an existing repository.
func tryOpenExistingRepo(path string, logger logr.Logger) *git.Repository {
// Check if .git directory exists
@@ -566,119 +581,38 @@ func setHead(r *git.Repository, branchName string) error {
return r.Storer.SetReference(newHeadRef)
}
-// applyEventToWorktree applies an event to the worktree, returning true if changes were made.
-func applyEventToWorktree(
- ctx context.Context,
- writer eventContentWriter,
- worktree *git.Worktree,
- event Event,
-) (bool, error) {
- logger := log.FromContext(ctx)
-
- filePath := writer.filePathForIdentifier(event.Identifier)
- if event.Path != "" {
- if bf := sanitizePath(event.Path); bf != "" {
- filePath = path.Join(bf, filePath)
- }
+// manifestIdentity reads the content identity (GVK + namespace + name) from a live
+// object, matching how manifestedit derives identity from YAML. ok is false when
+// there is no object or it lacks the fields needed to identify it.
+func manifestIdentity(obj *unstructured.Unstructured) (manifestedit.Identity, bool) {
+ if obj == nil {
+ return manifestedit.Identity{}, false
}
-
- fullPath := filepath.Join(worktree.Filesystem.Root(), filePath)
-
- if event.Operation == "DELETE" {
- return handleDeleteOperation(logger, filePath, fullPath, worktree)
- }
-
- return handleCreateOrUpdateOperation(
- ctx,
- writer,
- event,
- filePath,
- fullPath,
- worktree,
- )
-}
-
-// handleDeleteOperation removes a file from the repository.
-// Returns true if the file was deleted, false if it didn't exist.
-func handleDeleteOperation(
- logger logr.Logger,
- filePath, fullPath string,
- worktree *git.Worktree,
-) (bool, error) {
- // Check if file exists before attempting deletion
- _, statErr := os.Stat(fullPath)
- if statErr == nil {
- // Remove file from filesystem
- if err := os.Remove(fullPath); err != nil {
- return false, fmt.Errorf("failed to delete file %s: %w", filePath, err)
- }
-
- // Stage deletion in git
- if _, err := worktree.Remove(filePath); err != nil {
- return false, fmt.Errorf("failed to remove file %s from git: %w", filePath, err)
- }
-
- logger.Info("Deleted file from repository", "file", filePath)
- return true, nil
+ id := manifestedit.Identity{
+ APIVersion: obj.GetAPIVersion(),
+ Kind: obj.GetKind(),
+ Namespace: obj.GetNamespace(),
+ Name: obj.GetName(),
}
-
- if os.IsNotExist(statErr) {
- // File doesn't exist, log and skip (already deleted or never committed)
- logger.Info("File does not exist, skipping deletion", "file", filePath)
- return false, nil
+ if id.APIVersion == "" || id.Kind == "" || id.Name == "" {
+ return manifestedit.Identity{}, false
}
-
- return false, fmt.Errorf("failed to check file status %s: %w", filePath, statErr)
+ return id, true
}
-// handleCreateOrUpdateOperation writes and stages a file in the repository.
-// Returns true if changes were made, false if the file already has the desired content.
-func handleCreateOrUpdateOperation(
- ctx context.Context,
- writer eventContentWriter,
- event Event,
+// removeFileFromWorktree deletes a file from disk and stages the removal in git.
+func removeFileFromWorktree(
+ logger logr.Logger,
filePath, fullPath string,
worktree *git.Worktree,
) (bool, error) {
- content, err := writer.buildContentForWrite(ctx, event)
- if err != nil {
- if writer.isSensitiveIdentifier(event.Identifier) {
- log.FromContext(ctx).Info(
- "Sensitive resource write skipped because encryption failed",
- "resource", event.Identifier.String(),
- "file", filePath,
- "error", err.Error(),
- )
- }
- return false, err
+ if err := os.Remove(fullPath); err != nil {
+ return false, fmt.Errorf("failed to delete file %s: %w", filePath, err)
}
-
- // Check if file already exists with same content
- if existingContent, err := os.ReadFile(fullPath); err == nil {
- if bytes.Equal(existingContent, content) {
- // File already has the desired content, no changes needed
- return false, nil
- }
- if manifestsAreSemanticallyEqual(existingContent, content) {
- return false, nil
- }
+ if _, err := worktree.Remove(filePath); err != nil {
+ return false, fmt.Errorf("failed to remove file %s from git: %w", filePath, err)
}
-
- // Ensure directory exists
- if err := os.MkdirAll(filepath.Dir(fullPath), 0750); err != nil {
- return false, fmt.Errorf("failed to create directory for %s: %w", filePath, err)
- }
-
- // Write file
- if err := os.WriteFile(fullPath, content, 0600); err != nil {
- return false, fmt.Errorf("failed to write file %s: %w", filePath, err)
- }
-
- // Add to git
- if _, err := worktree.Add(filePath); err != nil {
- return false, fmt.Errorf("failed to add file %s to git: %w", filePath, err)
- }
-
+ logger.Info("Deleted file from repository", "file", filePath)
return true, nil
}
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
index 71ee2318..f8a6ac26 100644
--- a/internal/git/git_test.go
+++ b/internal/git/git_test.go
@@ -28,6 +28,30 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/types"
)
+func TestIsValidTargetPath(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ want bool
+ }{
+ {"empty is root", "", true},
+ {"dot is root", ".", true},
+ {"whitespace is root", " ", true},
+ {"clean relative path", "team/app", true},
+ {"trailing slash ok", "team/app/", true},
+ {"absolute rejected", "/team", false},
+ {"absolute root slash rejected", "/", false},
+ {"parent traversal rejected", "../team", false},
+ {"embedded traversal rejected", "team/../other", false},
+ {"backslash rejected", "team\\app", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, IsValidTargetPath(tc.path))
+ })
+ }
+}
+
func TestToGitPath_NamespacedResource(t *testing.T) {
testCases := []struct {
name string
diff --git a/internal/git/helpers.go b/internal/git/helpers.go
index 33ad7f70..c1159c90 100644
--- a/internal/git/helpers.go
+++ b/internal/git/helpers.go
@@ -22,7 +22,6 @@ import (
"context"
"errors"
"fmt"
- "path/filepath"
"strings"
gogit "github.com/go-git/go-git/v5"
@@ -33,72 +32,8 @@ import (
"github.com/ConfigButler/gitops-reverser/api/v1alpha1"
"github.com/ConfigButler/gitops-reverser/internal/ssh"
- itypes "github.com/ConfigButler/gitops-reverser/internal/types"
)
-// Worker configuration constants shared across implementations.
-const (
- // Path part counts for identifier parsing (avoid magic numbers).
- minCoreClusterParts = 3
- groupedClusterOrCoreNamespacedParts = 4
- groupedNamespacedParts = 5
-)
-
-// parseIdentifierFromPath parses "{group-or-core?}/{version}/{resource}/{namespace?}/{name}.yaml"
-// into a ResourceIdentifier. For core group, the path starts with version (e.g., "v1/...").
-// This is a shared helper used by both old and new worker implementations.
-func parseIdentifierFromPath(p string) (itypes.ResourceIdentifier, bool) {
- parts := strings.Split(p, "/")
- // Minimum cluster-scoped core: v1/{resource}/{name}.yaml => 3 parts
- // Minimum cluster-scoped grouped: {group}/{version}/{resource}/{name}.yaml => 4 parts
- if len(parts) < minCoreClusterParts {
- return itypes.ResourceIdentifier{}, false
- }
- last := parts[len(parts)-1]
- name := strings.TrimSuffix(last, filepath.Ext(last))
- name = strings.TrimSuffix(name, ".sops")
-
- var group, version, resource, namespace string
- switch len(parts) {
- case minCoreClusterParts: // core cluster-scoped: v1/resource/name.yaml
- group = ""
- version = parts[0]
- resource = parts[1]
- namespace = ""
- case groupedClusterOrCoreNamespacedParts: // grouped cluster-scoped OR core namespaced
- // Heuristic: if parts[0] looks like "v1" (starts with 'v' and digits), assume core namespaced is not possible with 4 parts.
- // For our current mapping, core namespaced has 4 parts: v1/resource/namespace/name.yaml
- // so handle that first.
- if strings.HasPrefix(parts[0], "v") { // v1/...
- group = ""
- version = parts[0]
- resource = parts[1]
- namespace = parts[2]
- } else {
- group = parts[0]
- version = parts[1]
- resource = parts[2]
- namespace = "" // cluster-scoped grouped
- }
- case groupedNamespacedParts: // grouped namespaced: group/version/resource/namespace/name.yaml
- group = parts[0]
- version = parts[1]
- resource = parts[2]
- namespace = parts[3]
- default:
- // Longer paths are not expected in current mapping
- return itypes.ResourceIdentifier{}, false
- }
-
- return itypes.ResourceIdentifier{
- Group: group,
- Version: version,
- Resource: resource,
- Namespace: namespace,
- Name: name,
- }, true
-}
-
// GetAuthFromSecret fetches authentication credentials from the specified secret.
// This is a public wrapper that can be used by controllers.
func GetAuthFromSecret(
diff --git a/internal/git/helpers_test.go b/internal/git/helpers_test.go
index 9ed740fa..3efeb0b5 100644
--- a/internal/git/helpers_test.go
+++ b/internal/git/helpers_test.go
@@ -388,28 +388,3 @@ func createTestEvent(tb testing.TB, name string) Event {
},
}
}
-
-func TestParseIdentifierFromPath_StripsYAMLExtensions(t *testing.T) {
- tests := []struct {
- path string
- name string
- }{
- {
- path: "v1/secrets/default/db-secret.yaml",
- name: "db-secret",
- },
- {
- path: "v1/secrets/default/db-secret.sops.yaml",
- name: "db-secret",
- },
- }
-
- for _, tt := range tests {
- id, ok := parseIdentifierFromPath(tt.path)
- require.True(t, ok)
- require.Equal(t, "v1", id.Version)
- require.Equal(t, "secrets", id.Resource)
- require.Equal(t, "default", id.Namespace)
- require.Equal(t, tt.name, id.Name)
- }
-}
diff --git a/internal/git/inplace_edit_test.go b/internal/git/inplace_edit_test.go
new file mode 100644
index 00000000..125b4954
--- /dev/null
+++ b/internal/git/inplace_edit_test.go
@@ -0,0 +1,211 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ gogit "github.com/go-git/go-git/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// inplaceCMEvent builds an UPDATE event for the default/app ConfigMap with its
+// single data key set to color.
+func inplaceCMEvent(color string) Event {
+ return Event{
+ Object: &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{"name": "app", "namespace": "default"},
+ "data": map[string]interface{}{"color": color},
+ }},
+ Identifier: types.ResourceIdentifier{
+ Group: "", Version: "v1", Resource: "configmaps", Namespace: "default", Name: "app",
+ },
+ Operation: "UPDATE",
+ }
+}
+
+// newWorktreeForTest gives a real git worktree rooted at a fresh temp dir.
+func newWorktreeForTest(t *testing.T) *gogit.Worktree {
+ t.Helper()
+ repo, err := gogit.PlainInit(t.TempDir(), false)
+ require.NoError(t, err)
+ worktree, err := repo.Worktree()
+ require.NoError(t, err)
+ return worktree
+}
+
+// applyEventsViaPlanFlush drives the M7 plan-then-flush write path for tests: it
+// builds a minimal structure-only worker (no mapper) and folds the events over the
+// worktree at base "" (events carry their own relative paths under the root). It is
+// the unit-level entry point that replaced the per-event applyEventToWorktree.
+func applyEventsViaPlanFlush(t *testing.T, writer *contentWriter, worktree *gogit.Worktree, events ...Event) bool {
+ t.Helper()
+ w := &BranchWorker{contentWriter: writer}
+ changed, err := w.flushEventsToWorktree(context.Background(), worktree, "", events)
+ require.NoError(t, err)
+ return changed
+}
+
+func applyEventsViaPlanFlushWithMapper(
+ t *testing.T,
+ writer *contentWriter,
+ worktree *gogit.Worktree,
+ mapper typeset.Lookup,
+ events ...Event,
+) bool {
+ t.Helper()
+ w := &BranchWorker{contentWriter: writer, mapper: mapper}
+ changed, err := w.flushEventsToWorktree(context.Background(), worktree, "", events)
+ require.NoError(t, err)
+ return changed
+}
+
+// When the file on disk is hand-authored (carries a comment), an update edits it
+// in place: the comment survives and only the changed field is rewritten. This is
+// the file-agnostic-placement "magic" landing in the live writer's plan-then-flush
+// path: the document is matched by content identity and patched, not overwritten.
+func TestPlanFlush_PreservesHandAuthoredFormatting(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ event := inplaceCMEvent("green")
+ relPath := writer.filePathForIdentifier(event.Identifier)
+ full := filepath.Join(root, relPath)
+
+ seeded := "apiVersion: v1\n" +
+ "kind: ConfigMap\n" +
+ "metadata:\n name: app\n namespace: default\n" +
+ "data:\n # keep this operator note across edits\n color: blue\n"
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, []byte(seeded), 0o600))
+
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+ require.True(t, changed, "a real value change must be written")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "# keep this operator note across edits",
+ "the hand-authored comment must survive the in-place edit")
+ assert.Contains(t, string(got), "color: green", "the changed value is applied")
+ assert.NotContains(t, string(got), "color: blue")
+}
+
+func TestPlanFlush_PreservesKustomizeNamespaceStyle(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ relPath := "apps/bundle.yaml"
+ full := filepath.Join(root, relPath)
+ seeded := "apiVersion: v1\n" +
+ "kind: ConfigMap\n" +
+ "metadata:\n name: app\n" +
+ "data:\n # keep this operator note across edits\n color: blue\n"
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, []byte(seeded), 0o600))
+ require.NoError(t, os.WriteFile(filepath.Join(root, "kustomization.yaml"), []byte(
+ "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n"+
+ "namespace: default\nresources:\n- apps/bundle.yaml\n",
+ ), 0o600))
+
+ mapper := typeset.NewSnapshotRegistry(typeset.Snapshot{
+ Entries: []typeset.Entry{{
+ GVK: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
+ GVR: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"},
+ Namespaced: true,
+ Allowed: true,
+ }},
+ })
+ changed := applyEventsViaPlanFlushWithMapper(t, writer, worktree, mapper, inplaceCMEvent("green"))
+ require.True(t, changed, "a real value change must be written")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ body := string(got)
+ assert.Contains(t, body, "# keep this operator note across edits")
+ assert.Contains(t, body, "color: green")
+ assert.NotContains(t, body, "color: blue")
+ assert.NotContains(t, body, "namespace:", "namespace should stay in kustomization.yaml, not the resource")
+}
+
+// A change against a canonical file applies the new value. The plan-then-flush path
+// deliberately patches in place (preserving any layout) rather than the old writer's
+// wholesale re-render, so the assertion is on the resulting value, not on byte
+// identity with a wholesale render.
+func TestPlanFlush_AppliesChangeToCanonicalFile(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ event := inplaceCMEvent("green")
+ relPath := writer.filePathForIdentifier(event.Identifier)
+ full := filepath.Join(root, relPath)
+
+ // Seed with the canonical rendering of a *different* value, so the update is a
+ // real change against an operator-canonical file.
+ seedCanonical, err := writer.buildContentForWrite(context.Background(), inplaceCMEvent("blue"))
+ require.NoError(t, err)
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, seedCanonical, 0o600))
+
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+ require.True(t, changed)
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "color: green", "the changed value is applied")
+ assert.NotContains(t, string(got), "color: blue")
+}
+
+// Re-applying the identical desired state is a no-op: the byte state machine and the
+// manifestedit no-op decision agree, so nothing is written and the flush reports no
+// change (avoiding an empty commit).
+func TestPlanFlush_IdenticalUpdateIsNoOp(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ event := inplaceCMEvent("blue")
+ relPath := writer.filePathForIdentifier(event.Identifier)
+ full := filepath.Join(root, relPath)
+
+ seed, err := writer.buildContentForWrite(context.Background(), event)
+ require.NoError(t, err)
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, seed, 0o600))
+
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+ assert.False(t, changed, "an identical update must report no change")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Equal(t, string(seed), string(got), "a no-op must not rewrite the file")
+}
diff --git a/internal/git/known_placement_bugs_test.go b/internal/git/known_placement_bugs_test.go
new file mode 100644
index 00000000..e668dfc0
--- /dev/null
+++ b/internal/git/known_placement_bugs_test.go
@@ -0,0 +1,181 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+// These tests guard writer behaviors that the M7 plan-then-flush path must keep:
+//
+// - File-agnostic placement: the writer matches a resource by content identity and
+// edits or deletes it where it actually lives, only falling back to the canonical
+// identity path for a genuinely new resource. A manifest a user placed at
+// apps/foo.yaml is updated and deleted in place, never duplicated at the canonical
+// path.
+//
+// - No empty commits: an update that resolves to no byte change reports no change,
+// so the commit executor does not attempt an empty commit.
+//
+// - No data loss: a wholesale write must never drop sibling documents in a
+// multi-document file.
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ gogit "github.com/go-git/go-git/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// a ConfigMap manifest a user committed at a hand-chosen, non-canonical path.
+const placedManifestPath = "apps/foo.yaml"
+
+const placedManifestBlue = "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: app\n namespace: default\n" +
+ "data:\n color: blue\n"
+
+// seedPlacedManifest writes a manifest at relPath and stages it, modelling a
+// resource that already lives (committed) at that location in Git. It returns the
+// absolute path so callers can assert on the file afterwards.
+func seedPlacedManifest(t *testing.T, worktree *gogit.Worktree, relPath, content string) string {
+ t.Helper()
+ full := filepath.Join(worktree.Filesystem.Root(), relPath)
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, []byte(content), 0o600))
+ _, err := worktree.Add(relPath)
+ require.NoError(t, err)
+ return full
+}
+
+// Flexible placement, update path: a user placed the ConfigMap at a hand-chosen path
+// (apps/foo.yaml), not the canonical identity path. A cluster update must edit the
+// resource where it already lives and must NOT spawn a duplicate at the canonical path.
+func TestPlanFlush_UpdateFollowsExistingPlacement(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ placedFull := seedPlacedManifest(t, worktree, placedManifestPath, placedManifestBlue)
+
+ event := inplaceCMEvent("green")
+ applyEventsViaPlanFlush(t, writer, worktree, event)
+
+ placedAfter, err := os.ReadFile(placedFull)
+ require.NoError(t, err)
+ assert.Contains(t, string(placedAfter), "color: green",
+ "the update must land in the existing manifest at apps/foo.yaml")
+
+ canonicalFull := filepath.Join(worktree.Filesystem.Root(), writer.filePathForIdentifier(event.Identifier))
+ _, statErr := os.Stat(canonicalFull)
+ assert.Truef(t, os.IsNotExist(statErr),
+ "no duplicate copy must be created at the canonical path %s", canonicalFull)
+}
+
+// Flexible placement, delete path: deleting the resource from the cluster must remove
+// the manifest where it actually lives (apps/foo.yaml). The delete event here carries
+// the object, so the writer content-matches it to its real location.
+func TestPlanFlush_DeleteFollowsExistingPlacement(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ placedFull := seedPlacedManifest(t, worktree, placedManifestPath, placedManifestBlue)
+
+ event := inplaceCMEvent("blue")
+ event.Operation = "DELETE"
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+
+ assert.True(t, changed, "deleting the resource must remove the manifest where it lives")
+ _, statErr := os.Stat(placedFull)
+ assert.True(t, os.IsNotExist(statErr), "apps/foo.yaml must be deleted, not orphaned")
+}
+
+// An in-place no-op must report no change. Multi-doc file: an unrelated resource is
+// the first document, and the SECOND document is the exact canonical rendering of the
+// watched object — so editing it in place is a genuine no-op. manifestedit returns the
+// file unchanged, and the flush must report changed=false rather than drive an empty
+// commit.
+func TestPlanFlush_NoOpInMultiDocReportsNoChange(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ event := inplaceCMEvent("blue")
+ full := filepath.Join(root, placedManifestPath)
+
+ // The target document, byte-for-byte as the operator would render it.
+ targetDoc, err := writer.buildContentForWrite(context.Background(), event)
+ require.NoError(t, err)
+
+ // An unrelated, editable resource as the first document of the file.
+ other := "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: other\n namespace: default\n" +
+ "data:\n k: v\n"
+ seeded := other + "---\n" + string(targetDoc)
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, []byte(seeded), 0o600))
+
+ before, err := os.ReadFile(full)
+ require.NoError(t, err)
+
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+
+ after, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Equal(t, string(before), string(after), "a no-op edit must not rewrite the file")
+ assert.False(t, changed,
+ "a no-op in-place edit must report no change, otherwise an empty commit is attempted")
+}
+
+// Data-loss guard: a wholesale write must never drop sibling documents. The canonical
+// path holds a multi-document file whose target document is non-editable (it uses a
+// YAML merge key), so it does not claim its identity and is not matched for an in-place
+// patch. The writer must refuse to overwrite the multi-document file wholesale (which
+// would drop the unrelated first document) and report no change.
+func TestPlanFlush_MultiDocCanonicalDoesNotDropSiblings(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ event := inplaceCMEvent("green")
+ relPath := writer.filePathForIdentifier(event.Identifier)
+ full := filepath.Join(root, relPath)
+
+ // Document 0: an unrelated, editable resource that must survive.
+ other := "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: other\n namespace: default\n" +
+ "data:\n k: v\n"
+ // Document 1: the target (default/app) written with a merge key, which
+ // manifestedit refuses to edit — so it does not claim its identity for the in-place
+ // match, and the canonical-path file is multi-document.
+ targetUneditable := "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: app\n namespace: default\n" +
+ "data: &d\n color: blue\nextra:\n <<: *d\n"
+ seeded := other + "---\n" + targetUneditable
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o750))
+ require.NoError(t, os.WriteFile(full, []byte(seeded), 0o600))
+
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+
+ after, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.False(t, changed, "an unsafe multi-document write must report no change")
+ assert.Equal(t, seeded, string(after),
+ "the sibling document must survive: the file must not be overwritten wholesale")
+}
diff --git a/internal/git/manifestedit/DECISION.md b/internal/git/manifestedit/DECISION.md
new file mode 100644
index 00000000..1b68fe51
--- /dev/null
+++ b/internal/git/manifestedit/DECISION.md
@@ -0,0 +1,171 @@
+# Decision record: in-document YAML editor
+
+> Outcome of the POC specified in
+> [docs/design/manifest/manifest-parser-poc.md](../../../docs/design/manifest/manifest-parser-poc.md).
+> This package is the throw-away prototype that POC asked for.
+
+## Decision
+
+Use **`gopkg.in/yaml.v3` node editing**, with two supporting pieces:
+
+1. **Textual per-document splitting** ([split.go](split.go)) — a file is carved
+ into documents as exact byte slices, block-scalar aware so a `---` inside a
+ literal block is not mistaken for a separator. Only the edited document is
+ re-rendered; every other document is spliced back verbatim.
+2. **Structural merge onto the node tree** ([merge.go](merge.go)) — the sanitized
+ desired object is merged onto the existing document's `yaml.Node`, touching
+ only changed nodes. Existing key order is kept, absent keys are deleted,
+ new keys are appended.
+3. **2-space encoder** — `yaml.v3`'s default is 4-space; `SetIndent(2)` keeps
+ common manifest style.
+
+`goccy/go-yaml`, a hybrid text-slice editor, and `kyaml` were **not needed**.
+`yaml.v3` passes the implemented hard requirements, with **one recorded drift
+limitation** (flush-left sequence indentation — see Caveats). It is the right
+spine; we do not jump to goccy.
+
+## What the tests showed
+
+The POC test categories pass (`go test ./internal/git/manifestedit/`,
+coverage ~91%). Highlights:
+
+- **Gating corpus round-trip:** every document in `testdata/corpus` (comments,
+ quoted string-like values, a literal block, an indented-sequence Deployment,
+ a multi-doc file) re-encodes **byte-for-byte identical**. The test fails on any
+ drift and prints the exact diff.
+- **Unrelated documents** stay byte-for-byte through an edit (text splice), incl.
+ a CRLF+BOM document and a trailing `...` marker.
+- **Edited-document framing** (CRLF, BOM, missing trailing newline, leading
+ `---`, trailing `...`) is restored after re-encoding by `reskinDocument`, so it
+ survives even when the edit is to another field in the **same** document.
+- **Unrelated block scalars** (a ConfigMap script) survive byte-for-byte when an
+ unrelated label changes; a *changed* script stays a literal block.
+- **Comments** on unchanged *and* changed nodes are preserved; a new sibling key
+ is appended in place rather than reordering the map.
+- **No-op vs cleaning:** a clean Git doc that matches is left untouched; a Git doc
+ carrying `resourceVersion` is rewritten to remove it (API is the truth).
+- **Duplicates** resolve first-occurrence-wins by stable path order; the loser is
+ deletable via `DeleteDocument`.
+- **Disallowed constructs** — anchors, aliases, merge keys, **duplicate keys**,
+ and **unusual tags** (local `!foo` tags, `!!binary`; but not `!!timestamp` from
+ a plain date) — are detected at the node level and marked non-editable *without*
+ materializing them, so an alias bomb does not blow up.
+- **Deletion** removes only the matching document and leaves the rest
+ byte-identical; removing the only document signals `FileEmpty` so the caller
+ deletes the file.
+- **Recursive scan** (`IndexDir`) walks a folder, indexes `.yaml`/`.yml` by
+ identity, and **skips symlinks** (no following, no cycles, no escaping root).
+- **SOPS** files (by extension) are indexed by cleartext identity when they have
+ a `sops` key; fully-encrypted (identity hidden) or sops-less files are skipped.
+
+## Behavior reference (observed, pinned by tests)
+
+A running list of the small behaviors and edge cases, so the eventual user-facing
+docs can describe them precisely. Each is pinned by a test; the test file is named
+per row.
+
+### Comments (`comments_chomp_test.go`)
+
+| Case | Behavior |
+|---|---|
+| Standalone comment line above a field (head comment) | Preserved. |
+| Comment after a value (line comment) | Preserved. |
+| Line comment on a value whose value changes | Preserved and carried to the new value (`color: blue # brand` → `color: green # brand`). |
+| New key added to a map | Appended after existing keys, with no comment. |
+| **Head comment whose field is deleted** | **Removed together with its field** — a head comment belongs to the field below it. A sibling's own comment is unaffected. |
+| Foot comment at the end of a map | Not separately tested; yaml.v3 foot-comment handling is known to be quirky. Treat as best-effort. |
+
+### Block scalars (`comments_chomp_test.go`)
+
+| Style | Behavior |
+|---|---|
+| Literal strip `\|-` | Chomp indicator and exact line layout preserved. |
+| Literal clip `\|` | Chomp indicator and exact line layout preserved. |
+| Literal keep `\|+` | Preserved when meaningful (a trailing blank line to keep). Without trailing blanks it equals `\|` and canonicalizes to `\|` (same value). |
+| Folded `>`, `>-`, `>+` | Style, chomp, and string value kept, but yaml.v3 **re-flows line wrapping** on re-encode — folded source layout is not byte-stable. Recorded limitation. |
+
+### Document framing (`additions_test.go`, `manifestedit_test.go`)
+
+| Case | Behavior |
+|---|---|
+| Unrelated documents in a multi-doc file | Spliced back byte-for-byte. |
+| Edited document: CRLF, BOM, trailing-newline presence, leading `---`, trailing `...` | Restored by `reskinDocument` after re-encode — **on the patch path only**. |
+| Edited document interior content (non-block scalars) | Byte-stable for the house style; flush-left sequence indentation is re-indented (recorded limitation). |
+| Whole-document fallback (`wholeReplace`) | Canonical render; does **not** restore framing (it is the explicit "preservation not possible" path). |
+| Delete document 0 of a multi-doc file | The now-leading `---` separator is dropped so the file does not start with a stray separator; the survivor's *content* is unchanged (only the separator is affected). |
+
+### Inventory / safety (`manifestedit_test.go`, `additions_test.go`)
+
+| Case | Behavior |
+|---|---|
+| Duplicate identity across files/docs | First-occurrence-wins by stable path order; loser is deletable. |
+| Anchors, aliases, merge keys | Detected at the node level, marked non-editable, never materialized (alias bomb safe). |
+| Duplicate keys, unusual tags (`!foo`, `!!binary`) | Non-editable with a diagnostic. `!!timestamp` from a plain date is fine. |
+| Non-KRM / empty document | Ignored with a diagnostic; does not block siblings. |
+| Sequence (list) matching | **Index-based by default** (`limitations_test.go` pins it): an in-place item change is precise, but a **reorder** rewrites slot-by-slot and **mis-attributes item comments** (semantically correct and convergent). **Keyed matching is now available as an injected strategy** — set `EditOptions.ListMatch.KeyField` (e.g. `name`) and items are matched by key, so comments travel with their item across a reorder; it falls back to index when items are not uniformly keyed mappings (`keyedlist_test.go`). The GVK→key choice lives with the caller, never in the merge. |
+| Inventory status surface | `Inventory.Summary()` gives bounded counts (documents/editable/non-editable/encrypted/duplicates) and `CountByLevel` groups diagnostics — the "stats first" seed, so status need not enumerate thousands of manifests. |
+| SOPS file (by extension) | Indexed by cleartext identity when it has a `sops` key; identity-hidden or sops-less files are skipped as invalid. |
+| Encrypted document patched in place | **Refused** — `PatchDocument` skips any document with a top-level `sops` key (indexed/authoritative ≠ patchable). It must go through the re-encrypt writer path, never an in-place merge. |
+| Symlinks during folder scan | Skipped (never followed), with a diagnostic. |
+
+## Guarantees we can honestly promise
+
+Hard guarantees:
+
+- unrelated documents preserved byte-for-byte
+- unrelated scalars/block scalars in the edited document preserved (see caveat)
+- semantic no-ops cause no write; dirty server fields are cleaned out
+- disallowed constructs are ignored with a diagnostic, never silently rewritten
+- **convergence:** the first write may normalize known drift and clean server
+ fields, but every reconcile after that is a byte-stable no-op — no separate
+ reconciliation-state layer needed (pinned by `convergence_test.go`)
+
+Best-effort (with fallback + diagnostic when not possible):
+
+- comment preservation around changed nodes
+- scalar-style preservation for directly changed strings
+
+## Caveats / out of scope
+
+- **Recorded drift limitation:** the **edited** document is re-encoded, so the
+ fidelity of its *untouched* scalars relies on `yaml.v3` round-trip. The corpus
+ (house style) is byte-identical, but `yaml.v3` **normalizes flush-left sequence
+ indentation** to its own indented style — proven and pinned by
+ `TestRoundTrip_KnownDrift_FlushLeftSequence`. For such input the edited document
+ is best-effort (semantics preserved, formatting normalized), while its framing
+ is still restored. A stricter preflight or per-scalar text-slice fallback could
+ close this later; it was not needed to choose the parser.
+- Framing (CRLF, BOM, trailing newline, `...`) is restored on the edited document
+ **only on the patch path**, and spliced verbatim on unrelated documents. The
+ whole-document fallback (`wholeReplace`) renders canonically and does not restore
+ framing. Internal *content* bytes of the edited document are not guaranteed for
+ every input style (see above).
+- **Index-based list matching by default; keyed matching is opt-in.** With no
+ list strategy, updating a list item in place is precise, but reordering a list
+ rewrites it slot-by-slot and moves item-attached comments to the wrong item
+ (semantically correct and convergent). Injecting `EditOptions.ListMatch.KeyField`
+ (e.g. `name`) matches items by key instead, so comments travel with their item
+ across a reorder; the merge falls back to index when items are not uniformly
+ keyed mappings. The Kubernetes GVK→key knowledge stays with the caller.
+- **Encrypted records are indexed and authoritative but not patchable.** A SOPS
+ document is found and owns its location, yet `PatchDocument` refuses to edit it
+ in place (it would strip the sops key and write the secret in cleartext). The
+ real writer must route encrypted resources through the re-encrypt path.
+- **Manifest identity only.** Mapping a GVK to a watched GVR needs a live
+ RESTMapper and is out of scope here (tracked in docs/TODO.md), as is namespace
+ elision.
+- **Bigger vision items not in this POC** (they belong to the inventory/writer
+ integration, not the parser decision): Helm/Kustomize detection, watched-GVR
+ filtering, placement policy for new resources, and bootstrap files. This POC is
+ the in-document editing + indexing spine those build on.
+
+## Implementation impact
+
+The pieces map directly onto the vision document's decisions ("per-document text
+split is the baseline", "structural merge, not diff-then-map", "identity is the
+key", "duplicates first-wins delete", "ignore disallow-listed constructs",
+"SOPS by extension"). Graduating this into the real writer means reusing
+`internal/sanitize` for the desired projection (already done here) and wiring
+`PatchDocument`/`DeleteDocument` behind the inventory's
+`resource identity -> location` lookup. The package is intentionally isolated so
+it can be rewritten if integration surfaces new edge cases.
diff --git a/internal/git/manifestedit/additions_test.go b/internal/git/manifestedit/additions_test.go
new file mode 100644
index 00000000..ea58c4a7
--- /dev/null
+++ b/internal/git/manifestedit/additions_test.go
@@ -0,0 +1,362 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func yamlUnmarshal(s string, out interface{}) error {
+ return yaml.Unmarshal([]byte(s), out)
+}
+
+// --- Gating corpus round-trip: every document must re-encode byte-for-byte. ---
+
+func TestCorpusRoundTrip_ByteIdentical(t *testing.T) {
+ files, err := filepath.Glob("testdata/corpus/*.yaml")
+ require.NoError(t, err)
+ require.NotEmpty(t, files)
+
+ for _, f := range files {
+ content, err := os.ReadFile(f)
+ require.NoError(t, err)
+ for i, d := range splitDocuments(string(content)) {
+ root, empty, err := decodeDoc(d.body)
+ if empty || err != nil {
+ continue
+ }
+ out, err := encodeNode(root)
+ require.NoError(t, err)
+ if string(out) != d.body {
+ t.Errorf("round-trip drift in %s doc %d\n--- original ---\n%s\n--- re-encoded ---\n%s",
+ filepath.Base(f), i, d.body, out)
+ }
+ }
+ }
+}
+
+// TestRoundTrip_KnownDrift_FlushLeftSequence records a real, structured
+// limitation: yaml.v3 normalizes flush-left sequence indentation to its own
+// indented style. The edited document therefore is best-effort, not byte-exact,
+// for that input style. This test fails loudly if the behavior ever changes.
+func TestRoundTrip_KnownDrift_FlushLeftSequence(t *testing.T) {
+ flushLeft := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+spec:
+ items:
+ - one
+ - two
+`
+ root, _, err := decodeDoc(flushLeft)
+ require.NoError(t, err)
+ out, err := encodeNode(root)
+ require.NoError(t, err)
+
+ if string(out) == flushLeft {
+ t.Fatal("expected yaml.v3 to normalize flush-left sequence indentation; it did not — update the docs")
+ }
+ t.Logf("known drift (flush-left sequence) re-encoded as:\n%s", out)
+
+ // It must still mean the same thing.
+ var a, b map[string]interface{}
+ require.NoError(t, yamlUnmarshal(flushLeft, &a))
+ require.NoError(t, yamlUnmarshal(string(out), &b))
+ assert.Equal(t, normalizeJSON(a), normalizeJSON(b))
+}
+
+// --- Edited-document framing fidelity (CRLF, BOM, trailing newline, ..., ---). ---
+
+func TestPatch_EditedCRLFBOMDocumentKeepsFraming(t *testing.T) {
+ doc := "\ufeffapiVersion: v1\r\nkind: ConfigMap\r\nmetadata:\r\n" +
+ " name: a\r\n namespace: default\r\ndata:\r\n color: blue\r\n"
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+ labels:
+ app: demo
+data:
+ color: blue
+`)
+ res, _ := patch([]byte(doc), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.True(t, strings.HasPrefix(out, "\ufeff"), "BOM must survive editing the same document")
+ assert.Contains(t, out, "\r\n", "CRLF must survive")
+ assert.NotContains(t, strings.ReplaceAll(out, "\r\n", ""), "\n", "no bare LF should remain")
+ assert.Contains(t, out, "app: demo", "the actual edit still applied")
+}
+
+func TestPatch_EditedDocumentNoTrailingNewlinePreserved(t *testing.T) {
+ doc := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\ndata:\n color: blue"
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+data:
+ color: red
+`)
+ res, _ := patch([]byte(doc), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.False(t, strings.HasSuffix(string(res.Content), "\n"), "missing trailing newline must stay missing")
+ assert.Contains(t, string(res.Content), "color: red")
+}
+
+func TestPatch_EditedDocumentLeadingSeparatorPreserved(t *testing.T) {
+ doc := "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\ndata:\n color: blue\n"
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+data:
+ color: red
+`)
+ res, _ := patch([]byte(doc), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.True(t, strings.HasPrefix(string(res.Content), "---\n"), "leading --- must survive editing the document")
+}
+
+func TestPatch_EditedDocumentTrailingEndMarkerPreserved(t *testing.T) {
+ doc := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\ndata:\n color: blue\n...\n"
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+data:
+ color: red
+`)
+ res, _ := patch([]byte(doc), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.True(t, strings.HasSuffix(strings.TrimRight(string(res.Content), "\n"), "..."),
+ "trailing ... marker must survive editing the document")
+}
+
+// --- Duplicate keys and unusual tags are disallowed. ---
+
+func TestIndex_DuplicateKeyNonEditable(t *testing.T) {
+ content := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\ndata:\n k: 1\n k: 2\n"
+ inv, diags := IndexFile("dup.yaml", []byte(content))
+
+ for _, r := range inv.Records {
+ assert.False(t, r.Editable, "a duplicate-key document must not be editable")
+ }
+ _, ok := inv.Location(Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "a"})
+ assert.False(t, ok, "a duplicate-key document must not be an authoritative location")
+ assert.NotEmpty(t, diags)
+}
+
+func TestPatch_DuplicateKeySkipped(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n k: 1\n k: 2\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ res, diags := patch(content, 0, desired)
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.NotEmpty(t, diags)
+}
+
+func TestIndex_UnusualTagNonEditable(t *testing.T) {
+ for _, tagged := range []string{
+ "apiVersion: v1\nkind: Secret\nmetadata:\n name: a\ndata:\n p: !vault secret\n",
+ "apiVersion: v1\nkind: Secret\nmetadata:\n name: a\ndata:\n p: !!binary aGk=\n",
+ } {
+ inv, _ := IndexFile("tag.yaml", []byte(tagged))
+ require.Len(t, inv.Records, 1)
+ assert.False(t, inv.Records[0].Editable, "unusual tags must be non-editable: %q", tagged)
+ }
+}
+
+func TestPatch_EncryptedDocumentNotPatchedInPlace(t *testing.T) {
+ // In-place patching an encrypted file would drop the sops key and write the
+ // secret back in cleartext. PatchDocument must refuse.
+ enc := []byte(`apiVersion: v1
+kind: Secret
+metadata:
+ name: db
+ namespace: default
+data:
+ password: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]
+sops:
+ age: []
+`)
+ desired := mustObj(t, `apiVersion: v1
+kind: Secret
+metadata:
+ name: db
+ namespace: default
+data:
+ password: hunter2
+`)
+ res, diags := patch(enc, 0, desired)
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.Equal(t, enc, res.Content, "encrypted file must be left untouched")
+ assert.NotContains(t, string(res.Content), "hunter2", "secret must never be written in cleartext")
+
+ var guarded bool
+ for _, d := range diags {
+ if strings.Contains(d.Message, "encrypted document") {
+ guarded = true
+ }
+ }
+ assert.True(t, guarded, "a diagnostic should explain the encrypted-file refusal")
+}
+
+func TestIndex_PlainTimestampStillEditable(t *testing.T) {
+ // An unquoted date resolves to !!timestamp but is perfectly normal; it must
+ // not be treated as an unusual tag.
+ content := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\ndata:\n when: not-a-date\nspec:\n at: 2020-01-01T00:00:00Z\n"
+ inv, _ := IndexFile("ts.yaml", []byte(content))
+ require.Len(t, inv.Records, 1)
+ assert.True(t, inv.Records[0].Editable, "a plain timestamp value must remain editable")
+}
+
+// --- Deletion of the matching document only. ---
+
+func TestDelete_MiddleDocumentKeepsOthersByteIdentical(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+data:
+ color: blue
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: b
+data:
+ color: green
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: c
+data:
+ color: red
+`
+ doc0 := docBody(content, 0)
+ res, _ := DeleteDocument([]byte(content), 1)
+ require.Equal(t, EditDeleted, res.Mode)
+ assert.False(t, res.FileEmpty)
+
+ assert.Equal(t, doc0, docBody(string(res.Content), 0), "untouched doc 0 must be byte-identical")
+ assert.NotContains(t, string(res.Content), "name: b", "deleted document is gone")
+ assert.Contains(t, string(res.Content), "name: c", "later document survives")
+
+ // Result must still be valid, indexable YAML with the right documents.
+ inv, _ := IndexFile("after.yaml", res.Content)
+ assert.Len(t, inv.Records, 2)
+}
+
+func TestDelete_OnlyDocumentReportsFileEmpty(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ res, _ := DeleteDocument(content, 0)
+ assert.Equal(t, EditDeleted, res.Mode)
+ assert.True(t, res.FileEmpty, "removing the only document should signal file deletion")
+}
+
+func TestDelete_FirstDocumentDropsLeadingSeparator(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n---\n" +
+ "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n")
+ survivorBefore := docBody(string(content), 1)
+
+ res, _ := DeleteDocument(content, 0)
+ require.Equal(t, EditDeleted, res.Mode)
+
+ // The file must not start with a stray separator; only the separator is
+ // dropped, the survivor's content is unchanged.
+ assert.False(t, strings.HasPrefix(string(res.Content), "---"), "file should not start with a stray separator")
+ assert.Equal(t, survivorBefore, docBody(string(res.Content), 0),
+ "survivor content unchanged (only the separator dropped)")
+ assert.Contains(t, string(res.Content), "name: b")
+ assert.NotContains(t, string(res.Content), "name: a")
+}
+
+func TestDelete_DuplicateLoserLocation(t *testing.T) {
+ doc := "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\n namespace: default\n"
+ winner := "apps/app.yaml"
+ loserFile := "overlays/dev/app.yaml"
+ inv, _ := IndexFiles([]FileContent{
+ {Path: winner, Content: []byte(doc)},
+ {Path: loserFile, Content: []byte(doc)},
+ })
+ require.Len(t, inv.Duplicates(), 1)
+ loser := inv.Duplicates()[0].Location
+ assert.Equal(t, loserFile, loser.Path)
+
+ // Deleting the loser's only document empties that file.
+ res, _ := DeleteDocument([]byte(doc), loser.DocumentIndex)
+ assert.True(t, res.FileEmpty)
+}
+
+func TestDelete_IndexOutOfRange(t *testing.T) {
+ res, diags := DeleteDocument([]byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n"), 9)
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.NotEmpty(t, diags)
+}
+
+// --- Recursive scanning with symlink skipping. ---
+
+func TestIndexDir_ScansRecursivelyAndSkipsSymlinks(t *testing.T) {
+ root := t.TempDir()
+ cm := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s\n namespace: default\n"
+
+ writeManifest := func(rel, name string) {
+ require.NoError(t, os.WriteFile(
+ filepath.Join(root, rel), []byte(strings.Replace(cm, "%s", name, 1)), 0o600))
+ }
+ writeManifest("a.yaml", "a")
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "sub"), 0o750))
+ writeManifest(filepath.Join("sub", "b.yml"), "b")
+ require.NoError(t, os.WriteFile(filepath.Join(root, "notes.txt"), []byte("ignore me"), 0o600))
+
+ // A symlink to a real manifest must be skipped, not indexed twice.
+ if err := os.Symlink(filepath.Join(root, "a.yaml"), filepath.Join(root, "link.yaml")); err != nil {
+ t.Skipf("symlinks unsupported on this platform: %v", err)
+ }
+
+ inv, diags := IndexDir(root)
+
+ _, okA := inv.Location(Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "a"})
+ _, okB := inv.Location(Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "b"})
+ assert.True(t, okA, "a.yaml indexed")
+ assert.True(t, okB, "sub/b.yml indexed recursively")
+ assert.Len(t, inv.Records, 2, "the symlink must not produce a duplicate record")
+
+ var skipped bool
+ for _, d := range diags {
+ if strings.Contains(d.Message, "symlink skipped") {
+ skipped = true
+ }
+ }
+ assert.True(t, skipped, "a symlink-skipped diagnostic should be emitted")
+}
diff --git a/internal/git/manifestedit/comments_chomp_test.go b/internal/git/manifestedit/comments_chomp_test.go
new file mode 100644
index 00000000..b051d736
--- /dev/null
+++ b/internal/git/manifestedit/comments_chomp_test.go
@@ -0,0 +1,224 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Comments: a standalone comment line above a field (head comment), and a
+// trailing comment after a value (line comment), both on UNCHANGED nodes, are
+// preserved exactly. A newly added sibling key is appended without a comment.
+func TestPatch_Comments_HeadStandaloneAndTrailingPreserved(t *testing.T) {
+ before := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ # this is a standalone comment about labels
+ labels:
+ app: demo # trailing comment on app
+data:
+ # comment above color
+ color: blue # trailing on color
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ labels:
+ app: demo
+ tier: web
+data:
+ color: blue
+`)
+ after := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ # this is a standalone comment about labels
+ labels:
+ app: demo # trailing comment on app
+ tier: web
+data:
+ # comment above color
+ color: blue # trailing on color
+`
+ res, _ := patch([]byte(before), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.Equal(t, after, string(res.Content))
+}
+
+// Comments: a trailing comment on a node whose VALUE changes is carried over to
+// the new value (best-effort comment preservation, and it works here).
+func TestPatch_Comments_TrailingCommentSurvivesValueChange(t *testing.T) {
+ before := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ color: blue # the brand color
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ color: green
+`)
+ after := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ color: green # the brand color
+`
+ res, _ := patch([]byte(before), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.Equal(t, after, string(res.Content))
+}
+
+// Comments: a head comment is attached to the field below it, so deleting that
+// field removes its head comment too. A sibling's own comment is unaffected.
+func TestPatch_Comments_HeadCommentDeletedWithItsField(t *testing.T) {
+ before := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ labels:
+ app: demo # keep this
+ # this comment belongs to drop-me
+ drop-me: "yes"
+data:
+ color: blue
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ labels:
+ app: demo
+data:
+ color: blue
+`)
+ after := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ labels:
+ app: demo # keep this
+data:
+ color: blue
+`
+ res, _ := patch([]byte(before), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.Equal(t, after, string(res.Content))
+}
+
+// Block scalars: a LITERAL block with strip (|-) or clip (|) keeps its chomping
+// indicator AND its exact line layout when an unrelated field changes. This is
+// the ConfigMap-script guarantee.
+func TestPatch_LiteralBlockChompPreservedOnUnrelatedEdit(t *testing.T) {
+ for _, chomp := range []string{"|-", "|"} {
+ before := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: default\n" +
+ "data:\n script: " + chomp + "\n line one\n line two\n"
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n"+
+ " namespace: default\n labels:\n a: b\ndata:\n script: "+chomp+"\n line one\n line two\n")
+
+ res, _ := patch([]byte(before), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, "a: b", "%s: label added", chomp)
+ assert.Contains(t, out, " script: "+chomp+"\n line one\n line two",
+ "%s: literal block keeps chomp indicator and exact layout", chomp)
+ }
+}
+
+// Block scalars: the keep indicator (|+) is preserved when it is meaningful, i.e.
+// when there are trailing blank lines to keep. Without trailing blanks, |+ is
+// equivalent to | and yaml.v3 canonicalizes it to | (the string value is the
+// same either way).
+func TestPatch_LiteralKeepChompPreservedWhenMeaningful(t *testing.T) {
+ // A trailing blank line inside the block is what |+ exists to preserve.
+ before := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: default\n" +
+ "data:\n script: |+\n line one\n line two\n\n"
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n"+
+ " namespace: default\n labels:\n a: b\ndata:\n script: |+\n line one\n line two\n\n")
+
+ res, _ := patch([]byte(before), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+ assert.Contains(t, out, "script: |+", "the keep indicator survives when there is a trailing blank to keep")
+ assert.Contains(t, out, " line one\n line two", "block layout preserved")
+}
+
+// Block scalars: a FOLDED block (>, >-, >+) keeps its style and chomping and the
+// same string VALUE, but yaml.v3 re-flows the line wrapping on re-encode. This is
+// a recorded limitation: folded source layout is not byte-stable through an edit,
+// even when the folded value itself does not change.
+func TestPatch_FoldedBlockReflowsButKeepsValue(t *testing.T) {
+ before := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ note: >-
+ line one
+ line two
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+ labels:
+ a: b
+data:
+ note: >-
+ line one
+ line two
+`)
+ res, _ := patch([]byte(before), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, "note: >-", "folded style and chomp are kept")
+ assert.Contains(t, out, "line one line two", "yaml.v3 re-flows folded text onto one line")
+ assert.NotContains(t, out, " line one\n line two", "original folded line layout is not preserved")
+
+ // The decoded value is unchanged: folding "line one\nline two" yields the
+ // same string either way.
+ var got map[string]interface{}
+ require.NoError(t, yamlUnmarshal(out, &got))
+ data, _ := got["data"].(map[string]interface{})
+ assert.Equal(t, "line one line two", data["note"], "folded string value is preserved")
+}
diff --git a/internal/git/manifestedit/convergence_test.go b/internal/git/manifestedit/convergence_test.go
new file mode 100644
index 00000000..39cb467f
--- /dev/null
+++ b/internal/git/manifestedit/convergence_test.go
@@ -0,0 +1,210 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// assertConverges is the convergence property every edit strategy must satisfy:
+// after the first Apply with a desired object, a second Decide returns NoChange
+// and a second Apply is byte-identical, and it stays that way. The first write
+// may normalize known drift (folded scalars reflow, flush-left sequences
+// re-indent) and clean server fields; every reconcile after it must be a
+// byte-stable no-op.
+//
+// The rule the vision leans on is exactly this: Decide after an Apply with the
+// same desired must return NoChange. Wiring a new strategy (keyed lists, field
+// ownership) through this helper is how it inherits the guarantee — it is the
+// one property a strategy cannot quietly break. It returns the first EditResult
+// so a caller can make additional first-write assertions.
+func assertConverges(
+ t *testing.T,
+ git []byte,
+ idx int,
+ desired *unstructured.Unstructured,
+ opts EditOptions,
+) EditResult {
+ t.Helper()
+
+ first := applyOnce(git, idx, desired, opts)
+ require.NotEqual(t, EditSkipped, first.Mode, "the document under test must be editable")
+
+ // Decide after the first Apply, with the same desired, must be NoChange.
+ c := Comparison{Git: gitDoc(first.Content, idx), Desired: desired, Options: opts}
+ d := Decide(c)
+ assert.Equal(t, ActionNoChange, d.Action, "second Decide must settle to NoChange")
+
+ second, _ := Apply(c, d)
+ assert.Equal(t, EditNoChange, second.Mode, "the next reconcile must be a no-op")
+ assert.Equal(t, string(first.Content), string(second.Content), "and byte-stable")
+
+ // And it stays converged.
+ third := applyOnce(second.Content, idx, desired, opts)
+ assert.Equal(t, EditNoChange, third.Mode, "convergence must hold across reconciles")
+ assert.Equal(t, string(second.Content), string(third.Content))
+
+ return first
+}
+
+// applyOnce runs one Decide + Apply over a fresh Comparison.
+func applyOnce(content []byte, idx int, desired *unstructured.Unstructured, opts EditOptions) EditResult {
+ c := Comparison{Git: gitDoc(content, idx), Desired: desired, Options: opts}
+ res, _ := Apply(c, Decide(c))
+ return res
+}
+
+// gitDoc builds a Document for the target index; the range flag is ignored
+// because Decide reports an out-of-range index itself.
+func gitDoc(content []byte, idx int) *Document {
+ doc, _ := NewDocument(content, idx)
+ return doc
+}
+
+// Convergence is the property the vision leans on: repeated reconciles must
+// settle to "no change" without keeping a separate reconciliation-state layer.
+// The first patch may normalize known drift (folded scalars reflow, flush-left
+// sequences re-indent) and clean server fields (status), but every patch after
+// that must be a byte-stable no-op.
+func TestPatch_ConvergesAfterFirstWrite(t *testing.T) {
+ git := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ items:
+ - one
+ - two
+ note: >-
+ line one
+ line two
+status:
+ replicas: 3
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+ labels:
+ app: demo
+spec:
+ items:
+ - one
+ - two
+ note: >-
+ line one
+ line two
+`)
+
+ first := assertConverges(t, []byte(git), 0, desired, EditOptions{Render: testRender})
+ require.Equal(t, EditPatched, first.Mode)
+ assert.Contains(t, string(first.Content), "app: demo", "the change is applied")
+ assert.NotContains(t, string(first.Content), "status:", "server-side status is cleaned from Git")
+}
+
+// A pure no-op never re-encodes, so even a folded scalar that would reflow on a
+// real edit is preserved byte-for-byte when nothing actually changes.
+func TestPatch_NoOpDoesNotReflowFoldedScalar(t *testing.T) {
+ git := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ note: >-
+ line one
+ line two
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ note: >-
+ line one
+ line two
+`)
+ res, _ := patch([]byte(git), 0, desired)
+ assert.Equal(t, EditNoChange, res.Mode)
+ assert.Equal(t, git, string(res.Content), "a no-op preserves the folded layout exactly")
+}
+
+// TestConvergence_Corpus gates the property across every editable document in
+// the corpus: editing each one to a perturbed projection of itself must settle
+// to a byte-stable no-op on the next reconcile. This is the guardrail any future
+// strategy inherits — a regression announces itself by failing here.
+func TestConvergence_Corpus(t *testing.T) {
+ files, err := filepath.Glob("testdata/corpus/*.yaml")
+ require.NoError(t, err)
+ require.NotEmpty(t, files)
+
+ opts := EditOptions{Render: testRender}
+ for _, f := range files {
+ content, err := os.ReadFile(f)
+ require.NoError(t, err)
+ base := filepath.Base(f)
+
+ for i, d := range splitDocuments(string(content)) {
+ root, empty, decErr := decodeDoc(d.body)
+ if empty || decErr != nil || root.Kind != yaml.MappingNode {
+ continue
+ }
+ if _, ok := identityFromNode(root); !ok {
+ continue
+ }
+ if _, bad := hasDisallowed(root); bad {
+ continue
+ }
+ t.Run(fmt.Sprintf("%s/doc%d", base, i), func(t *testing.T) {
+ // Perturb the projection with a new label so the first reconcile is a
+ // real patch, then prove the next reconcile converges.
+ desired := perturbWithLabel(t, d.body)
+ assertConverges(t, content, i, desired, opts)
+ })
+ }
+ }
+}
+
+// perturbWithLabel parses a document body into a desired object and adds a label,
+// guaranteeing a real first-write patch for the convergence property to exercise.
+func perturbWithLabel(t *testing.T, body string) *unstructured.Unstructured {
+ t.Helper()
+ var m map[string]interface{}
+ require.NoError(t, yaml.Unmarshal([]byte(body), &m))
+ obj := &unstructured.Unstructured{Object: m}
+
+ labels := obj.GetLabels()
+ if labels == nil {
+ labels = map[string]string{}
+ }
+ labels["convergence.test"] = "1"
+ obj.SetLabels(labels)
+ return obj
+}
diff --git a/internal/git/manifestedit/decision.go b/internal/git/manifestedit/decision.go
new file mode 100644
index 00000000..bed56817
--- /dev/null
+++ b/internal/git/manifestedit/decision.go
@@ -0,0 +1,395 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "reflect"
+
+ "gopkg.in/yaml.v3"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// Everything this package does is a function of two representations of the same
+// Kubernetes object: the Git version (a document at a known location) and the
+// desired version (the clean object Git should contain). Comparison makes that
+// two-version comparison a first-class value; Decide is a pure preflight over it
+// and Apply is the authoritative edit. See
+// docs/design/manifest/manifestedit-abstraction-plan.md.
+
+// Document is immutable data describing one target document inside a file: the
+// whole file content, the target document index, and the manifest identity. It
+// deliberately carries no parsed node tree — Decide and Apply each parse
+// internally, so nothing one mutates can affect the other. This is what lets
+// Decide stay non-mutating.
+type Document struct {
+ // Path is the file location relative to the scan root, carried so Apply's
+ // diagnostics can name the file (e.g. "apps/deploy.yaml doc 0"), not just the
+ // document index. It is informational: it does not affect the edit.
+ Path string
+ // Content is the whole file, so Apply can splice the edited document back
+ // among its untouched siblings.
+ Content []byte
+ // DocumentIndex is the target document's position within the file.
+ DocumentIndex int
+ // Identity is the manifest identity of the target document, as written.
+ Identity Identity
+}
+
+// NewDocument builds a Document for one target document with no known file path.
+// See NewDocumentAt to carry the path for diagnostics.
+func NewDocument(content []byte, documentIndex int) (*Document, bool) {
+ return NewDocumentAt("", content, documentIndex)
+}
+
+// NewDocumentAt builds a Document for one target document at a known path,
+// deriving its identity from the content. ok is false when the index is out of
+// range; the Document is still returned (with a zero Identity) so callers can
+// hand it to Decide, which reports the out-of-range condition as a skip.
+func NewDocumentAt(path string, content []byte, documentIndex int) (*Document, bool) {
+ doc := &Document{Path: path, Content: content, DocumentIndex: documentIndex}
+ docs := splitDocuments(string(content))
+ if documentIndex < 0 || documentIndex >= len(docs) {
+ return doc, false
+ }
+ if root, empty, err := decodeDoc(docs[documentIndex].body); err == nil && !empty {
+ doc.Identity, _ = identityFromNode(root)
+ }
+ return doc, true
+}
+
+// FieldPath is a path to a node within a document, used by the ownership
+// predicate. The root object is the empty path; "spec", "replicas" addresses
+// spec.replicas.
+type FieldPath []string
+
+// ListMatchStrategy aligns desired and Git sequence items. The zero value
+// matches by index (today's behavior). A keyed strategy names the field to
+// match on; the GVK->field choice is made above this layer, never baked into
+// the YAML merge.
+type ListMatchStrategy struct {
+ // KeyField, when set, matches list items by that field instead of by index.
+ KeyField string
+}
+
+// EditOptions carries the injected strategies. They are the one seam where later
+// strategies plug in, so the core merge stays small and pure.
+type EditOptions struct {
+ // Render is the canonical renderer for whole-document replacement and new
+ // files — the house output format, so it is policy: injected, not owned here.
+ // Nil is allowed only when no canonical output is needed (pure patch, no-op,
+ // delete); a path that needs it with no Render fails loudly with a diagnostic.
+ Render func(*unstructured.Unstructured) ([]byte, error)
+ // ListMatch aligns sequences (default: by index).
+ ListMatch ListMatchStrategy
+ // Owns is a DORMANT mechanism seam, not a product feature. It reports whether a
+ // field path is owned by the reverser; an absent field is deleted only when
+ // owned. The product decision is API-first, whole-object truth: production
+ // MUST leave this nil (own everything), so a field absent from the desired
+ // projection is deleted from Git. See
+ // docs/design/manifest/manifestedit-field-ownership-spike.md. Do not grow configuration
+ // on top of this; it exists only to keep the deletion decision explicit and to
+ // keep the merge testable.
+ Owns func(path FieldPath) bool
+}
+
+// Comparison is the two-version comparison: an existing Git document against the
+// desired object Git should contain.
+type Comparison struct {
+ // Git is required: a Comparison always describes an existing document. A nil
+ // Git is not a valid comparison — creating a brand-new resource is a placement
+ // decision owned upstream, not a content edit.
+ Git *Document
+ // Desired is the clean object Git should contain. Nil means "absent" and
+ // models deletion as just another cell of the same comparison.
+ Desired *unstructured.Unstructured
+ // Options injects the renderer and the (future) list-match and ownership
+ // strategies.
+ Options EditOptions
+}
+
+// DecisionAction is the intent Decide states before any merge runs.
+type DecisionAction string
+
+const (
+ // ActionNoChange means Git already matches the desired projection.
+ ActionNoChange DecisionAction = "no-change"
+ // ActionPatch means a field-level in-place edit is expected.
+ ActionPatch DecisionAction = "patch"
+ // ActionReplace means the document must be re-rendered canonically.
+ ActionReplace DecisionAction = "replace"
+ // ActionDelete means the document should be removed.
+ ActionDelete DecisionAction = "delete"
+ // ActionSkip means the document is left untouched, with a diagnostic.
+ ActionSkip DecisionAction = "skip"
+)
+
+// SnapshotRef is the identity and content fingerprint that Decide observed.
+// Apply re-parses the document and validates against this, refusing if the file
+// drifted in between.
+type SnapshotRef struct {
+ // Identity is the observed manifest identity of the target document.
+ Identity Identity
+ // DocumentIndex is the observed target document index.
+ DocumentIndex int
+ // BodyHash fingerprints the target document body only — sibling documents can
+ // change without invalidating the target edit.
+ BodyHash string
+}
+
+// Decision is the result of the pure preflight. It states an intent; the merge
+// happens only in Apply, whose EditResult.Mode is authoritative.
+type Decision struct {
+ Action DecisionAction
+ Reason string
+ Snapshot SnapshotRef
+ // level is the diagnostic severity to surface for a skip; Apply emits it.
+ level DiagnosticLevel
+}
+
+// Decide is a pure preflight: it inspects and compares, never mutating Git. It
+// runs only cheap, non-mutating checks — parseable? disallowed construct?
+// encrypted? non-mapping root? object-level equality — and never runs the
+// structural merge, so a decision can never silently change Git.
+func Decide(c Comparison) Decision {
+ if c.Git == nil {
+ return Decision{Action: ActionSkip, level: DiagError,
+ Reason: "comparison requires an existing Git document"}
+ }
+
+ docs := splitDocuments(string(c.Git.Content))
+ idx := c.Git.DocumentIndex
+ if idx < 0 || idx >= len(docs) {
+ return Decision{Action: ActionSkip, level: DiagError, Reason: "document index out of range"}
+ }
+ target := docs[idx].body
+ snap := SnapshotRef{DocumentIndex: idx, BodyHash: hashBody(target)}
+
+ // Deletion is content-agnostic: it never decrypts or merges, so an encrypted,
+ // disallowed-construct, or duplicate-loser document can always be pruned. It
+ // is decided before any content check.
+ if c.Desired == nil {
+ if root, empty, err := decodeDoc(target); err == nil && !empty {
+ snap.Identity, _ = identityFromNode(root)
+ }
+ return Decision{Action: ActionDelete, Reason: "desired absent: delete the document", Snapshot: snap}
+ }
+
+ return decideContentEdit(c, target, snap)
+}
+
+// decideContentEdit runs the cheap, non-mutating checks for a content edit
+// (Patch / Replace / NoChange) plus the refusals that apply only to content
+// edits. It never runs the structural merge, so a decision can never change Git.
+func decideContentEdit(c Comparison, target string, snap SnapshotRef) Decision {
+ root, empty, err := decodeDoc(target)
+ if err != nil {
+ return Decision{Action: ActionSkip, level: DiagWarning, Reason: "invalid YAML", Snapshot: snap}
+ }
+ if empty {
+ return Decision{Action: ActionSkip, level: DiagWarning,
+ Reason: "empty document, nothing to patch", Snapshot: snap}
+ }
+ if reason, bad := hasDisallowed(root); bad {
+ return Decision{Action: ActionSkip, level: DiagWarning,
+ Reason: "ignored: " + reason + " is not editable", Snapshot: snap}
+ }
+ // Encrypted documents are authoritative but never patched in place: an
+ // in-place merge would drop the sops metadata and write the secret back in
+ // cleartext. Route them to the re-encrypt writer instead.
+ if nodeMapGet(root, "sops") != nil {
+ return Decision{Action: ActionSkip, level: DiagWarning,
+ Reason: "encrypted document: in-place patch is unsafe, use the re-encrypt writer path",
+ Snapshot: snap}
+ }
+ snap.Identity, _ = identityFromNode(root)
+ if root.Kind != yaml.MappingNode {
+ return Decision{Action: ActionReplace,
+ Reason: "non-mapping root cannot be patched field-by-field", Snapshot: snap}
+ }
+
+ // No-op vs change: compare the raw Git document to the desired projection.
+ // Equal means a true no-op (preserve bytes); different means a patch.
+ var rawObj map[string]interface{}
+ if err := yaml.Unmarshal([]byte(target), &rawObj); err == nil {
+ if reflect.DeepEqual(normalizeJSON(rawObj), normalizeJSON(c.Desired.Object)) {
+ return Decision{Action: ActionNoChange, Reason: "Git already matches desired", Snapshot: snap}
+ }
+ }
+ return Decision{Action: ActionPatch, Reason: "Git differs from desired", Snapshot: snap}
+}
+
+// Apply is authoritative: it re-parses c.Git, validates the snapshot, performs
+// the edit, and returns what actually happened. There is no separate file
+// argument — c.Git is the single source of truth for the bytes. The returned
+// EditResult.Mode is the truth about what happened, not the Decision: a Patch
+// intent may legitimately land on Replace if a node turns out ambiguous, or on a
+// soft Skip if the snapshot drifted.
+func Apply(c Comparison, d Decision) (EditResult, []Diagnostic) {
+ if c.Git == nil {
+ return EditResult{Mode: EditSkipped}, []Diagnostic{{Level: d.level, Message: d.Reason}}
+ }
+ content := c.Git.Content
+ loc := Location{Path: c.Git.Path, DocumentIndex: c.Git.DocumentIndex}
+
+ if d.Action == ActionSkip {
+ level := d.level
+ if level == "" {
+ level = DiagWarning
+ }
+ return EditResult{Content: content, Mode: EditSkipped}, []Diagnostic{diag(level, loc, "%s", d.Reason)}
+ }
+
+ docs := splitDocuments(string(content))
+ idx := c.Git.DocumentIndex
+ if idx < 0 || idx >= len(docs) {
+ return EditResult{Content: content, Mode: EditSkipped},
+ []Diagnostic{diag(DiagError, loc, "document index out of range")}
+ }
+ target := docs[idx].body
+
+ if drift := validateSnapshot(d.Snapshot, idx, target, loc); drift != nil {
+ return EditResult{Content: content, Mode: EditSkipped}, []Diagnostic{*drift}
+ }
+
+ return applyDecision(c, d, docs, idx, target, loc)
+}
+
+// validateSnapshot enforces the full Decide->Apply contract: the document Apply
+// is about to edit must be the same one Decide compared — same index, same
+// identity, same body. A mismatch returns a soft skip diagnostic so the next
+// reconcile can re-decide cleanly against the changed file, rather than landing a
+// stale edit on the wrong document. It returns nil when the snapshot still holds.
+//
+// The body hash is the strongest check (identical bytes imply identical identity),
+// but the index and identity checks make the contract explicit and give a precise
+// diagnostic when a Decision is carried and applied against a drifted file.
+func validateSnapshot(snap SnapshotRef, idx int, target string, loc Location) *Diagnostic {
+ if snap.DocumentIndex != idx {
+ d := diag(DiagWarning, loc, "decision was for document %d, applying to %d, skipping",
+ snap.DocumentIndex, idx)
+ return &d
+ }
+ if snap.BodyHash != "" && hashBody(target) != snap.BodyHash {
+ d := diag(DiagWarning, loc, "document changed since decision, skipping")
+ return &d
+ }
+ if snap.Identity != (Identity{}) {
+ if root, empty, err := decodeDoc(target); err == nil && !empty {
+ if id, ok := identityFromNode(root); ok && id != snap.Identity {
+ d := diag(DiagWarning, loc, "document identity changed since decision, skipping")
+ return &d
+ }
+ }
+ }
+ return nil
+}
+
+// applyDecision performs the edit named by the validated decision. Apply has
+// already confirmed the document exists and the snapshot still matches.
+func applyDecision(
+ c Comparison,
+ d Decision,
+ docs []rawDoc,
+ idx int,
+ target string,
+ loc Location,
+) (EditResult, []Diagnostic) {
+ switch d.Action {
+ case ActionNoChange:
+ return EditResult{Content: c.Git.Content, Mode: EditNoChange}, nil
+ case ActionDelete:
+ return applyDelete(docs, idx), nil
+ case ActionReplace:
+ return applyReplace(docs, idx, c, loc)
+ case ActionPatch:
+ return applyPatch(docs, idx, target, c, loc)
+ case ActionSkip:
+ // Handled in Apply, before snapshot validation; here for exhaustiveness.
+ return EditResult{Content: c.Git.Content, Mode: EditSkipped}, nil
+ default:
+ return EditResult{Content: c.Git.Content, Mode: EditSkipped},
+ []Diagnostic{diag(DiagError, loc, "unknown decision action %q", d.Action)}
+ }
+}
+
+// applyDelete removes the target document, splicing siblings verbatim. Removing
+// the only document yields empty content so the caller can delete the file.
+func applyDelete(docs []rawDoc, idx int) EditResult {
+ if len(docs) == 1 {
+ return EditResult{Content: nil, Mode: EditDeleted}
+ }
+ docs = append(docs[:idx], docs[idx+1:]...)
+ // Drop the leading separator so a deleted first document does not leave the
+ // file starting with "---".
+ if idx == 0 {
+ docs[0].sep = ""
+ }
+ return EditResult{Content: []byte(joinDocuments(docs)), Mode: EditDeleted}
+}
+
+// applyPatch merges the desired object onto a fresh parse of the target, falling
+// back to a whole-document replace if the merge turns out ambiguous.
+func applyPatch(docs []rawDoc, idx int, target string, c Comparison, loc Location) (EditResult, []Diagnostic) {
+ root, _, err := decodeDoc(target)
+ if err != nil || root == nil || root.Kind != yaml.MappingNode {
+ return applyReplace(docs, idx, c, loc)
+ }
+
+ changed, ok := mergeMapping(mergeCtx{owns: c.Options.Owns, list: c.Options.ListMatch}, nil, root, c.Desired.Object)
+ if !ok {
+ return applyReplace(docs, idx, c, loc)
+ }
+ if !changed {
+ return EditResult{Content: []byte(joinDocuments(docs)), Mode: EditNoChange}, nil
+ }
+
+ encoded, err := encodeNode(root)
+ if err != nil {
+ return applyReplace(docs, idx, c, loc)
+ }
+
+ docs[idx].body = reskinDocument(target, string(encoded))
+ return EditResult{Content: []byte(joinDocuments(docs)), Mode: EditPatched}, nil
+}
+
+// applyReplace re-renders the target document canonically using the injected
+// renderer. With no renderer it fails loudly: a missing wiring must not mask
+// itself as plausible YAML.
+func applyReplace(docs []rawDoc, idx int, c Comparison, loc Location) (EditResult, []Diagnostic) {
+ if c.Options.Render == nil {
+ return EditResult{Content: []byte(joinDocuments(docs)), Mode: EditSkipped},
+ []Diagnostic{diag(DiagError, loc, "canonical output required but no Render injected")}
+ }
+ rendered, err := c.Options.Render(c.Desired)
+ if err != nil {
+ return EditResult{Content: []byte(joinDocuments(docs)), Mode: EditSkipped},
+ []Diagnostic{diag(DiagError, loc, "cannot render document: %v", err)}
+ }
+ docs[idx].body = string(rendered)
+ return EditResult{Content: []byte(joinDocuments(docs)), Mode: EditWholeReplace},
+ []Diagnostic{diag(DiagWarning, loc, "field-level preservation not possible, replaced whole document")}
+}
+
+// hashBody fingerprints a document body for snapshot validation.
+func hashBody(body string) string {
+ sum := sha256.Sum256([]byte(body))
+ return hex.EncodeToString(sum[:])
+}
diff --git a/internal/git/manifestedit/decision_test.go b/internal/git/manifestedit/decision_test.go
new file mode 100644
index 00000000..e9e26785
--- /dev/null
+++ b/internal/git/manifestedit/decision_test.go
@@ -0,0 +1,224 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// --- The two-version comparison as a first-class value ---
+
+func TestDecide_NoChangeWhenGitMatchesDesired(t *testing.T) {
+ doc := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\ndata:\n color: blue\n"
+ git, ok := NewDocument([]byte(doc), 0)
+ require.True(t, ok)
+ assert.Equal(t, Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "a"}, git.Identity)
+
+ desired := mustObj(t, doc)
+ d := Decide(Comparison{Git: git, Desired: desired})
+ assert.Equal(t, ActionNoChange, d.Action)
+ assert.NotEmpty(t, d.Snapshot.BodyHash)
+}
+
+func TestDecide_PatchWhenGitDiffers(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n")
+ git, _ := NewDocument(content, 0)
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: red\n")
+ assert.Equal(t, ActionPatch, Decide(Comparison{Git: git, Desired: desired}).Action)
+}
+
+func TestDecide_DeleteWhenDesiredAbsent(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ git, _ := NewDocument(content, 0)
+ d := Decide(Comparison{Git: git, Desired: nil})
+ assert.Equal(t, ActionDelete, d.Action)
+}
+
+func TestDecide_ReplaceWhenRootNotMapping(t *testing.T) {
+ git, _ := NewDocument([]byte("- a\n- b\n"), 0)
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ assert.Equal(t, ActionReplace, Decide(Comparison{Git: git, Desired: desired}).Action)
+}
+
+// Git is required: a Comparison always describes an existing document. Creating a
+// brand-new resource is a placement decision owned upstream, not a content edit.
+func TestDecide_NilGitIsInvalid(t *testing.T) {
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ d := Decide(Comparison{Git: nil, Desired: desired})
+ assert.Equal(t, ActionSkip, d.Action)
+
+ res, diags := Apply(Comparison{Git: nil, Desired: desired}, d)
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+ assert.Equal(t, DiagError, diags[0].Level)
+}
+
+// Decide is a pure preflight: it must never mutate Git, or a "decision" could
+// silently change Git before anything is applied.
+func TestDecide_DoesNotMutateGit(t *testing.T) {
+ original := "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n replicas: 1\n stale: drop\n"
+ content := []byte(original)
+ git, _ := NewDocument(content, 0)
+ desired := mustObj(t, "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n replicas: 2\n")
+
+ _ = Decide(Comparison{Git: git, Desired: desired})
+ assert.Equal(t, original, string(content), "Decide must not touch the underlying bytes")
+ assert.Equal(t, original, string(git.Content))
+}
+
+// Apply re-parses c.Git and validates the snapshot, refusing if the target
+// document drifted since Decide compared it. One source of truth; no stale edit
+// applied to a changed shape.
+func TestApply_SnapshotDriftSkips(t *testing.T) {
+ before := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: red\n")
+
+ gitBefore, _ := NewDocument(before, 0)
+ d := Decide(Comparison{Git: gitBefore, Desired: desired})
+ require.Equal(t, ActionPatch, d.Action)
+
+ // The file changed out from under the decision.
+ after := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: green\n")
+ gitAfter, _ := NewDocument(after, 0)
+ res, diags := Apply(Comparison{Git: gitAfter, Desired: desired, Options: EditOptions{Render: testRender}}, d)
+
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.Equal(t, after, res.Content, "a drifted document is left untouched")
+ require.NotEmpty(t, diags)
+ assert.Contains(t, diags[0].Message, "changed since decision")
+}
+
+// Apply enforces the full snapshot contract: a Decision carried to a different
+// document index must not apply there, even if the bytes happen to match.
+func TestApply_IndexMismatchSkips(t *testing.T) {
+ doc := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n"
+ // Two byte-identical documents: only the index distinguishes them.
+ content := []byte(doc + "---\n" + doc)
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: red\n")
+
+ git0, _ := NewDocument(content, 0)
+ d := Decide(Comparison{Git: git0, Desired: desired})
+ require.Equal(t, ActionPatch, d.Action)
+ require.Equal(t, 0, d.Snapshot.DocumentIndex)
+
+ // Apply the doc-0 decision against doc 1 (same bytes, so body hash alone would
+ // not catch it). The explicit index check must.
+ git1, _ := NewDocument(content, 1)
+ res, diags := Apply(Comparison{Git: git1, Desired: desired, Options: EditOptions{Render: testRender}}, d)
+
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+ assert.Contains(t, diags[0].Message, "decision was for document 0, applying to 1")
+}
+
+// Apply names the file in its diagnostics when the Document carries a path.
+func TestApply_DiagnosticsCarryPath(t *testing.T) {
+ git, _ := NewDocumentAt("apps/deploy.yaml", []byte("- a\n- b\n"), 0)
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ c := Comparison{Git: git, Desired: desired} // no renderer -> loud skip with a path
+ _, diags := Apply(c, Decide(c))
+
+ require.NotEmpty(t, diags)
+ assert.Equal(t, "apps/deploy.yaml", diags[0].Path, "the diagnostic names the file, not just the index")
+}
+
+// A sibling document changing must not invalidate the target edit: the snapshot
+// fingerprints the target document body, not the whole file.
+func TestApply_SiblingChangeDoesNotBlockEdit(t *testing.T) {
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: red\n")
+ target := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n"
+
+ before := []byte(target + "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: sibling\ndata:\n x: 1\n")
+ gitBefore, _ := NewDocument(before, 0)
+ d := Decide(Comparison{Git: gitBefore, Desired: desired})
+ require.Equal(t, ActionPatch, d.Action)
+
+ // Only the sibling (doc 1) changes; the target (doc 0) is byte-identical.
+ after := []byte(target + "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: sibling\ndata:\n x: 999\n")
+ gitAfter, _ := NewDocument(after, 0)
+ res, _ := Apply(Comparison{Git: gitAfter, Desired: desired, Options: EditOptions{Render: testRender}}, d)
+
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.Contains(t, string(res.Content), "color: red")
+ assert.Contains(t, string(res.Content), "x: 999", "the sibling change survives")
+}
+
+// There is no silent production default: a path that needs canonical output with
+// no renderer injected must fail loudly with a diagnostic.
+func TestApply_ReplaceWithoutRendererFailsLoudly(t *testing.T) {
+ git, _ := NewDocument([]byte("- a\n- b\n"), 0)
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ c := Comparison{Git: git, Desired: desired} // no Options.Render
+ res, diags := Apply(c, Decide(c))
+
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+ assert.Equal(t, DiagError, diags[0].Level)
+ assert.Contains(t, diags[0].Message, "no Render injected")
+}
+
+// The preservation and delete paths need no renderer at all.
+func TestApply_PatchAndDeleteNeedNoRenderer(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: red\n")
+
+ git, _ := NewDocument(content, 0)
+ patchC := Comparison{Git: git, Desired: desired} // no renderer
+ patchRes, patchDiags := Apply(patchC, Decide(patchC))
+ assert.Equal(t, EditPatched, patchRes.Mode)
+ assert.Empty(t, patchDiags)
+
+ delC := Comparison{Git: git, Desired: nil} // no renderer
+ delRes, delDiags := Apply(delC, Decide(delC))
+ assert.Equal(t, EditDeleted, delRes.Mode)
+ assert.Empty(t, delDiags)
+}
+
+// The product policy is API-first, whole-object truth: production always passes
+// Owns == nil, so a field absent from the desired projection is deleted from Git
+// (see docs/design/manifest/manifestedit-field-ownership-spike.md). This is the only
+// supported behavior, pinned here.
+func TestApply_WholeObjectTruth_AbsentFieldIsDeleted(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\nextra: drop\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n")
+
+ res, _ := PatchDocument(content, 0, desired, EditOptions{Render: testRender}) // Owns nil = own all
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.NotContains(t, string(res.Content), "extra", "whole-object truth: an absent field is deleted from Git")
+}
+
+// TestApply_OwnsSeam_DormantMechanism exercises the dormant Owns seam directly.
+// It is NOT a product feature: production must never set Owns (see the field
+// ownership decision). This test exists only to keep the mechanism honest — an
+// unowned path is left in Git — so the seam cannot silently rot. Do not read it
+// as partial ownership being supported.
+func TestApply_OwnsSeam_DormantMechanism(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\nextra: keep\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: red\n")
+
+ owns := func(path FieldPath) bool { return len(path) != 1 || path[0] != "extra" }
+ res, _ := PatchDocument(content, 0, desired, EditOptions{Render: testRender, Owns: owns})
+
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.Contains(t, string(res.Content), "color: red", "an owned field is still updated")
+ assert.Contains(t, string(res.Content), "extra: keep", "the dormant seam leaves an unowned path in Git")
+}
diff --git a/internal/git/manifestedit/delete.go b/internal/git/manifestedit/delete.go
new file mode 100644
index 00000000..54dcfdc1
--- /dev/null
+++ b/internal/git/manifestedit/delete.go
@@ -0,0 +1,60 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+// EditDeleted means a document was removed from the file.
+const EditDeleted EditMode = "deleted"
+
+// DeleteResult is the outcome of removing one document from a file.
+type DeleteResult struct {
+ // Content is the file content after removal. It is nil when FileEmpty is true.
+ Content []byte
+ // FileEmpty is true when the removed document was the only one, so the caller
+ // should delete the file rather than write empty content.
+ FileEmpty bool
+ Mode EditMode
+}
+
+// DeleteDocument removes one document from a file, leaving every surviving
+// document's content byte-for-byte intact. Removing the only document reports
+// FileEmpty so the caller can delete the file. This serves both resource deletion
+// and pruning a duplicate loser.
+//
+// It is a thin wrapper over Decide + Apply with Desired == nil. Deletion is the
+// content-agnostic cell of the comparison: it never decrypts or merges, so an
+// encrypted document, a disallowed-construct document, or a duplicate loser can
+// always be pruned. No renderer is needed.
+//
+// When the first document is removed, the new first document's leading "---"
+// separator is dropped so the file does not start with a stray separator. Only
+// the separator is affected; the document content is unchanged. We deliberately
+// prefer a clean leading document over preserving a now-pointless separator.
+func DeleteDocument(content []byte, documentIndex int) (DeleteResult, []Diagnostic) {
+ git, _ := NewDocument(content, documentIndex)
+ c := Comparison{Git: git, Desired: nil}
+ res, diags := Apply(c, Decide(c))
+
+ if res.Mode != EditDeleted {
+ return DeleteResult{Content: content, Mode: res.Mode}, diags
+ }
+ if len(res.Content) == 0 {
+ return DeleteResult{FileEmpty: true, Mode: EditDeleted}, diags
+ }
+ return DeleteResult{Content: res.Content, Mode: EditDeleted}, diags
+}
diff --git a/internal/git/manifestedit/edge_test.go b/internal/git/manifestedit/edge_test.go
new file mode 100644
index 00000000..e67b01f9
--- /dev/null
+++ b/internal/git/manifestedit/edge_test.go
@@ -0,0 +1,235 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPatch_IndexOutOfRange(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+
+ res, diags := patch(content, 5, desired)
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.Equal(t, content, res.Content)
+ require.NotEmpty(t, diags)
+ assert.Equal(t, DiagError, diags[0].Level)
+}
+
+func TestPatch_EmptyDocumentSkipped(t *testing.T) {
+ content := []byte("# only a comment\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+
+ res, diags := patch(content, 0, desired)
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+}
+
+func TestPatch_InvalidYAMLSkipped(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata: [unterminated\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+
+ res, diags := patch(content, 0, desired)
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+}
+
+func TestPatch_DisallowedDocumentSkipped(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n x: &x 1\n y: *x\n")
+ desired := mustObj(t, "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+
+ res, diags := patch(content, 0, desired)
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+}
+
+func TestPatch_NonMappingRootFallsBackToWholeReplace(t *testing.T) {
+ // A top-level sequence is not a Kubernetes object; the editor cannot patch it
+ // field-by-field and falls back to a whole-document render with a diagnostic.
+ content := []byte("- a\n- b\n")
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+data:
+ color: blue
+`)
+
+ res, diags := patch(content, 0, desired)
+ assert.Equal(t, EditWholeReplace, res.Mode)
+ assert.Contains(t, string(res.Content), "kind: ConfigMap")
+ require.NotEmpty(t, diags)
+ assert.Equal(t, DiagWarning, diags[0].Level)
+}
+
+func TestPatch_SequenceItemRemovedAndAdded(t *testing.T) {
+ content := []byte(`apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ template:
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: old
+ image: old:1.0
+`)
+ // Remove the "old" container, add a "new" one: exercises truncate + append.
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ template:
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: new
+ image: new:1.0
+`)
+ res, _ := patch(content, 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+ assert.Contains(t, out, "name: new")
+ assert.Contains(t, out, "image: new:1.0")
+ assert.NotContains(t, out, "name: old")
+}
+
+func TestPatch_SequenceGrows(t *testing.T) {
+ content := []byte(`apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ template:
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+`)
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ template:
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0
+`)
+ res, _ := patch(content, 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.Contains(t, string(res.Content), "name: sidecar")
+}
+
+func TestIndex_ClusterScopedDuplicate(t *testing.T) {
+ doc := "apiVersion: v1\nkind: Namespace\nmetadata:\n name: team-a\n"
+ inv, diags := IndexFiles([]FileContent{
+ {Path: "b.yaml", Content: []byte(doc)},
+ {Path: "a.yaml", Content: []byte(doc)},
+ })
+
+ loc, ok := inv.Location(Identity{APIVersion: "v1", Kind: "Namespace", Name: "team-a"})
+ require.True(t, ok)
+ assert.Equal(t, "a.yaml", loc.Path)
+
+ var clusterMsg bool
+ for _, d := range diags {
+ if assert.ObjectsAreEqual(DiagWarning, d.Level) && containsAll(d.Message, "v1/Namespace/_cluster/team-a") {
+ clusterMsg = true
+ }
+ }
+ assert.True(t, clusterMsg, "cluster-scoped identity should render with _cluster")
+}
+
+func TestIndex_InvalidYAMLDiagnostic(t *testing.T) {
+ inv, diags := IndexFile("broken.yaml", []byte("metadata: [unterminated\n"))
+ assert.Empty(t, inv.Records)
+ require.NotEmpty(t, diags)
+ assert.Equal(t, DiagError, diags[0].Level)
+}
+
+func TestPatch_TypeChangeScalarToMapAndSequence(t *testing.T) {
+ content := []byte(`apiVersion: example.com/v1
+kind: Widget
+metadata:
+ name: w
+ namespace: default
+spec:
+ value: hello
+ items: single
+`)
+ desired := mustObj(t, `apiVersion: example.com/v1
+kind: Widget
+metadata:
+ name: w
+ namespace: default
+spec:
+ value:
+ nested: x
+ items:
+ - a
+ - b
+`)
+ res, _ := patch(content, 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+ assert.Contains(t, out, "nested: x", "scalar replaced by a map")
+ assert.Contains(t, out, "- a", "scalar replaced by a sequence")
+}
+
+func TestIndex_LeadingSeparator(t *testing.T) {
+ content := []byte("---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: default\n")
+ inv, _ := IndexFile("lead.yaml", content)
+
+ loc, ok := inv.Location(Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "a"})
+ require.True(t, ok)
+ assert.Equal(t, 0, loc.DocumentIndex, "a leading --- must not create a spurious empty document 0")
+}
+
+func containsAll(s string, subs ...string) bool {
+ for _, sub := range subs {
+ found := false
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/git/manifestedit/fieldpatch.go b/internal/git/manifestedit/fieldpatch.go
new file mode 100644
index 00000000..22d0d857
--- /dev/null
+++ b/internal/git/manifestedit/fieldpatch.go
@@ -0,0 +1,191 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "errors"
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// Field patches are the editing primitive behind subresource audit resolution
+// (docs/design/manifest/version2/scale-subresource-audit-rehydration.md). A
+// mutating subresource such as deployments/scale does not carry a full parent
+// object, only a bounded set of changed field paths ("spec.replicas: 3"). Rather
+// than hydrate the parent, carry those assignments to Git and set exactly them on
+// the already committed manifest, leaving every other byte untouched.
+//
+// This is the mirror image of the package's whole-object merge. The merge owns
+// every field, so a field absent from the desired projection is deleted (the
+// production API-first truth). A field patch owns ONLY its assigned paths, so the
+// same merge sets the assigned fields and leaves everything else — including
+// fields it never mentioned — exactly as Git holds them. The two helpers here are
+// the entire surface: PartialDesired builds the partial Comparison.Desired, and
+// OwnsAssignedPaths builds the per-patch Comparison.Options.Owns. Nothing else in
+// the Decide/Apply path changes, so a field patch inherits the snapshot-drift
+// guard, the encrypted-document refusal, and the formatting preservation for free.
+
+// FieldAssignment is one (path, value) assignment of a field patch: set the node
+// at Path to Value, leaving every other field in the document untouched. Path is
+// from the document root, e.g. {"spec","replicas"}. Value is a JSON-native
+// unstructured value (string, int64, float64, bool, nil, map[string]interface{},
+// []interface{}) — exactly what decoding an audit body as unstructured yields.
+type FieldAssignment struct {
+ Path []string
+ Value any
+}
+
+// PartialDesired builds the Comparison.Desired for a field patch: an object
+// carrying only the parent identity plus each assignment's value at its path.
+// Identity is included so the whole-object merge does not see apiVersion/kind/
+// metadata as "absent from desired" — those are not owned by the patch, so they
+// would be left regardless, but carrying them keeps Decide's identity snapshot
+// meaningful and the no-op comparison honest.
+//
+// Assignments must have non-empty, disjoint paths; an empty path or a path that
+// descends through a value an earlier assignment set as a scalar is a programming
+// error and returns one.
+func PartialDesired(id Identity, assignments []FieldAssignment) (*unstructured.Unstructured, error) {
+ if id.APIVersion == "" || id.Kind == "" {
+ return nil, fmt.Errorf("partial desired requires APIVersion and Kind, got %+v", id)
+ }
+ obj := map[string]interface{}{
+ "apiVersion": id.APIVersion,
+ "kind": id.Kind,
+ }
+ if meta := identityMetadata(id); len(meta) > 0 {
+ obj["metadata"] = meta
+ }
+ for _, a := range assignments {
+ if len(a.Path) == 0 {
+ return nil, errors.New("field assignment has an empty path")
+ }
+ if err := setPath(obj, a.Path, a.Value); err != nil {
+ return nil, err
+ }
+ }
+ return &unstructured.Unstructured{Object: obj}, nil
+}
+
+// identityMetadata returns the metadata sub-map for an identity, omitting an empty
+// namespace so a cluster-scoped object does not gain a spurious namespace key.
+func identityMetadata(id Identity) map[string]interface{} {
+ meta := map[string]interface{}{}
+ if id.Name != "" {
+ meta["name"] = id.Name
+ }
+ if id.Namespace != "" {
+ meta["namespace"] = id.Namespace
+ }
+ return meta
+}
+
+// setPath sets value at a nested key path, creating intermediate maps as needed.
+// It does not deep-copy or validate the value (unlike unstructured.SetNestedField,
+// which rejects non-JSON-native scalars such as a plain int), so a caller can pass
+// values straight through. Descending through a non-map intermediate means two
+// assignments overlap, which a field patch forbids: it returns an error rather
+// than clobbering the earlier assignment.
+func setPath(root map[string]interface{}, path []string, value any) error {
+ cur := root
+ for i, key := range path {
+ if i == len(path)-1 {
+ cur[key] = value
+ return nil
+ }
+ switch next := cur[key].(type) {
+ case nil:
+ child := map[string]interface{}{}
+ cur[key] = child
+ cur = child
+ case map[string]interface{}:
+ cur = next
+ default:
+ return fmt.Errorf("assignment path %v overlaps an earlier assignment at %q", path, key)
+ }
+ }
+ return nil
+}
+
+// OwnsAssignedPaths returns the Comparison.Options.Owns predicate for a field
+// patch: it owns exactly the assigned paths and their descendants. Because the
+// merge consults ownership only to decide whether a Git field absent from desired
+// should be deleted, owning just the assigned subtrees means the patch can replace
+// an assigned field (including pruning sub-keys of a map-valued assignment) while
+// never deleting any field outside an assignment. A scalar assignment has no
+// descendants, so it cannot delete anything at all — it only overwrites its leaf.
+//
+// This predicate is always derived from the assignments, never caller-supplied:
+// a field patch's ownership is a property of the patch, not a tunable. That is the
+// one sanctioned non-nil Owns (the field-ownership spike forbids ownership as
+// configuration); here it is scoped to a single edit and not exposed as a knob.
+func OwnsAssignedPaths(assignments []FieldAssignment) func(FieldPath) bool {
+ owned := make([][]string, len(assignments))
+ for i, a := range assignments {
+ owned[i] = append([]string(nil), a.Path...)
+ }
+ return func(path FieldPath) bool {
+ for _, prefix := range owned {
+ if pathHasPrefix(path, prefix) {
+ return true
+ }
+ }
+ return false
+ }
+}
+
+// pathHasPrefix reports whether path is prefix or a descendant of it.
+func pathHasPrefix(path FieldPath, prefix []string) bool {
+ if len(path) < len(prefix) {
+ return false
+ }
+ for i := range prefix {
+ if path[i] != prefix[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// PatchFields applies a field patch to one document inside a file: it sets the
+// assigned paths on the document for id and leaves every other field and document
+// byte-for-byte identical. It is the field-patch analog of PatchDocument, wiring
+// PartialDesired and OwnsAssignedPaths through the same Decide + Apply path, so it
+// inherits the snapshot guard, the encrypted-document refusal, and formatting
+// preservation. opts.Owns is always overwritten with the patch's own ownership;
+// the caller injects only opts.Render (for the replace fallback) and opts.ListMatch.
+//
+// A skip (the document is missing, encrypted, or non-editable) surfaces as an
+// EditSkipped result with a diagnostic, exactly like PatchDocument — the caller
+// decides whether that is "no parent in Git, drop" or "unsafe, drop".
+func PatchFields(
+ content []byte,
+ documentIndex int,
+ id Identity,
+ assignments []FieldAssignment,
+ opts EditOptions,
+) (EditResult, []Diagnostic) {
+ desired, err := PartialDesired(id, assignments)
+ if err != nil {
+ return EditResult{Mode: EditSkipped}, []Diagnostic{{Level: DiagError, Message: err.Error()}}
+ }
+ opts.Owns = OwnsAssignedPaths(assignments)
+ return PatchDocument(content, documentIndex, desired, opts)
+}
diff --git a/internal/git/manifestedit/fieldpatch_test.go b/internal/git/manifestedit/fieldpatch_test.go
new file mode 100644
index 00000000..9a29a591
--- /dev/null
+++ b/internal/git/manifestedit/fieldpatch_test.go
@@ -0,0 +1,279 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// handFormattedDeployment is a manifest written the way a human would: a document
+// header comment, an inline comment on replicas, a label comment, deliberate key
+// order, and a block-sequence container. The whole point of a field patch is that
+// scaling it changes ONLY spec.replicas and preserves all of this.
+const handFormattedDeployment = `# Production web tier — GitOps owns spec.replicas; do not hand-edit.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: shop
+ labels:
+ app: web # owned by team storefront
+spec:
+ replicas: 1 # current desired scale
+ selector:
+ matchLabels:
+ app: web
+ template:
+ metadata:
+ labels:
+ app: web
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.25
+`
+
+// TestPatchFields_ScaleRoundTrip is the headline spike: the captured
+// deployments/scale audit event reduces to one assignment, spec.replicas: 3, and
+// applying it as a field patch updates exactly that value while leaving every
+// comment, sibling field, and the block-sequence container byte-stable. This is
+// the proof the subresource design rests on — no hydration, no whole-document
+// rewrite, just the audited field landing on the committed manifest.
+func TestPatchFields_ScaleRoundTrip(t *testing.T) {
+ id := Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "shop", Name: "web"}
+ assignments := []FieldAssignment{{Path: []string{"spec", "replicas"}, Value: int64(3)}}
+
+ res, diags := PatchFields([]byte(handFormattedDeployment), 0, id, assignments, EditOptions{Render: testRender})
+
+ assert.Empty(t, diags, "a clean field patch emits no diagnostics")
+ assert.Equal(t, EditPatched, res.Mode)
+
+ out := string(res.Content)
+ assert.Contains(t, out, "replicas: 3", "the audited field is updated")
+ assert.NotContains(t, out, "replicas: 1", "the old value is gone")
+ // Everything the patch did not name survives, including comments and style.
+ assert.Contains(t, out, "# Production web tier", "document header comment preserved")
+ assert.Contains(t, out, "# current desired scale", "inline comment travels with the changed scalar")
+ assert.Contains(t, out, "# owned by team storefront", "unrelated label comment preserved")
+ assert.Contains(t, out, "matchLabels", "selector preserved")
+ assert.Contains(t, out, "image: nginx:1.25", "container spec preserved")
+ assert.Contains(t, out, "- name: web", "block-sequence container preserved")
+}
+
+// TestPatchFields_NoOpWhenValueMatches proves a redundant patch is a true no-op:
+// scaling to the value Git already holds rewrites nothing.
+func TestPatchFields_NoOpWhenValueMatches(t *testing.T) {
+ content := []byte("apiVersion: apps/v1\nkind: Deployment\n" +
+ "metadata:\n name: web\n namespace: shop\nspec:\n replicas: 3\n")
+ id := Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "shop", Name: "web"}
+
+ res, _ := PatchFields(content, 0, id,
+ []FieldAssignment{{Path: []string{"spec", "replicas"}, Value: int64(3)}}, EditOptions{Render: testRender})
+
+ assert.Equal(t, EditNoChange, res.Mode)
+ assert.Equal(t, string(content), string(res.Content), "no node changed, so bytes are identical")
+}
+
+// TestPatchFields_LeavesUnassignedFields is the defining contrast with the
+// whole-object merge: a field the patch never mentions is NOT deleted, where
+// whole-object truth (Owns nil) would drop it.
+func TestPatchFields_LeavesUnassignedFields(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n shape: round\n")
+ id := Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"}
+
+ res, diags := PatchFields(content, 0, id,
+ []FieldAssignment{{Path: []string{"data", "color"}, Value: "red"}}, EditOptions{Render: testRender})
+
+ assert.Empty(t, diags)
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.Contains(t, string(res.Content), "color: red", "the assigned field is updated")
+ assert.Contains(t, string(res.Content), "shape: round", "an unassigned sibling survives a field patch")
+}
+
+// TestPatchFields_MapValueReplacesSubtree shows the descendant-ownership rule: a
+// map-valued assignment owns its subtree, so sub-keys absent from the new value
+// are pruned — "set data to exactly this map" rather than "merge into data".
+func TestPatchFields_MapValueReplacesSubtree(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\ndata:\n color: blue\n shape: round\n")
+ id := Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"}
+
+ res, _ := PatchFields(content, 0, id,
+ []FieldAssignment{{Path: []string{"data"}, Value: map[string]interface{}{"color": "red"}}},
+ EditOptions{Render: testRender})
+
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.Contains(t, string(res.Content), "color: red")
+ assert.NotContains(t, string(res.Content), "shape", "the owned subtree is replaced, pruning its absent sub-key")
+}
+
+// TestPatchFields_MultipleFields proves a single patch can carry several
+// assignments — a subresource that mutates more than one parent field lands all
+// of them in one edit, while every unassigned sibling stays put. This is the
+// natural shape of the generic rule, which emits one assignment per spec leaf.
+func TestPatchFields_MultipleFields(t *testing.T) {
+ content := []byte("apiVersion: apps/v1\nkind: Deployment\n" +
+ "metadata:\n name: web\n namespace: shop\n labels:\n app: web\n" +
+ "spec:\n replicas: 1\n minReadySeconds: 0\n paused: false\n")
+ id := Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "shop", Name: "web"}
+
+ assignments := []FieldAssignment{
+ {Path: []string{"spec", "replicas"}, Value: int64(3)},
+ {Path: []string{"spec", "paused"}, Value: true},
+ }
+ res, diags := PatchFields(content, 0, id, assignments, EditOptions{Render: testRender})
+
+ assert.Empty(t, diags)
+ assert.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+ assert.Contains(t, out, "replicas: 3", "first assignment landed")
+ assert.Contains(t, out, "paused: true", "second assignment landed")
+ assert.Contains(t, out, "minReadySeconds: 0", "an unassigned spec sibling is untouched")
+ assert.Contains(t, out, "app: web", "an unassigned subtree is untouched")
+}
+
+// TestPatchFields_MultipleFieldsAcrossSubtrees proves assignments in different
+// subtrees (data and metadata.annotations) coexist in one patch, each adding to
+// its map without disturbing the existing keys around it.
+func TestPatchFields_MultipleFieldsAcrossSubtrees(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: a\n annotations:\n keep: \"yes\"\n" +
+ "data:\n color: blue\n shape: round\n")
+ id := Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"}
+
+ assignments := []FieldAssignment{
+ {Path: []string{"data", "color"}, Value: "red"},
+ {Path: []string{"metadata", "annotations", "team"}, Value: "storefront"},
+ }
+ res, _ := PatchFields(content, 0, id, assignments, EditOptions{Render: testRender})
+
+ assert.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+ assert.Contains(t, out, "color: red", "data assignment landed")
+ assert.Contains(t, out, "team: storefront", "annotation assignment landed")
+ assert.Contains(t, out, "keep: \"yes\"", "the existing annotation is preserved beside the new one")
+ assert.Contains(t, out, "shape: round", "the unassigned data key is preserved")
+}
+
+// TestPatchFields_MultiDocOnlyTargetChanges proves a field patch touches only its
+// target document and leaves siblings in the same file byte-for-byte identical.
+func TestPatchFields_MultiDocOnlyTargetChanges(t *testing.T) {
+ content := []byte(
+ "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: keep\ndata:\n v: original\n" +
+ "---\n" +
+ "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: target\ndata:\n v: original\n")
+ before0 := docBody(string(content), 0)
+
+ res, _ := PatchFields(content, 1, Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "target"},
+ []FieldAssignment{{Path: []string{"data", "v"}, Value: "patched"}}, EditOptions{Render: testRender})
+
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.Equal(t, before0, docBody(string(res.Content), 0), "the sibling document is untouched")
+ assert.Contains(t, docBody(string(res.Content), 1), "v: patched")
+}
+
+// TestPatchFields_MissingDocumentSkips proves an out-of-range target is a soft
+// skip with a diagnostic, not a panic — the "no parent in Git" caller path.
+func TestPatchFields_MissingDocumentSkips(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+
+ res, diags := PatchFields(content, 5, Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"},
+ []FieldAssignment{{Path: []string{"data", "x"}, Value: "y"}}, EditOptions{Render: testRender})
+
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.NotEmpty(t, diags)
+}
+
+// TestPatchFields_EncryptedSkips proves a field patch inherits the encrypted-
+// document refusal: a SOPS document is skipped, never cleartext-patched in place.
+func TestPatchFields_EncryptedSkips(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: a\ndata:\n k: ZW5j\nsops:\n mac: x\n")
+
+ res, _ := PatchFields(content, 0, Identity{APIVersion: "v1", Kind: "Secret", Name: "a"},
+ []FieldAssignment{{Path: []string{"data", "k"}, Value: "dgo="}}, EditOptions{Render: testRender})
+
+ assert.Equal(t, EditSkipped, res.Mode)
+ assert.Contains(t, string(res.Content), "sops:", "the encrypted document is left intact")
+}
+
+// TestPatchFields_InvalidAssignmentSkips proves a malformed assignment surfaces a
+// loud error diagnostic instead of editing anything.
+func TestPatchFields_InvalidAssignmentSkips(t *testing.T) {
+ content := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n")
+
+ res, diags := PatchFields(content, 0, Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"},
+ []FieldAssignment{{Path: nil, Value: 1}}, EditOptions{Render: testRender})
+
+ assert.Equal(t, EditSkipped, res.Mode)
+ require.NotEmpty(t, diags)
+ assert.Equal(t, DiagError, diags[0].Level)
+}
+
+func TestPartialDesired(t *testing.T) {
+ id := Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "shop", Name: "web"}
+
+ obj, err := PartialDesired(id, []FieldAssignment{{Path: []string{"spec", "replicas"}, Value: int64(3)}})
+ require.NoError(t, err)
+
+ assert.Equal(t, "apps/v1", obj.GetAPIVersion())
+ assert.Equal(t, "Deployment", obj.GetKind())
+ assert.Equal(t, "web", obj.GetName())
+ assert.Equal(t, "shop", obj.GetNamespace())
+ replicas, found, err := unstructured.NestedInt64(obj.Object, "spec", "replicas")
+ require.NoError(t, err)
+ assert.True(t, found)
+ assert.Equal(t, int64(3), replicas)
+}
+
+func TestPartialDesired_ClusterScopedOmitsNamespace(t *testing.T) {
+ obj, err := PartialDesired(Identity{APIVersion: "rbac.authorization.k8s.io/v1", Kind: "ClusterRole", Name: "view"},
+ []FieldAssignment{{Path: []string{"spec", "x"}, Value: "y"}})
+ require.NoError(t, err)
+
+ _, found, _ := unstructured.NestedString(obj.Object, "metadata", "namespace")
+ assert.False(t, found, "a cluster-scoped identity gains no spurious namespace key")
+}
+
+func TestPartialDesired_Errors(t *testing.T) {
+ _, err := PartialDesired(Identity{Kind: "Deployment"}, nil)
+ require.Error(t, err, "missing APIVersion is rejected")
+
+ _, err = PartialDesired(Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"},
+ []FieldAssignment{{Path: nil, Value: 1}})
+ require.Error(t, err, "an empty assignment path is rejected")
+
+ _, err = PartialDesired(Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "a"}, []FieldAssignment{
+ {Path: []string{"spec", "replicas"}, Value: int64(3)},
+ {Path: []string{"spec", "replicas", "deep"}, Value: 1},
+ })
+ require.Error(t, err, "overlapping assignments are rejected")
+}
+
+func TestOwnsAssignedPaths(t *testing.T) {
+ owns := OwnsAssignedPaths([]FieldAssignment{{Path: []string{"spec", "replicas"}}})
+
+ assert.True(t, owns(FieldPath{"spec", "replicas"}), "the assigned path is owned")
+ assert.True(t, owns(FieldPath{"spec", "replicas", "deep"}), "a descendant of an assigned path is owned")
+ assert.False(t, owns(FieldPath{"spec"}), "an ancestor of an assigned path is not owned")
+ assert.False(t, owns(FieldPath{"spec", "selector"}), "a sibling of an assigned path is not owned")
+ assert.False(t, owns(FieldPath{"status"}), "an unrelated path is not owned")
+}
diff --git a/internal/git/manifestedit/framing.go b/internal/git/manifestedit/framing.go
new file mode 100644
index 00000000..4bd9606b
--- /dev/null
+++ b/internal/git/manifestedit/framing.go
@@ -0,0 +1,64 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import "strings"
+
+const byteOrderMark = "\ufeff"
+
+// reskinDocument re-applies the original document's framing to freshly encoded
+// content. The encoder always emits LF, no BOM, a single trailing newline, and
+// no document-end marker; this restores the original BOM, line-ending style,
+// trailing-newline presence, and a trailing "..." marker so those survive an
+// edit to another field in the same document.
+func reskinDocument(original, encoded string) string {
+ out := encoded
+
+ if hasDocEndMarker(original) {
+ out += "...\n"
+ }
+ if !strings.HasSuffix(original, "\n") {
+ out = strings.TrimRight(out, "\n")
+ }
+ if usesCRLF(original) {
+ out = strings.ReplaceAll(out, "\n", "\r\n")
+ }
+ if strings.HasPrefix(original, byteOrderMark) {
+ out = byteOrderMark + out
+ }
+ return out
+}
+
+// usesCRLF reports whether the original document uses Windows line endings.
+func usesCRLF(original string) bool {
+ return strings.Contains(original, "\r\n")
+}
+
+// hasDocEndMarker reports whether the document ends with a "..." marker line.
+func hasDocEndMarker(original string) bool {
+ lines := strings.Split(original, "\n")
+ for i := len(lines) - 1; i >= 0; i-- {
+ line := strings.TrimRight(lines[i], " \t\r")
+ if line == "" {
+ continue
+ }
+ return line == "..."
+ }
+ return false
+}
diff --git a/internal/git/manifestedit/index.go b/internal/git/manifestedit/index.go
new file mode 100644
index 00000000..7d851cbd
--- /dev/null
+++ b/internal/git/manifestedit/index.go
@@ -0,0 +1,283 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+// IndexFile builds an inventory from a single file's content.
+func IndexFile(path string, content []byte) (Inventory, []Diagnostic) {
+ return IndexFiles([]FileContent{{Path: path, Content: content}})
+}
+
+// IndexFiles builds an inventory from several files. Scan order is deterministic
+// (lexicographic path, then document index) so duplicate resolution is stable.
+func IndexFiles(files []FileContent) (Inventory, []Diagnostic) {
+ sorted := append([]FileContent(nil), files...)
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })
+
+ var records []DocumentRecord
+ var diags []Diagnostic
+
+ for _, f := range sorted {
+ recs, ds := indexOneFile(f)
+ records = append(records, recs...)
+ diags = append(diags, ds...)
+ }
+
+ inv, dupDiags := resolveDuplicates(records)
+ diags = append(diags, dupDiags...)
+ return inv, diags
+}
+
+// indexOneFile indexes the documents of one file.
+func indexOneFile(f FileContent) ([]DocumentRecord, []Diagnostic) {
+ encrypted := isSOPSFile(f.Path)
+ docs := splitDocuments(string(f.Content))
+
+ var records []DocumentRecord
+ var diags []Diagnostic
+
+ for i, doc := range docs {
+ loc := Location{Path: f.Path, DocumentIndex: i}
+ root, empty, err := decodeDoc(doc.body)
+ if err != nil {
+ diags = append(diags, diagR(DiagError, ReasonInvalidYAML, loc, "invalid YAML: %v", err))
+ continue
+ }
+ if empty {
+ diags = append(diags, diagR(DiagInfo, ReasonEmptyDocument, loc, "empty document, ignored"))
+ continue
+ }
+
+ id, ok := identityFromNode(root)
+ if !ok {
+ diags = append(diags, diagR(DiagInfo, ReasonNotKRM, loc, "not a Kubernetes manifest, ignored"))
+ continue
+ }
+
+ if reason, bad := hasDisallowed(root); bad {
+ diags = append(diags, diagR(DiagWarning, ReasonNonEditable, loc, "ignored: %s is not editable", reason))
+ records = append(records, DocumentRecord{Identity: id, Location: loc, Editable: false, Reason: reason})
+ continue
+ }
+
+ if encrypted {
+ if nodeMapGet(root, "sops") == nil {
+ diags = append(
+ diags,
+ diagR(DiagError, ReasonMissingSopsKey, loc, "SOPS file without a sops key, invalid"),
+ )
+ continue
+ }
+ records = append(records, DocumentRecord{Identity: id, Location: loc, Editable: true, Encrypted: true})
+ continue
+ }
+
+ records = append(records, DocumentRecord{Identity: id, Location: loc, Editable: true})
+ }
+
+ return records, diags
+}
+
+// resolveDuplicates applies first-occurrence-wins: the first record for an
+// identity keeps its location, later copies become deletable duplicates.
+func resolveDuplicates(records []DocumentRecord) (Inventory, []Diagnostic) {
+ inv := Inventory{byIdentity: make(map[Identity]Location)}
+ var diags []Diagnostic
+
+ for _, rec := range records {
+ inv.Records = append(inv.Records, rec)
+ if !rec.Editable {
+ continue
+ }
+ if winner, seen := inv.byIdentity[rec.Identity]; seen {
+ inv.duplicates = append(inv.duplicates, rec)
+ diags = append(diags, Diagnostic{
+ Level: DiagWarning,
+ Reason: ReasonDuplicateIdentity,
+ Path: rec.Location.Path,
+ DocumentIndex: rec.Location.DocumentIndex,
+ Message: fmt.Sprintf("%s: keeping %s document %d, removing duplicate in %s document %d",
+ identityString(rec.Identity), winner.Path, winner.DocumentIndex,
+ rec.Location.Path, rec.Location.DocumentIndex),
+ })
+ continue
+ }
+ inv.byIdentity[rec.Identity] = rec.Location
+ }
+
+ return inv, diags
+}
+
+// decodeDoc parses a document body into its root node without expanding aliases,
+// so a billion-laughs alias bomb cannot blow up here. It reports empty documents.
+func decodeDoc(body string) (*yaml.Node, bool, error) {
+ if strings.TrimSpace(stripComments(body)) == "" {
+ return nil, true, nil
+ }
+ var docNode yaml.Node
+ if err := yaml.Unmarshal([]byte(body), &docNode); err != nil {
+ return nil, false, err
+ }
+ if docNode.Kind == 0 || len(docNode.Content) == 0 {
+ return nil, true, nil
+ }
+ return docNode.Content[0], false, nil
+}
+
+// stripComments removes whole-line comments so a comment-only document reads as
+// empty. It is only used for the empty-document check.
+func stripComments(body string) string {
+ var b strings.Builder
+ for _, line := range strings.Split(body, "\n") {
+ t := strings.TrimSpace(line)
+ if strings.HasPrefix(t, "#") {
+ continue
+ }
+ b.WriteString(line)
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+// identityFromNode reads the manifest identity from a mapping root node.
+func identityFromNode(root *yaml.Node) (Identity, bool) {
+ if root == nil || root.Kind != yaml.MappingNode {
+ return Identity{}, false
+ }
+ id := Identity{
+ APIVersion: scalarOf(nodeMapGet(root, "apiVersion")),
+ Kind: scalarOf(nodeMapGet(root, "kind")),
+ }
+ md := nodeMapGet(root, "metadata")
+ id.Name = scalarOf(nodeMapGet(md, "name"))
+ id.Namespace = scalarOf(nodeMapGet(md, "namespace"))
+
+ ok := id.APIVersion != "" && id.Kind != "" && id.Name != ""
+ return id, ok
+}
+
+// hasDisallowed reports the first disallowed construct (anchor, alias, merge
+// key, duplicate key, unusual tag) found in a node tree, walking without
+// materializing aliases so an alias bomb cannot blow up here.
+func hasDisallowed(n *yaml.Node) (string, bool) {
+ if n == nil {
+ return "", false
+ }
+ if n.Kind == yaml.AliasNode {
+ return "alias", true
+ }
+ if n.Anchor != "" {
+ return "anchor", true
+ }
+ if isUnusualTag(n.Tag) {
+ return "unusual tag " + n.Tag, true
+ }
+ if n.Kind == yaml.MappingNode {
+ seen := make(map[string]bool)
+ for i := 0; i+1 < len(n.Content); i += 2 {
+ key := n.Content[i]
+ if key.Tag == "!!merge" || key.Value == "<<" {
+ return "merge key", true
+ }
+ if seen[key.Value] {
+ return "duplicate key " + key.Value, true
+ }
+ seen[key.Value] = true
+ }
+ }
+ for _, c := range n.Content {
+ if reason, ok := hasDisallowed(c); ok {
+ return reason, true
+ }
+ }
+ return "", false
+}
+
+// isUnusualTag reports whether an explicit YAML tag is one the editor refuses to
+// edit through: a local/custom tag (single "!") or binary. Core resolved tags
+// such as !!str, !!int, !!bool and !!timestamp (from a plain date) are fine.
+func isUnusualTag(tag string) bool {
+ if tag == "" {
+ return false
+ }
+ if strings.HasPrefix(tag, "!!") {
+ return tag == "!!binary"
+ }
+ return strings.HasPrefix(tag, "!")
+}
+
+// nodeMapGet returns the value node for a key in a mapping node, or nil.
+func nodeMapGet(m *yaml.Node, key string) *yaml.Node {
+ if m == nil || m.Kind != yaml.MappingNode {
+ return nil
+ }
+ for i := 0; i+1 < len(m.Content); i += 2 {
+ if m.Content[i].Value == key {
+ return m.Content[i+1]
+ }
+ }
+ return nil
+}
+
+// scalarOf returns the value of a scalar node, or "".
+func scalarOf(n *yaml.Node) string {
+ if n == nil || n.Kind != yaml.ScalarNode {
+ return ""
+ }
+ return n.Value
+}
+
+// isSOPSFile reports whether a path is a SOPS-managed file by extension.
+func isSOPSFile(path string) bool {
+ return strings.HasSuffix(path, ".sops.yaml") || strings.HasSuffix(path, ".sops.yml")
+}
+
+// identityString renders an identity like "apps/v1/Deployment/default/app".
+func identityString(id Identity) string {
+ ns := id.Namespace
+ if ns == "" {
+ ns = "_cluster"
+ }
+ return fmt.Sprintf("%s/%s/%s/%s", id.APIVersion, id.Kind, ns, id.Name)
+}
+
+// diag is a small constructor for formatted diagnostics tied to a location.
+func diag(level DiagnosticLevel, loc Location, format string, args ...any) Diagnostic {
+ return Diagnostic{
+ Level: level,
+ Path: loc.Path,
+ DocumentIndex: loc.DocumentIndex,
+ Message: fmt.Sprintf(format, args...),
+ }
+}
+
+// diagR is diag with a structured reason code attached, so callers can classify
+// the document without parsing the message text.
+func diagR(level DiagnosticLevel, reason DiagReason, loc Location, format string, args ...any) Diagnostic {
+ d := diag(level, loc, format, args...)
+ d.Reason = reason
+ return d
+}
diff --git a/internal/git/manifestedit/keyedlist_test.go b/internal/git/manifestedit/keyedlist_test.go
new file mode 100644
index 00000000..6fb93a76
--- /dev/null
+++ b/internal/git/manifestedit/keyedlist_test.go
@@ -0,0 +1,219 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// keyedByName injects the keyed list-match strategy used for KRM lists whose
+// items are identified by their name field (containers, env, volumes, ...). The
+// GVK->key choice lives with the caller; the document model only sees "match by
+// this field".
+func keyedByName() EditOptions {
+ return EditOptions{Render: testRender, ListMatch: ListMatchStrategy{KeyField: "name"}}
+}
+
+// Keyed matching fixes the recorded index-based limitation: reordering a list
+// matches each item to its counterpart by key, so a comment travels with its
+// item instead of staying on the slot. Compare with
+// TestPatch_KnownLimitation_ListReorderMisattributesComments, which pins the
+// default index-based behavior.
+func TestPatch_KeyedListReorderKeepsCommentWithItem(t *testing.T) {
+ git := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: sidecar
+ image: envoy:1.0 # the mesh sidecar
+ - name: web
+ image: nginx:1.0
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0
+`)
+
+ first := assertConverges(t, []byte(git), 0, desired, keyedByName())
+ require.Equal(t, EditPatched, first.Mode)
+ out := string(first.Content)
+
+ // The comment stays attached to the sidecar, where it belongs.
+ assert.Contains(t, out, "image: envoy:1.0 # the mesh sidecar",
+ "keyed matching keeps the comment with its item across a reorder")
+ assert.NotContains(t, out, "image: nginx:1.0 # the mesh sidecar",
+ "the comment must not migrate onto the web container")
+
+ // Git now reflects the desired order: web before sidecar.
+ assert.Less(t, strings.Index(out, "name: web"), strings.Index(out, "name: sidecar"),
+ "the list is rebuilt in desired order")
+}
+
+// Keyed matching applies field-level merges to the matched item, so editing one
+// container's image leaves the other (and its comment) byte-stable.
+func TestPatch_KeyedListEditsMatchedItemInPlace(t *testing.T) {
+ git := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0 # the mesh sidecar
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:2.0
+ - name: sidecar
+ image: envoy:1.0
+`)
+
+ first := assertConverges(t, []byte(git), 0, desired, keyedByName())
+ require.Equal(t, EditPatched, first.Mode)
+ out := string(first.Content)
+
+ assert.Contains(t, out, "image: nginx:2.0", "the matched container is updated in place")
+ assert.Contains(t, out, "image: envoy:1.0 # the mesh sidecar",
+ "the untouched container keeps its comment")
+}
+
+// A desired item with no Git counterpart is added; a Git item absent from desired
+// is dropped — matched by key, not slot.
+func TestPatch_KeyedListAddsAndRemovesByKey(t *testing.T) {
+ git := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: logger
+ image: fluentd:1.0
+`)
+
+ first := assertConverges(t, []byte(git), 0, desired, keyedByName())
+ require.Equal(t, EditPatched, first.Mode)
+ out := string(first.Content)
+
+ assert.Contains(t, out, "name: logger", "a desired-only item is added")
+ assert.Contains(t, out, "name: web", "a matched item is kept")
+ assert.NotContains(t, out, "name: sidecar", "a Git item absent from desired is dropped")
+}
+
+// When the list is not uniformly keyed (here, a list of scalars), keyed matching
+// does not apply and the merge falls back to index-based matching, which still
+// converges.
+func TestPatch_KeyedListFallsBackToIndexForScalars(t *testing.T) {
+ git := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+spec:
+ items:
+ - one
+ - two
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+spec:
+ items:
+ - one
+ - two
+ - three
+`)
+
+ first := assertConverges(t, []byte(git), 0, desired, keyedByName())
+ require.Equal(t, EditPatched, first.Mode)
+ assert.Contains(t, string(first.Content), "- three", "index fallback still appends the new scalar")
+}
+
+// A keyed no-op must stay a byte-stable no-op: same items in the same order with
+// no field change means nothing is rewritten.
+func TestPatch_KeyedListNoOpPreservesBytes(t *testing.T) {
+ git := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0 # primary
+ - name: sidecar
+ image: envoy:1.0
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0
+`)
+
+ res, _ := PatchDocument([]byte(git), 0, desired, keyedByName())
+ assert.Equal(t, EditNoChange, res.Mode)
+ assert.Equal(t, git, string(res.Content), "an unchanged keyed list preserves bytes and comments")
+}
diff --git a/internal/git/manifestedit/limitations_test.go b/internal/git/manifestedit/limitations_test.go
new file mode 100644
index 00000000..8ef29406
--- /dev/null
+++ b/internal/git/manifestedit/limitations_test.go
@@ -0,0 +1,101 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Known limitation: sequence matching is index-based, so reordering a list (same
+// items, different order) rewrites it slot-by-slot. The result is semantically
+// correct and converges, but a comment attached to a list item stays on its
+// slot — it ends up on the wrong item. Kubernetes-aware keyed matching (e.g. by
+// container `name`) would fix this; it is deliberately deferred. This test pins
+// the current behavior so we notice when keyed matching changes it.
+func TestPatch_KnownLimitation_ListReorderMisattributesComments(t *testing.T) {
+ git := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: sidecar
+ image: envoy:1.0 # the mesh sidecar
+ - name: web
+ image: nginx:1.0
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0
+`)
+ res, _ := patch([]byte(git), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ // Semantically correct: each container has the right image.
+ assert.Contains(t, out, "name: web")
+ assert.Contains(t, out, "name: sidecar")
+
+ // Limitation: the sidecar comment migrated onto the (now slot-0) web image.
+ assert.Contains(t, out, "image: nginx:1.0 # the mesh sidecar",
+ "index-based matching mis-attributes the comment on reorder (keyed matching would fix this)")
+
+ // It still converges: a second reconcile is a no-op.
+ res2, _ := patch(res.Content, 0, desired)
+ assert.Equal(t, EditNoChange, res2.Mode)
+}
+
+// Bounded-stats summary: inventory exposes high-level counts (and diagnostics can
+// be grouped by level) so a status surface need not enumerate every manifest.
+func TestInventory_SummaryAndDiagnosticCounts(t *testing.T) {
+ good := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: %s\n namespace: default\n"
+ anchor := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: anc\n namespace: default\ndata:\n a: &a 1\n b: *a\n"
+ encrypted := "apiVersion: v1\nkind: Secret\nmetadata:\n name: s\n namespace: default\n" +
+ "data:\n p: ENC[AES256_GCM,data:x]\nsops:\n age: []\n"
+
+ inv, diags := IndexFiles([]FileContent{
+ {Path: "a.yaml", Content: []byte(strings.Replace(good, "%s", "a", 1))},
+ {Path: "b.yaml", Content: []byte(strings.Replace(good, "%s", "a", 1))}, // duplicate of a
+ {Path: "anchor.yaml", Content: []byte(anchor)}, // non-editable
+ {Path: "secret.sops.yaml", Content: []byte(encrypted)}, // encrypted
+ })
+
+ s := inv.Summary()
+ assert.Equal(t, 4, s.Documents)
+ assert.Equal(t, 1, s.NonEditable, "the anchor document")
+ assert.Equal(t, 1, s.Encrypted, "the sops secret")
+ assert.Equal(t, 1, s.Duplicates, "b.yaml lost to a.yaml")
+ assert.Equal(t, 3, s.Editable, "two configmaps + the encrypted secret are editable records")
+
+ counts := CountByLevel(diags)
+ assert.GreaterOrEqual(t, counts[DiagWarning], 1, "duplicate and anchor warnings are counted")
+}
diff --git a/internal/git/manifestedit/manifestedit_test.go b/internal/git/manifestedit/manifestedit_test.go
new file mode 100644
index 00000000..3756010a
--- /dev/null
+++ b/internal/git/manifestedit/manifestedit_test.go
@@ -0,0 +1,654 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// mustObj parses YAML into an unstructured object for use as a desired state.
+func mustObj(t *testing.T, y string) *unstructured.Unstructured {
+ t.Helper()
+ var m map[string]interface{}
+ require.NoError(t, yaml.Unmarshal([]byte(y), &m))
+ return &unstructured.Unstructured{Object: m}
+}
+
+// testRender is the small canonical renderer the tests inject in place of the
+// production house renderer (sanitize.MarshalToOrderedYAML). The package is
+// mechanism, not policy, so the renderer is injected, never owned here.
+func testRender(obj *unstructured.Unstructured) ([]byte, error) {
+ return yaml.Marshal(obj.Object)
+}
+
+// patch wraps PatchDocument with the injected test renderer, so the many edit
+// tests read like the original three-argument call while the package stays
+// renderer-agnostic. The desired object is the already-projected Git state.
+func patch(content []byte, documentIndex int, desired *unstructured.Unstructured) (EditResult, []Diagnostic) {
+ return PatchDocument(content, documentIndex, desired, EditOptions{Render: testRender})
+}
+
+// docBody returns the body bytes of one document in content.
+func docBody(content string, idx int) string {
+ docs := splitDocuments(content)
+ if idx < 0 || idx >= len(docs) {
+ return ""
+ }
+ return docs[idx].body
+}
+
+// --- Test 1: No-op round-trip drift (hard, gating) ---
+
+func TestRoundTripDrift_Baseline(t *testing.T) {
+ manifest := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config # stable name
+ namespace: default
+data:
+ build: "00123"
+ enabled: "false"
+ start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server
+`
+ root, empty, err := decodeDoc(manifest)
+ require.NoError(t, err)
+ require.False(t, empty)
+
+ out, err := encodeNode(root)
+ require.NoError(t, err)
+
+ if string(out) == manifest {
+ t.Log("yaml.v3 round-trip is byte-for-byte identical for this manifest")
+ return
+ }
+ // Not byte-identical is the interesting POC finding; record exactly where.
+ t.Logf("yaml.v3 round-trip drifted from source.\n--- original ---\n%s\n--- re-encoded ---\n%s", manifest, out)
+
+ // It must at least stay semantically equivalent.
+ var a, b map[string]interface{}
+ require.NoError(t, yaml.Unmarshal([]byte(manifest), &a))
+ require.NoError(t, yaml.Unmarshal(out, &b))
+ assert.Equal(t, normalizeJSON(a), normalizeJSON(b), "round-trip must preserve meaning")
+}
+
+// --- Test 2: Multi-document inventory ---
+
+func TestIndex_MultiDocument(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+---
+# intentionally empty document
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+`
+ inv, _ := IndexFile("app.yaml", []byte(content))
+
+ cm, ok := inv.Location(Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "app-config"})
+ require.True(t, ok)
+ assert.Equal(t, 0, cm.DocumentIndex)
+
+ dep, ok := inv.Location(Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "app"})
+ require.True(t, ok)
+ assert.Equal(t, 2, dep.DocumentIndex, "deployment must keep document index 2 despite the empty doc 1")
+}
+
+// --- Test 3: Non-KRM YAML ---
+
+func TestIndex_NonKRMIgnored(t *testing.T) {
+ content := `# just some config
+database:
+ host: localhost
+ port: 5432
+`
+ inv, diags := IndexFile("values.yaml", []byte(content))
+ assert.Empty(t, inv.Records, "non-KRM YAML must not be indexed as a resource")
+ assert.NotEmpty(t, diags, "non-KRM YAML should emit a diagnostic")
+}
+
+// --- Test 4: Duplicate identity ---
+
+func TestIndex_DuplicateFirstWins(t *testing.T) {
+ doc := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+`
+ inv, diags := IndexFiles([]FileContent{
+ {Path: "overlays/dev/app.yaml", Content: []byte(doc)},
+ {Path: "apps/app.yaml", Content: []byte(doc)},
+ })
+
+ id := Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "app"}
+ loc, ok := inv.Location(id)
+ require.True(t, ok)
+ assert.Equal(t, "apps/app.yaml", loc.Path, "first by lexicographic path wins")
+
+ require.Len(t, inv.Duplicates(), 1)
+ assert.Equal(t, "overlays/dev/app.yaml", inv.Duplicates()[0].Location.Path)
+
+ var found bool
+ for _, d := range diags {
+ if strings.Contains(d.Message, "removing duplicate") {
+ found = true
+ }
+ }
+ assert.True(t, found, "a duplicate diagnostic should be emitted")
+}
+
+// --- Test 5: Semantic no-op vs cleaning (hard) ---
+
+func TestPatch_TrueNoOpPreservesBytes(t *testing.T) {
+ gitContent := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ color: blue
+`
+ // The caller already projected the API object to the clean Git state, so the
+ // desired object matches Git exactly: the package never sanitizes internally.
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ color: blue
+`)
+ res, _ := patch([]byte(gitContent), 0, desired)
+ assert.Equal(t, EditNoChange, res.Mode)
+ assert.Equal(t, gitContent, string(res.Content), "a true no-op must preserve bytes")
+}
+
+func TestPatch_DirtyGitFieldIsCleaned(t *testing.T) {
+ // resourceVersion lives in Git: it must be deleted, not preserved.
+ gitContent := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+ resourceVersion: "12345"
+data:
+ color: blue
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ color: blue
+`)
+ res, _ := patch([]byte(gitContent), 0, desired)
+ assert.Equal(t, EditPatched, res.Mode)
+ assert.NotContains(t, string(res.Content), "resourceVersion", "dirty resourceVersion must be cleaned from Git")
+ assert.Contains(t, string(res.Content), "color: blue")
+}
+
+// --- Test 6: Document-scoped update (hard) ---
+
+func TestPatch_DocumentScopedUpdate(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ color: blue
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: other
+ namespace: default
+data:
+ color: green
+`
+ doc0Before := docBody(content, 0)
+ doc1Before := docBody(content, 1)
+
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: other
+ namespace: default
+data:
+ color: red
+`)
+ res, _ := patch([]byte(content), 1, desired)
+ require.Equal(t, EditPatched, res.Mode)
+
+ assert.Equal(t, doc0Before, docBody(string(res.Content), 0), "untouched document 0 must be byte-for-byte identical")
+ assert.NotEqual(t, doc1Before, docBody(string(res.Content), 1))
+ assert.Contains(t, docBody(string(res.Content), 1), "color: red")
+}
+
+// --- Test 7: Comment preservation ---
+
+func TestPatch_CommentPreservation(t *testing.T) {
+ content := `# app config
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config # stable name
+ namespace: default
+ labels:
+ app: demo # selector label
+data:
+ color: blue
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+ labels:
+ app: demo
+ tier: frontend
+data:
+ color: blue
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, "tier: frontend", "new label added")
+ assert.Contains(t, out, "# app config", "head comment preserved")
+ assert.Contains(t, out, "stable name", "comment on unchanged name field preserved")
+ assert.Contains(t, out, "selector label", "comment on unchanged label preserved")
+}
+
+// --- Test 8: ConfigMap script block survives unrelated edits (hard) ---
+
+func TestPatch_ScriptBlockSurvivesUnrelatedEdit(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+data:
+ start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+ labels:
+ app: demo
+data:
+ start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, "app: demo", "label added")
+ const block = ` start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server`
+ assert.Contains(t, out, block, "the script block must survive byte-for-byte")
+}
+
+// --- Test 9: ConfigMap script block changes intentionally ---
+
+func TestPatch_ScriptBlockChangesStayBlock(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+data:
+ start.sh: |-
+ #!/bin/sh
+ echo "old"
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+data:
+ start.sh: |-
+ #!/bin/sh
+ echo "new"
+ echo "line two"
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, "start.sh: |-", "changed script should stay a literal block, not an escaped string")
+ assert.Contains(t, out, `echo "new"`)
+ assert.NotContains(t, out, `\n`, "must not become an escaped one-line string")
+}
+
+// --- Test 10: Quoted and string-like values ---
+
+func TestPatch_QuotedValuesPreserved(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ build: "00123"
+ enabled: "false"
+`
+ // Unrelated edit (add a label); the quoted string-like values must not change.
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+ labels:
+ app: demo
+data:
+ build: "00123"
+ enabled: "false"
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, `build: "00123"`, "quoting preserved, not turned into a number")
+ assert.Contains(t, out, `enabled: "false"`, "quoting preserved, not turned into a bool")
+}
+
+// --- Test 11: List item update ---
+
+func TestPatch_ListItemImageUpdate(t *testing.T) {
+ content := `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ template:
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ - name: sidecar
+ image: envoy:1.0
+`
+ desired := mustObj(t, `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ template:
+ spec:
+ containers:
+ - name: web
+ image: nginx:2.0
+ - name: sidecar
+ image: envoy:1.0
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.Contains(t, out, "image: nginx:2.0", "changed image updated")
+ assert.Contains(t, out, "image: envoy:1.0", "unchanged image preserved")
+}
+
+// --- Test 12: Field deletion ---
+
+func TestPatch_FieldDeletion(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+ labels:
+ app: demo # keep
+ drop-me: yes # remove this label
+data:
+ color: blue
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+ labels:
+ app: demo
+data:
+ color: blue
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ out := string(res.Content)
+
+ assert.NotContains(t, out, "drop-me", "removed label must be gone")
+ assert.Contains(t, out, "app: demo", "sibling label preserved")
+ assert.Contains(t, out, "color: blue", "unrelated field preserved")
+}
+
+// --- Test 13: Disallowed constructs are ignored, not materialized (hard) ---
+
+func TestIndex_AnchorsAndAliasesIgnored(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ base: &base value
+ copy: *base
+`
+ inv, diags := IndexFile("anchor.yaml", []byte(content))
+ require.Len(t, inv.Records, 1)
+ assert.False(t, inv.Records[0].Editable, "documents with anchors/aliases must be non-editable")
+
+ _, ok := inv.Location(inv.Records[0].Identity)
+ assert.False(t, ok, "a non-editable record must not become an authoritative location")
+
+ var warned bool
+ for _, d := range diags {
+ if strings.Contains(d.Message, "not editable") {
+ warned = true
+ }
+ }
+ assert.True(t, warned)
+}
+
+func TestIndex_AliasBombDoesNotBlowUp(t *testing.T) {
+ // A billion-laughs style alias bomb must be detected at the node level
+ // without ever being materialized.
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: bomb
+ namespace: default
+data:
+ a: &a ["x","x","x","x","x","x","x","x","x"]
+ b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
+ c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
+ d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
+ e: [*d,*d,*d,*d,*d,*d,*d,*d,*d]
+`
+ inv, _ := IndexFile("bomb.yaml", []byte(content))
+ // It is indexed (identity readable) but never editable, and we never expanded it.
+ // Using a plain ".yaml" path (not ".sops.yaml") keeps the record present: a
+ // ".sops.yaml" file without a sops key is invalid and indexes to zero records,
+ // which would make the assertion below pass vacuously.
+ require.Len(t, inv.Records, 1, "alias-bomb input should still index when identity is readable")
+ assert.False(t, inv.Records[0].Editable, "alias-bomb input must be marked non-editable without expansion")
+}
+
+func TestIndex_MergeKeyIgnored(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data: &d
+ color: blue
+extra:
+ <<: *d
+ shade: dark
+`
+ inv, _ := IndexFile("merge.yaml", []byte(content))
+ require.Len(t, inv.Records, 1)
+ assert.False(t, inv.Records[0].Editable, "merge keys must be non-editable")
+}
+
+// --- Test 14: Line-ending and boundary fidelity (hard) ---
+
+func TestPatch_UnrelatedCRLFDocumentPreserved(t *testing.T) {
+ // Document 0 uses CRLF and a BOM; an edit to document 1 must not touch it.
+ doc0 := "\ufeffapiVersion: v1\r\nkind: ConfigMap\r\nmetadata:\r\n name: crlf\r\n namespace: default\r\ndata:\r\n color: blue\r\n"
+ doc1 := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: lf\n namespace: default\ndata:\n color: green\n"
+ content := doc0 + "---\n" + doc1
+
+ doc0Before := docBody(content, 0)
+
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: lf
+ namespace: default
+data:
+ color: red
+`)
+ res, _ := patch([]byte(content), 1, desired)
+ require.Equal(t, EditPatched, res.Mode)
+
+ assert.Equal(t, doc0Before, docBody(string(res.Content), 0),
+ "CRLF+BOM document must survive an unrelated edit byte-for-byte")
+}
+
+func TestPatch_TrailingDocumentEndMarkerPreserved(t *testing.T) {
+ content := `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+data:
+ color: blue
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: b
+ namespace: default
+data:
+ color: green
+...
+`
+ desired := mustObj(t, `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+data:
+ color: red
+`)
+ res, _ := patch([]byte(content), 0, desired)
+ require.Equal(t, EditPatched, res.Mode)
+ assert.Contains(t, string(res.Content), "...", "trailing document-end marker on an unrelated doc must survive")
+}
+
+// --- Test 15: Partially-encrypted manifest indexing ---
+
+func TestIndex_SOPSPartialEncrypted(t *testing.T) {
+ content := `apiVersion: v1
+kind: Secret
+metadata:
+ name: db
+ namespace: default
+data:
+ password: ENC[AES256_GCM,data:abc,iv:def,tag:ghi,type:str]
+sops:
+ age: []
+ lastmodified: "2026-01-01T00:00:00Z"
+`
+ inv, _ := IndexFile("secret.sops.yaml", []byte(content))
+ require.Len(t, inv.Records, 1)
+ assert.True(t, inv.Records[0].Encrypted)
+ assert.True(t, inv.Records[0].Editable)
+}
+
+func TestIndex_SOPSMissingSopsKeyInvalid(t *testing.T) {
+ content := `apiVersion: v1
+kind: Secret
+metadata:
+ name: db
+ namespace: default
+data:
+ password: ENC[AES256_GCM,data:abc]
+`
+ inv, diags := IndexFile("secret.sops.yaml", []byte(content))
+ assert.Empty(t, inv.Records, "a SOPS file without a sops key is invalid and not indexed")
+
+ var invalid bool
+ for _, d := range diags {
+ if strings.Contains(d.Message, "without a sops key") {
+ invalid = true
+ }
+ }
+ assert.True(t, invalid)
+}
+
+func TestIndex_SOPSFullyEncryptedIdentityHiddenSkipped(t *testing.T) {
+ // Fully encrypted: identity fields are not readable, so it cannot be sync material.
+ content := `data: ENC[AES256_GCM,data:abcdef,iv:xyz,tag:t,type:str]
+sops:
+ age: []
+`
+ inv, diags := IndexFile("opaque.sops.yaml", []byte(content))
+ assert.Empty(t, inv.Records, "a file without readable identity must be skipped")
+ assert.NotEmpty(t, diags)
+}
diff --git a/internal/git/manifestedit/merge.go b/internal/git/manifestedit/merge.go
new file mode 100644
index 00000000..55efc33d
--- /dev/null
+++ b/internal/git/manifestedit/merge.go
@@ -0,0 +1,344 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "encoding/json"
+ "reflect"
+ "sort"
+
+ "gopkg.in/yaml.v3"
+)
+
+// mergeCtx carries the injected merge strategies down the node walk: the
+// field-ownership predicate and the list-match strategy. It is the seam where
+// keyed-list matching and declared-subset ownership plug in; the defaults
+// reproduce today's whole-object, index-based behavior.
+type mergeCtx struct {
+ // owns reports whether a field path is owned by the reverser. Nil means
+ // "own everything", today's whole-object truth.
+ owns func(path FieldPath) bool
+ // list aligns sequence items. The zero value matches by index.
+ list ListMatchStrategy
+}
+
+// ownsPath reports whether the reverser owns a field path. An unowned path is
+// left in Git even when absent from desired, so a field present in Git but
+// absent from desired is deleted only when owned.
+func (m mergeCtx) ownsPath(path FieldPath) bool {
+ if m.owns == nil {
+ return true
+ }
+ return m.owns(path)
+}
+
+// mergeMapping merges a desired map onto an existing mapping node. Existing key
+// order is preserved, owned keys absent from desired are deleted, and desired-only
+// keys are appended in sorted order. It returns whether anything changed, and
+// whether the merge stayed unambiguous (false asks the caller to fall back).
+func mergeMapping(ctx mergeCtx, path FieldPath, node *yaml.Node, desired map[string]interface{}) (bool, bool) {
+ changed := false
+ rebuilt := make([]*yaml.Node, 0, len(node.Content))
+ present := make(map[string]bool, len(desired))
+
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ keyNode := node.Content[i]
+ valNode := node.Content[i+1]
+
+ childPath := make(FieldPath, len(path)+1)
+ copy(childPath, path)
+ childPath[len(path)] = keyNode.Value
+ desiredVal, want := desired[keyNode.Value]
+ if !want {
+ if ctx.ownsPath(childPath) {
+ changed = true // owned field present in Git, absent from desired: delete it
+ continue
+ }
+ rebuilt = append(rebuilt, keyNode, valNode) // unowned: leave it in Git
+ continue
+ }
+ present[keyNode.Value] = true
+
+ c, sub := mergeValue(ctx, childPath, valNode, desiredVal)
+ if !sub {
+ return changed, false
+ }
+ changed = changed || c
+ rebuilt = append(rebuilt, keyNode, valNode)
+ }
+
+ extra := make([]string, 0)
+ for k := range desired {
+ if !present[k] {
+ extra = append(extra, k)
+ }
+ }
+ sort.Strings(extra)
+ for _, k := range extra {
+ valNode, err := encodeValue(desired[k])
+ if err != nil {
+ return changed, false
+ }
+ keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}
+ rebuilt = append(rebuilt, keyNode, valNode)
+ changed = true
+ }
+
+ node.Content = rebuilt
+ return changed, true
+}
+
+// mergeValue merges a desired value onto an existing node, recursing for maps and
+// sequences and replacing scalars only when their value actually differs.
+func mergeValue(ctx mergeCtx, path FieldPath, node *yaml.Node, desired interface{}) (bool, bool) {
+ switch d := desired.(type) {
+ case map[string]interface{}:
+ if node.Kind == yaml.MappingNode {
+ return mergeMapping(ctx, path, node, d)
+ }
+ changed := replaceNode(node, desired)
+ return changed, changed
+ case []interface{}:
+ if node.Kind == yaml.SequenceNode {
+ return mergeSequence(ctx, path, node, d)
+ }
+ changed := replaceNode(node, desired)
+ return changed, changed
+ default:
+ if nodeEqualsValue(node, desired) {
+ return false, true // unchanged: leave the node (and its style) untouched
+ }
+ // replaceNode returns false only when the value cannot be encoded. Report
+ // that as a failed sub-merge (ok=false) so the caller falls back to a whole-
+ // document replace instead of silently dropping the edit.
+ changed := replaceNode(node, desired)
+ return changed, changed
+ }
+}
+
+// mergeSequence merges a desired slice onto an existing sequence node. With a
+// keyed list-match strategy it matches items by their key field, so an item is
+// compared to its counterpart (carrying its comments and style) rather than to
+// whatever happens to share its slot. When the strategy does not apply — no key
+// configured, or the items are not uniformly keyed mappings — it falls back to
+// index-based matching.
+func mergeSequence(ctx mergeCtx, path FieldPath, node *yaml.Node, desired []interface{}) (bool, bool) {
+ if ctx.list.KeyField != "" {
+ if changed, ok, applied := mergeSequenceKeyed(ctx, path, node, desired); applied {
+ return changed, ok
+ }
+ }
+ return mergeSequenceByIndex(ctx, path, node, desired)
+}
+
+// mergeSequenceByIndex merges a desired slice onto an existing sequence node by
+// index: slot i in Git is compared to slot i in desired. A reorder therefore
+// rewrites slots (a recorded limitation keyed matching fixes).
+func mergeSequenceByIndex(ctx mergeCtx, path FieldPath, node *yaml.Node, desired []interface{}) (bool, bool) {
+ changed := false
+ if len(desired) < len(node.Content) {
+ node.Content = node.Content[:len(desired)]
+ changed = true
+ }
+ for i := range node.Content {
+ c, sub := mergeValue(ctx, path, node.Content[i], desired[i])
+ if !sub {
+ return changed, false
+ }
+ changed = changed || c
+ }
+ for i := len(node.Content); i < len(desired); i++ {
+ valNode, err := encodeValue(desired[i])
+ if err != nil {
+ return changed, false
+ }
+ node.Content = append(node.Content, valNode)
+ changed = true
+ }
+ return changed, true
+}
+
+// mergeSequenceKeyed merges a desired slice onto an existing sequence node by a
+// key field instead of by index. The result is rebuilt in desired order, each
+// matched item carrying its existing node (so comments and style travel with the
+// item, not the slot); desired-only items are encoded fresh, and Git items absent
+// from desired are dropped.
+//
+// The returns are (changed, ok, applied). applied is false when keyed matching
+// cannot be applied cleanly — items are not all mappings carrying a non-empty,
+// unique key — so the caller falls back to index matching. ok is false (with
+// applied true) when a matched item's sub-merge is ambiguous, so the caller falls
+// back to a whole-document replace, exactly as with index matching.
+func mergeSequenceKeyed(ctx mergeCtx, path FieldPath, node *yaml.Node, desired []interface{}) (bool, bool, bool) {
+ key := ctx.list.KeyField
+
+ existingByKey, indexable := indexNodesByKey(node.Content, key)
+ if !indexable {
+ return false, false, false
+ }
+ desiredMaps, desiredKeys, keyable := keyedDesiredItems(desired, key)
+ if !keyable {
+ return false, false, false
+ }
+
+ changed := false
+ rebuilt := make([]*yaml.Node, 0, len(desired))
+ for i, dm := range desiredMaps {
+ existing, found := existingByKey[desiredKeys[i]]
+ if !found {
+ fresh, err := encodeValue(dm)
+ if err != nil {
+ return changed, false, true
+ }
+ rebuilt = append(rebuilt, fresh)
+ continue
+ }
+ itemPath := make(FieldPath, len(path)+1)
+ copy(itemPath, path)
+ itemPath[len(path)] = desiredKeys[i]
+ c, sub := mergeMapping(ctx, itemPath, existing, dm)
+ if !sub {
+ return changed, false, true
+ }
+ changed = changed || c
+ rebuilt = append(rebuilt, existing)
+ }
+
+ changed = changed || sequenceReordered(node.Content, rebuilt)
+ node.Content = rebuilt
+ return changed, true, true
+}
+
+// indexNodesByKey maps each item node to its key value, requiring every item to
+// be a mapping with a non-empty, unique value for key. indexable is false
+// otherwise, so the caller falls back to index matching.
+func indexNodesByKey(items []*yaml.Node, key string) (map[string]*yaml.Node, bool) {
+ byKey := make(map[string]*yaml.Node, len(items))
+ for _, item := range items {
+ if item.Kind != yaml.MappingNode {
+ return nil, false
+ }
+ kv := scalarOf(nodeMapGet(item, key))
+ if kv == "" {
+ return nil, false
+ }
+ if _, dup := byKey[kv]; dup {
+ return nil, false
+ }
+ byKey[kv] = item
+ }
+ return byKey, true
+}
+
+// keyedDesiredItems extracts the desired items as maps with their key values,
+// requiring every item to be a map with a non-empty, unique string key. keyable
+// is false otherwise.
+func keyedDesiredItems(desired []interface{}, key string) ([]map[string]interface{}, []string, bool) {
+ maps := make([]map[string]interface{}, 0, len(desired))
+ keys := make([]string, 0, len(desired))
+ seen := make(map[string]bool, len(desired))
+ for _, d := range desired {
+ dm, isMap := d.(map[string]interface{})
+ if !isMap {
+ return nil, nil, false
+ }
+ kv, isString := dm[key].(string)
+ if !isString || kv == "" || seen[kv] {
+ return nil, nil, false
+ }
+ seen[kv] = true
+ maps = append(maps, dm)
+ keys = append(keys, kv)
+ }
+ return maps, keys, true
+}
+
+// sequenceReordered reports whether rebuilt differs from original in length or in
+// the identity/order of its nodes, which (together with any sub-merge change)
+// tells keyed matching whether the sequence actually changed.
+func sequenceReordered(original, rebuilt []*yaml.Node) bool {
+ if len(original) != len(rebuilt) {
+ return true
+ }
+ for i := range rebuilt {
+ if rebuilt[i] != original[i] {
+ return true
+ }
+ }
+ return false
+}
+
+// replaceNode overwrites a node with a freshly encoded value, keeping the old
+// node's comments and, for strings, its quoting/block style when sensible.
+func replaceNode(node *yaml.Node, desired interface{}) bool {
+ fresh, err := encodeValue(desired)
+ if err != nil {
+ return false
+ }
+ if node.Kind == yaml.ScalarNode && fresh.Kind == yaml.ScalarNode {
+ if _, isString := desired.(string); isString && isPreservableStringStyle(node.Style) {
+ fresh.Style = node.Style
+ }
+ }
+ fresh.HeadComment = node.HeadComment
+ fresh.LineComment = node.LineComment
+ fresh.FootComment = node.FootComment
+ *node = *fresh
+ return true
+}
+
+// isPreservableStringStyle reports whether a scalar style is worth carrying over
+// to a changed string value (quoting and block styles, but not plain or tagged).
+func isPreservableStringStyle(s yaml.Style) bool {
+ return s == yaml.SingleQuotedStyle || s == yaml.DoubleQuotedStyle ||
+ s == yaml.LiteralStyle || s == yaml.FoldedStyle
+}
+
+// nodeEqualsValue reports whether a node already represents the desired scalar
+// value, comparing through a JSON round-trip so int/float typing does not matter.
+func nodeEqualsValue(node *yaml.Node, desired interface{}) bool {
+ var got interface{}
+ if err := node.Decode(&got); err != nil {
+ return false
+ }
+ return reflect.DeepEqual(normalizeJSON(got), normalizeJSON(desired))
+}
+
+// encodeValue builds a fresh node tree representing a Go value.
+func encodeValue(v interface{}) (*yaml.Node, error) {
+ var n yaml.Node
+ if err := n.Encode(v); err != nil {
+ return nil, err
+ }
+ return &n, nil
+}
+
+// normalizeJSON round-trips a value through JSON so numeric and structural types
+// from different decoders compare equal.
+func normalizeJSON(v interface{}) interface{} {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return v
+ }
+ var out interface{}
+ if err := json.Unmarshal(b, &out); err != nil {
+ return v
+ }
+ return out
+}
diff --git a/internal/git/manifestedit/patch.go b/internal/git/manifestedit/patch.go
new file mode 100644
index 00000000..e5fe3ff7
--- /dev/null
+++ b/internal/git/manifestedit/patch.go
@@ -0,0 +1,63 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "bytes"
+
+ "gopkg.in/yaml.v3"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// PatchDocument updates one document inside a file to match the desired object,
+// touching only what changed and leaving every other document byte-for-byte
+// identical. It is a thin wrapper over Decide + Apply.
+//
+// The desired object must already be the clean Git projection: this package is
+// mechanism, not policy, so it never sanitizes internally. The caller passes the
+// projected object and injects the canonical renderer (opts.Render), used for the
+// whole-document replace fallback.
+func PatchDocument(
+ content []byte,
+ documentIndex int,
+ desired *unstructured.Unstructured,
+ opts EditOptions,
+) (EditResult, []Diagnostic) {
+ git, _ := NewDocument(content, documentIndex)
+ c := Comparison{Git: git, Desired: desired, Options: opts}
+ return Apply(c, Decide(c))
+}
+
+// yamlIndent is the indentation used when re-encoding an edited document. It
+// matches common manifest style rather than yaml.v3's 4-space default.
+const yamlIndent = 2
+
+// encodeNode serializes a node with two-space indentation.
+func encodeNode(node *yaml.Node) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(yamlIndent)
+ if err := enc.Encode(node); err != nil {
+ return nil, err
+ }
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/git/manifestedit/scan.go b/internal/git/manifestedit/scan.go
new file mode 100644
index 00000000..1d8b6fd2
--- /dev/null
+++ b/internal/git/manifestedit/scan.go
@@ -0,0 +1,83 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// IndexDir recursively scans a folder for YAML manifests and builds an inventory.
+// Paths in the inventory are relative to root. Symlinks are never followed: a
+// symlinked file or directory is skipped, which avoids escaping the scan root and
+// symlink cycles.
+func IndexDir(root string) (Inventory, []Diagnostic) {
+ var files []FileContent
+ var diags []Diagnostic
+
+ walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ // Record the walk error and keep scanning the rest of the tree.
+ diags = append(diags, Diagnostic{Level: DiagWarning, Path: path, Message: err.Error()})
+ return nil //nolint:nilerr // a per-entry error must not abort the whole scan
+ }
+ // Never follow symlinks, for files or directories.
+ if d.Type()&fs.ModeSymlink != 0 {
+ rel := relPath(root, path)
+ diags = append(diags, Diagnostic{Level: DiagInfo, Path: rel, Message: "symlink skipped"})
+ if d.IsDir() {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if d.IsDir() || !isYAMLFile(path) {
+ return nil
+ }
+ content, readErr := os.ReadFile(path) //nolint:gosec // scanning a user-pointed manifest folder is the feature
+ if readErr != nil {
+ diags = append(diags, Diagnostic{Level: DiagWarning, Path: relPath(root, path), Message: readErr.Error()})
+ return nil //nolint:nilerr // an unreadable file must not abort the whole scan
+ }
+ files = append(files, FileContent{Path: relPath(root, path), Content: content})
+ return nil
+ })
+ if walkErr != nil {
+ diags = append(diags, Diagnostic{Level: DiagError, Path: root, Message: walkErr.Error()})
+ }
+
+ inv, indexDiags := IndexFiles(files)
+ diags = append(diags, indexDiags...)
+ return inv, diags
+}
+
+// isYAMLFile reports whether a path is a YAML manifest by extension.
+func isYAMLFile(path string) bool {
+ return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")
+}
+
+// relPath returns path relative to root, falling back to path on error.
+func relPath(root, path string) string {
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return path
+ }
+ return rel
+}
diff --git a/internal/git/manifestedit/split.go b/internal/git/manifestedit/split.go
new file mode 100644
index 00000000..be6bdeb5
--- /dev/null
+++ b/internal/git/manifestedit/split.go
@@ -0,0 +1,197 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestedit
+
+import "strings"
+
+// rawDoc is one YAML document carved out of a file as exact bytes. The file is
+// reconstructed by concatenating sep+body of every rawDoc in order, so unrelated
+// documents survive an edit byte-for-byte.
+type rawDoc struct {
+ // sep is the leading separator segment ("" for the first document, "---\n"
+ // or similar otherwise), preserved verbatim.
+ sep string
+ // body is the document content, preserved verbatim.
+ body string
+}
+
+// splitDocuments carves content into its YAML documents without losing a byte.
+// It is block-scalar aware so a "---" line inside a literal block is treated as
+// content, not as a document separator — matching real YAML semantics, where a
+// less-indented "---" would end the block anyway.
+func splitDocuments(content string) []rawDoc {
+ lines := splitLinesKeepEnds(content)
+
+ var docs []rawDoc
+ cur := rawDoc{}
+ inBlock := false
+ blockIndent := 0
+
+ addBody := func(line string) { cur.body += line }
+
+ for _, line := range lines {
+ if inBlock {
+ if isBlankLine(line) || leadingSpaces(line) > blockIndent {
+ addBody(line)
+ continue
+ }
+ inBlock = false // less indented: the block ended; reprocess this line
+ }
+
+ if isSeparatorLine(line) {
+ // A separator with nothing at all before it just opens the first
+ // document; it does not create a spurious empty document 0.
+ if len(docs) == 0 && cur.sep == "" && cur.body == "" {
+ cur.sep = line
+ continue
+ }
+ docs = append(docs, cur)
+ cur = rawDoc{sep: line}
+ continue
+ }
+
+ if ind, ok := opensBlockScalar(line); ok {
+ inBlock = true
+ blockIndent = ind
+ }
+ addBody(line)
+ }
+
+ docs = append(docs, cur)
+ return docs
+}
+
+// DocumentCount reports how many non-empty YAML documents a file holds. It is
+// block-scalar aware (it reuses the byte-faithful splitter), and ignores empty
+// documents such as a trailing "---". Callers use it to refuse a single-document
+// wholesale write that would silently drop the other documents in a shared file.
+func DocumentCount(content []byte) int {
+ n := 0
+ for _, d := range splitDocuments(string(content)) {
+ if strings.TrimSpace(d.body) != "" {
+ n++
+ }
+ }
+ return n
+}
+
+// joinDocuments reassembles documents into file content.
+func joinDocuments(docs []rawDoc) string {
+ var b strings.Builder
+ for _, d := range docs {
+ b.WriteString(d.sep)
+ b.WriteString(d.body)
+ }
+ return b.String()
+}
+
+// splitLinesKeepEnds splits content into lines, each retaining its line ending
+// (\n or \r\n). The final line keeps whatever ending it had, possibly none.
+func splitLinesKeepEnds(content string) []string {
+ var lines []string
+ start := 0
+ for i := range len(content) {
+ if content[i] == '\n' {
+ lines = append(lines, content[start:i+1])
+ start = i + 1
+ }
+ }
+ if start < len(content) {
+ lines = append(lines, content[start:])
+ }
+ return lines
+}
+
+// stripLineEnding removes a trailing \n and \r from a line.
+func stripLineEnding(line string) string {
+ line = strings.TrimSuffix(line, "\n")
+ line = strings.TrimSuffix(line, "\r")
+ return line
+}
+
+// isBlankLine reports whether a line is empty or only whitespace.
+func isBlankLine(line string) bool {
+ return strings.TrimSpace(line) == ""
+}
+
+// leadingSpaces counts the indentation of a line. Tabs are not valid YAML
+// indentation, so a tab is treated as a large indent to keep such lines inside a
+// surrounding block rather than misreading them as structure.
+func leadingSpaces(line string) int {
+ n := 0
+ for _, c := range line {
+ switch c {
+ case ' ':
+ n++
+ case '\t':
+ n += 8
+ default:
+ return n
+ }
+ }
+ return n
+}
+
+// isSeparatorLine reports whether a line is a YAML document separator ("---").
+func isSeparatorLine(line string) bool {
+ s := strings.TrimRight(stripLineEnding(line), " \t")
+ return s == "---" || strings.HasPrefix(s, "--- ")
+}
+
+// opensBlockScalar reports whether a line introduces a literal/folded block
+// scalar (value is |, >, optionally with chomping/indent indicators), returning
+// the indentation of the introducing line.
+func opensBlockScalar(line string) (int, bool) {
+ indent := leadingSpaces(line)
+ s := stripTrailingComment(strings.TrimRight(stripLineEnding(line), " \t"))
+ s = strings.TrimRight(s, " \t")
+
+ var val string
+ switch {
+ case strings.Contains(s, ": "):
+ val = strings.TrimSpace(s[strings.LastIndex(s, ": ")+1:])
+ case strings.HasSuffix(s, ":"):
+ val = ""
+ default:
+ // sequence entry like "- |"
+ val = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "-"))
+ }
+
+ if val == "" || (val[0] != '|' && val[0] != '>') {
+ return 0, false
+ }
+ for _, c := range val[1:] {
+ if c != '+' && c != '-' && (c < '0' || c > '9') {
+ return 0, false
+ }
+ }
+ return indent, true
+}
+
+// stripTrailingComment removes a trailing " #..." comment from a line fragment.
+// It is a heuristic: it only triggers on a hash preceded by whitespace, which is
+// enough for the block-scalar indicator detection it supports.
+func stripTrailingComment(s string) string {
+ for i := 1; i < len(s); i++ {
+ if s[i] == '#' && (s[i-1] == ' ' || s[i-1] == '\t') {
+ return s[:i]
+ }
+ }
+ return s
+}
diff --git a/internal/git/manifestedit/testdata/corpus/configmap-script.yaml b/internal/git/manifestedit/testdata/corpus/configmap-script.yaml
new file mode 100644
index 00000000..538a17c8
--- /dev/null
+++ b/internal/git/manifestedit/testdata/corpus/configmap-script.yaml
@@ -0,0 +1,17 @@
+# startup scripts for the app
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: startup-scripts
+ namespace: default
+ labels:
+ app: demo # selector label
+data:
+ build: "00123"
+ enabled: "false"
+ start.sh: |-
+ #!/bin/sh
+ set -eu
+
+ echo "starting app"
+ exec /app/server
diff --git a/internal/git/manifestedit/testdata/corpus/deployment.yaml b/internal/git/manifestedit/testdata/corpus/deployment.yaml
new file mode 100644
index 00000000..d69742b6
--- /dev/null
+++ b/internal/git/manifestedit/testdata/corpus/deployment.yaml
@@ -0,0 +1,25 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: app
+ namespace: default
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: app
+ template:
+ metadata:
+ labels:
+ app: app
+ spec:
+ containers:
+ - name: web
+ image: nginx:1.0
+ ports:
+ - containerPort: 80
+ env:
+ - name: LOG_LEVEL
+ value: info
+ - name: sidecar
+ image: envoy:1.0
diff --git a/internal/git/manifestedit/testdata/corpus/multidoc.yaml b/internal/git/manifestedit/testdata/corpus/multidoc.yaml
new file mode 100644
index 00000000..2cdd137c
--- /dev/null
+++ b/internal/git/manifestedit/testdata/corpus/multidoc.yaml
@@ -0,0 +1,19 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app-config
+ namespace: default
+data:
+ color: blue
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: app
+ namespace: default
+spec:
+ selector:
+ app: app
+ ports:
+ - port: 80
+ targetPort: 8080
diff --git a/internal/git/manifestedit/types.go b/internal/git/manifestedit/types.go
new file mode 100644
index 00000000..bdd7737a
--- /dev/null
+++ b/internal/git/manifestedit/types.go
@@ -0,0 +1,195 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package manifestedit is an isolated proof of concept for the manifest-inventory
+"file-agnostic placement" feature. It indexes Kubernetes resources from YAML
+content and edits a single document in place while preserving the formatting of
+everything it did not change.
+
+It is intentionally throw-away: the package proves whether gopkg.in/yaml.v3 node
+editing is good enough before any of this is wired into the real writer. See
+docs/design/manifest/manifest-parser-poc.md.
+*/
+package manifestedit
+
+// Identity is the manifest (content) identity of a Kubernetes object: the GVK
+// plus name and, for namespaced objects, namespace, exactly as written in YAML.
+// It is deliberately not the API-side resource identity (GVR); mapping a GVK to
+// a GVR needs a live RESTMapper and is out of scope for this POC.
+type Identity struct {
+ APIVersion string `json:"apiVersion"`
+ Kind string `json:"kind"`
+ Namespace string `json:"namespace"`
+ Name string `json:"name"`
+}
+
+// Location points at one document inside one file, relative to the scan root.
+type Location struct {
+ Path string
+ DocumentIndex int
+}
+
+// DiagnosticLevel classifies how serious a diagnostic is.
+type DiagnosticLevel string
+
+const (
+ // DiagInfo is informational and never blocks editing.
+ DiagInfo DiagnosticLevel = "info"
+ // DiagWarning marks something skipped or ignored but not fatal to the file.
+ DiagWarning DiagnosticLevel = "warning"
+ // DiagError marks content that cannot be edited safely.
+ DiagError DiagnosticLevel = "error"
+)
+
+// DiagReason is a structured, machine-readable cause for a diagnostic. It lets
+// callers classify a document from a code rather than by parsing the
+// human-readable Message — which the manifest materialization design explicitly
+// forbids. The zero value is the empty reason, used for diagnostics that carry no
+// structured classification (e.g. edit-time skips).
+type DiagReason string
+
+const (
+ // ReasonInvalidYAML marks a document that does not parse as YAML.
+ ReasonInvalidYAML DiagReason = "invalid-yaml"
+ // ReasonEmptyDocument marks an empty or comment-only document.
+ ReasonEmptyDocument DiagReason = "empty-document"
+ // ReasonNotKRM marks valid YAML that is not a Kubernetes manifest.
+ ReasonNotKRM DiagReason = "not-krm"
+ // ReasonNonEditable marks a manifest the editor refuses to edit in place
+ // (anchors, aliases, merge keys, unusual tags, duplicate keys).
+ ReasonNonEditable DiagReason = "non-editable"
+ // ReasonMissingSopsKey marks a .sops.yaml file lacking a sops stanza.
+ ReasonMissingSopsKey DiagReason = "missing-sops-key"
+ // ReasonDuplicateIdentity marks a document whose manifest identity duplicates
+ // an earlier occurrence.
+ ReasonDuplicateIdentity DiagReason = "duplicate-identity"
+)
+
+// Diagnostic explains an inventory or edit decision.
+type Diagnostic struct {
+ Level DiagnosticLevel `json:"level"`
+ // Reason is the structured cause, set for index-time classification so callers
+ // never parse Message. It is empty for diagnostics with no structured code.
+ Reason DiagReason `json:"reason,omitempty"`
+ Message string `json:"message"`
+ Path string `json:"path"`
+ DocumentIndex int `json:"documentIndex"`
+}
+
+// DocumentRecord is one indexed Kubernetes document.
+type DocumentRecord struct {
+ Identity Identity
+ Location Location
+ // Editable is false when the document uses constructs the POC refuses to edit
+ // (anchors, aliases, merge keys) or when it lost a duplicate-identity contest.
+ Editable bool
+ // Reason explains a non-editable record.
+ Reason string
+ // Encrypted is true for a SOPS-managed document with cleartext identity.
+ Encrypted bool
+}
+
+// Inventory is the mapping from resource identity to its authoritative location,
+// plus the full list of records and any duplicate losers that must be deleted.
+type Inventory struct {
+ // Records are all indexed documents in stable scan order (path, then index).
+ Records []DocumentRecord
+ // byIdentity holds the winning location for each identity.
+ byIdentity map[Identity]Location
+ // duplicates are records that lost the first-occurrence-wins contest and
+ // should be deleted so Git converges to a single copy.
+ duplicates []DocumentRecord
+}
+
+// Location returns the authoritative location for an identity, if indexed.
+func (inv Inventory) Location(id Identity) (Location, bool) {
+ loc, ok := inv.byIdentity[id]
+ return loc, ok
+}
+
+// Duplicates returns the records that lost the first-occurrence-wins contest.
+func (inv Inventory) Duplicates() []DocumentRecord {
+ return inv.duplicates
+}
+
+// Summary is a compact, bounded overview of an inventory. The vision flags that
+// GitTarget status cannot enumerate thousands of manifests, so this seeds the
+// "high-level stats first" direction: a status surface shows these counts and
+// keeps per-resource detail for a separate read path.
+type Summary struct {
+ Documents int
+ Editable int
+ NonEditable int
+ Encrypted int
+ Duplicates int
+}
+
+// Summary returns bounded counts over the inventory.
+func (inv Inventory) Summary() Summary {
+ s := Summary{Duplicates: len(inv.duplicates)}
+ for _, r := range inv.Records {
+ s.Documents++
+ if r.Editable {
+ s.Editable++
+ } else {
+ s.NonEditable++
+ }
+ if r.Encrypted {
+ s.Encrypted++
+ }
+ }
+ return s
+}
+
+// CountByLevel groups diagnostics by severity, for a bounded status summary
+// instead of listing every diagnostic.
+func CountByLevel(diags []Diagnostic) map[DiagnosticLevel]int {
+ out := make(map[DiagnosticLevel]int)
+ for _, d := range diags {
+ out[d.Level]++
+ }
+ return out
+}
+
+// EditMode describes what PatchDocument did.
+type EditMode string
+
+const (
+ // EditNoChange means the document already matched the clean desired projection.
+ EditNoChange EditMode = "no-change"
+ // EditPatched means only the changed nodes were updated in place.
+ EditPatched EditMode = "patched"
+ // EditWholeReplace means the whole document body was re-rendered as a fallback.
+ EditWholeReplace EditMode = "whole-replace"
+ // EditSkipped means the document was left untouched because editing was unsafe.
+ EditSkipped EditMode = "skipped"
+)
+
+// EditResult is the outcome of editing one document.
+type EditResult struct {
+ // Content is the full file content after the edit.
+ Content []byte
+ Mode EditMode
+}
+
+// FileContent pairs a path with its raw bytes for multi-file indexing.
+type FileContent struct {
+ Path string
+ Content []byte
+}
diff --git a/internal/git/pending_writes.go b/internal/git/pending_writes.go
index 4a8e9314..f3350c72 100644
--- a/internal/git/pending_writes.go
+++ b/internal/git/pending_writes.go
@@ -184,7 +184,7 @@ func (w *BranchWorker) resolveTargetMetadata(
// MessageKind is derived from the pending write's shape.
func (p PendingWrite) MessageKind() CommitMessageKind {
- if p.Kind == PendingWriteAtomic {
+ if p.Kind == PendingWriteAtomic || p.Kind == PendingWriteResync {
return CommitMessageSnapshot
}
if len(p.Events) == 1 {
@@ -220,7 +220,7 @@ func (p PendingWrite) Target() ResolvedTargetMetadata {
}
func (p PendingWrite) targetIdentity() (string, string) {
- if p.Kind == PendingWriteAtomic {
+ if p.Kind == PendingWriteAtomic || p.Kind == PendingWriteResync {
return p.GitTargetName, p.GitTargetNamespace
}
if len(p.Events) == 0 {
diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go
new file mode 100644
index 00000000..7e1e37c6
--- /dev/null
+++ b/internal/git/plan_flush.go
@@ -0,0 +1,573 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+
+ gogit "github.com/go-git/go-git/v5"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestreport"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// flushEventsToWorktree is the plan-then-flush write path (M7), described in
+// docs/design/manifest/current-manifest-support-review.md ("Writer Model: Plan,
+// Apply, Dirty Flush"). It replaces the per-event locate+write loop: it builds the
+// byte-free structure model for the GitTarget subtree once, resolves each coalesced
+// event to a single-identity action over that model, applies the actions to
+// hydrated commit-scoped file buffers, and flushes only the files whose bytes
+// changed or were deleted. It returns true when at least one file was written or
+// removed.
+//
+// This is the steady-state half of the design's "Two Paths, One Plan Type"
+// (docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md): every event is
+// a single-identity intent — an upsert (create/patch/replace) for an object-bearing
+// event, or a delete-document for a DELETE — and the writer NEVER mark-and-sweeps a
+// batch. Whole-folder mark-and-sweep is the resync mechanism (M8), not steady state.
+func (w *BranchWorker) flushEventsToWorktree(
+ ctx context.Context,
+ worktree *gogit.Worktree,
+ base string,
+ events []Event,
+) (bool, error) {
+ root := worktree.Filesystem.Root()
+ files, err := scanWorktreeYAML(filepath.Join(root, base))
+ if err != nil {
+ return false, err
+ }
+
+ batch := newWriteBatch(ctx, w.contentWriter, w.mapper, files)
+ for _, event := range events {
+ if err := batch.applyEvent(ctx, event); err != nil {
+ return false, err
+ }
+ }
+ return batch.flush(ctx, worktree, root, base)
+}
+
+// writeBatch is the commit-scoped plan-then-flush working set for one GitTarget
+// subtree. The store is the byte-free model the batch resolves identities against;
+// contentByPath holds the worktree bytes so a touched file is hydrated lazily into
+// a fileBuffer; buffers accumulates the mutations the events produce.
+type writeBatch struct {
+ writer eventContentWriter
+ mapper typeset.Lookup
+ store *manifestanalyzer.ManifestStore
+ docLoc map[*manifestanalyzer.DocumentModel]manifestanalyzer.RecordRef
+ contentByPath map[string][]byte
+ buffers map[string]*fileBuffer
+}
+
+func newWriteBatch(
+ ctx context.Context,
+ writer eventContentWriter,
+ mapper typeset.Lookup,
+ files []manifestedit.FileContent,
+) *writeBatch {
+ // An empty allowlist materialises every KRM document — the live writer indexes
+ // the whole subtree for placement, exactly as the per-event inventory did. The
+ // acceptance gate (allowlist, scope, refusals) is applied upstream, not here.
+ store := manifestanalyzer.BuildStoreFromFiles(ctx, files, mapper, manifestanalyzer.Allowlist{})
+ contentByPath := make(map[string][]byte, len(files))
+ for _, f := range files {
+ contentByPath[f.Path] = f.Content
+ }
+ return &writeBatch{
+ writer: writer,
+ mapper: mapper,
+ store: store,
+ docLoc: store.DocumentLocations(),
+ contentByPath: contentByPath,
+ buffers: map[string]*fileBuffer{},
+ }
+}
+
+// fileBuffer is the commit-scoped, hydrated working copy of one file under the
+// GitTarget base path. original is the worktree bytes (nil for a file the batch
+// creates); current is the bytes after applying actions (nil means the file should
+// be removed). Dirty/Deleted are derived exactly as the design's FileModel — two
+// byte slices are the whole state machine, so there is no flag to forget to flip.
+type fileBuffer struct {
+ rel string
+ original []byte
+ current []byte
+}
+
+func (b *fileBuffer) dirty() bool { return b.current != nil && !bytes.Equal(b.current, b.original) }
+func (b *fileBuffer) deleted() bool { return b.current == nil && b.original != nil }
+
+// buffer returns the hydrated working copy for a base-relative path, reading the
+// worktree bytes into Original/Current on first touch. A path with no worktree
+// bytes is a new file (Original nil).
+func (wb *writeBatch) buffer(rel string) *fileBuffer {
+ if b, ok := wb.buffers[rel]; ok {
+ return b
+ }
+ b := &fileBuffer{rel: rel}
+ if orig, ok := wb.contentByPath[rel]; ok {
+ b.original = orig
+ b.current = orig
+ }
+ wb.buffers[rel] = b
+ return b
+}
+
+// upsertOutcome is what an upsert actually did to the worktree bytes, so a caller can
+// count create/update accurately from the apply rather than from a separate plan
+// estimate (which mislabels a re-encrypted sensitive resource as skipped).
+type upsertOutcome int
+
+const (
+ upsertNoChange upsertOutcome = iota
+ upsertCreated
+ upsertUpdated
+)
+
+// applyEvent folds one event into the batch: a field patch sets bounded fields on an
+// existing parent, a DELETE removes a document, anything else is an upsert (the
+// object-bearing event the stream guarantees for non-deletes). The steady-state
+// writer does not need the upsert outcome (it flushes by byte state), so it is
+// discarded here; the resync planner consumes it for stats.
+func (wb *writeBatch) applyEvent(ctx context.Context, event Event) error {
+ switch {
+ case event.IsFieldPatch():
+ return wb.applyFieldPatch(ctx, event)
+ case event.Operation == "DELETE":
+ wb.applyDelete(event)
+ return nil
+ default:
+ _, err := wb.applyUpsert(ctx, event)
+ return err
+ }
+}
+
+// applyUpsert resolves an object-bearing event against the subtree. When a managed
+// document for its identity already lives there — even moved off the canonical path —
+// the resource is edited where it lives: a non-sensitive document is patched in place;
+// a sensitive document is re-encrypted wholesale AT ITS EXISTING PATH (never patched in
+// place — that would drop the SOPS metadata and write the secret back in cleartext, and
+// never at the canonical path, which would orphan the moved copy). A resource with no
+// existing document is a new file at the canonical placement path. It returns what it
+// did to the bytes (created / updated / no change).
+func (wb *writeBatch) applyUpsert(ctx context.Context, event Event) (upsertOutcome, error) {
+ if id, ok := manifestIdentity(event.Object); ok {
+ if dm := wb.store.ByManifestIdentity[id]; dm != nil {
+ filePath := wb.docLoc[dm].FilePath
+ if wb.writer.isSensitiveIdentifier(event.Identifier) {
+ return wb.writeWholeFile(ctx, event, filePath)
+ }
+ return wb.patchExisting(ctx, event, filePath, id, dm)
+ }
+ }
+ return wb.writeWholeFile(ctx, event, wb.writer.filePathForIdentifier(event.Identifier))
+}
+
+// applyFieldPatch folds a subresource field-patch event into the batch: it locates the
+// existing managed parent document by content identity and sets only the patch's
+// declared field paths via manifestedit.PatchFields, preserving every other byte.
+//
+// Two deliberate refusals make this safe for a partial intent:
+// - There is NO creation path. A patch whose parent is absent from Git is dropped,
+// because fabricating the parent would mean guessing every unaudited field.
+// - The renderer is NOT injected. A document that cannot be patched field-by-field
+// is SKIPPED, not whole-replaced — a whole-replace from the partial desired would
+// delete every field the subresource did not mention. An encrypted parent is
+// likewise skipped (PatchFields inherits the SOPS refusal from Decide).
+//
+// The document index is re-derived from the buffer's CURRENT bytes so an earlier event
+// in the same batch that shifted a multi-document file does not misdirect the edit.
+func (wb *writeBatch) applyFieldPatch(ctx context.Context, event Event) error {
+ filePath, id, ok := wb.resolveFieldPatchTarget(event)
+ if !ok {
+ log.FromContext(ctx).Info("Dropping field patch: parent manifest not present in Git",
+ "resource", event.Identifier.String(), "source", event.FieldPatch.Source,
+ "reason", "subresource_patch_no_parent")
+ return nil
+ }
+
+ buf := wb.buffer(filePath)
+ idx, found := currentDocIndex(filePath, buf.current, id)
+ if !found {
+ // An earlier event in this batch already removed the document; nothing to patch.
+ return nil
+ }
+
+ res, diags := manifestedit.PatchFields(
+ buf.current, idx, id, event.FieldPatch.Assignments, manifestedit.EditOptions{},
+ )
+ switch res.Mode {
+ case manifestedit.EditPatched:
+ buf.current = res.Content
+ case manifestedit.EditNoChange, manifestedit.EditDeleted:
+ // No-op: the audited value already matched (or, impossible here, a delete).
+ case manifestedit.EditSkipped, manifestedit.EditWholeReplace:
+ // EditSkipped (encrypted, non-editable, or snapshot drift), or a defensive
+ // EditWholeReplace we must never apply from a partial desired.
+ log.FromContext(ctx).Info("Field patch not applied: parent is encrypted or not field-patchable",
+ "resource", event.Identifier.String(), "source", event.FieldPatch.Source,
+ "reason", "subresource_patch_unsafe")
+ logManifestDiagnostics(ctx, diags)
+ }
+ return nil
+}
+
+// resolveFieldPatchTarget locates the parent manifest a field-patch event targets.
+// The parent is resolved from its objectRef GVR through the same resource-identity
+// inventory the GVR-only delete uses (PlanDelete), which the live-catalog mapper
+// populates while scanning the GitTarget folder. The returned identity is the parent
+// document's own manifest identity (full GVK from the committed YAML), so the patch
+// is applied with the parent's real Kind, never one guessed from the subresource body.
+//
+// found is false when Git holds no managed document for the parent identity.
+func (wb *writeBatch) resolveFieldPatchTarget(event Event) (string, manifestedit.Identity, bool) {
+ if action, emitted := manifestanalyzer.PlanDelete(wb.store, event.Identifier); emitted {
+ return action.Ref.FilePath, action.Identity, true
+ }
+ return "", manifestedit.Identity{}, false
+}
+
+// patchExisting edits the existing managed document for id in place via manifestedit,
+// preserving the sibling documents' bytes and the target's hand-authored formatting.
+// The no-op / patch / whole-replace / skip choice is a plan decision (Decide), not a
+// per-event heuristic. The document position is re-derived from the buffer's CURRENT
+// bytes (currentDocIndex), not the pre-batch store index, so an earlier event in the
+// same batch that shifted a multi-document file does not misdirect this edit. A
+// document the store located but an earlier event already removed is simply absent now,
+// so there is nothing to patch.
+func (wb *writeBatch) patchExisting(
+ ctx context.Context,
+ event Event,
+ filePath string,
+ id manifestedit.Identity,
+ dm *manifestanalyzer.DocumentModel,
+) (upsertOutcome, error) {
+ buf := wb.buffer(filePath)
+ idx, ok := currentDocIndex(filePath, buf.current, rawManifestIDForCurrentBytes(id, dm))
+ if !ok {
+ return upsertNoChange, nil
+ }
+ gitDoc, _ := manifestedit.NewDocumentAt(filePath, buf.current, idx)
+ desired := event.Object
+ if dm.NamespaceInheritedFromContext() && desired != nil {
+ desired = desired.DeepCopy()
+ desired.SetNamespace("")
+ }
+ c := manifestedit.Comparison{
+ Git: gitDoc,
+ Desired: manifestreport.Project(desired),
+ Options: manifestreport.EditOptions(),
+ }
+ res, diags := manifestedit.Apply(c, manifestedit.Decide(c))
+ switch res.Mode {
+ case manifestedit.EditPatched, manifestedit.EditWholeReplace:
+ buf.current = res.Content
+ return upsertUpdated, nil
+ case manifestedit.EditNoChange, manifestedit.EditSkipped, manifestedit.EditDeleted:
+ // No-op, an unsafe edit left untouched, or (impossible here) a delete: leave
+ // the bytes as they are. Surface a skip so an operator can see a document Git
+ // holds but the editor refused.
+ if res.Mode == manifestedit.EditSkipped {
+ logManifestDiagnostics(ctx, diags)
+ }
+ }
+ return upsertNoChange, nil
+}
+
+// writeWholeFile renders the event's clean content (sanitized, or SOPS-encrypted for a
+// sensitive resource) and writes it wholesale at rel: the canonical placement path for a
+// new resource, or the existing file path for a located sensitive resource. It keeps the
+// two per-event-writer safety rules: it never overwrites a multi-document file (which
+// would drop siblings — splicing a single rendered/encrypted document into a multi-doc
+// file is unsupported, so it is refused), and a write that matches the current bytes is a
+// no-op (the byte state machine, with the semantic-equality guard for comment-only diffs).
+func (wb *writeBatch) writeWholeFile(ctx context.Context, event Event, rel string) (upsertOutcome, error) {
+ content, err := wb.writer.buildContentForWrite(ctx, event)
+ if err != nil {
+ if wb.writer.isSensitiveIdentifier(event.Identifier) {
+ log.FromContext(ctx).Info(
+ "Sensitive resource write skipped because encryption failed",
+ "resource", event.Identifier.String(),
+ "error", err.Error(),
+ )
+ }
+ return upsertNoChange, err
+ }
+
+ buf := wb.buffer(rel)
+ isNew := buf.current == nil
+ if buf.current != nil {
+ if manifestedit.DocumentCount(buf.current) > 1 {
+ log.FromContext(ctx).Info(
+ "Skipping wholesale write: target holds a multi-document file",
+ "file", rel,
+ "resource", event.Identifier.String(),
+ )
+ return upsertNoChange, nil
+ }
+ if bytes.Equal(buf.current, content) || manifestsAreSemanticallyEqual(buf.current, content) {
+ return upsertNoChange, nil
+ }
+ }
+ buf.current = content
+ if isNew {
+ return upsertCreated, nil
+ }
+ return upsertUpdated, nil
+}
+
+// applyDelete removes the document a DELETE event targets. The document is located by
+// content (resolveDelete), so a manifest moved off its canonical path is still deleted.
+// The position is re-derived from the buffer's CURRENT bytes, so an earlier delete in
+// the same batch that shifted a multi-document file does not misdirect this one.
+// Removing the last document in a file marks it for deletion; otherwise the surviving
+// documents are kept byte-for-byte.
+func (wb *writeBatch) applyDelete(event Event) {
+ target, found := wb.resolveDelete(event)
+ if !found {
+ return
+ }
+ buf := wb.buffer(target.filePath)
+ if buf.current == nil {
+ return
+ }
+ idx, ok := currentDocIndex(target.filePath, buf.current, target.id)
+ if !ok {
+ return
+ }
+ res, _ := manifestedit.DeleteDocument(buf.current, idx)
+ if res.FileEmpty {
+ buf.current = nil
+ return
+ }
+ buf.current = res.Content
+}
+
+// deleteTarget names the file and manifest identity a delete targets. The document
+// position is re-derived from the live bytes at apply time because a multi-document
+// file's indices can shift within a batch.
+type deleteTarget struct {
+ filePath string
+ id manifestedit.Identity
+}
+
+// resolveDelete locates the managed document a DELETE event targets, content-first:
+//
+// 1. A delete event that still carries its object is matched by manifest identity,
+// so it follows a moved manifest (the placement guarantee the per-event writer had).
+// 2. A GVR-only delete is resolved through PlanDelete's resource-identity inventory.
+//
+// found is false when Git holds no managed document for the resource.
+func (wb *writeBatch) resolveDelete(event Event) (deleteTarget, bool) {
+ if id, ok := manifestIdentity(event.Object); ok {
+ if dm := wb.store.ByManifestIdentity[id]; dm != nil {
+ return deleteTarget{filePath: wb.docLoc[dm].FilePath, id: rawManifestIDForCurrentBytes(id, dm)}, true
+ }
+ }
+ action, emitted := manifestanalyzer.PlanDelete(wb.store, event.Identifier)
+ if emitted {
+ id := action.Identity
+ if dm := wb.store.ByManifestIdentity[id]; dm != nil {
+ id = rawManifestIDForCurrentBytes(id, dm)
+ }
+ return deleteTarget{filePath: action.Ref.FilePath, id: id}, true
+ }
+ return deleteTarget{}, false
+}
+
+// rawManifestIDForCurrentBytes maps an effective manifest identity back to the raw
+// identity as written in the file: when the namespace was inherited from kustomization
+// context, the file bytes carry no metadata.namespace, so the document is located by a
+// namespace-less identity.
+func rawManifestIDForCurrentBytes(
+ id manifestedit.Identity,
+ dm *manifestanalyzer.DocumentModel,
+) manifestedit.Identity {
+ if dm != nil && dm.NamespaceInheritedFromContext() {
+ id.Namespace = ""
+ }
+ return id
+}
+
+// currentDocIndex re-derives the position of the managed document for id within the
+// file's live bytes. The pre-batch store index can go stale when an earlier event in the
+// same batch shifts a multi-document file (a delete drops a document, renumbering its
+// successors), so any edit/delete recomputes the position against the current bytes
+// rather than trusting the index captured at scan time. ok is false when no document of
+// that identity is present in the bytes (e.g. already removed earlier in the batch).
+func currentDocIndex(filePath string, content []byte, id manifestedit.Identity) (int, bool) {
+ inv, _ := manifestedit.IndexFile(filePath, content)
+ loc, ok := inv.Location(id)
+ return loc.DocumentIndex, ok
+}
+
+// flush writes every dirty buffer and removes every deleted buffer under the
+// GitTarget base path, staging each change in the worktree. It returns true when at
+// least one file was written or removed.
+func (wb *writeBatch) flush(ctx context.Context, worktree *gogit.Worktree, root, base string) (bool, error) {
+ logger := log.FromContext(ctx)
+ changed := false
+ for _, rel := range sortedBufferKeys(wb.buffers) {
+ buf := wb.buffers[rel]
+ worktreePath := path.Join(base, rel)
+ fullPath := filepath.Join(root, base, rel)
+ switch {
+ case buf.deleted():
+ if _, err := removeFileFromWorktree(logger, worktreePath, fullPath, worktree); err != nil {
+ return changed, err
+ }
+ changed = true
+ case buf.dirty():
+ if err := writeAndStageFile(worktree, worktreePath, fullPath, buf.current); err != nil {
+ return changed, err
+ }
+ changed = true
+ }
+ }
+ return changed, nil
+}
+
+// writeAndStageFile writes a file's bytes to disk (creating parent directories) and
+// stages it in the worktree.
+func writeAndStageFile(worktree *gogit.Worktree, worktreePath, fullPath string, content []byte) error {
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0o750); err != nil {
+ return wrapPathErr("create directory for", worktreePath, err)
+ }
+ // fullPath is an internally derived repo path: the GitTarget segment is run
+ // through sanitizePath and the rest comes from the resource's API identity or a
+ // content-indexed worktree file, joined under the worktree root — not external input.
+ if err := os.WriteFile(fullPath, content, 0o600); err != nil {
+ return wrapPathErr("write file", worktreePath, err)
+ }
+ if _, err := worktree.Add(worktreePath); err != nil {
+ return wrapPathErr("add file", worktreePath, err)
+ }
+ return nil
+}
+
+// scanWorktreeYAML reads every YAML manifest under absBase into base-relative
+// FileContent for store construction and hydration. A missing base directory (a
+// never-written GitTarget path) yields no files, not an error. Symlinks are never
+// followed.
+func scanWorktreeYAML(absBase string) ([]manifestedit.FileContent, error) {
+ var files []manifestedit.FileContent
+ walkErr := filepath.WalkDir(absBase, func(p string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.Type()&os.ModeSymlink != 0 {
+ if d.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if d.IsDir() || !isYAMLManifest(p) {
+ return nil
+ }
+ rel, relErr := filepath.Rel(absBase, p)
+ if relErr != nil {
+ return relErr
+ }
+ content, readErr := os.ReadFile(p) //nolint:gosec // scanning the GitTarget worktree subtree is the feature
+ if readErr != nil {
+ return readErr
+ }
+ files = append(files, manifestedit.FileContent{Path: filepath.ToSlash(rel), Content: content})
+ return nil
+ })
+ if walkErr != nil && !os.IsNotExist(walkErr) {
+ return nil, walkErr
+ }
+ sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
+ return files, nil
+}
+
+// isYAMLManifest reports whether a path is a YAML manifest by extension.
+func isYAMLManifest(p string) bool {
+ ext := filepath.Ext(p)
+ return ext == ".yaml" || ext == ".yml"
+}
+
+// groupEventsByBase buckets events by their sanitized GitTarget base path, preserving
+// arrival order within each bucket. A grouped commit window is single-target (one
+// base) by construction; the grouping stays correct for any future multi-target batch.
+func groupEventsByBase(events []Event) map[string][]Event {
+ byBase := map[string][]Event{}
+ for _, event := range events {
+ base := sanitizePath(event.Path)
+ byBase[base] = append(byBase[base], event)
+ }
+ return byBase
+}
+
+// sortedBufferKeys returns the buffer paths in lexicographic order so flushing is
+// deterministic regardless of map iteration order.
+func sortedBufferKeys(buffers map[string]*fileBuffer) []string {
+ keys := make([]string, 0, len(buffers))
+ for k := range buffers {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// sortedBaseKeys returns the base paths in lexicographic order so subtrees are
+// flushed deterministically.
+func sortedBaseKeys(byBase map[string][]Event) []string {
+ keys := make([]string, 0, len(byBase))
+ for k := range byBase {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// logManifestDiagnostics surfaces manifestedit diagnostics at low verbosity so a
+// skipped edit is observable without noise on the happy path.
+func logManifestDiagnostics(ctx context.Context, diags []manifestedit.Diagnostic) {
+ logger := log.FromContext(ctx)
+ for _, d := range diags {
+ logger.V(1).Info("manifest edit diagnostic",
+ "level", d.Level, "file", d.Path, "documentIndex", d.DocumentIndex, "message", d.Message)
+ }
+}
+
+// wrapPathErr wraps a worktree file operation error with the action and path.
+func wrapPathErr(action, p string, err error) error {
+ return &pathOpError{action: action, path: p, err: err}
+}
+
+type pathOpError struct {
+ action string
+ path string
+ err error
+}
+
+func (e *pathOpError) Error() string {
+ return "failed to " + e.action + " " + e.path + ": " + e.err.Error()
+}
+func (e *pathOpError) Unwrap() error { return e.err }
diff --git a/internal/git/plan_flush_test.go b/internal/git/plan_flush_test.go
new file mode 100644
index 00000000..5dfbebcf
--- /dev/null
+++ b/internal/git/plan_flush_test.go
@@ -0,0 +1,234 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// cmEvent builds an event for an arbitrary ConfigMap identity/value.
+func cmEvent(op, name, color string) Event {
+ return Event{
+ Object: &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{"name": name, "namespace": "default"},
+ "data": map[string]interface{}{"color": color},
+ }},
+ Identifier: types.ResourceIdentifier{
+ Group: "", Version: "v1", Resource: "configmaps", Namespace: "default", Name: name,
+ },
+ Operation: op,
+ }
+}
+
+// A resource with no document in Git is created at its canonical placement path with
+// the canonical rendered content.
+func TestPlanFlush_CreatesNewResourceAtCanonicalPath(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ event := cmEvent("CREATE", "fresh", "green")
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+ require.True(t, changed, "a new resource must be written")
+
+ canonical := filepath.Join(root, writer.filePathForIdentifier(event.Identifier))
+ got, err := os.ReadFile(canonical)
+ require.NoError(t, err)
+ want, err := writer.buildContentForWrite(context.Background(), event)
+ require.NoError(t, err)
+ assert.Equal(t, string(want), string(got), "a new file is the canonical rendering")
+}
+
+// Deleting one document from a multi-document file removes only that document and
+// keeps the file (and its siblings) when documents remain. The delete event carries
+// its object, so it content-matches the right document.
+func TestPlanFlush_DeleteOneDocFromMultiDocKeepsSiblings(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ keep := "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: keep\n namespace: default\n" +
+ "data:\n k: v\n"
+ drop := "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: drop\n namespace: default\n" +
+ "data:\n k: v\n"
+ rel := "apps/multi.yaml"
+ full := filepath.Join(root, rel)
+ seedPlacedManifest(t, worktree, rel, keep+"---\n"+drop)
+
+ del := cmEvent("DELETE", "drop", "v")
+ changed := applyEventsViaPlanFlush(t, writer, worktree, del)
+ require.True(t, changed, "the targeted document must be removed")
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "name: keep", "the sibling document must survive")
+ assert.NotContains(t, string(got), "name: drop", "the targeted document must be gone")
+}
+
+// Deleting a resource the folder never materialised is a no-op: nothing to remove,
+// no change reported.
+func TestPlanFlush_DeleteOfAbsentResourceIsNoOp(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ del := Event{Identifier: cmEvent("DELETE", "ghost", "").Identifier, Operation: "DELETE"}
+ changed := applyEventsViaPlanFlush(t, writer, worktree, del)
+ assert.False(t, changed, "deleting an absent resource changes nothing")
+}
+
+// A GVR-only DELETE event (no object body, the reconcile/orphan shape) still finds a
+// manifest moved off its canonical path when a mapper is wired: the resource-identity
+// index resolves the GVR to the document's content identity (M6's PlanDelete folded
+// into the writer). This is the path the live-catalog mapper enables in production.
+func TestPlanFlush_DeleteByGVROnlyFollowsMovedManifestViaMapper(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ // A ConfigMap a user moved off its canonical path.
+ placedFull := seedPlacedManifest(t, worktree, placedManifestPath, placedManifestBlue)
+
+ mapper := typeset.NewSnapshotRegistry(typeset.Snapshot{
+ Entries: []typeset.Entry{{
+ GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"},
+ GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"},
+ Namespaced: true,
+ Allowed: true,
+ }},
+ })
+ w := &BranchWorker{contentWriter: writer, mapper: mapper}
+
+ // DELETE carrying only the GVR identity — no object body.
+ del := Event{
+ Identifier: types.ResourceIdentifier{
+ Group: "", Version: "v1", Resource: "configmaps", Namespace: "default", Name: "app",
+ },
+ Operation: "DELETE",
+ }
+ changed, err := w.flushEventsToWorktree(context.Background(), worktree, "", []Event{del})
+ require.NoError(t, err)
+ assert.True(t, changed, "the moved manifest must be deleted via the resolved resource identity")
+ _, statErr := os.Stat(placedFull)
+ assert.True(t, os.IsNotExist(statErr), "apps/foo.yaml must be deleted, not orphaned")
+}
+
+// A sensitive (SOPS) resource a user moved off its canonical .sops path must be
+// re-encrypted wholesale AT ITS EXISTING PATH — never patched in place (which would
+// leak the secret in cleartext) and never duplicated at the canonical path (which would
+// orphan the moved copy). This pins the regression where sensitive upserts skipped
+// content placement and always wrote the canonical path.
+func TestPlanFlush_SensitiveMovedResourceRewritesInPlaceNotCanonical(t *testing.T) {
+ enc := &stubEncryptor{result: []byte(
+ "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: default\n" +
+ "data:\n k: ENC[AES256,data:NEW,iv:cc,tag:dd]\nsops:\n version: 3.9.0\n mac: NEW\n")}
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ writer.setEncryptor(enc, "test-scope")
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ // A Secret moved off its canonical .sops path. It carries a cleartext identity and a
+ // sops key, so the store indexes it as an encrypted managed document. The encrypted
+ // data differs from the new render, so the write is a real change, not a no-op.
+ movedRel := "secrets/app.sops.yaml"
+ seeded := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: default\n" +
+ "data:\n k: ENC[AES256,data:OLD,iv:aa,tag:bb]\nsops:\n version: 3.9.0\n mac: OLD\n"
+ movedFull := seedPlacedManifest(t, worktree, movedRel, seeded)
+
+ event := Event{
+ Object: &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1", "kind": "Secret",
+ "metadata": map[string]interface{}{"name": "app", "namespace": "default"},
+ "data": map[string]interface{}{"k": "dg=="},
+ }},
+ Identifier: types.ResourceIdentifier{
+ Group: "", Version: "v1", Resource: "secrets", Namespace: "default", Name: "app",
+ },
+ Operation: "UPDATE",
+ }
+ changed := applyEventsViaPlanFlush(t, writer, worktree, event)
+ require.True(t, changed)
+
+ got, err := os.ReadFile(movedFull)
+ require.NoError(t, err)
+ assert.Equal(t, string(enc.result), string(got), "the moved secret is re-encrypted at its existing path")
+
+ canonicalFull := filepath.Join(root, writer.filePathForIdentifier(event.Identifier))
+ _, statErr := os.Stat(canonicalFull)
+ assert.Truef(t, os.IsNotExist(statErr),
+ "no duplicate secret must be created at the canonical .sops path %s", canonicalFull)
+}
+
+// Within one batch, deleting a document from a multi-document file shifts the indices of
+// the documents after it. A later event updating a surviving sibling must target it by
+// its CURRENT position, not the stale pre-batch index — otherwise the update is dropped
+// (index out of range) or lands on the wrong document.
+func TestPlanFlush_BatchDeleteThenUpdateSiblingTargetsCorrectDoc(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ rel := "apps/multi.yaml"
+ first := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: first\n namespace: default\ndata:\n k: v\n"
+ second := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: second\n namespace: default\ndata:\n color: blue\n"
+ full := filepath.Join(root, rel)
+ seedPlacedManifest(t, worktree, rel, first+"---\n"+second)
+
+ // Event A deletes document 0 ("first"); event B updates "second", originally at
+ // document 1 but at document 0 once "first" is gone.
+ delFirst := cmEvent("DELETE", "first", "v")
+ updSecond := cmEvent("UPDATE", "second", "green")
+ changed := applyEventsViaPlanFlush(t, writer, worktree, delFirst, updSecond)
+ require.True(t, changed)
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.NotContains(t, string(got), "name: first", "the deleted document is gone")
+ assert.Contains(t, string(got), "name: second", "the surviving document remains")
+ assert.Contains(t, string(got), "color: green", "the update must land on the correct sibling")
+ assert.NotContains(t, string(got), "color: blue", "the stale-index bug would have left the old value")
+}
+
+// groupEventsByBase buckets events by their sanitized GitTarget path, preserving
+// arrival order within a bucket.
+func TestGroupEventsByBase(t *testing.T) {
+ a1 := Event{Path: "apps", Operation: "CREATE"}
+ b1 := Event{Path: "infra", Operation: "CREATE"}
+ a2 := Event{Path: "apps/", Operation: "DELETE"} // sanitizes to "apps"
+
+ byBase := groupEventsByBase([]Event{a1, b1, a2})
+ require.Len(t, byBase, 2)
+ require.Len(t, byBase["apps"], 2)
+ require.Len(t, byBase["infra"], 1)
+ assert.Equal(t, "CREATE", byBase["apps"][0].Operation)
+ assert.Equal(t, "DELETE", byBase["apps"][1].Operation, "arrival order is preserved within a base")
+}
diff --git a/internal/git/resync_flush.go b/internal/git/resync_flush.go
new file mode 100644
index 00000000..f357fc9f
--- /dev/null
+++ b/internal/git/resync_flush.go
@@ -0,0 +1,358 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+
+ gogit "github.com/go-git/go-git/v5"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestreport"
+ "github.com/ConfigButler/gitops-reverser/internal/sanitize"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// handleResyncRequest applies one revision-pinned resync in order on the worker
+// goroutine. It mirrors the atomic-commit path: any open live window is finalized
+// first so arrival order is preserved, the resync is committed as one local commit,
+// retained for the normal cooldown-driven push, and the caller is replied to with the
+// plan's change counts. A build or commit failure replies with the error and commits
+// nothing — the gatherer already guaranteed the snapshot is complete, so a failure
+// here is a write fault, never a partial-snapshot drop.
+func (l *branchWorkerEventLoop) handleResyncRequest(req *ResyncRequest) {
+ l.finalizeOpenWindow()
+
+ stats := &ResyncStats{}
+ committed := false
+ pendingWrite, err := l.w.buildResyncPendingWrite(l.w.ctx, req, stats)
+ if err != nil {
+ l.w.Log.Error(err, "Failed to build resync pending write", "resources", len(req.Desired))
+ req.reply(ResyncResult{Err: err})
+ return
+ }
+ pendingWrite.Committed = &committed
+
+ if err := l.w.commitPendingWrites([]PendingWrite{*pendingWrite}, len(l.pendingWrites) > 0); err != nil {
+ l.w.Log.Error(err, "Resync commit failed; dropping request", "resources", len(req.Desired))
+ req.reply(ResyncResult{Err: err})
+ return
+ }
+
+ // Only retain and push when the resync actually committed. A no-op resync (e.g.
+ // the empty initial snapshot before any rule selects a resource) must not push: an
+ // empty push would advance the cooldown and delay the next real snapshot's push.
+ if committed {
+ l.pendingWrites = append(l.pendingWrites, *pendingWrite)
+ l.pendingWritesBytes += pendingWrite.ByteSize
+ l.maybeSchedulePush()
+ }
+ req.reply(ResyncResult{Stats: *stats})
+}
+
+// buildResyncPendingWrite resolves the GitTarget's write metadata (path, encryption,
+// signer) and packages the desired snapshot into a retained resync pending write. The
+// stats pointer is threaded onto the pending write so the apply can populate the
+// caller's reply during commit.
+func (w *BranchWorker) buildResyncPendingWrite(
+ ctx context.Context,
+ req *ResyncRequest,
+ stats *ResyncStats,
+) (*PendingWrite, error) {
+ if req == nil {
+ return nil, errors.New("resync request is required")
+ }
+ if req.GitTargetName == "" || req.GitTargetNamespace == "" {
+ return nil, errors.New("resync request requires a GitTarget name and namespace")
+ }
+
+ provider, err := w.getGitProvider(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get GitProvider: %w", err)
+ }
+ signer, err := getCommitSigner(ctx, w.Client, provider)
+ if err != nil {
+ return nil, fmt.Errorf("resolve signer: %w", err)
+ }
+
+ targetMetadata, err := w.resolveTargetMetadata(ctx, req.GitTargetName, req.GitTargetNamespace)
+ if err != nil {
+ return nil, err
+ }
+
+ return &PendingWrite{
+ Kind: PendingWriteResync,
+ Desired: req.Desired,
+ Revision: req.Revision,
+ ScopeGVR: req.ScopeGVR,
+ ResyncStats: stats,
+ CommitConfig: ResolveCommitConfig(provider.Spec.Commit),
+ Signer: signer,
+ GitTargetName: targetMetadata.Name,
+ GitTargetNamespace: targetMetadata.Namespace,
+ Targets: map[pendingTargetKey]ResolvedTargetMetadata{
+ {Name: targetMetadata.Name, Namespace: targetMetadata.Namespace}: targetMetadata,
+ },
+ ByteSize: estimateDesiredSize(req.Desired),
+ }, nil
+}
+
+// estimateDesiredSize approximates the serialized YAML size of a desired snapshot, so
+// a large resync is counted against the same retained-byte cap as live event windows.
+func estimateDesiredSize(desired []manifestanalyzer.DesiredResource) int64 {
+ var total int64
+ for _, dr := range desired {
+ if dr.Object == nil {
+ continue
+ }
+ if b, err := sanitize.MarshalToOrderedYAML(dr.Object); err == nil {
+ total += int64(len(b))
+ }
+ }
+ return total
+}
+
+// executeResyncPendingWrite materialises a resync pending write: it configures the
+// subtree's secret encryptor, folds the desired snapshot over the worktree, records
+// the plan stats on the caller's reply, and commits once when anything changed. A
+// resync that finds the mirror already in sync changes nothing and creates no commit.
+func (w *BranchWorker) executeResyncPendingWrite(
+ ctx context.Context,
+ repo *gogit.Repository,
+ worktree *gogit.Worktree,
+ pendingWrite PendingWrite,
+) (int, error) {
+ target := pendingWrite.Target()
+ base := sanitizePath(target.Path)
+
+ // Stage the path's bootstrap template (its directory and any .sops.yaml) before
+ // applying, exactly as the per-event path does via ensureBootstrapTemplateInPath.
+ // Without it a first resync into a fresh subtree has no directory for SOPS to chdir
+ // into when it encrypts a Secret.
+ if err := ensureBootstrapTemplateInPath(repo, base, target.BootstrapOptions); err != nil {
+ return 0, err
+ }
+
+ encryptionPath := filepath.Join(worktree.Filesystem.Root(), base)
+ if err := configureSecretEncryptionWriter(w.contentWriter, encryptionPath, target.EncryptionConfig); err != nil {
+ return 0, fmt.Errorf("configure secret encryptor: %w", err)
+ }
+
+ stats, anyChanges, err := w.applyResyncToWorktree(ctx, worktree, base, pendingWrite.Desired, pendingWrite.ScopeGVR)
+ if err != nil {
+ return 0, err
+ }
+ if pendingWrite.ResyncStats != nil {
+ *pendingWrite.ResyncStats = stats
+ }
+ if !anyChanges {
+ return 0, nil
+ }
+
+ // Render the provider's snapshot commit template (e.g. a custom snapshot message),
+ // counting the resources the resync changed — the resync carries no events, so it
+ // cannot reuse the event-count snapshot path. Setting the rendered message as the
+ // pending write's literal message routes commitMetadata through the verbatim path.
+ changed := stats.Created + stats.Updated + stats.Deleted
+ rendered, err := renderResyncCommitMessage(changed, target.Name, pendingWrite.CommitConfig)
+ if err != nil {
+ return 0, err
+ }
+ pendingWrite.CommitMessage = rendered
+ message, options, err := pendingWrite.commitMetadata()
+ if err != nil {
+ return 0, err
+ }
+ if _, err := worktree.Commit(message, options); err != nil {
+ return 0, fmt.Errorf("failed to create resync commit: %w", err)
+ }
+ if pendingWrite.Committed != nil {
+ *pendingWrite.Committed = true
+ }
+ log.FromContext(ctx).Info("git resync commit created",
+ "created", stats.Created, "updated", stats.Updated,
+ "deleted", stats.Deleted, "skipped", stats.Skipped, "revision", pendingWrite.Revision)
+ return 1, nil
+}
+
+// applyResyncToWorktree is the streaming mark-and-sweep resync apply (M8), described
+// in docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md ("Two Paths, One
+// Plan Type" — the Resync path). It folds the COMPLETE desired snapshot over the
+// content-derived store of the GitTarget subtree:
+//
+// - every desired resource is upserted through the same proven, content-derived
+// single-identity path the steady-state writer uses (applyUpsert): a managed
+// document for its identity is patched in place even when moved off its canonical
+// path, a sensitive resource is re-encrypted wholesale at its existing path, and a
+// resource with no managed document is created at its canonical placement path;
+// - every watched, resolved managed document the snapshot did NOT contain is a
+// managed drop (mark-and-sweep): the planner's PlanDropOrphan set, deleted by
+// RecordRef so a manifest moved off its canonical path is still removed.
+//
+// The desired set MUST be the whole watched state at one consistent revision (the
+// gatherer aborts and produces nothing on a partial stream), so an empty desired set
+// is authoritative — the cluster genuinely holds no watched resources, and the mirror
+// is swept clean to match. Nothing is flushed until every action applies cleanly, so a
+// mid-resync error (e.g. an encryption failure) commits nothing rather than a partial
+// sweep.
+func (w *BranchWorker) applyResyncToWorktree(
+ ctx context.Context,
+ worktree *gogit.Worktree,
+ base string,
+ desired []manifestanalyzer.DesiredResource,
+ scopeGVR *schema.GroupVersionResource,
+) (ResyncStats, bool, error) {
+ root := worktree.Filesystem.Root()
+ files, err := scanWorktreeYAML(filepath.Join(root, base))
+ if err != nil {
+ return ResyncStats{}, false, err
+ }
+
+ batch := newWriteBatch(ctx, w.contentWriter, w.mapper, files)
+ // The store is built from the same files the planner reads, so the plan and the apply
+ // see identical bytes. The planner is the authoritative mark-and-sweep over the resolved
+ // resource-identity index; the upserts reuse the steady-state writer. A scoped resync
+ // (M12 per-type) restricts the sweep to one type so no sibling document is dropped.
+ plan := resyncPlan(batch.store, files, desired, scopeGVR)
+
+ stats, err := batch.applyResyncPlan(ctx, desired, plan)
+ if err != nil {
+ return ResyncStats{}, false, err
+ }
+ changed, err := batch.flush(ctx, worktree, root, base)
+ return stats, changed, err
+}
+
+// applyResyncPlan folds the desired set and the plan's managed drops into the
+// commit-scoped buffers. Upserts run first (they only patch in place or write new
+// files, so they never shift a sibling document's index); the drops run second and
+// re-derive each target's position from the live bytes, exactly as the steady-state
+// delete path does. An upsert error aborts before any flush, so a failed resync
+// writes nothing.
+func (wb *writeBatch) applyResyncPlan(
+ ctx context.Context,
+ desired []manifestanalyzer.DesiredResource,
+ plan manifestanalyzer.Plan,
+) (ResyncStats, error) {
+ var stats ResyncStats
+ for _, dr := range desired {
+ if dr.Object == nil {
+ // A malformed snapshot entry is not a delete; BuildPlan already protected
+ // the matching document from the sweep and diagnosed it. Skip the upsert.
+ continue
+ }
+ // Count from what the upsert actually did, not from the plan: a sensitive
+ // resource is PlanSkip in the plan but applyUpsert re-encrypts and changes it,
+ // so plan-based stats would report a real commit as skipped.
+ outcome, err := wb.applyUpsert(ctx, eventForDesired(dr))
+ if err != nil {
+ return ResyncStats{}, err
+ }
+ switch outcome {
+ case upsertCreated:
+ stats.Created++
+ case upsertUpdated:
+ stats.Updated++
+ case upsertNoChange:
+ }
+ }
+ for _, action := range plan.Actions {
+ if action.Kind == manifestanalyzer.PlanDropOrphan {
+ if wb.dropDocument(action.Ref.FilePath, action.Identity) {
+ stats.Deleted++
+ }
+ }
+ }
+ // Skipped stays a plan view (documents present but not editable in place); it is
+ // informational only and not part of the GitTarget status.
+ stats.Skipped = plan.Counts()[manifestanalyzer.PlanSkip]
+ return stats, nil
+}
+
+// dropDocument removes the managed document for id from filePath, re-deriving its
+// position from the buffer's CURRENT bytes (an earlier drop in the same resync can
+// renumber a multi-document file). Removing the last document empties the file, which
+// flush turns into a file deletion. It reports whether a document was actually removed;
+// a document already absent is a no-op.
+func (wb *writeBatch) dropDocument(filePath string, id manifestedit.Identity) bool {
+ buf := wb.buffer(filePath)
+ if buf.current == nil {
+ return false
+ }
+ idx, ok := currentDocIndex(filePath, buf.current, id)
+ if !ok {
+ return false
+ }
+ res, _ := manifestedit.DeleteDocument(buf.current, idx)
+ if res.FileEmpty {
+ buf.current = nil
+ return true
+ }
+ buf.current = res.Content
+ return true
+}
+
+// eventForDesired adapts a desired snapshot entry into the Event the content-derived
+// upsert path consumes. The operation is informational here (applyUpsert only
+// distinguishes DELETE from everything else); the object and identity carry
+// everything placement, rendering, and sensitive-resource encryption need.
+func eventForDesired(dr manifestanalyzer.DesiredResource) Event {
+ return Event{
+ Object: dr.Object,
+ Identifier: dr.Resource,
+ Operation: "RECONCILE",
+ }
+}
+
+// resyncPlan builds the mark-and-sweep plan for a resync. A nil scopeGVR is the
+// whole-GitTarget resync (BuildPlan sweeps every managed document absent from desired); a
+// non-nil scopeGVR is the M12 per-type reconcile/sweep, where BuildScopedPlan restricts the
+// sweep to that type's (group, resource) so a removed type's documents drop while every
+// sibling type is left exactly as Git holds it. The upsert side is scoped by desired itself.
+func resyncPlan(
+ store *manifestanalyzer.ManifestStore,
+ files []manifestedit.FileContent,
+ desired []manifestanalyzer.DesiredResource,
+ scopeGVR *schema.GroupVersionResource,
+) manifestanalyzer.Plan {
+ if scopeGVR == nil {
+ return manifestanalyzer.BuildPlan(store, files, desired, resyncPlanPolicy())
+ }
+ gvr := *scopeGVR
+ inScope := func(ri types.ResourceIdentifier) bool {
+ return ri.Group == gvr.Group && ri.Resource == gvr.Resource
+ }
+ return manifestanalyzer.BuildScopedPlan(store, files, desired, resyncPlanPolicy(), inScope)
+}
+
+// resyncPlanPolicy is the planning policy for a resync: the same sanitized projection
+// and edit options the steady-state writer uses, so a resync and a live event reach
+// the same patch/replace/skip decision for the same resource.
+func resyncPlanPolicy() manifestanalyzer.Policy {
+ return manifestanalyzer.Policy{
+ Project: manifestreport.Project,
+ EditOptions: manifestreport.EditOptions(),
+ }
+}
diff --git a/internal/git/resync_flush_test.go b/internal/git/resync_flush_test.go
new file mode 100644
index 00000000..c4dc91bc
--- /dev/null
+++ b/internal/git/resync_flush_test.go
@@ -0,0 +1,362 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package git
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ gogit "github.com/go-git/go-git/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// configMapMapper resolves v1/ConfigMap to a served, allowed resource, so the resync
+// planner treats ConfigMap documents as watched, resolved, sweepable managed members.
+func configMapMapper() typeset.Lookup {
+ return typeset.NewSnapshotRegistry(typeset.Snapshot{Entries: []typeset.Entry{{
+ GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"},
+ GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"},
+ Namespaced: true,
+ Allowed: true,
+ }}})
+}
+
+// desiredCM builds a desired ConfigMap snapshot entry for the default namespace.
+func desiredCM(name, color string) manifestanalyzer.DesiredResource {
+ return manifestanalyzer.DesiredResource{
+ Resource: types.ResourceIdentifier{
+ Group: "", Version: "v1", Resource: "configmaps", Namespace: "default", Name: name,
+ },
+ Object: &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{"name": name, "namespace": "default"},
+ "data": map[string]interface{}{"color": color},
+ }},
+ }
+}
+
+// cmManifest renders the canonical ConfigMap YAML used to seed the worktree.
+func cmManifest(name, color string) string {
+ return "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: " + name + "\n namespace: default\n" +
+ "data:\n color: " + color + "\n"
+}
+
+// applyResyncViaWorktree drives the M8 resync apply for tests: it folds the desired
+// snapshot over the worktree at base "" with the given mapper. A nil mapper makes the
+// store structure-only (no resolved mappings), so no managed drop is ever planned.
+func applyResyncViaWorktree(
+ t *testing.T,
+ writer *contentWriter,
+ mapper typeset.Lookup,
+ worktree *gogit.Worktree,
+ desired ...manifestanalyzer.DesiredResource,
+) (ResyncStats, bool) {
+ t.Helper()
+ w := &BranchWorker{contentWriter: writer, mapper: mapper}
+ stats, changed, err := w.applyResyncToWorktree(context.Background(), worktree, "", desired, nil)
+ require.NoError(t, err)
+ return stats, changed
+}
+
+// A desired resource with no managed document in Git is created at its canonical
+// placement path during resync.
+func TestResync_CreatesMissingResource(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree, desiredCM("api", "green"))
+ require.True(t, changed, "a missing resource must be created")
+ assert.Equal(t, 1, stats.Created)
+ assert.Equal(t, 0, stats.Deleted)
+
+ id := desiredCM("api", "green").Resource
+ canonical := filepath.Join(root, writer.filePathForIdentifier(id))
+ got, err := os.ReadFile(canonical)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "color: green")
+}
+
+// A managed document that differs from its desired resource is patched in place,
+// preserving hand-authored formatting (the file-agnostic-placement guarantee).
+func TestResync_UpdatesDriftedResourceInPlace(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+
+ seeded := "apiVersion: v1\nkind: ConfigMap\n" +
+ "metadata:\n name: app\n namespace: default\n" +
+ "data:\n # operator note kept across edits\n color: blue\n"
+ full := seedPlacedManifest(t, worktree, "apps/app.yaml", seeded)
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree, desiredCM("app", "green"))
+ require.True(t, changed, "a drifted resource must be updated")
+ assert.Equal(t, 1, stats.Updated)
+ assert.Equal(t, 0, stats.Created)
+ assert.Equal(t, 0, stats.Deleted)
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "color: green", "the field is rewritten")
+ assert.Contains(t, string(got), "operator note kept", "the comment survives the in-place patch")
+}
+
+// A resync over a mirror already matching the cluster changes nothing and creates no
+// commit-worthy diff.
+func TestResync_InSyncResourceIsNoOp(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ seedPlacedManifest(t, worktree, "apps/app.yaml", cmManifest("app", "blue"))
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree, desiredCM("app", "blue"))
+ assert.False(t, changed, "an in-sync resource is not rewritten")
+ assert.Zero(t, stats.Created)
+ assert.Zero(t, stats.Updated)
+ assert.Zero(t, stats.Deleted)
+}
+
+// The core M8 behavior: a watched, resolved managed document absent from the desired
+// snapshot is a managed drop. Its file is deleted (mark-and-sweep).
+func TestResync_DropsManagedResourceAbsentFromCluster(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ keepFull := seedPlacedManifest(t, worktree, "apps/keep.yaml", cmManifest("keep", "blue"))
+ dropFull := seedPlacedManifest(t, worktree, "apps/drop.yaml", cmManifest("drop", "blue"))
+
+ // Desired snapshot holds only "keep"; "drop" left the cluster.
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree, desiredCM("keep", "blue"))
+ require.True(t, changed, "the orphaned managed resource must be swept")
+ assert.Equal(t, 1, stats.Deleted)
+
+ _, keepErr := os.Stat(keepFull)
+ require.NoError(t, keepErr, "the still-present resource is retained")
+ _, dropErr := os.Stat(dropFull)
+ assert.True(t, os.IsNotExist(dropErr), "the orphaned resource's file is deleted")
+}
+
+// A manifest a human moved off its canonical path is still swept by content identity
+// when it is absent from the cluster — the moved-manifest disease M8 cures: the sweep
+// targets the document's real RecordRef, not a regenerated canonical path.
+func TestResync_DropsMovedManifestByContentIdentity(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ movedFull := seedPlacedManifest(t, worktree, "legacy/moved.yaml", cmManifest("app", "blue"))
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree)
+ require.True(t, changed, "the moved orphan must be swept")
+ assert.Equal(t, 1, stats.Deleted)
+ _, statErr := os.Stat(movedFull)
+ assert.True(t, os.IsNotExist(statErr), "the moved manifest is deleted at its real path, not orphaned")
+}
+
+// An empty desired snapshot (the cluster genuinely holds no watched resources) sweeps
+// every managed resolved document — the authoritative "empty cluster, empty mirror".
+func TestResync_EmptyClusterSweepsAllManaged(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ aFull := seedPlacedManifest(t, worktree, "apps/a.yaml", cmManifest("a", "x"))
+ bFull := seedPlacedManifest(t, worktree, "apps/b.yaml", cmManifest("b", "y"))
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree)
+ require.True(t, changed)
+ assert.Equal(t, 2, stats.Deleted)
+ for _, full := range []string{aFull, bFull} {
+ _, err := os.Stat(full)
+ assert.True(t, os.IsNotExist(err), "every managed document is swept")
+ }
+}
+
+// twoTypeMapper resolves both ConfigMap and Secret as served, allowed members, so a
+// whole-folder sweep would drop either — isolating the scope as the only thing that
+// protects the sibling type in the per-type sweep test.
+func twoTypeMapper() typeset.Lookup {
+ return typeset.NewSnapshotRegistry(typeset.Snapshot{Entries: []typeset.Entry{
+ {
+ GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"},
+ GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"},
+ Namespaced: true, Allowed: true,
+ },
+ {
+ GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
+ GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"},
+ Namespaced: true, Allowed: true,
+ },
+ }})
+}
+
+// secretManifest renders a plain (unencrypted) Secret used only to prove a per-type sweep
+// leaves a sibling type alone; it is never upserted, so no encryption is involved.
+func secretManifest(name string) string {
+ return "apiVersion: v1\nkind: Secret\n" +
+ "metadata:\n name: " + name + "\n namespace: default\n" +
+ "data:\n k: dg==\n"
+}
+
+// The M12 per-type sweep: a ScopeGVR'd resync with an empty desired set drops only the
+// removed type's documents and leaves every sibling type exactly as Git holds it — even
+// though under a whole-folder resync the sibling would also be an orphan.
+func TestResync_ScopedSweepDropsOnlyTargetType(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ cmFull := seedPlacedManifest(t, worktree, "apps/cm.yaml", cmManifest("cfg", "blue"))
+ secretFull := seedPlacedManifest(t, worktree, "apps/secret.yaml", secretManifest("sec"))
+
+ w := &BranchWorker{contentWriter: writer, mapper: twoTypeMapper()}
+ scope := &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}
+ stats, changed, err := w.applyResyncToWorktree(context.Background(), worktree, "", nil, scope)
+ require.NoError(t, err)
+ require.True(t, changed, "the removed type's document is swept")
+ assert.Equal(t, 1, stats.Deleted, "exactly the configmap is swept, not the secret")
+
+ _, cmErr := os.Stat(cmFull)
+ assert.True(t, os.IsNotExist(cmErr), "the removed type's document is deleted")
+ _, secErr := os.Stat(secretFull)
+ assert.NoError(t, secErr, "a sibling type's document is never touched by a per-type sweep")
+}
+
+// Without a mapper the store is structure-only: no document resolves to a watched
+// resource, so an empty desired snapshot sweeps nothing. This preserves the no-cluster
+// safety promise — a resync can never drop what it could not classify as watched.
+func TestResync_StructureOnlyNeverDrops(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ full := seedPlacedManifest(t, worktree, "apps/app.yaml", cmManifest("app", "blue"))
+
+ stats, changed := applyResyncViaWorktree(t, writer, nil, worktree)
+ assert.False(t, changed, "a structure-only resync drops nothing")
+ assert.Zero(t, stats.Deleted)
+ _, err := os.Stat(full)
+ assert.NoError(t, err, "the unclassified document is left in place")
+}
+
+// One resync folds creates, in-place updates, and managed drops together over the same
+// content-derived store, and reports each in its stats.
+func TestResync_FoldsCreateUpdateDropTogether(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+ keepFull := seedPlacedManifest(t, worktree, "apps/keep.yaml", cmManifest("keep", "blue"))
+ dropFull := seedPlacedManifest(t, worktree, "apps/drop.yaml", cmManifest("drop", "blue"))
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree,
+ desiredCM("keep", "green"), // drift -> update
+ desiredCM("fresh", "red"), // missing -> create
+ // "drop" omitted -> managed drop
+ )
+ require.True(t, changed)
+ assert.Equal(t, 1, stats.Created, "fresh is created")
+ assert.Equal(t, 1, stats.Updated, "keep is updated")
+ assert.Equal(t, 1, stats.Deleted, "drop is swept")
+
+ keep, err := os.ReadFile(keepFull)
+ require.NoError(t, err)
+ assert.Contains(t, string(keep), "color: green")
+
+ _, dropErr := os.Stat(dropFull)
+ assert.True(t, os.IsNotExist(dropErr))
+
+ freshCanonical := filepath.Join(root, writer.filePathForIdentifier(desiredCM("fresh", "red").Resource))
+ _, freshErr := os.Stat(freshCanonical)
+ assert.NoError(t, freshErr, "the created resource lands at its canonical path")
+}
+
+// A sensitive (SOPS) resource that the resync re-encrypts is counted as Updated, not
+// Skipped. The planner marks an encrypted document PlanSkip (it cannot patch it in
+// place), but applyUpsert re-encrypts and commits it — so stats must come from the
+// actual apply, or status would report a real Secret update as skipped and the commit
+// message count would omit it.
+func TestResync_SensitiveUpdateCountsAsUpdatedNotSkipped(t *testing.T) {
+ enc := &stubEncryptor{result: []byte(
+ "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: default\n" +
+ "data:\n k: ENC[AES256,data:NEW,iv:cc,tag:dd]\nsops:\n version: 3.9.0\n mac: NEW\n")}
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ writer.setEncryptor(enc, "test-scope")
+ worktree := newWorktreeForTest(t)
+
+ // An already-encrypted Secret in Git whose encrypted bytes differ from the new render.
+ seeded := "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n namespace: default\n" +
+ "data:\n k: ENC[AES256,data:OLD,iv:aa,tag:bb]\nsops:\n version: 3.9.0\n mac: OLD\n"
+ full := seedPlacedManifest(t, worktree, "secrets/app.sops.yaml", seeded)
+
+ secretsMapper := typeset.NewSnapshotRegistry(typeset.Snapshot{Entries: []typeset.Entry{{
+ GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
+ GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"},
+ Namespaced: true,
+ Allowed: true,
+ }}})
+ desired := manifestanalyzer.DesiredResource{
+ Resource: types.ResourceIdentifier{
+ Group: "", Version: "v1", Resource: "secrets", Namespace: "default", Name: "app",
+ },
+ Object: &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "Secret",
+ "metadata": map[string]interface{}{"name": "app", "namespace": "default"},
+ "data": map[string]interface{}{"k": "dg=="},
+ }},
+ }
+
+ w := &BranchWorker{contentWriter: writer, mapper: secretsMapper}
+ stats, changed, err := w.applyResyncToWorktree(
+ context.Background(),
+ worktree,
+ "",
+ []manifestanalyzer.DesiredResource{desired},
+ nil,
+ )
+ require.NoError(t, err)
+ require.True(t, changed, "the secret is re-encrypted")
+ assert.Equal(t, 1, stats.Updated, "a re-encrypted sensitive resource is Updated, not Skipped")
+ assert.Zero(t, stats.Created)
+ assert.Zero(t, stats.Deleted)
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Equal(t, string(enc.result), string(got), "the secret is re-encrypted in place")
+}
+
+// Sweeping one document from a multi-document file removes only that document and keeps
+// the file when a managed sibling survives.
+func TestResync_DropsOneDocFromMultiDocKeepsSiblings(t *testing.T) {
+ writer := newContentWriter(types.SensitiveResourcePolicy{})
+ worktree := newWorktreeForTest(t)
+ root := worktree.Filesystem.Root()
+ rel := "apps/multi.yaml"
+ full := filepath.Join(root, rel)
+ seedPlacedManifest(t, worktree, rel, cmManifest("keep", "blue")+"---\n"+cmManifest("drop", "blue"))
+
+ stats, changed := applyResyncViaWorktree(t, writer, configMapMapper(), worktree, desiredCM("keep", "blue"))
+ require.True(t, changed)
+ assert.Equal(t, 1, stats.Deleted)
+
+ got, err := os.ReadFile(full)
+ require.NoError(t, err)
+ assert.Contains(t, string(got), "name: keep", "the surviving managed sibling is kept")
+ assert.NotContains(t, string(got), "name: drop", "the orphaned document is removed")
+}
diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go
index ab125b16..197753ae 100644
--- a/internal/git/secret_write_test.go
+++ b/internal/git/secret_write_test.go
@@ -34,10 +34,12 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
"github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
)
func installFakeSOPSBinary(t *testing.T) {
@@ -213,7 +215,9 @@ func TestBranchWorker_DeleteSecretRemovesSOPSPath(t *testing.T) {
repo, worktree := initLocalRepo(t, seedPath, remoteURL, "master")
sopsPath := filepath.Join(seedPath, "v1", "secrets", "default", "test-secret.sops.yaml")
require.NoError(t, os.MkdirAll(filepath.Dir(sopsPath), 0o750))
- require.NoError(t, os.WriteFile(sopsPath, []byte("encrypted"), 0o600))
+ seeded := "apiVersion: v1\nkind: Secret\nmetadata:\n name: test-secret\n namespace: default\n" +
+ "data:\n k: ENC[AES256,data:OLD]\nsops:\n version: 3.9.0\n"
+ require.NoError(t, os.WriteFile(sopsPath, []byte(seeded), 0o600))
_, err := worktree.Add("v1/secrets/default/test-secret.sops.yaml")
require.NoError(t, err)
_, err = worktree.Commit("seed", &gogit.CommitOptions{
@@ -226,6 +230,12 @@ func TestBranchWorker_DeleteSecretRemovesSOPSPath(t *testing.T) {
worker, err := newTestBranchWorker(remoteURL, "test-repo", "master")
require.NoError(t, err)
+ worker.mapper = typeset.NewSnapshotRegistry(typeset.Snapshot{Entries: []typeset.Entry{{
+ GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
+ GVR: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"},
+ Namespaced: true,
+ Allowed: true,
+ }}})
event := Event{
Identifier: types.ResourceIdentifier{
diff --git a/internal/git/types.go b/internal/git/types.go
index 3d90ccc5..cea32c74 100644
--- a/internal/git/types.go
+++ b/internal/git/types.go
@@ -24,8 +24,11 @@ import (
gogit "github.com/go-git/go-git/v5"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
v1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
"github.com/ConfigButler/gitops-reverser/internal/types"
)
@@ -135,6 +138,11 @@ const (
// PendingWriteAtomic is a caller-defined atomic request, typically from
// reconciliation.
PendingWriteAtomic PendingWriteKind = "atomic"
+ // PendingWriteResync is a streaming-snapshot resync (M8): it carries the COMPLETE
+ // desired resource set for one GitTarget, and the worker materialises it with a
+ // content-derived mark-and-sweep against the worktree (upsert every desired
+ // resource, drop every watched managed document the snapshot did not contain).
+ PendingWriteResync PendingWriteKind = "resync"
)
type pendingTargetKey struct {
@@ -163,6 +171,27 @@ type PendingWrite struct {
GitTargetNamespace string
Targets map[pendingTargetKey]ResolvedTargetMetadata
ByteSize int64
+
+ // Desired is the complete desired resource snapshot, set only for a
+ // PendingWriteResync. The worker folds it over the worktree's content-derived
+ // store to produce the resync plan (upserts + mark-and-sweep drops).
+ Desired []manifestanalyzer.DesiredResource
+ // ScopeGVR, when set, restricts the resync's mark-and-sweep to one type's
+ // (group, resource): the M12 per-type reconcile/sweep. Desired then carries only
+ // that type's objects (empty for a pure sweep), and no sibling type's document is
+ // ever dropped. Nil is the whole-GitTarget resync.
+ ScopeGVR *schema.GroupVersionResource
+ // Revision is the cluster snapshot resourceVersion the desired set is pinned to
+ // (the joined streaming-watch bookmark). Carried for diagnostics and logging.
+ Revision string
+ // ResyncStats, when non-nil, is populated during apply with the plan's
+ // create/update/delete/skip counts so a synchronous caller can report them.
+ ResyncStats *ResyncStats
+ // Committed, when non-nil, is set true during apply iff the resync produced a
+ // commit. A no-op resync (e.g. an empty initial snapshot) must not be retained or
+ // pushed: doing so would advance the push cooldown and delay the next real
+ // snapshot's push past its window.
+ Committed *bool
}
// CommitMessageKind determines which message/authorship path the executor uses.
@@ -175,21 +204,81 @@ const (
)
// WorkItem is the unit of work in the BranchWorker queue. Exactly one of
-// Request or Finalize is set.
+// Request, Finalize, or Resync is set.
type WorkItem struct {
// Request is a resource-write request.
Request *WriteRequest
// Finalize is a "finalize the open commit window now" signal.
Finalize *FinalizeSignal
+ // Resync is a streaming-snapshot resync request (M8): a synchronous
+ // request/reply that materialises a GitTarget's complete desired set.
+ Resync *ResyncRequest
+}
+
+// ResyncRequest is a synchronous resync of one GitTarget against a complete,
+// revision-pinned desired snapshot (M8). It rides the worker queue so the single
+// git-mutating goroutine applies it in order with live events, and replies on
+// Result once the local commit is created. The desired set is the whole watched
+// resource state at Revision; the worker's content-derived mark-and-sweep drops
+// any managed document the snapshot did not contain.
+type ResyncRequest struct {
+ Desired []manifestanalyzer.DesiredResource
+ Revision string
+ GitTargetName string
+ GitTargetNamespace string
+ // ScopeGVR, when set, makes this a per-type (M12) reconcile/sweep: the mark-and-sweep
+ // is restricted to the named type's (group, resource) and Desired carries only that
+ // type's objects (empty = pure sweep of a removed type). Nil is a whole-GitTarget resync.
+ ScopeGVR *schema.GroupVersionResource
+ // Result receives exactly one reply. It is buffered (cap 1) by the emitter so
+ // the worker never blocks delivering it.
+ Result chan ResyncResult
+}
+
+// ResyncResult is the reply to a ResyncRequest: the plan's change counts, or an
+// error if the resync could not be applied (in which case nothing was committed).
+type ResyncResult struct {
+ Stats ResyncStats
+ Err error
+}
+
+// ResyncStats summarises what a resync changed, for GitTarget status. Created,
+// Updated, and Deleted are the materialised create / patch+replace / managed-drop
+// counts; Skipped is documents present but not safely editable (e.g. encrypted or
+// disallowed constructs).
+type ResyncStats struct {
+ Created int
+ Updated int
+ Deleted int
+ Skipped int
+}
+
+// reply delivers a result on the request's buffered channel without blocking, so a
+// caller that already gave up (timeout/ctx cancel) never wedges the worker loop.
+func (r *ResyncRequest) reply(result ResyncResult) {
+ if r.Result == nil {
+ return
+ }
+ select {
+ case r.Result <- result:
+ default:
+ }
}
// Event represents a resource change event to be processed by a branch worker.
// Branch comes from the worker context (not stored in event).
// Path comes from the GitTarget that created this event.
type Event struct {
- // Object is the sanitized Kubernetes object.
+ // Object is the sanitized Kubernetes object. Exactly one of Object or
+ // FieldPatch is set for a resource mutation; a control or DELETE event may
+ // carry neither.
Object *unstructured.Unstructured
+ // FieldPatch, when set, replaces Object with a bounded in-place edit of an
+ // existing parent manifest (subresource audit resolution). It is mutually
+ // exclusive with Object.
+ FieldPatch *FieldPatch
+
// Identifier contains resource identification information.
Identifier types.ResourceIdentifier
@@ -214,6 +303,35 @@ type Event struct {
BootstrapOptions pathBootstrapOptions
}
+// IsFieldPatch reports whether the event carries a bounded field patch instead of
+// a full object. It is the single predicate the pipeline branches on to route a
+// patch to the in-place writer rather than the object writer.
+func (e Event) IsFieldPatch() bool {
+ return e.FieldPatch != nil
+}
+
+// FieldPatch is a bounded set of field assignments to an existing parent manifest,
+// carried in place of a full Object. It is how an author-preserving subresource
+// mutation (e.g. deployments/scale) reaches Git: set exactly the audited field
+// paths on the already committed parent, never reconstructing the whole object.
+// See docs/design/manifest/version2/scale-subresource-audit-rehydration.md.
+type FieldPatch struct {
+ // Assignments are the (path, value) pairs to set on the parent manifest. Paths
+ // are disjoint; each owns only its own subtree, so the patch is additive and
+ // leaves every unmentioned field in Git untouched.
+ Assignments []manifestedit.FieldAssignment
+ // Source is a bounded origin label for commit messages and metrics, e.g.
+ // "deployments/scale". Never the request URI.
+ //
+ // The parent Kind is intentionally NOT carried here. The audit objectRef gives
+ // only the GVR (plural resource), and the subresource body's own Kind (e.g.
+ // "Scale") is not the parent's. The writer resolves the parent document from the
+ // objectRef GVR through the same resource-identity inventory the GVR-only delete
+ // uses — it already has the live-catalog mapper — so the consumer never needs
+ // GVR->GVK resolution.
+ Source string
+}
+
// CommitConfig is the resolved commit behavior used by the git writer.
type CommitConfig struct {
Committer CommitterConfig
diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go
index 9be35f75..bb24cab3 100644
--- a/internal/git/worker_manager.go
+++ b/internal/git/worker_manager.go
@@ -28,6 +28,7 @@ import (
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
"github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
)
// DefaultBranchBufferMaxBytes is the default cap on a worker's combined event
@@ -48,6 +49,10 @@ type WorkerManager struct {
mu sync.RWMutex
workers map[BranchKey]*BranchWorker
ctx context.Context
+ // mapper is the GVK->GVR resolver injected into every worker so store scans build a
+ // resource-identity inventory. It is set once at startup (SetMapper) before any
+ // worker is created; a nil mapper keeps workers structure-only.
+ mapper typeset.Lookup
}
// NewWorkerManager creates a new worker manager.
@@ -71,6 +76,15 @@ func NewWorkerManager(
}
}
+// SetMapper injects the GVK->GVR resolver used by every worker's store scan. It is
+// called once at startup, before any GitTarget registers a worker, so each worker
+// created by EnsureWorker carries it.
+func (m *WorkerManager) SetMapper(mapper typeset.Lookup) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.mapper = mapper
+}
+
// RegisterTarget ensures a worker exists for the target's (provider, branch)
// and registers the target with that worker.
// This is called by GitTarget controller when a target becomes Ready.
@@ -123,6 +137,10 @@ func (m *WorkerManager) EnsureWorker(
newContentWriter(m.sensitiveResources),
m.branchBufferMaxBytes,
)
+ // Inject the resolver before Start: the field is read only by the event-loop
+ // goroutine Start spawns, so setting it here (under m.mu, before that goroutine
+ // exists) is race-free.
+ worker.mapper = m.mapper
if err := worker.Start(m.ctx); err != nil {
return fmt.Errorf("failed to start worker for %s: %w", key.String(), err)
diff --git a/internal/git/worker_manager_test.go b/internal/git/worker_manager_test.go
index 782f3836..aeb12c9a 100644
--- a/internal/git/worker_manager_test.go
+++ b/internal/git/worker_manager_test.go
@@ -25,6 +25,7 @@ import (
"time"
"github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -33,8 +34,33 @@ import (
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
"github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
)
+// TestWorkerManager_SetMapperInjectsIntoWorkers proves the production wiring: a mapper
+// set on the manager is handed to every worker it creates, so the live writer builds a
+// resource-identity inventory. Without injection worker.mapper is nil and object-less
+// deletes have no resource index to target.
+func TestWorkerManager_SetMapperInjectsIntoWorkers(t *testing.T) {
+ client := fake.NewClientBuilder().WithScheme(setupScheme()).Build()
+ manager := NewWorkerManager(client, logr.Discard(), 0, types.SensitiveResourcePolicy{})
+
+ mapper := typeset.NewSnapshotRegistry(typeset.Snapshot{})
+ manager.SetMapper(mapper)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ go func() { _ = manager.Start(ctx) }()
+ time.Sleep(100 * time.Millisecond) // allow Start to set m.ctx
+
+ require.NoError(t, manager.EnsureWorker(ctx, "repo1", testProviderNamespace, "main"))
+ worker, exists := manager.GetWorkerForTarget("repo1", testProviderNamespace, "main")
+ require.True(t, exists)
+ require.NotNil(t, worker)
+ assert.NotNil(t, worker.mapper, "the created worker must carry the injected mapper")
+ assert.Equal(t, typeset.Lookup(mapper), worker.mapper)
+}
+
const (
testProviderNamespace = "gitops-system"
testTargetNamespace = "default"
diff --git a/internal/manifestanalyzer/acceptance.go b/internal/manifestanalyzer/acceptance.go
new file mode 100644
index 00000000..61a6fa80
--- /dev/null
+++ b/internal/manifestanalyzer/acceptance.go
@@ -0,0 +1,372 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// Acceptance is the M4 adoption gate: the distinct step between "build the store"
+// and "use it as the planning model", described in
+// docs/design/manifest/current-manifest-support-review.md ("Acceptance Checks On
+// First Materialization"). A GitTarget folder is adopted only when it passes; any
+// blocking refusal stops it and reconciles nothing until a human cleans the folder.
+//
+// The gate implements the five-bucket classification and the refuse rules:
+//
+// - duplicate manifest identity (we will not guess which copy the author meant);
+// - a managed file that is not entirely valid KRM — a multi-document file may hold
+// only managed KRM documents, never an empty/comment/non-KRM/invalid passenger
+// (Non-Negotiable Design Decision #2). This is what lets the store drop the
+// per-document index: an accepted managed file's documents are contiguous;
+// - a standalone non-KRM or invalid YAML file (bucket 2: the dangerous unknown);
+// - unwatched API-backed KRM (bucket 4: served, but this GitTarget does not watch
+// it) — refused, never pruned;
+// - recognised KRM the mapper cannot tie to a single served, watched resource and
+// that is not allowlisted;
+// - a watched resource outside this GitTarget's scope (right kind, wrong namespace);
+// - a managed file that mixes managed resources with an allowlisted non-API KRM
+// document (allowlisted KRM must live in its own retained file).
+//
+// Allowlisted non-API KRM such as kustomization.yaml is retained outside the model
+// (store.Retained) and never materialised — see the Allowlist type. Non-YAML files
+// and standalone empty documents are ignored and never cause a refusal.
+//
+// The mapping-aware refusals (unwatched/unresolved/out-of-scope) require an API
+// source: a structure-only store cannot judge them, so they are skipped, leaving
+// the structure-only starter checks (duplicate, impure managed file, non-KRM,
+// invalid). This matches the design's "starter requirement".
+type Acceptance struct {
+ // Accepted is true only when no blocking refusal was found.
+ Accepted bool
+ // Issues names every refusal, each carrying the offending file and document so a
+ // human (and GitTarget status) can resolve it. Empty when Accepted.
+ Issues []AcceptanceIssue
+ // Retained lists the allowlisted documents kept outside the managed model. It is
+ // informational: retention never blocks acceptance on its own (only a managed
+ // file that shares bytes with one does, via IssueMixedFile).
+ Retained []RetainedDocument
+}
+
+// AcceptancePolicy configures the gate. The zero value allows no non-API KRM and
+// restricts no scope, which is the structure-only analyzer / CLI default.
+type AcceptancePolicy struct {
+ // Allowlist names the non-API KRM kinds retained outside the managed model. It
+ // is applied at store-build time (buildStoreFS), so allowlisted documents never
+ // enter FilesByPath; the gate only refuses a managed file that illegally shares
+ // bytes with one.
+ Allowlist Allowlist
+ // InScope reports whether a resolved resource belongs to this GitTarget's scope.
+ // A nil predicate means "no scope restriction": every resolved resource is in
+ // scope. The controller injects a namespace-aware predicate (M7); the CLI passes
+ // nil.
+ InScope func(types.ResourceIdentifier) bool
+}
+
+// IssueKind values added by the acceptance gate, beyond the structure-only
+// IssueDuplicate / IssueNonKRM / IssueInvalidYAML the analyzer already reports.
+const (
+ // IssueImpureManagedFile marks a file holding managed resources that also holds a
+ // non-managed document (empty/comment-only, non-KRM, or invalid YAML). A managed
+ // file may contain only valid KRM documents.
+ IssueImpureManagedFile IssueKind = "impure-managed-file"
+ // IssueMixedFile marks a managed file that also holds an allowlisted non-API KRM
+ // document. Allowlisted KRM must be retained in its own file.
+ IssueMixedFile IssueKind = "mixed-managed-allowlisted"
+ // IssueUnresolvedKRM marks recognised KRM the followability registry could not tie
+ // to a single served, followable resource and that is not allowlisted (not served,
+ // denied by policy, ambiguous, or missing a verb). It is refused, never pruned.
+ IssueUnresolvedKRM IssueKind = "unresolved-krm"
+ // IssueOutOfScope marks a watched kind whose resource falls outside this
+ // GitTarget's scope (right kind, wrong namespace).
+ IssueOutOfScope IssueKind = "out-of-scope"
+)
+
+// Allowlist is the set of build-directive files that are retained on disk but never
+// materialised — kustomization.yaml and friends. Membership is keyed by file
+// basename, not GVK: a real kustomization.yaml carries no metadata.name, so it never
+// becomes a KRM record and a GVK match would never see it. Matching the basename
+// (as kustomize itself does) recognises the file regardless of its contents. The
+// zero value allows nothing, the structure-only analyzer / legacy report default.
+type Allowlist struct {
+ names map[string]struct{}
+}
+
+// NewAllowlist builds an allowlist from the given file basenames. No names yields
+// the empty allowlist (allows nothing).
+func NewAllowlist(basenames ...string) Allowlist {
+ if len(basenames) == 0 {
+ return Allowlist{}
+ }
+ set := make(map[string]struct{}, len(basenames))
+ for _, n := range basenames {
+ set[n] = struct{}{}
+ }
+ return Allowlist{names: set}
+}
+
+// DefaultAllowlist returns the built-in build-directive allowlist: the kustomize
+// entrypoint filenames, which are KRM but never served by the Kubernetes API. It
+// returns a fresh value on every call, so no shared global state can be mutated.
+func DefaultAllowlist() Allowlist {
+ return NewAllowlist("kustomization.yaml", "kustomization.yml")
+}
+
+// Allows reports whether the file at path is an allowlisted build directive,
+// matching on basename.
+func (a Allowlist) Allows(path string) bool {
+ if a.names == nil {
+ return false
+ }
+ _, ok := a.names[filepathBase(path)]
+ return ok
+}
+
+// filepathBase returns the final path element of a slash-separated fs.FS path. The
+// analyzer walks an fs.FS, whose paths always use "/" regardless of OS, so this is
+// deliberately not filepath.Base (which would split on "\\" on Windows).
+func filepathBase(path string) string {
+ if i := strings.LastIndexByte(path, '/'); i >= 0 {
+ return path[i+1:]
+ }
+ return path
+}
+
+// Accept runs the adoption acceptance gate over a built store. The store must have
+// been built with policy.Allowlist (via buildStoreFS or Scan) so that allowlisted
+// documents are already in store.Retained rather than FilesByPath; Accept does not
+// re-derive retention. It is a pure function of (store, policy) and writes nothing.
+func Accept(store *ManifestStore, policy AcceptancePolicy) Acceptance {
+ var issues []AcceptanceIssue
+ issues = append(issues, duplicateRefusals(store)...)
+ issues = append(issues, recordlessRefusals(store)...)
+ issues = append(issues, mixedFileRefusals(store)...)
+ if hasAPISource(store) {
+ issues = append(issues, mappingRefusals(store, policy)...)
+ }
+ sortIssues(issues)
+ return Acceptance{
+ Accepted: len(issues) == 0,
+ Issues: issues,
+ Retained: store.Retained,
+ }
+}
+
+// duplicateRefusals refuses every duplicate manifest identity, naming the loser and
+// the first-occurrence winner. Positions come from documentLocations, which
+// reconstructs true file indices from record-less diagnostic gaps, so refused
+// non-contiguous files still name the right documents.
+func duplicateRefusals(store *ManifestStore) []AcceptanceIssue {
+ docLoc := documentLocations(store)
+ var out []AcceptanceIssue
+ for _, path := range sortedKeys(store.FilesByPath) {
+ for _, dm := range store.FilesByPath[path].Documents {
+ if !store.IsDuplicate(dm) {
+ continue
+ }
+ loser := docLoc[dm]
+ winner := docLoc[store.ByManifestIdentity[dm.ManifestIdentity]]
+ out = append(out, AcceptanceIssue{
+ Kind: IssueDuplicate,
+ Path: path,
+ DocumentIndex: loser.DocumentIndex,
+ Message: fmt.Sprintf("duplicate manifest identity %s at %s#%d; first occurrence at %s#%d",
+ identityRef(dm.ManifestIdentity), path, loser.DocumentIndex,
+ winner.FilePath, winner.DocumentIndex),
+ })
+ }
+ }
+ return out
+}
+
+// recordlessRefusals refuses the record-less documents — empty, non-KRM, invalid.
+// Inside a managed file any of them makes the file impure (a managed file may hold
+// only valid KRM); standalone, a non-KRM or invalid YAML file is the bucket-2
+// dangerous unknown, while a standalone empty document is ignored.
+func recordlessRefusals(store *ManifestStore) []AcceptanceIssue {
+ var out []AcceptanceIssue
+ for _, d := range store.Diagnostics {
+ if issue, ok := recordlessRefusal(store, d); ok {
+ out = append(out, issue)
+ }
+ }
+ return out
+}
+
+// recordlessRefusal classifies one diagnostic into a refusal, or reports that it is
+// not a blocking record-less fact (it accompanies a managed record, or is an
+// ignored standalone empty document).
+func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (AcceptanceIssue, bool) {
+ managed := store.FilesByPath[d.Path] != nil
+ switch d.Reason {
+ case manifestedit.ReasonEmptyDocument:
+ if managed {
+ return impureIssue(d, "an empty document"), true
+ }
+ return AcceptanceIssue{}, false
+ case manifestedit.ReasonNotKRM:
+ if managed {
+ return impureIssue(d, "a non-KRM document"), true
+ }
+ return AcceptanceIssue{
+ Kind: IssueNonKRM, Path: d.Path, DocumentIndex: d.DocumentIndex,
+ Message: "YAML is not a Kubernetes manifest",
+ }, true
+ case manifestedit.ReasonInvalidYAML, manifestedit.ReasonMissingSopsKey:
+ if managed {
+ return impureIssue(d, "an invalid document"), true
+ }
+ return AcceptanceIssue{
+ Kind: IssueInvalidYAML, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: d.Message,
+ }, true
+ case manifestedit.ReasonNonEditable, manifestedit.ReasonDuplicateIdentity:
+ // Accompany a managed record (handled by duplicateRefusals / the planner skip);
+ // not a record-less gap.
+ return AcceptanceIssue{}, false
+ }
+ return AcceptanceIssue{}, false
+}
+
+// impureIssue builds the all-or-nothing refusal for a non-managed document found in
+// a managed file.
+func impureIssue(d manifestedit.Diagnostic, what string) AcceptanceIssue {
+ return AcceptanceIssue{
+ Kind: IssueImpureManagedFile,
+ Path: d.Path,
+ DocumentIndex: d.DocumentIndex,
+ Message: fmt.Sprintf(
+ "a file with managed resources may contain only valid KRM documents; document #%d is %s",
+ d.DocumentIndex, what),
+ }
+}
+
+// mixedFileRefusals refuses a managed resource hiding in an allowlisted
+// build-directive file. A whole-file retention (no identity) is retained cleanly; a
+// retained entry that carries an identity is a named KRM record found inside an
+// allowlisted file, which must not be silently un-managed.
+func mixedFileRefusals(store *ManifestStore) []AcceptanceIssue {
+ var out []AcceptanceIssue
+ for _, rd := range store.Retained {
+ if rd.Identity.Name == "" {
+ continue // a clean whole-file retention, nothing to refuse
+ }
+ out = append(out, AcceptanceIssue{
+ Kind: IssueMixedFile,
+ Path: rd.Location.Path,
+ DocumentIndex: rd.Location.DocumentIndex,
+ Message: "managed resource " + identityRef(rd.Identity) +
+ " must not live in the allowlisted build-directive file " + rd.Location.Path,
+ })
+ }
+ return out
+}
+
+// mappingRefusals refuses every managed document whose mapping is not a watched,
+// in-scope resolution. It is called only when the store has an API source. Each
+// document's true file position comes from documentLocations (reconstructed from the
+// record-less diagnostic gaps), so a refusal names the right document even in an
+// impure, non-contiguous file.
+func mappingRefusals(store *ManifestStore, policy AcceptancePolicy) []AcceptanceIssue {
+ docLoc := documentLocations(store)
+ var out []AcceptanceIssue
+ for _, path := range sortedKeys(store.FilesByPath) {
+ for _, dm := range store.FilesByPath[path].Documents {
+ if store.IsDuplicate(dm) {
+ continue // already refused as a duplicate
+ }
+ if issue, ok := mappingRefusal(docLoc[dm], dm, policy); ok {
+ out = append(out, issue)
+ }
+ }
+ }
+ return out
+}
+
+// mappingRefusal classifies one document's followability outcome into a refusal, or
+// reports that it is an accepted, in-scope, followable resource. The registry is the
+// single owner of *why* a type is not followable; acceptance only needs the verdict,
+// so every not-followable case collapses to one refusal.
+func mappingRefusal(ref RecordRef, dm *DocumentModel, policy AcceptancePolicy) (AcceptanceIssue, bool) {
+ switch dm.Mapping {
+ case MappingFollowable:
+ if outOfScope(dm, policy) {
+ return refusal(IssueOutOfScope, ref,
+ "followable kind out of this GitTarget's scope: "+identityRef(dm.ManifestIdentity)), true
+ }
+ return AcceptanceIssue{}, false
+ case MappingNotFollowable:
+ return refusal(IssueUnresolvedKRM, ref,
+ "KRM "+identityRef(dm.ManifestIdentity)+" is not a followable resource type"), true
+ case MappingNoSource:
+ // hasAPISource gates this call, so a lone no-source document among followable
+ // ones is not judged on followability grounds.
+ return AcceptanceIssue{}, false
+ }
+ return AcceptanceIssue{}, false
+}
+
+// outOfScope reports whether a resolved document falls outside the policy scope. A
+// nil predicate means no scope restriction.
+func outOfScope(dm *DocumentModel, policy AcceptancePolicy) bool {
+ return policy.InScope != nil && dm.ResourceIdentity != nil && !policy.InScope(*dm.ResourceIdentity)
+}
+
+// refusal builds a per-document refusal at the given reference.
+func refusal(kind IssueKind, ref RecordRef, message string) AcceptanceIssue {
+ return AcceptanceIssue{
+ Kind: kind,
+ Path: ref.FilePath,
+ DocumentIndex: ref.DocumentIndex,
+ Message: message,
+ }
+}
+
+// hasAPISource reports whether any managed document was judged against a ready API
+// source. A structure-only store leaves every document MappingNoSource, so the
+// followability-aware refusals are skipped.
+func hasAPISource(store *ManifestStore) bool {
+ for _, fm := range store.FilesByPath {
+ for _, dm := range fm.Documents {
+ if dm.Mapping != MappingNoSource {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// sortIssues orders issues deterministically by file path, then document index, then
+// kind, so status/JSON/text output is stable.
+func sortIssues(issues []AcceptanceIssue) {
+ sort.SliceStable(issues, func(i, j int) bool {
+ a, b := issues[i], issues[j]
+ if a.Path != b.Path {
+ return a.Path < b.Path
+ }
+ if a.DocumentIndex != b.DocumentIndex {
+ return a.DocumentIndex < b.DocumentIndex
+ }
+ return a.Kind < b.Kind
+ })
+}
diff --git a/internal/manifestanalyzer/acceptance_test.go b/internal/manifestanalyzer/acceptance_test.go
new file mode 100644
index 00000000..7883e13a
--- /dev/null
+++ b/internal/manifestanalyzer/acceptance_test.go
@@ -0,0 +1,325 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "testing"
+ "testing/fstest"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+const (
+ plainSecretYAML = "apiVersion: v1\nkind: Secret\nmetadata:\n name: db\n namespace: default\n"
+ configMapCYAML = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: c\n namespace: default\n"
+ widgetYAMLDoc = "apiVersion: example.com/v1\nkind: Widget\nmetadata:\n name: w\n namespace: default\n"
+ kustomizationY = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - deploy.yaml\n"
+)
+
+// snapMapper is the ready static snapshot mapper used across acceptance tests.
+func snapMapper() typeset.Lookup {
+ return typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+}
+
+// acceptanceOf builds a store with the given allowlist and runs the gate.
+func acceptanceOf(
+ t *testing.T,
+ fsys fstest.MapFS,
+ mapper typeset.Lookup,
+ policy AcceptancePolicy,
+) (*ManifestStore, Acceptance) {
+ t.Helper()
+ store := buildStoreFS(context.Background(), fsys, mapper, policy.Allowlist)
+ return store, Accept(store, policy)
+}
+
+// countAcceptance returns the number of issues of a kind.
+func countAcceptance(acc Acceptance, kind IssueKind) int {
+ n := 0
+ for _, is := range acc.Issues {
+ if is.Kind == kind {
+ n++
+ }
+ }
+ return n
+}
+
+// onlyIssue asserts the gate refused with exactly one issue of the expected kind at
+// the expected path/index, and returns it.
+func onlyIssue(t *testing.T, acc Acceptance, kind IssueKind, path string, index int) AcceptanceIssue {
+ t.Helper()
+ if acc.Accepted {
+ t.Fatalf("expected refusal, got accepted")
+ }
+ if len(acc.Issues) != 1 {
+ t.Fatalf("want exactly one issue, got %+v", acc.Issues)
+ }
+ is := acc.Issues[0]
+ if is.Kind != kind || is.Path != path || is.DocumentIndex != index {
+ t.Fatalf("issue = %+v, want kind=%s path=%s#%d", is, kind, path, index)
+ }
+ return is
+}
+
+func TestAccept_CleanFolderPasses(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "cm.yaml": {Data: []byte(configMapsYAML)},
+ }
+ _, acc := acceptanceOf(t, fsys, snapMapper(), AcceptancePolicy{})
+ if !acc.Accepted || len(acc.Issues) != 0 {
+ t.Fatalf("clean folder should pass: accepted=%v issues=%+v", acc.Accepted, acc.Issues)
+ }
+}
+
+func TestAccept_DuplicateRefuses(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "dup.yaml": {Data: []byte(deployYAML)},
+ }
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ is := onlyIssue(t, acc, IssueDuplicate, "dup.yaml", 0)
+ if is.Message == "" {
+ t.Errorf("duplicate refusal should carry a message naming the winner")
+ }
+}
+
+func TestAccept_ImpureManagedFileEmptyDocument(t *testing.T) {
+ // A managed file with an empty document interspersed between two managed
+ // documents: refused, which is exactly what lets the store drop the index.
+ impure := deployYAML + "---\n# only a comment\n---\n" + configMapCYAML
+ fsys := fstest.MapFS{"app.yaml": {Data: []byte(impure)}}
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ onlyIssue(t, acc, IssueImpureManagedFile, "app.yaml", 1)
+}
+
+func TestAccept_ImpureManagedFileNonKRM(t *testing.T) {
+ // A non-KRM passenger document in a managed file is impure, not bucket-2 non-KRM.
+ impure := deployYAML + "---\njust: data\n"
+ fsys := fstest.MapFS{"app.yaml": {Data: []byte(impure)}}
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ onlyIssue(t, acc, IssueImpureManagedFile, "app.yaml", 1)
+}
+
+func TestAccept_StandaloneNonKRMRefuses(t *testing.T) {
+ fsys := fstest.MapFS{"values.yaml": {Data: []byte(plainYAML)}}
+ _, 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{})
+ is := onlyIssue(t, acc, IssueInvalidYAML, "broken.yaml", 0)
+ if is.Message == "" {
+ t.Errorf("invalid-yaml refusal should carry the parse error detail")
+ }
+}
+
+func TestAccept_StandaloneEmptyIgnored(t *testing.T) {
+ fsys := fstest.MapFS{"empty.yaml": {Data: []byte(emptyYAML)}}
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ if !acc.Accepted || len(acc.Issues) != 0 {
+ t.Fatalf("a standalone empty document is ignored, got %+v", acc.Issues)
+ }
+}
+
+func TestAccept_PolicyDeniedKRMRefuses(t *testing.T) {
+ // A Secret is served but denied by the sample snapshot's resource policy, so it is
+ // not followable and the folder is refused (never pruned).
+ fsys := fstest.MapFS{"secret.yaml": {Data: []byte(plainSecretYAML)}}
+ _, acc := acceptanceOf(t, fsys, snapMapper(), AcceptancePolicy{})
+ onlyIssue(t, acc, IssueUnresolvedKRM, "secret.yaml", 0)
+}
+
+func TestAccept_UnresolvedKRMRefuses(t *testing.T) {
+ // An unserved kind (no snapshot entry) is recognised KRM the mapper cannot tie to
+ // a watched resource, and is not allowlisted.
+ fsys := fstest.MapFS{"w.yaml": {Data: []byte(widgetYAMLDoc)}}
+ mapper := typeset.NewSnapshotRegistry(typeset.Snapshot{Generation: 1})
+ _, acc := acceptanceOf(t, fsys, mapper, AcceptancePolicy{})
+ onlyIssue(t, acc, IssueUnresolvedKRM, "w.yaml", 0)
+}
+
+func TestAccept_OutOfScopeRefuses(t *testing.T) {
+ fsys := fstest.MapFS{"deploy.yaml": {Data: []byte(deployYAML)}}
+ policy := AcceptancePolicy{
+ InScope: func(ri types.ResourceIdentifier) bool { return ri.Namespace == "kube-system" },
+ }
+ _, acc := acceptanceOf(t, fsys, snapMapper(), policy)
+ onlyIssue(t, acc, IssueOutOfScope, "deploy.yaml", 0)
+}
+
+func TestAccept_StructureOnlySkipsMappingChecks(t *testing.T) {
+ // Structure-only: a Secret cannot be judged unwatched without an API source, so
+ // the mapping refusals are skipped and the folder passes.
+ fsys := fstest.MapFS{"secret.yaml": {Data: []byte(plainSecretYAML)}}
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ if !acc.Accepted {
+ t.Fatalf("structure-only should not refuse on mapping grounds, got %+v", acc.Issues)
+ }
+}
+
+func TestAccept_AllowlistedFileRetained(t *testing.T) {
+ fsys := fstest.MapFS{
+ "kustomization.yaml": {Data: []byte(kustomizationY)},
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ }
+ store, acc := acceptanceOf(t, fsys, snapMapper(), AcceptancePolicy{Allowlist: DefaultAllowlist()})
+
+ if store.FilesByPath["kustomization.yaml"] != nil {
+ t.Errorf("allowlisted file must never enter FilesByPath")
+ }
+ if !acc.Accepted {
+ t.Fatalf("allowlisted build directive beside a clean resource should pass, got %+v", acc.Issues)
+ }
+ if len(acc.Retained) != 1 || acc.Retained[0].Location.Path != "kustomization.yaml" {
+ t.Fatalf("retained = %+v, want one whole-file kustomization.yaml entry", acc.Retained)
+ }
+ if acc.Retained[0].Identity.Name != "" {
+ t.Errorf("a nameless build directive should be a whole-file retention, got %+v", acc.Retained[0])
+ }
+}
+
+func TestAccept_MixedManagedInAllowlistedFileRefuses(t *testing.T) {
+ // A named Deployment hiding inside kustomization.yaml: refused, never silently
+ // un-managed.
+ mixed := kustomizationY + "---\n" + deployYAML
+ fsys := fstest.MapFS{"kustomization.yaml": {Data: []byte(mixed)}}
+ store, acc := acceptanceOf(t, fsys, snapMapper(), AcceptancePolicy{Allowlist: DefaultAllowlist()})
+
+ if store.FilesByPath["kustomization.yaml"] != nil {
+ t.Errorf("an allowlisted file is never materialised, even with a managed passenger")
+ }
+ if countAcceptance(acc, IssueMixedFile) != 1 {
+ t.Fatalf("want one mixed-file refusal, got %+v", acc.Issues)
+ }
+}
+
+func TestAccept_MultipleRefusalsSorted(t *testing.T) {
+ fsys := fstest.MapFS{
+ "a-bad.yaml": {Data: []byte(plainYAML)}, // non-KRM
+ "b-dup.yaml": {Data: []byte(deployYAML)}, // duplicate winner
+ "c-dup.yaml": {Data: []byte(deployYAML)}, // duplicate loser
+ }
+ _, acc := acceptanceOf(t, fsys, nil, AcceptancePolicy{})
+ if acc.Accepted {
+ t.Fatalf("expected refusal")
+ }
+ // Sorted by path: a-bad.yaml (non-krm) before c-dup.yaml (duplicate).
+ if acc.Issues[0].Path != "a-bad.yaml" || acc.Issues[len(acc.Issues)-1].Path != "c-dup.yaml" {
+ t.Errorf("issues not sorted by path: %+v", acc.Issues)
+ }
+}
+
+func TestAccept_MultipleRetainedSorted(t *testing.T) {
+ fsys := fstest.MapFS{
+ "b/kustomization.yaml": {Data: []byte(kustomizationY)},
+ "a/kustomization.yaml": {Data: []byte(kustomizationY)},
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ }
+ store, acc := acceptanceOf(t, fsys, snapMapper(), AcceptancePolicy{Allowlist: DefaultAllowlist()})
+ if !acc.Accepted {
+ t.Fatalf("two clean build directives beside a resource should pass: %+v", acc.Issues)
+ }
+ if len(store.Retained) != 2 {
+ t.Fatalf("retained = %+v, want two whole-file entries", store.Retained)
+ }
+ if store.Retained[0].Location.Path != "a/kustomization.yaml" ||
+ store.Retained[1].Location.Path != "b/kustomization.yaml" {
+ t.Errorf("retained entries should be sorted by path, got %+v", store.Retained)
+ }
+}
+
+func TestAccept_MappingRefusalIndexAfterGap(t *testing.T) {
+ // An empty document at file index 0, then a denied Secret at file index 1. The
+ // Secret is the only managed record (loop index 0), but its mapping refusal must
+ // name its TRUE file index 1 — proving mappingRefusals uses reconstructed
+ // positions, not the loop index.
+ src := "# only a comment\n---\n" + plainSecretYAML
+ fsys := fstest.MapFS{"app.yaml": {Data: []byte(src)}}
+ _, acc := acceptanceOf(t, fsys, snapMapper(), AcceptancePolicy{})
+
+ if acc.Accepted {
+ t.Fatalf("expected refusal")
+ }
+ var unresolved *AcceptanceIssue
+ for i := range acc.Issues {
+ if acc.Issues[i].Kind == IssueUnresolvedKRM {
+ unresolved = &acc.Issues[i]
+ }
+ }
+ if unresolved == nil {
+ t.Fatalf("expected an unresolved-krm refusal, got %+v", acc.Issues)
+ }
+ if unresolved.DocumentIndex != 1 {
+ t.Errorf("unresolved refusal should carry the true file index 1, got %d", unresolved.DocumentIndex)
+ }
+ // The empty document also makes the file impure, named at its own true index 0.
+ if countAcceptance(acc, IssueImpureManagedFile) != 1 {
+ t.Errorf("the empty document should also make the file impure, got %+v", acc.Issues)
+ }
+}
+
+// TestReconstructManagedIndices_RecordlessGapsLeaveDiagnostics makes the
+// reconstruction's load-bearing invariant explicit: every record-less document kind
+// (non-KRM, invalid YAML, empty) leaves a diagnostic at its position, so the managed
+// documents always reconstruct to their true file indices. Managed docs sit at file
+// indices 0, 2, 4 here, interleaved with a non-KRM doc, an invalid doc, and a
+// trailing empty doc.
+func TestReconstructManagedIndices_RecordlessGapsLeaveDiagnostics(t *testing.T) {
+ src := deployYAML + // web @0
+ "---\njust: data\n" + // non-KRM @1
+ "---\n" + configMapCYAML + // c @2
+ "---\nfoo: [bar\n" + // invalid YAML @3
+ "---\n" + plainSecretYAML + // db @4
+ "---\n# trailing comment\n" // empty @5
+ fsys := fstest.MapFS{"app.yaml": {Data: []byte(src)}}
+ store := buildStoreFS(context.Background(), fsys, nil, Allowlist{})
+
+ loc := documentLocations(store)
+ got := map[string]int{}
+ for _, dm := range store.FilesByPath["app.yaml"].Documents {
+ got[dm.ManifestIdentity.Name] = loc[dm].DocumentIndex
+ }
+ want := map[string]int{"web": 0, "c": 2, "db": 4}
+ for name, idx := range want {
+ if got[name] != idx {
+ t.Errorf("%s reconstructed to #%d, want #%d (all=%+v)", name, got[name], idx, got)
+ }
+ }
+}
+
+func TestAllowlist(t *testing.T) {
+ def := DefaultAllowlist()
+ if !def.Allows("kustomization.yaml") || !def.Allows("base/kustomization.yaml") {
+ t.Errorf("DefaultAllowlist should match kustomization.yaml by basename")
+ }
+ if def.Allows("deploy.yaml") || def.Allows("kustomization.yaml.bak") {
+ t.Errorf("DefaultAllowlist should not match unrelated files")
+ }
+ if (Allowlist{}).Allows("kustomization.yaml") {
+ t.Errorf("the zero allowlist should allow nothing")
+ }
+ if NewAllowlist().Allows("kustomization.yaml") {
+ t.Errorf("an empty NewAllowlist should allow nothing")
+ }
+}
diff --git a/internal/manifestanalyzer/analyzer.go b/internal/manifestanalyzer/analyzer.go
new file mode 100644
index 00000000..9721e464
--- /dev/null
+++ b/internal/manifestanalyzer/analyzer.go
@@ -0,0 +1,590 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package manifestanalyzer is a runtime-independent analyzer for a folder of
+Kubernetes manifests. It is the proof-of-concept core described in
+docs/design/manifest/current-manifest-support-review.md: build the manifest model
+once, classify every file, and report what we know about it — without any
+controller runtime, and without writing anything.
+
+The package is deliberately decoupled from the controller so the same logic can
+back both the live writer and a standalone CLI:
+
+ - Filesystem access goes through fs.FS, so it runs against a git worktree, an
+ arbitrary directory (os.DirFS), or an in-memory tree (fstest.MapFS).
+ - The analysis path is strictly read-only; it produces a Report and never
+ mutates the tree.
+
+This first slice is structure-only and needs no cluster: it classifies files,
+detects duplicates, and reports the inventory of every GVK found. Comparing those
+GVKs against a live API (the "what is in the API" source of truth, which decides
+what is watched, unwatched, or orphaned) is a deliberate later step.
+
+It builds on internal/git/manifestedit for the YAML mechanism (splitting,
+manifest identity, duplicate detection, SOPS handling) and adds classification,
+a bounded summary, and acceptance issues on top.
+*/
+package manifestanalyzer
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+ "sort"
+ "strings"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// GVK is a parsed group/version/kind. Group is empty for core resources.
+type GVK struct {
+ Group string `json:"group"`
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+}
+
+// ParseGVK derives a GVK from a manifest's apiVersion and kind. An apiVersion of
+// "apps/v1" yields group "apps"; a bare "v1" yields an empty group.
+func ParseGVK(apiVersion, kind string) GVK {
+ g := GVK{Kind: kind}
+ if i := strings.LastIndex(apiVersion, "/"); i >= 0 {
+ g.Group = apiVersion[:i]
+ g.Version = apiVersion[i+1:]
+ } else {
+ g.Version = apiVersion
+ }
+ return g
+}
+
+// String renders a GVK as "group/version/kind", or "version/kind" for core.
+func (g GVK) String() string {
+ if g.Group == "" {
+ return g.Version + "/" + g.Kind
+ }
+ return g.Group + "/" + g.Version + "/" + g.Kind
+}
+
+// Empty reports whether the GVK carries no information (a non-KRM document).
+func (g GVK) Empty() bool {
+ return g.Group == "" && g.Version == "" && g.Kind == ""
+}
+
+// Class is the bucket a file or document falls into, mirroring the design doc:
+// non-YAML files are ignored, non-KRM YAML is the dangerous unknown, and KRM is
+// every valid Kubernetes manifest. Which GVKs those manifests are is reported via
+// the GVK inventory (Summary.ByGVK) rather than by sub-classing the bucket;
+// comparing them against a live API is a deliberate later step.
+type Class string
+
+const (
+ // ClassNonYAML is a file that is not YAML by extension. Always ignored.
+ ClassNonYAML Class = "non-yaml"
+ // ClassEmpty is a YAML document that is empty or comment-only.
+ ClassEmpty Class = "empty"
+ // ClassInvalidYAML is a document that does not parse as YAML.
+ ClassInvalidYAML Class = "invalid-yaml"
+ // ClassNonKRM is valid YAML that is not a Kubernetes manifest.
+ ClassNonKRM Class = "non-krm"
+ // ClassKRM is a valid Kubernetes manifest.
+ ClassKRM Class = "krm"
+)
+
+// DocumentReport describes one YAML document inside a file.
+type DocumentReport struct {
+ Index int `json:"index"`
+ Class Class `json:"class"`
+ GVK GVK `json:"gvk"`
+ Identity manifestedit.Identity `json:"identity"`
+ Editable bool `json:"editable"`
+ // Cause is the structured reason a KRM document is not cleanly editable
+ // (encrypted, non-editable construct). It is nil for an editable document and
+ // for non-KRM/empty/invalid rows. Duplicate identity is no longer a per-document
+ // attribute — it surfaces as an acceptance issue and a diagnostic instead.
+ Cause *DocumentCause `json:"cause,omitempty"`
+}
+
+// FileReport describes one file under the scanned root. Non-YAML files carry no
+// documents; YAML files carry one DocumentReport per document.
+type FileReport struct {
+ Path string `json:"path"`
+ IsYAML bool `json:"isYaml"`
+ Documents []DocumentReport `json:"documents,omitempty"`
+}
+
+// Summary is a bounded, status-friendly overview of a Report. It never grows with
+// the number of resources beyond the small set of class and GVK keys.
+type Summary struct {
+ FilesTotal int `json:"filesTotal"`
+ YAMLFiles int `json:"yamlFiles"`
+ NonYAMLFiles int `json:"nonYamlFiles"`
+ Documents int `json:"documents"`
+ Duplicates int `json:"duplicates"`
+ Encrypted int `json:"encrypted"`
+ ByClass map[Class]int `json:"byClass"`
+ ByGVK map[string]int `json:"byGvk"`
+ Diagnostics map[manifestedit.DiagnosticLevel]int `json:"diagnostics"`
+}
+
+// IssueKind classifies an acceptance issue.
+type IssueKind string
+
+const (
+ // IssueDuplicate marks a document that duplicates an earlier manifest identity.
+ IssueDuplicate IssueKind = "duplicate-identity"
+ // IssueNonKRM marks YAML that does not parse as a Kubernetes manifest.
+ IssueNonKRM IssueKind = "non-krm-yaml"
+ // IssueInvalidYAML marks a document that does not parse as YAML.
+ IssueInvalidYAML IssueKind = "invalid-yaml"
+)
+
+// AcceptanceIssue is a fact about the tree that a stricter adoption policy may
+// treat as blocking. The analyzer always reports issues; deciding whether they
+// block (the refuse/scan/prune policy) is left to the caller.
+type AcceptanceIssue struct {
+ Kind IssueKind `json:"kind"`
+ Path string `json:"path"`
+ DocumentIndex int `json:"documentIndex"`
+ Message string `json:"message"`
+}
+
+// Report is the full result of analyzing a tree.
+type Report struct {
+ Root string `json:"root"`
+ Files []FileReport `json:"files"`
+ Summary Summary `json:"summary"`
+ Issues []AcceptanceIssue `json:"issues"`
+ Diagnostics []manifestedit.Diagnostic `json:"diagnostics"`
+}
+
+// AnalyzeDir analyzes the directory at root. It verifies root is a directory,
+// then runs Analyze over os.DirFS(root). Symlinks are never followed.
+func AnalyzeDir(root string) (Report, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return Report{}, err
+ }
+ if !info.IsDir() {
+ return Report{}, fmt.Errorf("not a directory: %s", root)
+ }
+ rep := Analyze(os.DirFS(root))
+ rep.Root = root
+ return rep, nil
+}
+
+// BuildStore walks fsys and returns the byte-free ManifestStore: the managed
+// FileModels and the scan/index diagnostics. It is the structure spine the Report
+// is projected from, and the entry point downstream layers (planner, live writer)
+// will consume directly. It is read-only and never fails.
+//
+// lookup resolves each managed document's GVK to a served resource identity; pass
+// nil (or an un-ready registry) to keep the no-cluster, structure-only mode.
+//
+// BuildStore materialises every KRM document (the empty-allowlist case). Scan mode
+// passes the acceptance policy's allowlist through buildStoreFS so non-API KRM such
+// as kustomization.yaml is retained outside the model rather than materialised.
+func BuildStore(ctx context.Context, fsys fs.FS, lookup typeset.Lookup) *ManifestStore {
+ return buildStoreFS(ctx, fsys, lookup, Allowlist{})
+}
+
+// buildStoreFS is BuildStore with an explicit allowlist: a record whose GVK the
+// allowlist matches is retained (kept out of FilesByPath) instead of materialised.
+func buildStoreFS(
+ ctx context.Context,
+ fsys fs.FS,
+ lookup typeset.Lookup,
+ allowlist Allowlist,
+) *ManifestStore {
+ yamlFiles, _, scanDiags := collectFiles(fsys)
+ return buildStore(ctx, yamlFiles, scanDiags, lookup, allowlist)
+}
+
+// Analyze scans fsys and returns a Report. It is read-only and never fails: any
+// per-entry problem (unreadable file, walk error, invalid YAML) becomes a
+// diagnostic rather than an error. The Report is a projection rendered from the
+// ManifestStore built by buildStore.
+func Analyze(fsys fs.FS) Report {
+ yamlFiles, nonYAML, scanDiags := collectFiles(fsys)
+ // Analyze is the no-cluster default: a nil mapper keeps it structure-only, so the
+ // resource index stays empty and no mapping diagnostics are emitted. It
+ // materialises every KRM document (the empty allowlist), since the legacy report
+ // classifies the whole tree rather than adopting it.
+ store := buildStore(context.Background(), yamlFiles, scanDiags, nil, Allowlist{})
+ return projectReport(store, yamlFiles, nonYAML)
+}
+
+// projectReport renders the analyzer Report from the store plus the scan's file
+// skeleton (which YAML and non-YAML files exist). Managed KRM documents come from
+// the store; non-KRM, empty, and invalid documents are reconstructed from the
+// store's diagnostics, exactly as the pre-store analyzer derived them.
+func projectReport(store *ManifestStore, yamlFiles []manifestedit.FileContent, nonYAML []string) Report {
+ // Non-YAML scan diagnostics (read errors, skipped symlinks, walk errors) never
+ // share a path with an indexed YAML file, so grouping every diagnostic by path
+ // yields exactly the per-document index diagnostics for each YAML file.
+ diagsByPath := diagnosticsByPath(store.Diagnostics)
+
+ files := make([]FileReport, 0, len(yamlFiles)+len(nonYAML))
+ // Duplicate identities are acceptance facts derived from the store's collapsed
+ // index. Their position is reconstructed per file (DocumentModel no longer stores
+ // its index), so the duplicate set is collected alongside the file reports.
+ duplicates := map[RecordRef]bool{}
+ for _, f := range yamlFiles {
+ fr, dups := projectFileReport(store, f.Path, store.FilesByPath[f.Path], diagsByPath[f.Path])
+ files = append(files, fr)
+ for ref := range dups {
+ duplicates[ref] = true
+ }
+ }
+ for _, p := range nonYAML {
+ files = append(files, FileReport{Path: p, IsYAML: false})
+ }
+ sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
+
+ // Capture the detail for every diagnostic classFromDiag maps to invalid-YAML
+ // (a parse failure or a .sops.yaml missing its sops stanza), so the resulting
+ // IssueInvalidYAML carries a message rather than an empty string.
+ invalidMsgs := map[RecordRef]string{}
+ for _, d := range store.Diagnostics {
+ if d.Reason == manifestedit.ReasonInvalidYAML || d.Reason == manifestedit.ReasonMissingSopsKey {
+ invalidMsgs[RecordRef{FilePath: d.Path, DocumentIndex: d.DocumentIndex}] = d.Message
+ }
+ }
+
+ return Report{
+ Root: store.Root,
+ Files: files,
+ Summary: buildSummary(files, store.Diagnostics, len(duplicates)),
+ Issues: buildIssues(files, duplicates, invalidMsgs),
+ Diagnostics: store.Diagnostics,
+ }
+}
+
+// collectFiles walks fsys, returning YAML files (path + content), the paths of
+// non-YAML files, and scan-level diagnostics. Symlinks are skipped.
+func collectFiles(fsys fs.FS) ([]manifestedit.FileContent, []string, []manifestedit.Diagnostic) {
+ var (
+ yamlFiles []manifestedit.FileContent
+ nonYAML []string
+ diags []manifestedit.Diagnostic
+ )
+
+ walkErr := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ diags = append(
+ diags,
+ manifestedit.Diagnostic{Level: manifestedit.DiagWarning, Path: path, Message: err.Error()},
+ )
+ return nil //nolint:nilerr // a per-entry error must not abort the whole scan
+ }
+ if path == "." {
+ return nil
+ }
+ if d.Type()&fs.ModeSymlink != 0 {
+ diags = append(
+ diags,
+ manifestedit.Diagnostic{Level: manifestedit.DiagInfo, Path: path, Message: "symlink skipped"},
+ )
+ if d.IsDir() {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if !isYAMLFile(path) {
+ nonYAML = append(nonYAML, path)
+ return nil
+ }
+ content, readErr := fs.ReadFile(fsys, path)
+ if readErr != nil {
+ diags = append(
+ diags,
+ manifestedit.Diagnostic{Level: manifestedit.DiagWarning, Path: path, Message: readErr.Error()},
+ )
+ return nil //nolint:nilerr // an unreadable file must not abort the whole scan
+ }
+ yamlFiles = append(yamlFiles, manifestedit.FileContent{Path: path, Content: content})
+ return nil
+ })
+ if walkErr != nil {
+ diags = append(
+ diags,
+ manifestedit.Diagnostic{Level: manifestedit.DiagError, Path: ".", Message: walkErr.Error()},
+ )
+ }
+
+ sort.Slice(yamlFiles, func(i, j int) bool { return yamlFiles[i].Path < yamlFiles[j].Path })
+ sort.Strings(nonYAML)
+ return yamlFiles, nonYAML, diags
+}
+
+// projectFileReport assembles per-document classification for one YAML file by
+// merging the store's managed KRM documents (the authoritative manifest documents)
+// with diagnostics (which cover empty, invalid, and non-KRM documents) on document
+// index. fm is nil for a YAML file that holds no KRM document.
+//
+// DocumentModel no longer stores its position, so each managed document's true file
+// index is reconstructed from the record-less diagnostic gaps: empty, non-KRM, and
+// invalid documents leave a diagnostic at their position, and the managed documents
+// fill the remaining positions in document order. It also returns the duplicate
+// losers of this file keyed by their reconstructed RecordRef.
+func projectFileReport(
+ store *ManifestStore,
+ path string,
+ fm *FileModel,
+ diags []manifestedit.Diagnostic,
+) (FileReport, map[RecordRef]bool) {
+ managedIdx := reconstructManagedIndices(fm, gapIndices(diags))
+
+ docByIdx := map[int]*DocumentModel{}
+ dups := map[RecordRef]bool{}
+ if fm != nil {
+ for i, dm := range fm.Documents {
+ docByIdx[managedIdx[i]] = dm
+ if store.IsDuplicate(dm) {
+ dups[RecordRef{FilePath: path, DocumentIndex: managedIdx[i]}] = true
+ }
+ }
+ }
+ diagByIdx := map[int]manifestedit.Diagnostic{}
+ for _, d := range diags {
+ if _, ok := diagByIdx[d.DocumentIndex]; !ok {
+ diagByIdx[d.DocumentIndex] = d
+ }
+ }
+
+ fr := FileReport{Path: path, IsYAML: true}
+ for _, i := range mergedIndices(docByIdx, diagByIdx) {
+ // A managed KRM document always wins over a co-located diagnostic (for
+ // example a non-editable document paired with a warning about anchors).
+ if dm, ok := docByIdx[i]; ok {
+ fr.Documents = append(fr.Documents, krmDocReport(i, dm))
+ continue
+ }
+ fr.Documents = append(fr.Documents, DocumentReport{Index: i, Class: classFromDiag(diagByIdx[i])})
+ }
+ return fr, dups
+}
+
+// diagnosticsByPath groups diagnostics by their file path, preserving order. It is
+// the shared input for position reconstruction (the report and the planner both
+// recover a managed document's true file index from the record-less gaps).
+func diagnosticsByPath(diags []manifestedit.Diagnostic) map[string][]manifestedit.Diagnostic {
+ out := map[string][]manifestedit.Diagnostic{}
+ for _, d := range diags {
+ out[d.Path] = append(out[d.Path], d)
+ }
+ return out
+}
+
+// gapIndices collects the file positions held by a record-less document — empty,
+// non-KRM, or invalid YAML — each of which leaves exactly one diagnostic at its
+// position. The non-editable and duplicate reasons accompany a managed record and
+// are therefore NOT gaps. mapping diagnostics (a different DiagReason value) also
+// accompany a managed record and fall through.
+func gapIndices(diags []manifestedit.Diagnostic) map[int]bool {
+ gaps := map[int]bool{}
+ for _, d := range diags {
+ switch d.Reason {
+ case manifestedit.ReasonEmptyDocument, manifestedit.ReasonNotKRM,
+ manifestedit.ReasonInvalidYAML, manifestedit.ReasonMissingSopsKey:
+ gaps[d.DocumentIndex] = true
+ case manifestedit.ReasonNonEditable, manifestedit.ReasonDuplicateIdentity:
+ // Accompany a managed record, so the record holds the position, not a gap.
+ }
+ }
+ return gaps
+}
+
+// reconstructManagedIndices returns the true file index of each managed document in
+// fm.Documents. The managed documents fill the file positions not taken by a gap
+// (record-less) document, in document order, so the i-th managed document gets the
+// i-th non-gap position. It returns nil for a file with no managed documents.
+//
+// LOAD-BEARING INVARIANT: every record-less document — empty/comment-only, non-KRM,
+// invalid YAML, and a .sops.yaml missing its sops key — emits exactly one structured
+// manifestedit diagnostic at its position (see indexOneFile), and no managed record
+// shares a position with such a diagnostic. If a record-less document ever produced
+// no diagnostic, its position would be wrongly handed to a managed document and every
+// later managed index would shift. The mixed-gap test
+// (TestReconstructManagedIndices_RecordlessGapsLeaveDiagnostics) guards this; the M4
+// acceptance gate's impure-managed-file refusal means an accepted file has no gaps at
+// all, so the reconstruction only ever matters on a tree that is being refused.
+func reconstructManagedIndices(fm *FileModel, gaps map[int]bool) []int {
+ if fm == nil {
+ return nil
+ }
+ out := make([]int, len(fm.Documents))
+ pos := 0
+ for i := range fm.Documents {
+ for gaps[pos] {
+ pos++
+ }
+ out[i] = pos
+ pos++
+ }
+ return out
+}
+
+// mergedIndices returns the sorted union of the managed-document and diagnostic
+// positions, so the per-document report lists every position in file order.
+func mergedIndices(docByIdx map[int]*DocumentModel, diagByIdx map[int]manifestedit.Diagnostic) []int {
+ set := map[int]bool{}
+ for i := range docByIdx {
+ set[i] = true
+ }
+ for i := range diagByIdx {
+ set[i] = true
+ }
+ out := make([]int, 0, len(set))
+ for i := range set {
+ out = append(out, i)
+ }
+ sort.Ints(out)
+ return out
+}
+
+// krmDocReport builds a DocumentReport for one managed KRM document.
+func krmDocReport(i int, dm *DocumentModel) DocumentReport {
+ return DocumentReport{
+ Index: i,
+ Class: ClassKRM,
+ GVK: ParseGVK(dm.ManifestIdentity.APIVersion, dm.ManifestIdentity.Kind),
+ Identity: dm.ManifestIdentity,
+ Editable: dm.Editable,
+ Cause: causePtr(dm.Cause),
+ }
+}
+
+// causePtr returns a pointer to a non-empty cause, or nil for a cleanly editable
+// document, so the JSON omits "cause" entirely in the common case.
+func causePtr(c DocumentCause) *DocumentCause {
+ if c.Kind == CauseNone {
+ return nil
+ }
+ return &c
+}
+
+// classFromDiag classifies a record-less document from its structured diagnostic
+// reason. Classification reads the reason code, never the message text.
+func classFromDiag(d manifestedit.Diagnostic) Class {
+ switch d.Reason {
+ case manifestedit.ReasonEmptyDocument:
+ return ClassEmpty
+ case manifestedit.ReasonInvalidYAML, manifestedit.ReasonMissingSopsKey:
+ return ClassInvalidYAML
+ case manifestedit.ReasonNotKRM:
+ return ClassNonKRM
+ case manifestedit.ReasonNonEditable, manifestedit.ReasonDuplicateIdentity:
+ // These reasons always accompany a record, which wins the merge, so they are
+ // never classified here; fall through to the non-KRM default for safety.
+ return ClassNonKRM
+ default:
+ return ClassNonKRM
+ }
+}
+
+// buildSummary produces the bounded overview. dupCount is the number of duplicate
+// identities, derived by the caller from the store's collapsed index.
+func buildSummary(files []FileReport, diags []manifestedit.Diagnostic, dupCount int) Summary {
+ s := Summary{
+ Duplicates: dupCount,
+ ByClass: map[Class]int{},
+ ByGVK: map[string]int{},
+ Diagnostics: map[manifestedit.DiagnosticLevel]int{},
+ }
+ for _, f := range files {
+ s.FilesTotal++
+ if !f.IsYAML {
+ s.NonYAMLFiles++
+ continue
+ }
+ s.YAMLFiles++
+ for _, d := range f.Documents {
+ s.Documents++
+ s.ByClass[d.Class]++
+ if !d.GVK.Empty() {
+ s.ByGVK[d.GVK.String()]++
+ }
+ if d.Cause != nil && d.Cause.Kind == CauseEncrypted {
+ s.Encrypted++
+ }
+ }
+ }
+ for _, d := range diags {
+ s.Diagnostics[d.Level]++
+ }
+ return s
+}
+
+// buildIssues derives acceptance issues from the classified documents. These are
+// the structural facts a stricter adoption policy may treat as blocking; whether
+// each manifest belongs (the watched/unwatched comparison against a live API) is
+// a deliberate later step.
+// duplicates marks the (file, index) of every duplicate-identity loser; invalidMsgs
+// carries the parse-error detail for invalid-YAML documents, both keyed by RecordRef.
+func buildIssues(
+ files []FileReport,
+ duplicates map[RecordRef]bool,
+ invalidMsgs map[RecordRef]string,
+) []AcceptanceIssue {
+ var issues []AcceptanceIssue
+ for _, f := range files {
+ for _, d := range f.Documents {
+ ref := RecordRef{FilePath: f.Path, DocumentIndex: d.Index}
+ if duplicates[ref] {
+ issues = append(issues, AcceptanceIssue{
+ Kind: IssueDuplicate, Path: f.Path, DocumentIndex: d.Index,
+ Message: "duplicate of " + identityRef(d.Identity),
+ })
+ }
+ switch d.Class {
+ case ClassNonKRM:
+ issues = append(issues, AcceptanceIssue{
+ Kind: IssueNonKRM, Path: f.Path, DocumentIndex: d.Index,
+ Message: "YAML is not a Kubernetes manifest",
+ })
+ case ClassInvalidYAML:
+ issues = append(issues, AcceptanceIssue{
+ Kind: IssueInvalidYAML, Path: f.Path, DocumentIndex: d.Index, Message: invalidMsgs[ref],
+ })
+ case ClassNonYAML, ClassEmpty, ClassKRM:
+ // Not acceptance issues: ignored files, empty documents, and valid KRM.
+ }
+ }
+ }
+ return issues
+}
+
+// identityRef renders a manifest identity like "apps/v1/Deployment/default/web",
+// using "_cluster" for cluster-scoped objects.
+func identityRef(id manifestedit.Identity) string {
+ ns := id.Namespace
+ if ns == "" {
+ ns = "_cluster"
+ }
+ return ParseGVK(id.APIVersion, id.Kind).String() + "/" + ns + "/" + id.Name
+}
+
+// isYAMLFile reports whether a path is a YAML file by extension.
+func isYAMLFile(path string) bool {
+ return strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")
+}
diff --git a/internal/manifestanalyzer/analyzer_test.go b/internal/manifestanalyzer/analyzer_test.go
new file mode 100644
index 00000000..0cbc5fa3
--- /dev/null
+++ b/internal/manifestanalyzer/analyzer_test.go
@@ -0,0 +1,403 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+)
+
+const (
+ deployYAML = `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+ namespace: default
+spec:
+ replicas: 1
+`
+ configMapsYAML = `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+ namespace: default
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: b
+ namespace: default
+`
+ sopsSecretYAML = `apiVersion: v1
+kind: Secret
+metadata:
+ name: db
+ namespace: default
+sops:
+ version: "3"
+`
+ plainYAML = "foo: bar\nbaz: qux\n"
+ brokenYAML = "foo: [bar\n"
+ emptyYAML = "# only a comment\n"
+ notesText = "just some notes\n"
+)
+
+// sampleFS builds the canonical mixed tree used across tests. deploy.yaml sorts
+// before dup.yaml, so the Deployment in dup.yaml is the duplicate loser.
+func sampleFS() fstest.MapFS {
+ return fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "cm.yaml": {Data: []byte(configMapsYAML)},
+ "dup.yaml": {Data: []byte(deployYAML)},
+ "plain.yaml": {Data: []byte(plainYAML)},
+ "broken.yaml": {Data: []byte(brokenYAML)},
+ "empty.yaml": {Data: []byte(emptyYAML)},
+ "secret.sops.yaml": {Data: []byte(sopsSecretYAML)},
+ "docs/notes.txt": {Data: []byte(notesText)},
+ }
+}
+
+func TestAnalyze_Summary(t *testing.T) {
+ s := Analyze(sampleFS()).Summary
+
+ if s.FilesTotal != 8 || s.YAMLFiles != 7 || s.NonYAMLFiles != 1 {
+ t.Fatalf("file counts: total=%d yaml=%d nonyaml=%d", s.FilesTotal, s.YAMLFiles, s.NonYAMLFiles)
+ }
+ if s.Documents != 8 {
+ t.Fatalf("documents = %d, want 8", s.Documents)
+ }
+ if s.Duplicates != 1 || s.Encrypted != 1 {
+ t.Fatalf("duplicates=%d encrypted=%d, want 1 and 1", s.Duplicates, s.Encrypted)
+ }
+
+ // Every enum key is listed so the exhaustive linter guards future additions.
+ wantClass := map[Class]int{
+ ClassKRM: 5, // deploy, cm a, cm b, secret, dup
+ ClassNonKRM: 1,
+ ClassInvalidYAML: 1,
+ ClassEmpty: 1,
+ ClassNonYAML: 0,
+ }
+ for c, n := range wantClass {
+ if s.ByClass[c] != n {
+ t.Errorf("class %s = %d, want %d", c, s.ByClass[c], n)
+ }
+ }
+
+ // The GVK inventory reports every GVK found, regardless of any API.
+ wantGVK := map[string]int{"apps/v1/Deployment": 2, "v1/ConfigMap": 2, "v1/Secret": 1}
+ for g, n := range wantGVK {
+ if s.ByGVK[g] != n {
+ t.Errorf("gvk %s = %d, want %d", g, s.ByGVK[g], n)
+ }
+ }
+}
+
+func TestAnalyze_Issues(t *testing.T) {
+ rep := Analyze(sampleFS())
+ // The structure-only Analyze report surfaces only the structure-only issue kinds;
+ // the acceptance gate's mapping-aware kinds never appear here, so they are 0. All
+ // kinds are listed so the exhaustive linter guards future additions.
+ want := map[IssueKind]int{
+ IssueDuplicate: 1,
+ IssueNonKRM: 1,
+ IssueInvalidYAML: 1,
+ IssueImpureManagedFile: 0,
+ IssueMixedFile: 0,
+ IssueUnresolvedKRM: 0,
+ IssueOutOfScope: 0,
+ }
+ for kind, n := range want {
+ if got := countIssues(rep, kind); got != n {
+ t.Errorf("%s issues = %d, want %d", kind, got, n)
+ }
+ }
+}
+
+func TestAnalyze_DocumentDetail(t *testing.T) {
+ rep := Analyze(sampleFS())
+ cm := findFile(t, rep, "cm.yaml")
+ if len(cm.Documents) != 2 {
+ t.Fatalf("cm.yaml documents = %d, want 2", len(cm.Documents))
+ }
+ if cm.Documents[0].Identity.Name != "a" || cm.Documents[1].Identity.Name != "b" {
+ t.Errorf("cm.yaml doc names = %q,%q", cm.Documents[0].Identity.Name, cm.Documents[1].Identity.Name)
+ }
+
+ secret := findFile(t, rep, "secret.sops.yaml")
+ if len(secret.Documents) != 1 || secret.Documents[0].Cause == nil ||
+ secret.Documents[0].Cause.Kind != CauseEncrypted {
+ t.Errorf("secret.sops.yaml should be a single encrypted document: %+v", secret.Documents)
+ }
+
+ // Duplicate identity is no longer a per-document field; it surfaces as an
+ // acceptance issue derived from the store's collapsed manifest-identity index.
+ dup := findFile(t, rep, "dup.yaml")
+ if len(dup.Documents) != 1 {
+ t.Fatalf("dup.yaml documents = %d, want 1", len(dup.Documents))
+ }
+ if !hasIssue(rep, IssueDuplicate, "dup.yaml") {
+ t.Errorf("dup.yaml document should raise a duplicate-identity issue: %+v", rep.Issues)
+ }
+
+ notes := findFile(t, rep, "docs/notes.txt")
+ if notes.IsYAML || len(notes.Documents) != 0 {
+ t.Errorf("notes.txt should be non-yaml with no documents: %+v", notes)
+ }
+
+ plain := findFile(t, rep, "plain.yaml")
+ if len(plain.Documents) != 1 || plain.Documents[0].Class != ClassNonKRM {
+ t.Errorf("plain.yaml should be a single non-krm document: %+v", plain.Documents)
+ }
+}
+
+func TestAnalyze_NonEditableRecord(t *testing.T) {
+ const anchored = `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: anchored
+ namespace: default
+data: &a
+ k: v
+`
+ rep := Analyze(fstest.MapFS{"anchored.yaml": {Data: []byte(anchored)}})
+ doc := findFile(t, rep, "anchored.yaml").Documents[0]
+ if doc.Editable {
+ t.Errorf("anchored document should be non-editable")
+ }
+ if doc.Class != ClassKRM {
+ t.Errorf("anchored document class = %s, want krm", doc.Class)
+ }
+ if doc.Cause == nil || doc.Cause.Kind != CauseNonEditable || doc.Cause.Detail == "" {
+ t.Errorf("non-editable document should carry a structured cause: %+v", doc.Cause)
+ }
+}
+
+func TestAnalyze_EncryptedDuplicate(t *testing.T) {
+ rep := Analyze(fstest.MapFS{
+ "a.sops.yaml": {Data: []byte(sopsSecretYAML)},
+ "b.sops.yaml": {Data: []byte(sopsSecretYAML)},
+ })
+ if rep.Summary.Duplicates != 1 {
+ t.Errorf("encrypted duplicate should be counted: Duplicates = %d, want 1", rep.Summary.Duplicates)
+ }
+ if got := countIssues(rep, IssueDuplicate); got != 1 {
+ t.Errorf("encrypted duplicate should raise one duplicate-identity issue, got %d", got)
+ }
+}
+
+func TestAnalyze_MissingSopsKeyHasMessage(t *testing.T) {
+ // A .sops.yaml that parses as KRM but lacks a sops stanza is classified invalid;
+ // its acceptance issue must carry the diagnostic detail, not an empty message.
+ rep := Analyze(fstest.MapFS{"bad.sops.yaml": {Data: []byte(deployYAML)}})
+
+ var found bool
+ for _, is := range rep.Issues {
+ if is.Kind == IssueInvalidYAML && is.Path == "bad.sops.yaml" {
+ found = true
+ if is.Message == "" {
+ t.Errorf("missing-sops-key issue should carry a message")
+ }
+ }
+ }
+ if !found {
+ t.Errorf("expected an invalid-yaml issue for the sops file without a sops key: %+v", rep.Issues)
+ }
+}
+
+func TestAnalyze_EmptyTree(t *testing.T) {
+ rep := Analyze(fstest.MapFS{})
+ if rep.Summary.FilesTotal != 0 || len(rep.Issues) != 0 {
+ t.Errorf("empty tree should yield no files and no issues: %+v", rep.Summary)
+ }
+}
+
+func TestAnalyzeDir(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, "deploy.yaml", deployYAML)
+ writeFile(t, dir, "notes.txt", notesText)
+
+ rep, err := AnalyzeDir(dir)
+ if err != nil {
+ t.Fatalf("AnalyzeDir: %v", err)
+ }
+ if rep.Root != dir {
+ t.Errorf("root = %q, want %q", rep.Root, dir)
+ }
+ if rep.Summary.Documents != 1 || rep.Summary.NonYAMLFiles != 1 {
+ t.Errorf("unexpected summary: %+v", rep.Summary)
+ }
+}
+
+func TestAnalyzeDir_SymlinkSkipped(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, dir, "deploy.yaml", deployYAML)
+ if err := os.Symlink(filepath.Join(dir, "deploy.yaml"), filepath.Join(dir, "link.yaml")); err != nil {
+ t.Skipf("symlinks unsupported: %v", err)
+ }
+
+ rep, err := AnalyzeDir(dir)
+ if err != nil {
+ t.Fatalf("AnalyzeDir: %v", err)
+ }
+ if !hasDiag(rep, "link.yaml", "symlink skipped") {
+ t.Errorf("expected a symlink-skipped diagnostic for link.yaml: %+v", rep.Diagnostics)
+ }
+ if rep.Summary.YAMLFiles != 1 {
+ t.Errorf("yaml files = %d, want 1 (symlink not counted)", rep.Summary.YAMLFiles)
+ }
+}
+
+func TestAnalyzeDir_Errors(t *testing.T) {
+ if _, err := AnalyzeDir(filepath.Join(t.TempDir(), "missing")); err == nil {
+ t.Error("expected error for missing directory")
+ }
+
+ file := filepath.Join(t.TempDir(), "f.yaml")
+ writeFile(t, filepath.Dir(file), "f.yaml", deployYAML)
+ if _, err := AnalyzeDir(file); err == nil {
+ t.Error("expected error when root is not a directory")
+ }
+}
+
+// faultyFS injects read/walk failures to exercise the diagnostic branches.
+type faultyFS struct {
+ fstest.MapFS
+
+ failReadFile string
+ failReadDir string
+}
+
+func (f faultyFS) ReadFile(name string) ([]byte, error) {
+ if name == f.failReadFile {
+ return nil, errors.New("synthetic read error")
+ }
+ return f.MapFS.ReadFile(name)
+}
+
+func (f faultyFS) ReadDir(name string) ([]fs.DirEntry, error) {
+ if name == f.failReadDir {
+ return nil, errors.New("synthetic readdir error")
+ }
+ return f.MapFS.ReadDir(name)
+}
+
+func TestAnalyze_ReadFileError(t *testing.T) {
+ fsys := faultyFS{
+ MapFS: fstest.MapFS{"bad.yaml": {Data: []byte(deployYAML)}},
+ failReadFile: "bad.yaml",
+ }
+ rep := Analyze(fsys)
+ if rep.Summary.YAMLFiles != 0 {
+ t.Errorf("unreadable file should not be indexed: %+v", rep.Summary)
+ }
+ if !hasDiag(rep, "bad.yaml", "synthetic read error") {
+ t.Errorf("expected read-error diagnostic: %+v", rep.Diagnostics)
+ }
+}
+
+func TestAnalyze_WalkDirError(t *testing.T) {
+ fsys := faultyFS{
+ MapFS: fstest.MapFS{"sub/deploy.yaml": {Data: []byte(deployYAML)}},
+ failReadDir: "sub",
+ }
+ rep := Analyze(fsys)
+ if !hasDiag(rep, "sub", "synthetic readdir error") {
+ t.Errorf("expected walk-error diagnostic for sub: %+v", rep.Diagnostics)
+ }
+}
+
+func TestParseGVK(t *testing.T) {
+ cases := []struct {
+ apiVersion, kind string
+ want GVK
+ str string
+ }{
+ {"apps/v1", "Deployment", GVK{"apps", "v1", "Deployment"}, "apps/v1/Deployment"},
+ {"v1", "ConfigMap", GVK{"", "v1", "ConfigMap"}, "v1/ConfigMap"},
+ }
+ for _, c := range cases {
+ got := ParseGVK(c.apiVersion, c.kind)
+ if got != c.want {
+ t.Errorf("ParseGVK(%q,%q) = %+v, want %+v", c.apiVersion, c.kind, got, c.want)
+ }
+ if got.String() != c.str {
+ t.Errorf("String() = %q, want %q", got.String(), c.str)
+ }
+ if got.Empty() {
+ t.Errorf("%+v should not be Empty", got)
+ }
+ }
+ if !(GVK{}).Empty() {
+ t.Error("zero GVK should be Empty")
+ }
+}
+
+// --- helpers ---
+
+func countIssues(rep Report, kind IssueKind) int {
+ n := 0
+ for _, is := range rep.Issues {
+ if is.Kind == kind {
+ n++
+ }
+ }
+ return n
+}
+
+func hasIssue(rep Report, kind IssueKind, path string) bool {
+ for _, is := range rep.Issues {
+ if is.Kind == kind && is.Path == path {
+ return true
+ }
+ }
+ return false
+}
+
+func findFile(t *testing.T, rep Report, path string) FileReport {
+ t.Helper()
+ for _, f := range rep.Files {
+ if f.Path == path {
+ return f
+ }
+ }
+ t.Fatalf("file %q not found in report", path)
+ return FileReport{}
+}
+
+func hasDiag(rep Report, path, substr string) bool {
+ for _, d := range rep.Diagnostics {
+ if d.Path == path && strings.Contains(d.Message, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+func writeFile(t *testing.T, dir, name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+}
diff --git a/internal/manifestanalyzer/contextual_namespace_corpus_test.go b/internal/manifestanalyzer/contextual_namespace_corpus_test.go
new file mode 100644
index 00000000..b207e523
--- /dev/null
+++ b/internal/manifestanalyzer/contextual_namespace_corpus_test.go
@@ -0,0 +1,131 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// wantDoc is one expected document outcome in a contextual-namespace example folder:
+// the effective identity the store should index it under, and where its namespace
+// came from. An empty namespace with NamespaceNone is the refused/unsupplied case.
+type wantDoc struct {
+ namespace string
+ name string
+ source NamespaceSourceKind
+}
+
+// TestContextualNamespaceCorpus drives the supported/unsupported example folders under
+// testdata/contextual-namespace. Each folder is built as a GitTarget subtree and the
+// per-document namespace provenance is asserted, so the supported boundary is pinned by
+// real layouts rather than prose. See
+// docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md.
+func TestContextualNamespaceCorpus(t *testing.T) {
+ cases := []struct {
+ dir string
+ docs []wantDoc
+ ambiguousDiag bool
+ }{
+ {
+ dir: "supported/flat-namespace",
+ docs: []wantDoc{
+ {namespace: "app", name: "a", source: NamespaceKustomize},
+ {namespace: "app", name: "b", source: NamespaceKustomize},
+ },
+ },
+ {
+ dir: "supported/nested-base",
+ docs: []wantDoc{
+ {namespace: "app", name: "root", source: NamespaceKustomize},
+ {namespace: "app", name: "child", source: NamespaceKustomize},
+ },
+ },
+ {
+ dir: "supported/multi-doc",
+ docs: []wantDoc{
+ {namespace: "app", name: "one", source: NamespaceKustomize},
+ {namespace: "app", name: "two", source: NamespaceKustomize},
+ },
+ },
+ {
+ dir: "supported/explicit-namespace",
+ docs: []wantDoc{{namespace: "explicit-ns", name: "cm", source: NamespaceExplicit}},
+ },
+ {
+ dir: "unsupported/ambiguous-two-roots",
+ docs: []wantDoc{{name: "shared", source: NamespaceNone}},
+ ambiguousDiag: true,
+ },
+ {dir: "unsupported/patches", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ {dir: "unsupported/generators", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ {dir: "unsupported/components", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ {dir: "unsupported/helm", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ {dir: "unsupported/remote-base", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ {dir: "unsupported/name-prefix", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ {dir: "unsupported/no-context", docs: []wantDoc{{name: "cm", source: NamespaceNone}}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.dir, func(t *testing.T) {
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ fsys := os.DirFS(filepath.Join("testdata", "contextual-namespace", tc.dir))
+ store := BuildStore(context.Background(), fsys, mapper)
+
+ for _, want := range tc.docs {
+ id := manifestedit.Identity{
+ APIVersion: "v1",
+ Kind: "ConfigMap",
+ Namespace: want.namespace,
+ Name: want.name,
+ }
+ dm := store.ByManifestIdentity[id]
+ if dm == nil {
+ t.Fatalf("%s: ConfigMap %q should be indexed under namespace %q", tc.dir, want.name, want.namespace)
+ }
+ if dm.NamespaceSource.Kind != want.source {
+ t.Errorf("%s: ConfigMap %q NamespaceSource.Kind = %q, want %q",
+ tc.dir, want.name, dm.NamespaceSource.Kind, want.source)
+ }
+ if (dm.NamespaceSource.Kind == NamespaceKustomize) != dm.NamespaceInheritedFromContext() {
+ t.Errorf("%s: ConfigMap %q NamespaceInheritedFromContext disagrees with Kind %q",
+ tc.dir, want.name, dm.NamespaceSource.Kind)
+ }
+ }
+
+ if got := hasAmbiguousNamespaceDiag(store); got != tc.ambiguousDiag {
+ t.Errorf("%s: ambiguous-namespace diagnostic present = %v, want %v", tc.dir, got, tc.ambiguousDiag)
+ }
+ })
+ }
+}
+
+func hasAmbiguousNamespaceDiag(store *ManifestStore) bool {
+ for _, d := range store.Diagnostics {
+ if d.Reason == reasonAmbiguousNamespace {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/manifestanalyzer/delete_plan.go b/internal/manifestanalyzer/delete_plan.go
new file mode 100644
index 00000000..d313f631
--- /dev/null
+++ b/internal/manifestanalyzer/delete_plan.go
@@ -0,0 +1,86 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// PlanDelete resolves a steady-state DELETE watch event to a single delete-document
+// plan action over the store, or no action when Git holds no managed document for the
+// resource. It is the M6 milestone: closing the delete-identity gap the review names
+// (docs/design/manifest/current-manifest-support-review.md, "Cons And Gaps") so a
+// moved manifest is still deleted, and the writer (M7) deletes by RecordRef instead of
+// regenerating a canonical path.
+//
+// This is the per-event delete path of the design's "Two Paths, One Plan Type"
+// (docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md). Unlike BuildPlan's
+// full-snapshot mark-and-sweep, it targets exactly ONE identity and NEVER sweeps, so a
+// lone delete intent can never be mistaken for "every other document is now an orphan".
+// M7's steady-state loop folds this over its coalesced PendingChanges (a delete is a
+// PendingChange whose Object is nil).
+//
+// A DELETE event carries only a GVR-based resource identity and NO object body, so the
+// manifest identity cannot be derived from the event. The document is therefore located
+// only by its RESOLVED RESOURCE identity — the ByResourceIdentity index B3 built while
+// scanning the GitTarget folder. If that inventory has no entry, there is no managed
+// document to delete.
+//
+// Deletion is content-agnostic (manifestedit.DeleteDocument never decrypts or merges),
+// so an encrypted or non-editable document is still removed when its resource leaves the
+// cluster — editability gates patches, not removals.
+//
+// PlanDelete is a commit-boundary operation, not a per-event one: like the rest of the
+// planner it reuses documentLocations / collidedIdentities (each O(store)). M7 hoists
+// those per-commit maps so folding many deletes stays bounded by the batch; M9 caches
+// across batches.
+func PlanDelete(
+ store *ManifestStore,
+ resource types.ResourceIdentifier,
+) (PlanAction, bool) {
+ dm, found := resolveDeleteTarget(store, resource)
+ if !found {
+ // Git holds no managed document for this resource: the cluster dropped a
+ // resource Git never materialised. Already converged — nothing to delete.
+ return PlanAction{}, false
+ }
+ if collidedIdentities(store)[dm.ManifestIdentity] {
+ // A duplicate-identity collision refuses the whole GitTarget at acceptance
+ // (M4), so the steady-state writer — which gates on Accept — never reaches a
+ // collided identity here. Guard defensively anyway: deleting one arbitrary copy
+ // of a collided identity is exactly the ambiguity the design refuses to guess at.
+ return PlanAction{}, false
+ }
+ return PlanAction{
+ Kind: PlanDeleteDocument,
+ Ref: documentLocations(store)[dm],
+ Identity: dm.ManifestIdentity,
+ Resource: resource,
+ Reason: "watched resource deleted from the cluster: drop its managed document",
+ }, true
+}
+
+// resolveDeleteTarget locates the managed document a GVR-based delete event targets.
+// The second result is false when Git holds no managed document for the resource.
+func resolveDeleteTarget(store *ManifestStore, resource types.ResourceIdentifier) (*DocumentModel, bool) {
+ if dm := store.ByResourceIdentity[resource]; dm != nil {
+ return dm, true
+ }
+ return nil, false
+}
diff --git a/internal/manifestanalyzer/delete_plan_test.go b/internal/manifestanalyzer/delete_plan_test.go
new file mode 100644
index 00000000..5e5e3672
--- /dev/null
+++ b/internal/manifestanalyzer/delete_plan_test.go
@@ -0,0 +1,159 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "testing"
+ "testing/fstest"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// deployResource / configMapResource are the GVR-based identities a DELETE watch event
+// carries (no object body), mirroring desiredDeployWeb / desiredConfigMap's Resource.
+func deployResource() types.ResourceIdentifier {
+ return types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")
+}
+
+func configMapResource(name string) types.ResourceIdentifier {
+ return types.NewResourceIdentifier("", "v1", "configmaps", "default", name)
+}
+
+// TestPlanDelete_ByResourceIdentity: a DELETE event carrying only the GVR/name resolves
+// through the resource-identity index to the right document and emits one
+// delete-document action carrying the manifest (GVK) identity the writer needs.
+func TestPlanDelete_ByResourceIdentity(t *testing.T) {
+ store := planStore(t)
+ action, emitted := PlanDelete(store, deployResource())
+ if !emitted {
+ t.Fatalf("a resource that exists in Git should emit a delete action")
+ }
+ if action.Kind != PlanDeleteDocument {
+ t.Errorf("kind = %q, want delete-document", action.Kind)
+ }
+ if action.Ref != (RecordRef{FilePath: "deploy.yaml", DocumentIndex: 0}) {
+ t.Errorf("ref = %+v, want deploy.yaml#0", action.Ref)
+ }
+ wantID := manifestedit.Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "web"}
+ if action.Identity != wantID {
+ t.Errorf("identity = %+v, want %+v", action.Identity, wantID)
+ }
+ if action.Resource != deployResource() {
+ t.Errorf("resource = %+v, want %+v", action.Resource, deployResource())
+ }
+ if action.Desired != nil {
+ t.Errorf("a delete action carries no desired object, got %+v", action.Desired)
+ }
+}
+
+// TestPlanDelete_MovedManifest is the headline M6 case: the Deployment lives at a
+// NON-canonical path (legacy/foo.yaml, not apps/v1/deployments/default/web.yaml), and a
+// delete event with only GVR/name still finds it — because location is content-derived,
+// not path-derived. This is the gap the old path scan left (a moved manifest was
+// invisible to the canonical-path lookup).
+func TestPlanDelete_MovedManifest(t *testing.T) {
+ fsys := fstest.MapFS{"legacy/foo.yaml": {Data: []byte(deployYAML)}}
+ store := BuildStore(context.Background(), fsys, typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ action, emitted := PlanDelete(store, deployResource())
+ if !emitted {
+ t.Fatalf("moved manifest should resolve")
+ }
+ if action.Ref.FilePath != "legacy/foo.yaml" {
+ t.Errorf("ref path = %q, want the actual (moved) file legacy/foo.yaml", action.Ref.FilePath)
+ }
+}
+
+// TestPlanDelete_MultiDocIndex: deleting one document of a multi-document file targets
+// the right document index (cm.yaml holds ConfigMap a at #0 and b at #1).
+func TestPlanDelete_MultiDocIndex(t *testing.T) {
+ store := planStore(t)
+ action, emitted := PlanDelete(store, configMapResource("b"))
+ if !emitted {
+ t.Fatalf("ConfigMap b should resolve")
+ }
+ if action.Ref != (RecordRef{FilePath: "cm.yaml", DocumentIndex: 1}) {
+ t.Errorf("ref = %+v, want cm.yaml#1", action.Ref)
+ }
+}
+
+// TestPlanDelete_NotInGit: a delete for a resource Git never materialised is a no-op —
+// no action, no error. The cluster dropped something we do not track; already converged.
+func TestPlanDelete_NotInGit(t *testing.T) {
+ store := planStore(t)
+ action, emitted := PlanDelete(store, configMapResource("ghost"))
+ if emitted {
+ t.Errorf("a resource absent from Git should emit no action, got %+v", action)
+ }
+}
+
+// TestPlanDelete_EncryptedStillDeletes: deletion is content-agnostic, so an encrypted
+// (non-patchable) document is still removed when its resource leaves the cluster.
+// Editability gates patches, not removals.
+func TestPlanDelete_EncryptedStillDeletes(t *testing.T) {
+ const encConfigMap = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n" +
+ " name: enc\n namespace: default\nsops:\n version: \"3\"\n"
+ fsys := fstest.MapFS{"cm.sops.yaml": {Data: []byte(encConfigMap)}}
+ store := BuildStore(context.Background(), fsys, typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ dm := store.FilesByPath["cm.sops.yaml"].Documents[0]
+ if dm.Editable || dm.Cause.Kind != CauseEncrypted {
+ t.Fatalf("fixture should be an encrypted, non-editable ConfigMap, got editable=%v cause=%+v",
+ dm.Editable, dm.Cause)
+ }
+
+ action, emitted := PlanDelete(store, configMapResource("enc"))
+ if !emitted {
+ t.Fatalf("an encrypted resource should still delete")
+ }
+ if action.Kind != PlanDeleteDocument {
+ t.Errorf("kind = %q, want delete-document even for an encrypted document", action.Kind)
+ }
+}
+
+func TestPlanDelete_StructureOnlyStoreHasNoDeleteTarget(t *testing.T) {
+ store := BuildStore(context.Background(), planFS(), nil)
+ if len(store.ByResourceIdentity) != 0 {
+ t.Fatalf("structure-only store should have an empty resource index, got %d", len(store.ByResourceIdentity))
+ }
+
+ _, emitted := PlanDelete(store, deployResource())
+ if emitted {
+ t.Errorf("a store without resource inventory must not emit a delete")
+ }
+}
+
+// TestPlanDelete_DuplicateSuppressed: a collided manifest identity refuses the whole
+// GitTarget at acceptance, so a steady-state delete for it produces no action — deleting
+// one arbitrary copy of an ambiguous identity is exactly what the design refuses to do.
+func TestPlanDelete_DuplicateSuppressed(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "dup.yaml": {Data: []byte(deployYAML)},
+ }
+ store := BuildStore(context.Background(), fsys, typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ _, emitted := PlanDelete(store, deployResource())
+ if emitted {
+ t.Errorf("a collided identity must produce no delete action")
+ }
+}
diff --git a/internal/manifestanalyzer/plan.go b/internal/manifestanalyzer/plan.go
new file mode 100644
index 00000000..ed509f0f
--- /dev/null
+++ b/internal/manifestanalyzer/plan.go
@@ -0,0 +1,563 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "fmt"
+ "sort"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// Plan is the first-class, cross-layer contract described in
+// docs/design/manifest/current-manifest-support-review.md ("Writer Model: Plan,
+// Apply, Dirty Flush"). It is a pure function of (ManifestStore, desired set,
+// policy): the same value the live writer applies, scan mode renders, the CLI
+// prints, and GitTarget status summarizes. M3 builds the model and its
+// computation; applying it to a worktree is M7.
+//
+// It carries enough detail to render text/JSON/status without recomputing any
+// decision: each action names its kind, the document it concerns, and a reason.
+type Plan struct {
+ // Actions are the decided changes, in a deterministic order (by file path, then
+ // document index, then identity), so output is stable regardless of map
+ // iteration order. A resource already in sync produces NO action — the plan
+ // lists only what would change.
+ Actions []PlanAction
+ // Diagnostics are planning-level problems (e.g. a touched file whose bytes were
+ // not provided for hydration). Store-level diagnostics stay on the ManifestStore.
+ Diagnostics []manifestedit.Diagnostic
+}
+
+// PlanActionKind enumerates what a single action does. The seven kinds are the
+// full vocabulary the materialized model speaks; which milestone *emits* each is
+// noted below, because M3 (this milestone) computes the plan from a whole desired
+// set, while a few kinds only arise once the apply/event layers land.
+type PlanActionKind string
+
+const (
+ // PlanCreate places a desired resource that has no managed document in Git yet.
+ // The target path is a placement decision made at apply time (M7), so a create
+ // action carries the Desired object but a zero Ref.
+ PlanCreate PlanActionKind = "create"
+ // PlanPatch edits an existing document field-by-field (Decide said the Git
+ // document differs from desired and a mapping-root patch is possible).
+ PlanPatch PlanActionKind = "patch"
+ // PlanReplace re-renders an existing document canonically (Decide could not
+ // patch field-by-field, e.g. a non-mapping root).
+ PlanReplace PlanActionKind = "replace"
+ // PlanDeleteDocument removes one document from a multi-document file. The
+ // full-snapshot BuildPlan does not emit it — a resync managed drop is a
+ // PlanDropOrphan — but the steady-state per-event path does: PlanDelete (M6) emits
+ // it for a single live DELETE event, the design's "Two Paths, One Plan Type". Whether
+ // removing the document also empties the file (a follow-on file delete) is realized
+ // mechanically at apply time (M7), so a delete is one PlanDeleteDocument either way.
+ PlanDeleteDocument PlanActionKind = "delete-document"
+ // PlanDeleteFile removes a whole file (its last managed document was dropped).
+ // Like PlanDeleteDocument it is realized at apply time (M7), not emitted by the
+ // M3 planner. Reserved here for completeness.
+ PlanDeleteFile PlanActionKind = "delete-file"
+ // PlanDropOrphan deletes a watched resource the API no longer has — the managed
+ // drop. It is emitted only for a document whose GVK the registry resolved to a
+ // followable resource (MappingFollowable) that has no desired counterpart.
+ // Duplicate identities and not-followable KRM produce NO plan action: they are
+ // acceptance facts (M4), not planning outcomes. Allowlisted non-API KRM produces
+ // none either (it never resolves to a followable resource).
+ PlanDropOrphan PlanActionKind = "drop-orphan"
+ // PlanSkip marks a document that exists but cannot be edited in place
+ // (encrypted, a disallowed construct, or a soft Decide skip). It is reported,
+ // never silently acted on.
+ PlanSkip PlanActionKind = "skip"
+)
+
+// PlanAction is one decided change over a single document or desired resource.
+type PlanAction struct {
+ // Kind is what the action does.
+ Kind PlanActionKind
+ // Ref is the Git document the action concerns. It is the zero value for
+ // PlanCreate, which has no existing location.
+ Ref RecordRef
+ // Identity is the manifest identity (apiVersion + kind + namespace + name)
+ // involved, always set.
+ Identity manifestedit.Identity
+ // Resource is the resolved API-side identity (GVR + namespace + name). For a
+ // desired-side action (create / patch / replace / skip) it is the cluster-truth
+ // identity carried on the DesiredResource, so a create carries everything
+ // ResourceIdentifier.ToGitPath needs to place a new file at apply time (M7)
+ // without re-resolving the mapping. For a Git-only managed drop it is the
+ // store-resolved identity. It is the zero value only when neither is known (a
+ // skip over a document a structure-only store never resolved).
+ Resource types.ResourceIdentifier
+ // Desired is the clean object Git should contain, set for create/patch/replace
+ // and nil for removals and skips.
+ Desired *unstructured.Unstructured
+ // Reason is a human-readable explanation, carried so renderers and status need
+ // no recomputation.
+ Reason string
+}
+
+// Counts returns the number of actions per kind, for a bounded status summary.
+func (p Plan) Counts() map[PlanActionKind]int {
+ out := make(map[PlanActionKind]int)
+ for _, a := range p.Actions {
+ out[a.Kind]++
+ }
+ return out
+}
+
+// DesiredResource is one resource in the COMPLETE desired snapshot the planner
+// compares Git against: a resource the cluster currently has, paired with the
+// API-side identity the controller already resolved from the GVR it watched (or the
+// CLI from the mapper). Carrying the ResourceIdentifier is what lets a create place
+// its new file (ResourceIdentifier.ToGitPath) at apply time (M7) without re-resolving
+// the mapping.
+//
+// This is a full-snapshot input — the "Resync" path of the design's "Two Paths, One
+// Plan Type" (docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md). It is
+// NOT a per-event PendingChange: BuildPlan mark-and-sweeps every watched document
+// absent from this set as a managed drop, so the set must be the whole desired state
+// (scan mode / resync), never a partial batch. Steady-state, per-event planning that
+// targets a single identity and emits an explicit delete-document — without sweeping
+// — is the separate pending-change path (M7, on M6's delete-identity resolution).
+//
+// Object must be non-nil: every entry in a desired snapshot is a resource that
+// exists. A nil Object is a malformed entry — deliberately NOT a delete tombstone,
+// because in a sweeping planner a lone tombstone is indistinguishable from "every
+// other document is now an orphan". It cannot simply be skipped either: because the
+// planner mark-and-sweeps, skipping a nil entry would leave the matching managed
+// document unmatched and let the Git-only sweep DROP it. So BuildPlan instead
+// protects the matching document from the sweep (by resolved resource identity) and
+// emits a diagnostic, so a malformed entry never causes a destructive drop. A genuine
+// per-event delete (a DELETED watch event) is resolved separately by PlanDelete, which
+// targets one identity and never sweeps.
+type DesiredResource struct {
+ Resource types.ResourceIdentifier
+ Object *unstructured.Unstructured
+}
+
+// Policy is the injected planning policy. The planner stays a pure function and
+// pulls every cluster-shaped or rendering-shaped decision out into this struct, so
+// the production wiring (manifestreport.Project / EditOptions) lives at the call
+// sites and tests can substitute their own.
+type Policy struct {
+ // Project maps a live API object to the clean desired state Git should contain.
+ // A nil Project is treated as an identity passthrough (the object compared as-is).
+ Project func(*unstructured.Unstructured) *unstructured.Unstructured
+ // EditOptions are the manifestedit options (canonical renderer, list-match) used
+ // when Decide must compare and choose patch vs. whole-replace.
+ EditOptions manifestedit.EditOptions
+}
+
+// BuildPlan computes the Plan from the byte-free ManifestStore, the file bytes
+// that back it (hydration source for the patch/no-op decision), the COMPLETE desired
+// snapshot, and the policy. It graduates manifestreport.BuildReport's read-only
+// create/update/delete/skip comparison into the materialized model's plan.
+//
+// This is the full-snapshot "Resync" planner (scan mode, CLI, initial reconcile /
+// resync): it mark-and-sweeps — every watched document with no entry in desired is a
+// managed drop — so desired MUST be the whole desired state, never a partial batch.
+// The steady-state path (one plan action per live event, where a DELETED event is an
+// explicit delete-document and nothing re-sweeps) is PlanDelete for removals (M6); the
+// per-event create/patch twin and the writer that folds both arrive with M7.
+//
+// The store is expected to have been built with the same mapper whose watched set
+// produced desired; under a structure-only store (no resolved mappings) no managed
+// drop is ever emitted, preserving the no-cluster promise even if a desired set is
+// passed by mistake.
+func BuildPlan(
+ store *ManifestStore,
+ files []manifestedit.FileContent,
+ desired []DesiredResource,
+ policy Policy,
+) Plan {
+ return BuildScopedPlan(store, files, desired, policy, allInScope)
+}
+
+// allInScope is BuildPlan's whole-folder sweep predicate: every managed document is in
+// scope, so the Git-only sweep considers all of them. It makes BuildPlan a special case of
+// BuildScopedPlan with byte-identical behaviour.
+func allInScope(types.ResourceIdentifier) bool { return true }
+
+// BuildScopedPlan is BuildPlan restricted to the documents inScope reports: the desired set
+// is upserted as usual, but the Git-only mark-and-sweep only drops/skips a managed document
+// whose RESOLVED resource identity is in scope — every out-of-scope document is left
+// untouched, never swept. It is the per-type (M12) primitive: a reconcile passes that type's
+// desired objects with a predicate matching that type's (group, resource); a sweep passes an
+// EMPTY desired set with the same predicate, so a removed type's documents drop and no
+// sibling type is ever collaterally deleted. The caller MUST keep desired in scope, since
+// the desired set is the scope on the upsert side.
+//
+// With allInScope this is exactly BuildPlan — the full-snapshot mark-and-sweep — so the two
+// share one implementation and one set of safety guarantees. See
+// docs/design/manifest/version2/type-lifecycle-events-and-wobble-settling.md (Proposal 3 / M12).
+func BuildScopedPlan(
+ store *ManifestStore,
+ files []manifestedit.FileContent,
+ desired []DesiredResource,
+ policy Policy,
+ inScope func(types.ResourceIdentifier) bool,
+) Plan {
+ project := policy.Project
+ if project == nil {
+ project = func(obj *unstructured.Unstructured) *unstructured.Unstructured { return obj }
+ }
+ b := &planBuilder{
+ store: store,
+ project: project,
+ opts: policy.EditOptions,
+ contentByPath: indexContent(files),
+ docLoc: documentLocations(store),
+ nonClaiming: nonClaimingIdentities(store),
+ collided: collidedIdentities(store),
+ matched: map[*DocumentModel]bool{},
+ inScope: inScope,
+ }
+
+ // Desired side: create / patch / replace / skip for every cluster object, and
+ // mark the documents it matched so the Git-only sweep below does not drop them.
+ for _, dr := range desired {
+ if dr.Object == nil {
+ // A malformed snapshot entry. It is NOT a delete tombstone, but it cannot be
+ // silently skipped either: this planner sweeps, so an unmatched managed
+ // document would be dropped. Protect the matching document and diagnose
+ // instead — a nil object must never cause a destructive drop.
+ b.protectFromSweep(dr)
+ continue
+ }
+ b.planDesired(dr)
+ }
+
+ // Git-only side: documents with no desired counterpart — managed drops for
+ // watched resources the API no longer has, skips for non-editable constructs,
+ // and nothing at all for duplicates and unwatched API-backed KRM (acceptance
+ // facts, not plan actions).
+ for _, path := range sortedKeys(store.FilesByPath) {
+ for _, dm := range store.FilesByPath[path].Documents {
+ b.planGitOnly(dm)
+ }
+ }
+
+ sortActions(b.actions)
+ return Plan{Actions: b.actions, Diagnostics: b.diags}
+}
+
+// planBuilder accumulates a plan's actions and diagnostics while BuildPlan walks
+// the desired set and the store. It keeps the resolved inputs in one place so the
+// per-document classifiers stay small, and keeps Plan itself a pure value type.
+type planBuilder struct {
+ store *ManifestStore
+ project func(*unstructured.Unstructured) *unstructured.Unstructured
+ opts manifestedit.EditOptions
+ contentByPath map[string][]byte
+ docLoc map[*DocumentModel]RecordRef
+ nonClaiming map[manifestedit.Identity]bool
+ collided map[manifestedit.Identity]bool
+
+ matched map[*DocumentModel]bool
+ actions []PlanAction
+ diags []manifestedit.Diagnostic
+ // inScope gates the Git-only sweep to a subset of resolved resource identities. For the
+ // whole-folder BuildPlan it is allInScope (always true); for a per-type reconcile/sweep
+ // it matches one type's (group, resource), so out-of-scope documents are never dropped.
+ inScope func(types.ResourceIdentifier) bool
+}
+
+// planDesired classifies one desired resource against the store and appends its
+// action (or, for a no-op, nothing). dr.Resource is the cluster-truth identity, so
+// every desired-side action carries it — a create included.
+func (b *planBuilder) planDesired(dr DesiredResource) {
+ obj := dr.Object
+ id := identityOfObject(obj)
+ if b.collided[id] {
+ // A duplicate-identity collision refuses the whole GitTarget at acceptance
+ // (M4): neither the winner nor the losers of a collided identity produce a
+ // plan action. Suppress it here so an arbitrary copy is never edited.
+ return
+ }
+ dm := b.store.ByManifestIdentity[id]
+ if dm == nil {
+ // No document claims this identity. If a non-claiming construct document
+ // already holds it, Git has it but cannot edit it: defer to the skip the
+ // Git-only sweep emits, rather than report a contradictory create. Only a
+ // truly absent resource is a create.
+ if b.nonClaiming[id] {
+ return
+ }
+ b.actions = append(b.actions, PlanAction{
+ Kind: PlanCreate,
+ Identity: id,
+ Resource: dr.Resource,
+ Desired: b.project(obj),
+ Reason: "desired resource has no managed document in Git; placement is decided at apply time",
+ })
+ return
+ }
+
+ b.matched[dm] = true
+ ref := b.docLoc[dm]
+ if !dm.Editable {
+ // Claims its identity but is not patchable in place (encrypted, or a
+ // disallowed construct that still carries an identity): reported, never edited.
+ b.actions = append(b.actions, PlanAction{
+ Kind: PlanSkip, Ref: ref, Identity: id, Resource: dr.Resource,
+ Reason: skipReason(dm.Cause),
+ })
+ return
+ }
+
+ content, ok := b.contentByPath[ref.FilePath]
+ if !ok {
+ b.diags = append(b.diags, manifestedit.Diagnostic{
+ Level: manifestedit.DiagWarning, Path: ref.FilePath, DocumentIndex: ref.DocumentIndex,
+ Message: "no file content provided for hydration; cannot plan this document",
+ })
+ b.actions = append(b.actions, PlanAction{
+ Kind: PlanSkip, Ref: ref, Identity: id, Resource: dr.Resource,
+ Reason: "file content unavailable for planning",
+ })
+ return
+ }
+
+ gitDoc, _ := manifestedit.NewDocumentAt(ref.FilePath, content, ref.DocumentIndex)
+ decision := manifestedit.Decide(manifestedit.Comparison{
+ Git: gitDoc, Desired: b.project(obj), Options: b.opts,
+ })
+ if kind, action := actionFromDecision(decision.Action); action {
+ b.actions = append(b.actions, PlanAction{
+ Kind: kind, Ref: ref, Identity: id, Resource: dr.Resource,
+ Desired: b.project(obj), Reason: decision.Reason,
+ })
+ }
+}
+
+// protectFromSweep handles a desired entry whose Object is nil. A nil object is a
+// malformed snapshot entry, never a delete tombstone — but because BuildPlan
+// mark-and-sweeps, simply ignoring it would leave the matching managed document
+// unmatched and let the Git-only sweep drop it. So the matching document (resolved
+// by resource identity) is marked matched, protecting it from the sweep, and a
+// diagnostic records the malformed entry so the operator knows the snapshot was
+// incomplete. A nil entry that matches no managed document is inert (still diagnosed).
+func (b *planBuilder) protectFromSweep(dr DesiredResource) {
+ d := manifestedit.Diagnostic{
+ Level: manifestedit.DiagWarning,
+ Message: fmt.Sprintf(
+ "desired snapshot entry for %s has no object; protected from sweep (malformed entry, not a delete)",
+ dr.Resource.Key()),
+ }
+ if dm := b.store.ByResourceIdentity[dr.Resource]; dm != nil {
+ b.matched[dm] = true
+ ref := b.docLoc[dm]
+ d.Path = ref.FilePath
+ d.DocumentIndex = ref.DocumentIndex
+ }
+ b.diags = append(b.diags, d)
+}
+
+// planGitOnly classifies one Git document that no desired object matched.
+func (b *planBuilder) planGitOnly(dm *DocumentModel) {
+ if b.matched[dm] {
+ return
+ }
+ if b.inScope != nil && !b.inScope(resourceOf(dm)) {
+ // A per-type plan only sweeps its own type. A document of any other type — and any
+ // unresolved/non-claiming document, whose resourceOf is the zero identity — is left
+ // exactly as Git holds it, never dropped or even reported.
+ return
+ }
+ if b.collided[dm.ManifestIdentity] {
+ // Duplicate-identity collision: an acceptance fact (M4 refuses the folder),
+ // never a plan action — for the first-occurrence winner or its losers.
+ return
+ }
+ ref := b.docLoc[dm]
+ if !dm.claimsIdentity() {
+ // A disallowed construct that does not claim an identity: surfaced as a skip
+ // so an operator sees a document Git holds but the editor refuses.
+ b.actions = append(b.actions, PlanAction{
+ Kind: PlanSkip, Ref: ref, Identity: dm.ManifestIdentity, Resource: resourceOf(dm),
+ Reason: skipReason(dm.Cause),
+ })
+ return
+ }
+ // A claiming document with no desired counterpart. Only a followable resource —
+ // one the registry resolved to a served, policy-allowed GVR — is dropped.
+ // Not-followable KRM and no-source documents produce no action: they are refused
+ // at acceptance, never pruned.
+ if dm.Mapping == MappingFollowable {
+ b.actions = append(b.actions, PlanAction{
+ Kind: PlanDropOrphan, Ref: ref, Identity: dm.ManifestIdentity, Resource: resourceOf(dm),
+ Reason: "watched resource absent from the cluster: managed drop",
+ })
+ }
+}
+
+// actionFromDecision maps a manifestedit decision intent to a plan action kind. The
+// boolean is false for ActionNoChange, which produces no plan action: an in-sync
+// document is not a change. ActionDelete cannot arise here (desired is non-nil), so
+// it falls through to a defensive skip.
+func actionFromDecision(a manifestedit.DecisionAction) (PlanActionKind, bool) {
+ switch a {
+ case manifestedit.ActionNoChange:
+ return "", false
+ case manifestedit.ActionPatch:
+ return PlanPatch, true
+ case manifestedit.ActionReplace:
+ return PlanReplace, true
+ case manifestedit.ActionSkip:
+ return PlanSkip, true
+ case manifestedit.ActionDelete:
+ return PlanSkip, true
+ default:
+ return PlanSkip, true
+ }
+}
+
+// documentLocations indexes every managed document to its (file path, document
+// index) reference. DocumentModel stores neither: the file path is the map key, and
+// the document's TRUE file position is reconstructed from the record-less diagnostic
+// gaps (every empty/non-KRM/invalid document leaves a diagnostic at its position, so
+// the managed documents fill the remaining positions in order). This is exact for
+// every file, contiguous or not — so a plan's reference targets the right document
+// even for an impure managed file the acceptance gate is refusing, and scan mode
+// renders an accurate (not merely advisory) target. manifestedit is handed this
+// position at hydration time.
+func documentLocations(store *ManifestStore) map[*DocumentModel]RecordRef {
+ diagsByPath := diagnosticsByPath(store.Diagnostics)
+ out := map[*DocumentModel]RecordRef{}
+ for path, fm := range store.FilesByPath {
+ idxs := reconstructManagedIndices(fm, gapIndices(diagsByPath[path]))
+ for i, dm := range fm.Documents {
+ out[dm] = RecordRef{FilePath: path, DocumentIndex: idxs[i]}
+ }
+ }
+ return out
+}
+
+// nonClaimingIdentities collects the manifest identities held only by documents
+// that do not claim their identity (disallowed constructs). The desired side uses
+// it to defer to the Git-only skip instead of reporting a contradictory create.
+func nonClaimingIdentities(store *ManifestStore) map[manifestedit.Identity]bool {
+ out := map[manifestedit.Identity]bool{}
+ for _, fm := range store.FilesByPath {
+ for _, dm := range fm.Documents {
+ if !dm.claimsIdentity() {
+ out[dm.ManifestIdentity] = true
+ }
+ }
+ }
+ return out
+}
+
+// collidedIdentities collects every manifest identity involved in a duplicate
+// collision — the identities for which IsDuplicate flags at least one loser, which
+// by definition also covers the first-occurrence winner that shares the identity.
+// A duplicate collision refuses the whole GitTarget at acceptance (M4), so the
+// planner emits no action for either copy; this set is how it suppresses both.
+func collidedIdentities(store *ManifestStore) map[manifestedit.Identity]bool {
+ out := map[manifestedit.Identity]bool{}
+ for _, fm := range store.FilesByPath {
+ for _, dm := range fm.Documents {
+ if store.IsDuplicate(dm) {
+ out[dm.ManifestIdentity] = true
+ }
+ }
+ }
+ return out
+}
+
+// resourceOf returns the resolved resource identity of a document, or the zero
+// value when the mapper left it unresolved.
+func resourceOf(dm *DocumentModel) types.ResourceIdentifier {
+ if dm != nil && dm.ResourceIdentity != nil {
+ return *dm.ResourceIdentity
+ }
+ return types.ResourceIdentifier{}
+}
+
+// skipReason renders a display reason for a skipped document from its structured
+// cause, never from a diagnostic message string.
+func skipReason(c DocumentCause) string {
+ switch c.Kind {
+ case CauseEncrypted:
+ return "encrypted document: cannot patch in place"
+ case CauseNonEditable:
+ if c.Detail != "" {
+ return "not editable: " + c.Detail
+ }
+ return "not editable"
+ case CauseNone:
+ return "document cannot be edited in place"
+ default:
+ return "document cannot be edited in place"
+ }
+}
+
+// identityOfObject reads the manifest identity from a live API object, matching how
+// manifestedit derives identity from YAML.
+func identityOfObject(obj *unstructured.Unstructured) manifestedit.Identity {
+ return manifestedit.Identity{
+ APIVersion: obj.GetAPIVersion(),
+ Kind: obj.GetKind(),
+ Namespace: obj.GetNamespace(),
+ Name: obj.GetName(),
+ }
+}
+
+// indexContent maps each file path to its raw bytes for document hydration.
+func indexContent(files []manifestedit.FileContent) map[string][]byte {
+ out := make(map[string][]byte, len(files))
+ for _, f := range files {
+ out[f.Path] = f.Content
+ }
+ return out
+}
+
+// sortedKeys returns the file paths of the store's managed files in sorted order,
+// so the Git-only sweep is deterministic.
+func sortedKeys(m map[string]*FileModel) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// sortActions orders actions deterministically by file path, then document index,
+// then identity. Creates (zero Ref) group first, ordered by identity.
+func sortActions(actions []PlanAction) {
+ sort.SliceStable(actions, func(i, j int) bool {
+ a, b := actions[i], actions[j]
+ if a.Ref.FilePath != b.Ref.FilePath {
+ return a.Ref.FilePath < b.Ref.FilePath
+ }
+ if a.Ref.DocumentIndex != b.Ref.DocumentIndex {
+ return a.Ref.DocumentIndex < b.Ref.DocumentIndex
+ }
+ return identityString(a.Identity) < identityString(b.Identity)
+ })
+}
+
+// identityString renders a manifest identity for stable sorting.
+func identityString(id manifestedit.Identity) string {
+ return id.APIVersion + "/" + id.Kind + "/" + id.Namespace + "/" + id.Name
+}
diff --git a/internal/manifestanalyzer/plan_test.go b/internal/manifestanalyzer/plan_test.go
new file mode 100644
index 00000000..4c7acca9
--- /dev/null
+++ b/internal/manifestanalyzer/plan_test.go
@@ -0,0 +1,463 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "testing"
+ "testing/fstest"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// planFS is a duplicate-free tree (deploy.yaml, cm.yaml with two ConfigMaps, and an
+// encrypted Secret). The sample tree's dup.yaml would collide the Deployment
+// identity, which acceptance refuses — so the core plan cases use this clean tree
+// and the duplicate behavior gets its own focused test.
+func planFS() fstest.MapFS {
+ return fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "cm.yaml": {Data: []byte(configMapsYAML)},
+ "secret.sops.yaml": {Data: []byte(sopsSecretYAML)},
+ }
+}
+
+// planFiles returns planFS as the manifestedit.FileContent slice BuildPlan hydrates
+// from, so a plan and the store it plans over read the exact same bytes.
+func planFiles() []manifestedit.FileContent {
+ fsys := planFS()
+ var files []manifestedit.FileContent
+ for _, p := range []string{"deploy.yaml", "cm.yaml", "secret.sops.yaml"} {
+ files = append(files, manifestedit.FileContent{Path: p, Content: fsys[p].Data})
+ }
+ return files
+}
+
+// planStore builds the clean tree against the ready static snapshot (the Deployment
+// and ConfigMaps are watched/resolved; the Secret is served but disallowed).
+func planStore(t *testing.T) *ManifestStore {
+ t.Helper()
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ return BuildStore(context.Background(), planFS(), mapper)
+}
+
+// obj builds an unstructured live object with the given identity and an optional
+// spec/data payload merged in.
+func obj(apiVersion, kind, namespace, name string, extra map[string]interface{}) *unstructured.Unstructured {
+ o := map[string]interface{}{
+ "apiVersion": apiVersion,
+ "kind": kind,
+ "metadata": map[string]interface{}{"name": name, "namespace": namespace},
+ }
+ for k, v := range extra {
+ o[k] = v
+ }
+ return &unstructured.Unstructured{Object: o}
+}
+
+func deployWeb(replicas int64) *unstructured.Unstructured {
+ return obj("apps/v1", "Deployment", "default", "web",
+ map[string]interface{}{"spec": map[string]interface{}{"replicas": replicas}})
+}
+
+func configMap(name string) *unstructured.Unstructured {
+ return obj("v1", "ConfigMap", "default", name, nil)
+}
+
+// desiredDeployWeb / desiredConfigMap / desiredSecret pair a live object with the
+// resolved API identity the controller already knows from the GVR it watched.
+func desiredDeployWeb(replicas int64) DesiredResource {
+ return DesiredResource{
+ Resource: types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web"),
+ Object: deployWeb(replicas),
+ }
+}
+
+func desiredConfigMap(name string) DesiredResource {
+ return DesiredResource{
+ Resource: types.NewResourceIdentifier("", "v1", "configmaps", "default", name),
+ Object: configMap(name),
+ }
+}
+
+func desiredSecret() DesiredResource {
+ return DesiredResource{
+ Resource: types.NewResourceIdentifier("", "v1", "secrets", "default", "db"),
+ Object: obj("v1", "Secret", "default", "db", nil),
+ }
+}
+
+// inSync is the desired set that matches planFS byte-for-semantics, so adding one
+// more resource isolates a single action.
+func inSync() []DesiredResource {
+ return []DesiredResource{desiredDeployWeb(1), desiredConfigMap("a"), desiredConfigMap("b")}
+}
+
+// findAction returns the single action targeting the given file (the files queried
+// here hold one document each), or fails.
+func findAction(t *testing.T, plan Plan, path string) PlanAction {
+ t.Helper()
+ for _, a := range plan.Actions {
+ if a.Ref.FilePath == path {
+ return a
+ }
+ }
+ t.Fatalf("no action at %s; actions=%+v", path, plan.Actions)
+ return PlanAction{}
+}
+
+// TestBuildPlan_Patch: a desired Deployment that differs from Git is a patch
+// carrying the resolved resource identity and the desired object.
+func TestBuildPlan_Patch(t *testing.T) {
+ store := planStore(t)
+ // ConfigMaps in sync, Deployment differs.
+ desired := []DesiredResource{desiredConfigMap("a"), desiredConfigMap("b"), desiredDeployWeb(3)}
+ plan := BuildPlan(store, planFiles(), desired, Policy{})
+
+ if len(plan.Actions) != 1 {
+ t.Fatalf("want exactly one patch, got %+v", plan.Actions)
+ }
+ patch := findAction(t, plan, "deploy.yaml")
+ if patch.Kind != PlanPatch {
+ t.Errorf("deploy.yaml#0 kind = %q, want patch", patch.Kind)
+ }
+ wantRI := types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")
+ if patch.Resource != wantRI {
+ t.Errorf("patch Resource = %+v, want %+v", patch.Resource, wantRI)
+ }
+ if patch.Desired == nil {
+ t.Errorf("patch action should carry the desired object")
+ }
+}
+
+// TestBuildPlan_Create: a desired resource with no document in Git is a create that
+// carries the resolved resource identity (so M7 can place the new file via
+// ResourceIdentifier.ToGitPath without re-resolving the mapping), while the
+// resources already in sync produce no action.
+func TestBuildPlan_Create(t *testing.T) {
+ store := planStore(t)
+ desired := append(inSync(), desiredConfigMap("c")) // c is brand new
+ plan := BuildPlan(store, planFiles(), desired, Policy{})
+
+ if len(plan.Actions) != 1 {
+ t.Fatalf("actions = %+v, want exactly one create", plan.Actions)
+ }
+ create := plan.Actions[0]
+ if create.Kind != PlanCreate {
+ t.Fatalf("kind = %q, want create", create.Kind)
+ }
+ if create.Identity.Name != "c" || create.Ref != (RecordRef{}) {
+ t.Errorf("create = %+v, want identity c with a zero Ref", create)
+ }
+ wantRI := types.NewResourceIdentifier("", "v1", "configmaps", "default", "c")
+ if create.Resource != wantRI {
+ t.Errorf("create Resource = %+v, want %+v (placement needs the GVR)", create.Resource, wantRI)
+ }
+ if create.Desired == nil {
+ t.Errorf("create action should carry the desired object")
+ }
+ if got := create.Resource.ToGitPath(); got != "v1/configmaps/default/c.yaml" {
+ t.Errorf("create placement = %q, want v1/configmaps/default/c.yaml", got)
+ }
+}
+
+// TestBuildPlan_NoOp: every desired resource matching Git yields no actions at all.
+func TestBuildPlan_NoOp(t *testing.T) {
+ store := planStore(t)
+ plan := BuildPlan(store, planFiles(), inSync(), Policy{})
+ if len(plan.Actions) != 0 {
+ t.Fatalf("in-sync plan should have no actions, got %+v", plan.Actions)
+ }
+}
+
+// TestBuildPlan_DropOrphans: with an empty desired set every watched, resolved
+// document is a managed drop, while the disallowed Secret is left untouched.
+func TestBuildPlan_DropOrphans(t *testing.T) {
+ store := planStore(t)
+ plan := BuildPlan(store, planFiles(), nil, Policy{})
+
+ counts := plan.Counts()
+ if counts[PlanDropOrphan] != 3 || len(plan.Actions) != 3 {
+ t.Fatalf("counts=%v actions=%d, want exactly 3 drop-orphan", counts, len(plan.Actions))
+ }
+ for _, a := range plan.Actions {
+ if a.Ref.FilePath == "secret.sops.yaml" {
+ t.Errorf("disallowed Secret must not be dropped: %+v", a)
+ }
+ }
+ drop := findAction(t, plan, "deploy.yaml")
+ wantRI := types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")
+ if drop.Kind != PlanDropOrphan || drop.Resource != wantRI {
+ t.Errorf("deploy.yaml#0 = %+v, want drop-orphan with %+v", drop, wantRI)
+ }
+}
+
+// TestBuildPlan_SkipEncrypted: a desired object that matches an encrypted document
+// is a skip — encrypted documents are authoritative but never patched in place.
+func TestBuildPlan_SkipEncrypted(t *testing.T) {
+ store := planStore(t)
+ desired := append(inSync(), desiredSecret())
+ plan := BuildPlan(store, planFiles(), desired, Policy{})
+
+ skip := findAction(t, plan, "secret.sops.yaml")
+ if skip.Kind != PlanSkip {
+ t.Fatalf("secret.sops.yaml#0 kind = %q, want skip", skip.Kind)
+ }
+ if skip.Resource != (types.NewResourceIdentifier("", "v1", "secrets", "default", "db")) {
+ t.Errorf("skip should still carry the desired resource identity, got %+v", skip.Resource)
+ }
+ if skip.Desired != nil {
+ t.Errorf("skip action should not carry a desired object")
+ }
+}
+
+// TestBuildPlan_DuplicateSuppressed proves Finding 2: a duplicate-identity collision
+// produces NO plan action for either copy — not the loser and not the
+// first-occurrence winner — because acceptance (M4) refuses the whole folder.
+func TestBuildPlan_DuplicateSuppressed(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "dup.yaml": {Data: []byte(deployYAML)}, // same Deployment/default/web identity
+ }
+ files := []manifestedit.FileContent{
+ {Path: "deploy.yaml", Content: []byte(deployYAML)},
+ {Path: "dup.yaml", Content: []byte(deployYAML)},
+ }
+ store := BuildStore(context.Background(), fsys, typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ // A desired update to the collided identity is suppressed: the winner is not
+ // patched.
+ if plan := BuildPlan(store, files, []DesiredResource{desiredDeployWeb(3)}, Policy{}); len(plan.Actions) != 0 {
+ t.Errorf("collided identity should produce no action on update, got %+v", plan.Actions)
+ }
+
+ // An empty desired set does not drop the collided identity either.
+ if plan := BuildPlan(store, files, nil, Policy{}); len(plan.Actions) != 0 {
+ t.Errorf("collided identity should produce no drop, got %+v", plan.Actions)
+ }
+}
+
+// TestBuildPlan_StructureOnlyNeverDrops proves the no-cluster promise: a
+// structure-only store resolves no mappings, so even an empty desired set drops
+// nothing — yet a desired object that differs from Git is still a patch, because
+// manifest identity (and Decide) need no cluster.
+func TestBuildPlan_StructureOnlyNeverDrops(t *testing.T) {
+ store := BuildStore(context.Background(), planFS(), nil)
+
+ if plan := BuildPlan(store, planFiles(), nil, Policy{}); len(plan.Actions) != 0 {
+ t.Fatalf("structure-only plan should never drop, got %+v", plan.Actions)
+ }
+
+ plan := BuildPlan(store, planFiles(), []DesiredResource{desiredDeployWeb(5)}, Policy{})
+ if len(plan.Actions) != 1 || plan.Actions[0].Kind != PlanPatch {
+ t.Fatalf("structure-only differing object should patch, got %+v", plan.Actions)
+ }
+}
+
+// TestBuildPlan_NonEditableConstructSkips: a document using a disallowed construct
+// (a YAML anchor/alias) does not claim its identity, so a matching desired object is
+// not a create and the document surfaces as a single skip, never a drop.
+func TestBuildPlan_NonEditableConstructSkips(t *testing.T) {
+ const anchoredYAML = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n" +
+ " name: anchored\n namespace: default\ndata: &d\n k: v\nextra: *d\n"
+ fsys := fstest.MapFS{"anchored.yaml": {Data: []byte(anchoredYAML)}}
+ files := []manifestedit.FileContent{{Path: "anchored.yaml", Content: []byte(anchoredYAML)}}
+ store := BuildStore(context.Background(), fsys, typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ dm := store.FilesByPath["anchored.yaml"].Documents[0]
+ if dm.claimsIdentity() {
+ t.Fatalf("anchor/alias document should not claim its identity")
+ }
+
+ plan := BuildPlan(store, files, []DesiredResource{desiredConfigMap("anchored")}, Policy{})
+ if len(plan.Actions) != 1 || plan.Actions[0].Kind != PlanSkip {
+ t.Fatalf("non-editable construct should yield one skip, got %+v", plan.Actions)
+ }
+}
+
+// TestBuildPlan_ProjectPolicy proves the injected projection is applied: a Project
+// that rewrites the desired object so it matches Git collapses a would-be patch into
+// a no-op.
+func TestBuildPlan_ProjectPolicy(t *testing.T) {
+ store := planStore(t)
+ policy := Policy{Project: func(_ *unstructured.Unstructured) *unstructured.Unstructured {
+ return deployWeb(1) // normalize back to the Git value
+ }}
+ // Live says 9, the projection normalizes back to 1.
+ desired := []DesiredResource{desiredConfigMap("a"), desiredConfigMap("b"), desiredDeployWeb(9)}
+ plan := BuildPlan(store, planFiles(), desired, policy)
+
+ for _, a := range plan.Actions {
+ if a.Ref.FilePath == "deploy.yaml" {
+ t.Fatalf("projection should have collapsed deploy.yaml to a no-op, got %+v", a)
+ }
+ }
+}
+
+// TestBuildPlan_MissingHydration: a managed document whose file bytes were not
+// supplied cannot be planned, so it becomes a skip plus a diagnostic rather than a
+// silent or wrong edit.
+func TestBuildPlan_MissingHydration(t *testing.T) {
+ store := planStore(t)
+ var files []manifestedit.FileContent
+ for _, f := range planFiles() {
+ if f.Path != "deploy.yaml" {
+ files = append(files, f)
+ }
+ }
+ desired := []DesiredResource{desiredConfigMap("a"), desiredConfigMap("b"), desiredDeployWeb(3)}
+ plan := BuildPlan(store, files, desired, Policy{})
+
+ skip := findAction(t, plan, "deploy.yaml")
+ if skip.Kind != PlanSkip {
+ t.Errorf("un-hydrated document should skip, got %q", skip.Kind)
+ }
+ if len(plan.Diagnostics) == 0 {
+ t.Errorf("missing hydration should emit a diagnostic")
+ }
+}
+
+// TestBuildPlan_TwoCreatesSortByIdentity covers the deterministic ordering of
+// actions that share a zero Ref: two creates are ordered by manifest identity.
+func TestBuildPlan_TwoCreatesSortByIdentity(t *testing.T) {
+ store := planStore(t)
+ desired := append(inSync(), desiredConfigMap("z"), desiredConfigMap("m"))
+ plan := BuildPlan(store, planFiles(), desired, Policy{})
+
+ if len(plan.Actions) != 2 {
+ t.Fatalf("want two creates, got %+v", plan.Actions)
+ }
+ if plan.Actions[0].Identity.Name != "m" || plan.Actions[1].Identity.Name != "z" {
+ t.Errorf("creates not sorted by identity: %q then %q",
+ plan.Actions[0].Identity.Name, plan.Actions[1].Identity.Name)
+ }
+}
+
+// TestBuildPlan_NilObjectGhostInert: a nil-Object entry for a resource Git does not
+// have is inert — it neither creates nor drops — but it is still diagnosed as a
+// malformed snapshot entry.
+func TestBuildPlan_NilObjectGhostInert(t *testing.T) {
+ store := planStore(t)
+ desired := append(inSync(),
+ DesiredResource{Resource: types.NewResourceIdentifier("", "v1", "configmaps", "default", "ghost")})
+ plan := BuildPlan(store, planFiles(), desired, Policy{})
+
+ if len(plan.Actions) != 0 {
+ t.Fatalf("a nil-Object entry must produce no action, got %+v", plan.Actions)
+ }
+ if len(plan.Diagnostics) != 1 {
+ t.Errorf("a nil-Object entry should be diagnosed, got %+v", plan.Diagnostics)
+ }
+}
+
+// TestBuildPlan_NilObjectProtectsExistingResource is the key safety case: a nil
+// Object for a resource that DOES exist in Git must NOT become a managed drop via the
+// sweep. The matching document is protected and the malformed entry is diagnosed.
+func TestBuildPlan_NilObjectProtectsExistingResource(t *testing.T) {
+ store := planStore(t)
+ // The Deployment exists in deploy.yaml and resolves; its desired entry is nil.
+ desired := []DesiredResource{
+ desiredConfigMap("a"), desiredConfigMap("b"),
+ {Resource: types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")},
+ }
+ plan := BuildPlan(store, planFiles(), desired, Policy{})
+
+ for _, a := range plan.Actions {
+ if a.Kind == PlanDropOrphan {
+ t.Errorf("a nil object for an existing resource must never drop it: %+v", a)
+ }
+ }
+ if len(plan.Actions) != 0 {
+ t.Fatalf("the nil-protected resync should have no actions, got %+v", plan.Actions)
+ }
+ if len(plan.Diagnostics) != 1 || plan.Diagnostics[0].Path != "deploy.yaml" {
+ t.Errorf("want one diagnostic naming deploy.yaml, got %+v", plan.Diagnostics)
+ }
+}
+
+// TestBuildPlan_ImpureFileTrueIndices proves plan references carry the TRUE file
+// index even in a non-contiguous (impure) managed file the acceptance gate would
+// refuse: deploy@0, an empty document@1, ConfigMap@2. An empty desired set drops both
+// managed documents; the drop references must be #0 and #2, not the loop indices #0
+// and #1.
+func TestBuildPlan_ImpureFileTrueIndices(t *testing.T) {
+ impure := deployYAML + "---\n# comment\n---\n" + configMapCYAML
+ fsys := fstest.MapFS{"app.yaml": {Data: []byte(impure)}}
+ files := []manifestedit.FileContent{{Path: "app.yaml", Content: []byte(impure)}}
+ store := BuildStore(context.Background(), fsys, typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ plan := BuildPlan(store, files, nil, Policy{})
+
+ idxByKind := map[string]int{}
+ for _, a := range plan.Actions {
+ if a.Kind != PlanDropOrphan {
+ t.Fatalf("want only managed drops, got %+v", a)
+ }
+ idxByKind[a.Identity.Kind] = a.Ref.DocumentIndex
+ }
+ if idxByKind["Deployment"] != 0 || idxByKind["ConfigMap"] != 2 {
+ t.Errorf("drop refs should be true file indices (Deployment#0, ConfigMap#2), got %+v", idxByKind)
+ }
+}
+
+// TestActionFromDecision maps every manifestedit decision intent to a plan kind: a
+// no-change produces no action, and the editing intents map to their kinds.
+func TestActionFromDecision(t *testing.T) {
+ cases := []struct {
+ in manifestedit.DecisionAction
+ want PlanActionKind
+ emitted bool
+ }{
+ {manifestedit.ActionNoChange, "", false},
+ {manifestedit.ActionPatch, PlanPatch, true},
+ {manifestedit.ActionReplace, PlanReplace, true},
+ {manifestedit.ActionSkip, PlanSkip, true},
+ {manifestedit.ActionDelete, PlanSkip, true},
+ {manifestedit.DecisionAction("bogus"), PlanSkip, true},
+ }
+ for _, c := range cases {
+ got, emitted := actionFromDecision(c.in)
+ if emitted != c.emitted || got != c.want {
+ t.Errorf("actionFromDecision(%q) = (%q,%v), want (%q,%v)", c.in, got, emitted, c.want, c.emitted)
+ }
+ }
+}
+
+// TestSkipReason renders a display reason from each structured cause, never from a
+// message string.
+func TestSkipReason(t *testing.T) {
+ cases := []struct {
+ cause DocumentCause
+ want string
+ }{
+ {DocumentCause{Kind: CauseEncrypted}, "encrypted document: cannot patch in place"},
+ {DocumentCause{Kind: CauseNonEditable, Detail: "anchor"}, "not editable: anchor"},
+ {DocumentCause{Kind: CauseNonEditable}, "not editable"},
+ {DocumentCause{Kind: CauseNone}, "document cannot be edited in place"},
+ }
+ for _, c := range cases {
+ if got := skipReason(c.cause); got != c.want {
+ t.Errorf("skipReason(%+v) = %q, want %q", c.cause, got, c.want)
+ }
+ }
+}
diff --git a/internal/manifestanalyzer/render.go b/internal/manifestanalyzer/render.go
new file mode 100644
index 00000000..c199dbe0
--- /dev/null
+++ b/internal/manifestanalyzer/render.go
@@ -0,0 +1,314 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// RenderJSON writes the report as indented JSON. It is the machine-readable form
+// shared by the controller status path and the CLI.
+func RenderJSON(w io.Writer, rep Report) error {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(rep)
+}
+
+// RenderText writes a human-readable summary of the report.
+func RenderText(w io.Writer, rep Report) {
+ s := rep.Summary
+ fmt.Fprintf(w, "Manifest analysis: %s\n", rootOrFS(rep.Root))
+ fmt.Fprintf(w, " files: %d (yaml %d, other %d) documents: %d\n",
+ s.FilesTotal, s.YAMLFiles, s.NonYAMLFiles, s.Documents)
+ if len(s.ByClass) > 0 {
+ fmt.Fprintf(w, " classes: %s\n", joinCounts(classCounts(s.ByClass)))
+ }
+ if len(s.ByGVK) > 0 {
+ fmt.Fprintf(w, " gvks: %s\n", joinCounts(strCounts(s.ByGVK)))
+ }
+ fmt.Fprintf(w, " duplicates: %d encrypted: %d\n", s.Duplicates, s.Encrypted)
+ if len(s.Diagnostics) > 0 {
+ fmt.Fprintf(w, " diagnostics: %s\n", joinCounts(diagCounts(s.Diagnostics)))
+ }
+
+ fmt.Fprintln(w, "\nFiles:")
+ for _, f := range rep.Files {
+ renderFile(w, f)
+ }
+
+ fmt.Fprintln(w)
+ if len(rep.Issues) == 0 {
+ fmt.Fprintln(w, "Acceptance: no issues")
+ return
+ }
+ fmt.Fprintf(w, "Acceptance: %d issue(s)\n", len(rep.Issues))
+ for _, is := range rep.Issues {
+ fmt.Fprintf(w, " %-18s %s#%d %s\n", is.Kind, is.Path, is.DocumentIndex, is.Message)
+ }
+}
+
+// renderFile writes one file's line(s) for the text report.
+func renderFile(w io.Writer, f FileReport) {
+ if !f.IsYAML {
+ fmt.Fprintf(w, " %-44s (non-yaml, ignored)\n", f.Path)
+ return
+ }
+ fmt.Fprintf(w, " %s\n", f.Path)
+ for _, d := range f.Documents {
+ line := fmt.Sprintf(" [%d] %-14s", d.Index, d.Class)
+ if !d.GVK.Empty() {
+ line += " " + identityRef(d.Identity)
+ }
+ if tags := docTags(d); tags != "" {
+ line += " (" + tags + ")"
+ }
+ fmt.Fprintln(w, line)
+ }
+}
+
+// docTags renders the small set of flags shown after a document line. Duplicate
+// identity is no longer a per-document tag — it surfaces in the Acceptance section
+// as an issue.
+func docTags(d DocumentReport) string {
+ if d.Cause == nil {
+ return ""
+ }
+ switch d.Cause.Kind {
+ case CauseEncrypted:
+ return "encrypted"
+ case CauseNonEditable:
+ if d.Cause.Detail != "" {
+ return "non-editable: " + d.Cause.Detail
+ }
+ return "non-editable"
+ case CauseNone:
+ return ""
+ default:
+ return ""
+ }
+}
+
+// rootOrFS describes the scanned root, falling back to "(fs)" for an in-memory FS.
+func rootOrFS(root string) string {
+ if root == "" {
+ return "(fs)"
+ }
+ return root
+}
+
+// kv is a sortable label/count pair for deterministic count rendering.
+type kv struct {
+ label string
+ count int
+}
+
+// joinCounts formats sorted label/count pairs as "a 2, b 1".
+func joinCounts(items []kv) string {
+ parts := make([]string, len(items))
+ for i, it := range items {
+ parts[i] = fmt.Sprintf("%s %d", it.label, it.count)
+ }
+ return strings.Join(parts, ", ")
+}
+
+func classCounts(m map[Class]int) []kv {
+ out := make([]kv, 0, len(m))
+ for k, v := range m {
+ out = append(out, kv{string(k), v})
+ }
+ return sortKV(out)
+}
+
+func strCounts(m map[string]int) []kv {
+ out := make([]kv, 0, len(m))
+ for k, v := range m {
+ out = append(out, kv{k, v})
+ }
+ return sortKV(out)
+}
+
+func diagCounts(m map[manifestedit.DiagnosticLevel]int) []kv {
+ out := make([]kv, 0, len(m))
+ for k, v := range m {
+ out = append(out, kv{string(k), v})
+ }
+ return sortKV(out)
+}
+
+func sortKV(items []kv) []kv {
+ sort.Slice(items, func(i, j int) bool { return items[i].label < items[j].label })
+ return items
+}
+
+// RenderScanText writes a human-readable view of a scan: the acceptance decision and
+// its refusals, the retained allowlisted documents, and the full plan. It is the
+// M5 dry-run output for the CLI and doubles as a GitTarget status summary.
+func RenderScanText(w io.Writer, result ScanResult) {
+ renderAcceptanceText(w, result.Acceptance)
+ fmt.Fprintln(w)
+ renderPlanText(w, result.Plan)
+}
+
+// renderAcceptanceText writes the acceptance decision, refusals, and retained files.
+func renderAcceptanceText(w io.Writer, acc Acceptance) {
+ if acc.Accepted {
+ fmt.Fprintln(w, "Acceptance: accepted")
+ } else {
+ fmt.Fprintf(w, "Acceptance: REFUSED (%d issue(s))\n", len(acc.Issues))
+ }
+ for _, is := range acc.Issues {
+ fmt.Fprintf(w, " %-22s %s#%d %s\n", is.Kind, is.Path, is.DocumentIndex, is.Message)
+ }
+ if len(acc.Retained) > 0 {
+ fmt.Fprintf(w, "Retained (allowlisted, not materialized): %d\n", len(acc.Retained))
+ for _, rd := range acc.Retained {
+ fmt.Fprintf(w, " %s#%d %s\n", rd.Location.Path, rd.Location.DocumentIndex, identityRef(rd.Identity))
+ }
+ }
+}
+
+// renderPlanText writes the plan's actions and any planning diagnostics.
+func renderPlanText(w io.Writer, plan Plan) {
+ if len(plan.Actions) == 0 {
+ fmt.Fprintln(w, "Plan: no changes")
+ } else {
+ fmt.Fprintf(w, "Plan: %d action(s)\n", len(plan.Actions))
+ for _, a := range plan.Actions {
+ fmt.Fprintf(w, " %-12s %-40s %s\n", a.Kind, planActionTarget(a), a.Reason)
+ }
+ }
+ for _, d := range plan.Diagnostics {
+ fmt.Fprintf(w, " diag %-7s %s#%d %s\n", d.Level, d.Path, d.DocumentIndex, d.Message)
+ }
+}
+
+// planActionTarget renders the file an action touches: the placement path for a
+// create (which has no existing location yet), the existing document otherwise.
+func planActionTarget(a PlanAction) string {
+ if a.Kind == PlanCreate {
+ return a.Resource.ToGitPath()
+ }
+ return fmt.Sprintf("%s#%d", a.Ref.FilePath, a.Ref.DocumentIndex)
+}
+
+// RenderScanJSON writes the scan as indented JSON: the machine-readable form shared
+// by the CLI and the GitTarget status path. It omits the live desired objects, which
+// are unbounded; it carries only the decided plan.
+func RenderScanJSON(w io.Writer, result ScanResult) error {
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ return enc.Encode(scanToJSON(result))
+}
+
+// scanJSON is the compact, bounded JSON projection of a ScanResult.
+type scanJSON struct {
+ Accepted bool `json:"accepted"`
+ Issues []AcceptanceIssue `json:"issues"`
+ Retained []retainedJSON `json:"retained,omitempty"`
+ Plan planJSON `json:"plan"`
+}
+
+type retainedJSON struct {
+ Path string `json:"path"`
+ DocumentIndex int `json:"documentIndex"`
+ Identity manifestedit.Identity `json:"identity"`
+}
+
+type planJSON struct {
+ Counts map[string]int `json:"counts"`
+ Actions []planActionJSON `json:"actions"`
+ Diagnostics []manifestedit.Diagnostic `json:"diagnostics,omitempty"`
+}
+
+type planActionJSON struct {
+ Kind string `json:"kind"`
+ Path string `json:"path,omitempty"`
+ // DocumentIndex is a pointer so that index 0 — a real, common target for a patch
+ // or drop on a file's first document — is preserved, while a create (which has no
+ // existing document location) omits it. A plain int with omitempty would drop the
+ // meaningful 0 and weaken the machine-readable contract.
+ DocumentIndex *int `json:"documentIndex,omitempty"`
+ Identity manifestedit.Identity `json:"identity"`
+ Resource string `json:"resource,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// scanToJSON builds the compact JSON projection.
+func scanToJSON(result ScanResult) scanJSON {
+ out := scanJSON{
+ Accepted: result.Acceptance.Accepted,
+ Issues: result.Acceptance.Issues,
+ Plan: planJSON{
+ Counts: planCounts(result.Plan),
+ Diagnostics: result.Plan.Diagnostics,
+ },
+ }
+ for _, rd := range result.Acceptance.Retained {
+ out.Retained = append(out.Retained, retainedJSON{
+ Path: rd.Location.Path, DocumentIndex: rd.Location.DocumentIndex, Identity: rd.Identity,
+ })
+ }
+ for _, a := range result.Plan.Actions {
+ out.Plan.Actions = append(out.Plan.Actions, planActionJSON{
+ Kind: string(a.Kind),
+ Path: planActionTarget(a),
+ DocumentIndex: documentIndexJSON(a),
+ Identity: a.Identity,
+ Resource: resourceString(a.Resource),
+ Reason: a.Reason,
+ })
+ }
+ return out
+}
+
+// documentIndexJSON returns the action's document index as a pointer: nil for a
+// create (no existing document location, so the field is omitted), otherwise the
+// real index — including a meaningful 0.
+func documentIndexJSON(a PlanAction) *int {
+ if a.Kind == PlanCreate {
+ return nil
+ }
+ i := a.Ref.DocumentIndex
+ return &i
+}
+
+// planCounts converts the plan's per-kind counts to string keys for JSON.
+func planCounts(plan Plan) map[string]int {
+ out := map[string]int{}
+ for kind, n := range plan.Counts() {
+ out[string(kind)] = n
+ }
+ return out
+}
+
+// resourceString renders a resolved resource identity, or "" when it is the zero
+// value (a document a structure-only store never resolved).
+func resourceString(r types.ResourceIdentifier) string {
+ if r == (types.ResourceIdentifier{}) {
+ return ""
+ }
+ return r.Key()
+}
diff --git a/internal/manifestanalyzer/render_test.go b/internal/manifestanalyzer/render_test.go
new file mode 100644
index 00000000..f8e049fb
--- /dev/null
+++ b/internal/manifestanalyzer/render_test.go
@@ -0,0 +1,247 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+)
+
+func TestRenderText(t *testing.T) {
+ rep := Analyze(sampleFS())
+ rep.Root = "/tmp/repo"
+
+ var buf bytes.Buffer
+ RenderText(&buf, rep)
+ out := buf.String()
+
+ for _, want := range []string{
+ "Manifest analysis: /tmp/repo",
+ "files: 8 (yaml 7, other 1)",
+ "gvks: apps/v1/Deployment 2",
+ "docs/notes.txt",
+ "(non-yaml, ignored)",
+ "krm",
+ "apps/v1/Deployment/default/web",
+ "(encrypted)",
+ "Acceptance:",
+ // Duplicate identity is reported as an acceptance issue, not an inline tag.
+ "duplicate-identity",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("text output missing %q\n---\n%s", want, out)
+ }
+ }
+}
+
+func TestRenderText_NonEditableAndNoIssues(t *testing.T) {
+ rep := Report{
+ Root: "",
+ Files: []FileReport{{
+ Path: "cm.yaml",
+ IsYAML: true,
+ Documents: []DocumentReport{{
+ Index: 0,
+ Class: ClassKRM,
+ GVK: GVK{Version: "v1", Kind: "ConfigMap"},
+ Identity: manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "default", Name: "x"},
+ Editable: false,
+ Cause: &DocumentCause{Kind: CauseNonEditable, Detail: "anchor"},
+ }},
+ }},
+ Summary: buildSummary([]FileReport{}, nil, 0),
+ }
+ var buf bytes.Buffer
+ RenderText(&buf, rep)
+ out := buf.String()
+
+ if !strings.Contains(out, "(fs)") {
+ t.Errorf("empty root should render as (fs): %s", out)
+ }
+ if !strings.Contains(out, "non-editable: anchor") {
+ t.Errorf("expected non-editable tag: %s", out)
+ }
+ if !strings.Contains(out, "Acceptance: no issues") {
+ t.Errorf("expected no-issues line: %s", out)
+ }
+}
+
+func TestRenderJSON_RoundTrip(t *testing.T) {
+ rep := Analyze(sampleFS())
+
+ var buf bytes.Buffer
+ if err := RenderJSON(&buf, rep); err != nil {
+ t.Fatalf("RenderJSON: %v", err)
+ }
+
+ var decoded Report
+ if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if decoded.Summary.Documents != rep.Summary.Documents {
+ t.Errorf("round-trip documents = %d, want %d", decoded.Summary.Documents, rep.Summary.Documents)
+ }
+ if len(decoded.Files) != len(rep.Files) {
+ t.Errorf("round-trip files = %d, want %d", len(decoded.Files), len(rep.Files))
+ }
+ if decoded.Summary.ByClass[ClassKRM] != rep.Summary.ByClass[ClassKRM] {
+ t.Errorf("round-trip class counts differ")
+ }
+}
+
+// scanWithRefusalAndPlan builds a scan with one create, one patch, a retained file,
+// and a denied-Secret refusal, so the renderers exercise every branch.
+func scanWithRefusalAndPlan(t *testing.T) ScanResult {
+ t.Helper()
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "secret.yaml": {Data: []byte(plainSecretYAML)},
+ "kustomization.yaml": {Data: []byte(kustomizationY)},
+ }
+ desired := []DesiredResource{desiredDeployWeb(3), desiredConfigMap("new")}
+ policy := ScanPolicy{Acceptance: AcceptancePolicy{Allowlist: DefaultAllowlist()}}
+ return Scan(context.Background(), fsys, snapMapper(), desired, policy)
+}
+
+func TestRenderScanText(t *testing.T) {
+ var buf bytes.Buffer
+ RenderScanText(&buf, scanWithRefusalAndPlan(t))
+ out := buf.String()
+
+ for _, want := range []string{
+ "Acceptance: REFUSED",
+ "unresolved-krm",
+ "Retained (allowlisted, not materialized): 1",
+ "kustomization.yaml",
+ "Plan: 2 action(s)",
+ "create",
+ "v1/configmaps/default/new.yaml",
+ "patch",
+ "deploy.yaml#0",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("scan text output missing %q\n---\n%s", want, out)
+ }
+ }
+}
+
+func TestRenderScanText_Accepted(t *testing.T) {
+ fsys := fstest.MapFS{"deploy.yaml": {Data: []byte(deployYAML)}}
+ desired := []DesiredResource{desiredDeployWeb(1)}
+ result := Scan(context.Background(), fsys, snapMapper(), desired, ScanPolicy{})
+
+ var buf bytes.Buffer
+ RenderScanText(&buf, result)
+ out := buf.String()
+ if !strings.Contains(out, "Acceptance: accepted") {
+ t.Errorf("expected accepted line: %s", out)
+ }
+ if !strings.Contains(out, "Plan: no changes") {
+ t.Errorf("expected no-changes plan line: %s", out)
+ }
+}
+
+func TestRenderScanJSON(t *testing.T) {
+ var buf bytes.Buffer
+ if err := RenderScanJSON(&buf, scanWithRefusalAndPlan(t)); err != nil {
+ t.Fatalf("RenderScanJSON: %v", err)
+ }
+
+ var parsed struct {
+ Accepted bool `json:"accepted"`
+ Issues []struct {
+ Kind string `json:"kind"`
+ } `json:"issues"`
+ Retained []struct {
+ Path string `json:"path"`
+ } `json:"retained"`
+ Plan struct {
+ Counts map[string]int `json:"counts"`
+ Actions []struct {
+ Kind string `json:"kind"`
+ Path string `json:"path"`
+ Resource string `json:"resource"`
+ } `json:"actions"`
+ } `json:"plan"`
+ }
+ if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
+ t.Fatalf("scan JSON is invalid: %v\n%s", err, buf.String())
+ }
+ if parsed.Accepted {
+ t.Errorf("scan JSON should report refusal")
+ }
+ if len(parsed.Issues) == 0 || len(parsed.Retained) != 1 {
+ t.Errorf("scan JSON issues=%d retained=%d", len(parsed.Issues), len(parsed.Retained))
+ }
+ if parsed.Plan.Counts["create"] != 1 || parsed.Plan.Counts["patch"] != 1 {
+ t.Errorf("scan JSON plan counts = %+v", parsed.Plan.Counts)
+ }
+ if len(parsed.Plan.Actions) != 2 {
+ t.Errorf("scan JSON should carry both actions, got %+v", parsed.Plan.Actions)
+ }
+}
+
+// TestRenderScanJSON_PreservesDocumentIndexZero guards the machine-readable contract:
+// a patch on a file's first document must serialize documentIndex 0 (not omit it),
+// while a create — which has no existing document location — omits it.
+func TestRenderScanJSON_PreservesDocumentIndexZero(t *testing.T) {
+ fsys := fstest.MapFS{"deploy.yaml": {Data: []byte(deployYAML)}}
+ desired := []DesiredResource{desiredDeployWeb(3), desiredConfigMap("new")} // patch deploy#0 + create
+ result := Scan(context.Background(), fsys, snapMapper(), desired, ScanPolicy{})
+
+ var buf bytes.Buffer
+ if err := RenderScanJSON(&buf, result); err != nil {
+ t.Fatalf("RenderScanJSON: %v", err)
+ }
+ if !strings.Contains(buf.String(), `"documentIndex": 0`) {
+ t.Errorf("a patch on the first document must keep documentIndex 0:\n%s", buf.String())
+ }
+
+ var parsed struct {
+ Plan struct {
+ Actions []struct {
+ Kind string `json:"kind"`
+ DocumentIndex *int `json:"documentIndex"`
+ } `json:"actions"`
+ } `json:"plan"`
+ }
+ if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
+ t.Fatalf("invalid JSON: %v", err)
+ }
+ for _, a := range parsed.Plan.Actions {
+ switch a.Kind {
+ case "patch":
+ if a.DocumentIndex == nil || *a.DocumentIndex != 0 {
+ t.Errorf("patch action should carry documentIndex 0, got %+v", a.DocumentIndex)
+ }
+ case "create":
+ if a.DocumentIndex != nil {
+ t.Errorf("create action should omit documentIndex, got %v", *a.DocumentIndex)
+ }
+ default:
+ t.Errorf("unexpected action kind %q", a.Kind)
+ }
+ }
+}
diff --git a/internal/manifestanalyzer/scan.go b/internal/manifestanalyzer/scan.go
new file mode 100644
index 00000000..eb574328
--- /dev/null
+++ b/internal/manifestanalyzer/scan.go
@@ -0,0 +1,95 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "os"
+
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// Scan is the M5 dry-run: the one planner shared by the manifest-analyzer CLI and
+// the controller's scan path, described in
+// docs/design/manifest/current-manifest-support-review.md ("Scan Mode (Dry-Run)").
+// It builds the store (applying the policy's allowlist), runs the acceptance gate,
+// and computes the full plan against the desired set — then stops. It writes
+// nothing.
+//
+// The plan is ALWAYS computed, even when acceptance refuses, so an operator can see
+// exactly what reconcile would do (creates, patches, managed drops) alongside the
+// reasons a folder would be rejected. Whether to act on the plan is gated on
+// Acceptance.Accepted by the caller: the live writer (M7) applies a plan only for an
+// accepted folder. desired must be the COMPLETE desired snapshot (the planner
+// mark-and-sweeps); pass nil for a structure-only scan with no cluster.
+func Scan(
+ ctx context.Context,
+ fsys fs.FS,
+ lookup typeset.Lookup,
+ desired []DesiredResource,
+ policy ScanPolicy,
+) ScanResult {
+ yamlFiles, _, scanDiags := collectFiles(fsys)
+ store := buildStore(ctx, yamlFiles, scanDiags, lookup, policy.Acceptance.Allowlist)
+ acc := Accept(store, policy.Acceptance)
+ plan := BuildPlan(store, yamlFiles, desired, policy.Plan)
+ return ScanResult{Store: store, Acceptance: acc, Plan: plan}
+}
+
+// ScanDir is Scan over the directory at root (the CLI entry point). It verifies root
+// is a directory, then scans os.DirFS(root). Symlinks are never followed.
+func ScanDir(
+ ctx context.Context,
+ root string,
+ lookup typeset.Lookup,
+ desired []DesiredResource,
+ policy ScanPolicy,
+) (ScanResult, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return ScanResult{}, err
+ }
+ if !info.IsDir() {
+ return ScanResult{}, fmt.Errorf("not a directory: %s", root)
+ }
+ res := Scan(ctx, os.DirFS(root), lookup, desired, policy)
+ res.Store.Root = root
+ return res, nil
+}
+
+// ScanPolicy bundles the acceptance and planning policy for a dry-run scan, so a
+// caller configures the whole pipeline in one value.
+type ScanPolicy struct {
+ // Acceptance configures the adoption gate (allowlist + scope). Its allowlist also
+ // drives store construction, so allowlisted documents are retained, not planned.
+ Acceptance AcceptancePolicy
+ // Plan configures the planner (projection + edit options).
+ Plan Policy
+}
+
+// ScanResult is the dry-run outcome: the built store, the acceptance decision, and
+// the full plan. It carries everything needed to render the human, JSON, and status
+// views without recomputation.
+type ScanResult struct {
+ Store *ManifestStore
+ Acceptance Acceptance
+ Plan Plan
+}
diff --git a/internal/manifestanalyzer/scan_test.go b/internal/manifestanalyzer/scan_test.go
new file mode 100644
index 00000000..9d4f5869
--- /dev/null
+++ b/internal/manifestanalyzer/scan_test.go
@@ -0,0 +1,119 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "testing/fstest"
+)
+
+// TestScan_FullPlanAndRefusals proves the dry-run shows the full plan (a create and
+// a patch) alongside an acceptance refusal, computing both and writing nothing.
+func TestScan_FullPlanAndRefusals(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)}, // followable; differs from desired → patch
+ "secret.yaml": {Data: []byte(plainSecretYAML)}, // served but denied → refusal
+ }
+ desired := []DesiredResource{desiredDeployWeb(3), desiredConfigMap("new")} // patch + create
+ result := Scan(context.Background(), fsys, snapMapper(), desired, ScanPolicy{})
+
+ if result.Acceptance.Accepted {
+ t.Fatalf("the denied Secret should refuse the folder")
+ }
+ if countAcceptance(result.Acceptance, IssueUnresolvedKRM) != 1 {
+ t.Errorf("want one unresolved-krm refusal, got %+v", result.Acceptance.Issues)
+ }
+
+ counts := result.Plan.Counts()
+ if counts[PlanPatch] != 1 || counts[PlanCreate] != 1 || len(result.Plan.Actions) != 2 {
+ t.Fatalf("plan = %+v, want one patch and one create", result.Plan.Actions)
+ }
+ if result.Store == nil {
+ t.Errorf("scan should return the built store")
+ }
+}
+
+// TestScan_StructureOnly proves the no-cluster dry-run: an empty plan plus the
+// structural refusals (non-KRM YAML), with the allowlist applied.
+func TestScan_StructureOnly(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "values.yaml": {Data: []byte(plainYAML)}, // non-KRM → refusal
+ "kustomization.yaml": {Data: []byte(kustomizationY)}, // allowlisted → retained
+ }
+ policy := ScanPolicy{Acceptance: AcceptancePolicy{Allowlist: DefaultAllowlist()}}
+ result := Scan(context.Background(), fsys, nil, nil, policy)
+
+ if len(result.Plan.Actions) != 0 {
+ t.Fatalf("structure-only scan must not plan anything, got %+v", result.Plan.Actions)
+ }
+ if countAcceptance(result.Acceptance, IssueNonKRM) != 1 {
+ t.Errorf("values.yaml should refuse as non-KRM, got %+v", result.Acceptance.Issues)
+ }
+ if len(result.Acceptance.Retained) != 1 {
+ t.Errorf("kustomization.yaml should be retained, got %+v", result.Acceptance.Retained)
+ }
+}
+
+func TestScan_AcceptedInSync(t *testing.T) {
+ fsys := fstest.MapFS{
+ "deploy.yaml": {Data: []byte(deployYAML)},
+ "cm.yaml": {Data: []byte(configMapsYAML)},
+ }
+ desired := []DesiredResource{desiredDeployWeb(1), desiredConfigMap("a"), desiredConfigMap("b")}
+ result := Scan(context.Background(), fsys, snapMapper(), desired, ScanPolicy{})
+
+ if !result.Acceptance.Accepted {
+ t.Fatalf("clean in-sync folder should be accepted: %+v", result.Acceptance.Issues)
+ }
+ if len(result.Plan.Actions) != 0 {
+ t.Errorf("in-sync folder should plan no changes, got %+v", result.Plan.Actions)
+ }
+}
+
+func TestScanDir(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "deploy.yaml"), []byte(deployYAML), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ result, err := ScanDir(context.Background(), dir, nil, nil, ScanPolicy{})
+ if err != nil {
+ t.Fatalf("ScanDir: %v", err)
+ }
+ if result.Store.Root != dir {
+ t.Errorf("root = %q, want %q", result.Store.Root, dir)
+ }
+}
+
+func TestScanDir_Errors(t *testing.T) {
+ missing := filepath.Join(t.TempDir(), "missing")
+ if _, err := ScanDir(context.Background(), missing, nil, nil, ScanPolicy{}); err == nil {
+ t.Error("expected error for a missing directory")
+ }
+ file := filepath.Join(t.TempDir(), "f.yaml")
+ if err := os.WriteFile(file, []byte(deployYAML), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ if _, err := ScanDir(context.Background(), file, nil, nil, ScanPolicy{}); err == nil {
+ t.Error("expected error when root is not a directory")
+ }
+}
diff --git a/internal/manifestanalyzer/scoped_plan_test.go b/internal/manifestanalyzer/scoped_plan_test.go
new file mode 100644
index 00000000..b855c78c
--- /dev/null
+++ b/internal/manifestanalyzer/scoped_plan_test.go
@@ -0,0 +1,95 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "testing"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// inScopeGroupResource matches one type's (group, resource) — the per-type sweep predicate
+// the M12 watch layer builds from a removed/activated GVR.
+func inScopeGroupResource(group, resource string) func(types.ResourceIdentifier) bool {
+ return func(ri types.ResourceIdentifier) bool {
+ return ri.Group == group && ri.Resource == resource
+ }
+}
+
+// TestBuildScopedPlan_SweepsOnlyTargetType is the per-type sweep: an empty desired set scoped
+// to apps/deployments drops the Deployment only — the two ConfigMaps, a different type, are
+// left untouched even though they too have no desired counterpart.
+func TestBuildScopedPlan_SweepsOnlyTargetType(t *testing.T) {
+ store := planStore(t)
+ plan := BuildScopedPlan(store, planFiles(), nil, Policy{}, inScopeGroupResource("apps", "deployments"))
+
+ counts := plan.Counts()
+ if counts[PlanDropOrphan] != 1 || len(plan.Actions) != 1 {
+ t.Fatalf("counts=%v actions=%d, want exactly one drop (the Deployment)", counts, len(plan.Actions))
+ }
+ drop := findAction(t, plan, "deploy.yaml")
+ wantRI := types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")
+ if drop.Kind != PlanDropOrphan || drop.Resource != wantRI {
+ t.Errorf("deploy.yaml#0 = %+v, want drop-orphan with %+v", drop, wantRI)
+ }
+ for _, a := range plan.Actions {
+ if a.Ref.FilePath == "cm.yaml" {
+ t.Errorf("an out-of-scope ConfigMap must never be swept by a deployments-scoped plan: %+v", a)
+ }
+ }
+}
+
+// TestBuildScopedPlan_ReconcileDropsInScopeOrphanKeepsSiblings is the per-type reconcile: the
+// desired set holds only ConfigMap "a", scoped to v1/configmaps. ConfigMap "b" (in scope, not
+// desired) is dropped; ConfigMap "a" is in sync (no action); the Deployment (out of scope) is
+// never touched despite also being absent from desired.
+func TestBuildScopedPlan_ReconcileDropsInScopeOrphanKeepsSiblings(t *testing.T) {
+ store := planStore(t)
+ desired := []DesiredResource{desiredConfigMap("a")}
+ plan := BuildScopedPlan(store, planFiles(), desired, Policy{}, inScopeGroupResource("", "configmaps"))
+
+ if got := plan.Counts()[PlanDropOrphan]; got != 1 {
+ t.Fatalf("want exactly one drop (ConfigMap b), got %d (actions=%+v)", got, plan.Actions)
+ }
+ for _, a := range plan.Actions {
+ if a.Kind == PlanDropOrphan && a.Resource.Resource != "configmaps" {
+ t.Errorf("a configmaps-scoped reconcile must only drop configmaps, dropped: %+v", a)
+ }
+ if a.Resource.Resource == "deployments" {
+ t.Errorf("the out-of-scope Deployment must produce no action: %+v", a)
+ }
+ }
+}
+
+// TestBuildScopedPlan_AllInScopeEqualsBuildPlan proves the refactor is behaviour-preserving:
+// BuildScopedPlan with the always-true predicate is byte-identical to BuildPlan (the
+// whole-folder mark-and-sweep), so both share one set of safety guarantees.
+func TestBuildScopedPlan_AllInScopeEqualsBuildPlan(t *testing.T) {
+ store := planStore(t)
+ full := BuildPlan(store, planFiles(), nil, Policy{})
+ scoped := BuildScopedPlan(planStore(t), planFiles(), nil, Policy{}, allInScope)
+
+ if len(full.Actions) != len(scoped.Actions) {
+ t.Fatalf("action count differs: BuildPlan=%d allInScope=%d", len(full.Actions), len(scoped.Actions))
+ }
+ if full.Counts()[PlanDropOrphan] != scoped.Counts()[PlanDropOrphan] {
+ t.Errorf("drop-orphan count differs: BuildPlan=%d allInScope=%d",
+ full.Counts()[PlanDropOrphan], scoped.Counts()[PlanDropOrphan])
+ }
+}
diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go
new file mode 100644
index 00000000..b356c603
--- /dev/null
+++ b/internal/manifestanalyzer/store.go
@@ -0,0 +1,912 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "path"
+ "sort"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/yaml"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// ManifestStore is the byte-free, in-memory structure model of a GitTarget folder
+// described in docs/design/manifest/current-manifest-support-review.md ("Concrete
+// Data Structures"). It is the backbone the live writer, scan mode, the CLI, and
+// status all consume; the analyzer Report is rendered as a projection over it.
+//
+// Only MANAGED files live in FilesByPath: YAML files carrying at least one KRM
+// document. Non-YAML auxiliary files and YAML files with no KRM document are known
+// to the analyzer but never become FileModels, so they have no document set to
+// empty and can never be swept or deleted.
+type ManifestStore struct {
+ // Root is the scanned root, mirroring Report.Root. It is informational and
+ // empty for an in-memory fs.FS.
+ Root string
+
+ // FilesByPath holds only managed files — those with at least one tracked KRM
+ // document. A FileModel therefore always has at least one document until its
+ // last is dropped, at which point Current goes nil and Deleted() fires.
+ FilesByPath map[string]*FileModel
+
+ // Indexes hold pointers into FilesByPath, not (path, index) pairs, so a
+ // document delete that shifts a file's slice never invalidates them.
+ //
+ // ByManifestIdentity is single-valued: it is collected first-occurrence-wins
+ // over the documents that CLAIM their identity (the collapse), so a later
+ // document that duplicates an earlier identity is not the winner and is
+ // detectable as such. Claiming mirrors manifestedit's duplicate rule exactly —
+ // cleanly-editable and encrypted documents claim, documents with disallowed
+ // constructs do not — so the collapse and manifestedit's duplicate diagnostic
+ // agree. The diagnostic is emitted by the manifestedit index pass that feeds the
+ // collapse.
+ ByManifestIdentity map[manifestedit.Identity]*DocumentModel
+ // ByResourceIdentity is populated once the GVK->GVR mapper resolves resource
+ // identities (Track B / B3). It is empty under structure-only analysis.
+ ByResourceIdentity map[types.ResourceIdentifier]*DocumentModel
+ // ByGVK groups every managed document by its derived GroupVersionKind. It is
+ // multi-valued: many resources of one kind are normal.
+ ByGVK map[schema.GroupVersionKind][]*DocumentModel
+
+ // Diagnostics are the scan- and index-level diagnostics gathered while building
+ // the store, in scan order (scan diagnostics first, then per-document index
+ // diagnostics).
+ Diagnostics []manifestedit.Diagnostic
+
+ // Retained holds the allowlisted non-API KRM documents (build directives such as
+ // kustomization.yaml) recognised during the scan but deliberately kept OUT of
+ // FilesByPath and the indexes — exactly like non-YAML auxiliary files. They have
+ // no document set to empty, so they can never be swept, edited, or planned. They
+ // are recorded only so the acceptance gate can name them and refuse a managed
+ // file that illegally shares its bytes with one (a mixed file). It is empty
+ // unless the store was built with a non-empty allowlist.
+ Retained []RetainedDocument
+}
+
+// RetainedDocument records an allowlisted build-directive that is excluded from the
+// managed model. There are two shapes:
+//
+// - a whole-file retention (the common case): Location.Path names an allowlisted
+// file (e.g. kustomization.yaml), Identity is the zero value. The file is
+// retained as auxiliary input and never materialised, planned, or swept.
+// - a named record hiding in an allowlisted file: Location and Identity both set.
+// A managed-looking resource must not live in a build-directive file, so the
+// acceptance gate refuses it (IssueMixedFile) rather than silently un-managing it.
+type RetainedDocument struct {
+ Location manifestedit.Location
+ Identity manifestedit.Identity
+ GVK schema.GroupVersionKind
+}
+
+// FileModel is one managed file under the scanned root. Its document set and
+// classification are resident and cheap (header parse only); its bytes are
+// hydrated lazily and only at a commit boundary.
+type FileModel struct {
+ // Path is the file location relative to the scanned root.
+ Path string
+
+ // Documents are every managed document in the file, in document order.
+ Documents []*DocumentModel
+
+ // Original and Current are hydrated lazily at the commit boundary, and only for
+ // the files a batch touches — they are nil for every untouched file, so the
+ // resident store is byte-free. Structure-only analysis never hydrates, so both
+ // stay nil.
+ Original []byte // worktree bytes once hydrated; nil for a new or unhydrated file
+ Current []byte // bytes after applying plan actions; nil means "delete this file"
+}
+
+// Dirty reports whether the file's bytes changed and should be re-written. It is
+// derived, never stored: two byte slices are the whole state machine.
+func (f *FileModel) Dirty() bool { return f.Current != nil && !bytes.Equal(f.Current, f.Original) }
+
+// Deleted reports whether the file should be removed (its last managed document
+// was dropped). It is derived, never stored.
+func (f *FileModel) Deleted() bool { return f.Current == nil && f.Original != nil }
+
+// DocumentModel is one managed KRM document. It is byte-free: the full
+// manifestedit node tree is built only when a plan action touches the document
+// (Snapshot is the lazy handle), and it deliberately stores neither its file path
+// nor its position. The file path is the containing FileModel's; the document's TRUE
+// file index is reconstructed when needed (by reconstructManagedIndices) from the
+// record-less diagnostic gaps — every empty/non-KRM/invalid document leaves a
+// diagnostic at its position, so the managed documents fill the remaining positions
+// in document order. That recovers the right index for any file, contiguous or not,
+// so the report, the planner (documentLocations), and the acceptance gate all agree
+// without storing a fragile mutable field. The M4 acceptance gate additionally
+// refuses any managed file that is not entirely valid KRM (Decision #2), so an
+// accepted file is contiguous anyway. manifestedit is given the position only at
+// apply time. See docs/design/manifest/current-manifest-support-review.md ("Concrete
+// Data Structures") and the M4 acceptance gate (acceptance.go).
+type DocumentModel struct {
+ // ManifestIdentity is the EFFECTIVE content identity (apiVersion + kind +
+ // namespace + name). For a namespace-less namespaced resource it may carry a
+ // namespace inherited from a kustomization.yaml that references the document
+ // through its resources graph; NamespaceSource records that provenance.
+ ManifestIdentity manifestedit.Identity
+
+ // NamespaceSource records where ManifestIdentity.Namespace came from: the file
+ // itself, a kustomization context, or nowhere (absent and unsupplied/ambiguous).
+ // See NamespaceSourceKind.
+ NamespaceSource NamespaceSource
+
+ // ResourceIdentity is the API-side identity (GVR + namespace + name). It is set
+ // only when the injected GVK->GVR mapper resolves the document's GVK to a single
+ // served, allowed resource; structure-only analysis (and any unresolved lookup)
+ // leaves it nil.
+ ResourceIdentity *types.ResourceIdentifier
+
+ // Mapping records why ResourceIdentity is or is not set, derived from the
+ // followability registry. Structure-only analysis is always MappingNoSource
+ // because no API source is wired in.
+ Mapping MappingOutcome
+
+ // Editable is false for SOPS-encrypted or otherwise non-patchable documents;
+ // Cause carries the structured reason.
+ Editable bool
+
+ // Cause is the structured reason behind Editable — never free-text
+ // classification. CauseNone for a cleanly editable document.
+ Cause DocumentCause
+
+ // Snapshot is the lazy body handle. It is unbuilt (zero) until a plan action
+ // touches the document; identity indexing needs only a cheap header parse.
+ Snapshot manifestedit.SnapshotRef
+}
+
+// NamespaceSourceKind classifies where a document's effective namespace comes from.
+// It replaces an earlier "namespace came from kustomize" boolean so the store can also
+// explain the no-context and ambiguous cases to status, duplicate diagnostics, and
+// future placement — see
+// docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md.
+type NamespaceSourceKind string
+
+const (
+ // NamespaceExplicit means the namespace is authoritative as written in the file
+ // (metadata.namespace present), or the document is cluster-scoped / not yet
+ // resolved so no context is consulted. The file bytes own the namespace.
+ NamespaceExplicit NamespaceSourceKind = "Explicit"
+ // NamespaceKustomize means the namespace was inherited from a kustomization.yaml
+ // that references the document through its resources graph. metadata.namespace is
+ // absent from the file and must stay absent on write; Path names the kustomization.
+ NamespaceKustomize NamespaceSourceKind = "Kustomize"
+ // NamespaceNone means a namespaced, followable document omits metadata.namespace
+ // and no single supported context supplies one — either nothing references it, or
+ // the references disagree (ambiguous). The document is left namespace-less rather
+ // than guessed; an ambiguous case also emits a reasonAmbiguousNamespace diagnostic
+ // for the repository-validity layer.
+ NamespaceNone NamespaceSourceKind = "None"
+)
+
+// NamespaceSource records where a document's effective namespace came from. Kind
+// drives the one write-time decision the live writer makes (keep metadata.namespace
+// out of the file and locate by raw identity only when Kind is Kustomize); Path is the
+// kustomization file that supplied the namespace, set only for NamespaceKustomize.
+type NamespaceSource struct {
+ Kind NamespaceSourceKind
+ Path string
+}
+
+// NamespaceInheritedFromContext reports whether the document's effective namespace
+// comes from build context (a kustomization.yaml) rather than metadata.namespace in
+// the file. The writer uses it to keep metadata.namespace out of the file and to
+// locate the document by its raw (namespace-less) identity in the file bytes.
+func (dm *DocumentModel) NamespaceInheritedFromContext() bool {
+ return dm.NamespaceSource.Kind == NamespaceKustomize
+}
+
+// MappingOutcome records why a document's ResourceIdentity is or is not set, derived
+// from the followability registry. It is the analyzer's view of the single
+// followability question — there is no status vocabulary to interpret, only three
+// outcomes: followable (resolved), not followable (a source said so), or no API
+// source at all (structure-only / the registry is not ready, so nothing is judged).
+type MappingOutcome int
+
+const (
+ // MappingNoSource means no API source was consulted (structure-only analysis, or a
+ // registry that is not ready). It is the honest "this looks like KRM but nothing was
+ // asked what serves it"; it never drives a watched/unwatched or destructive decision.
+ MappingNoSource MappingOutcome = iota
+ // MappingFollowable means the GVK resolved to a single served, followable resource;
+ // ResourceIdentity is set.
+ MappingFollowable
+ // MappingNotFollowable means a ready source was consulted but the kind is not
+ // followable (not served, denied, ambiguous, or missing a verb); ResourceIdentity
+ // is nil. Why it is not followable is recorded centrally by the registry, not here.
+ MappingNotFollowable
+)
+
+// String renders a MappingOutcome for diagnostics and tests.
+func (o MappingOutcome) String() string {
+ switch o {
+ case MappingNoSource:
+ return "no-source"
+ case MappingFollowable:
+ return "followable"
+ case MappingNotFollowable:
+ return "not-followable"
+ default:
+ return "unknown"
+ }
+}
+
+// reasonUnresolvedMapping marks a build-time diagnostic for a KRM document whose GVK
+// the followability registry could not resolve to a single served, followable
+// resource. Structure-only analysis never emits it.
+const reasonUnresolvedMapping manifestedit.DiagReason = "unresolved-mapping"
+
+// reasonScopeMismatch marks a build-time diagnostic for a resolved KRM document
+// whose mapper-reported scope contradicts its manifest: a cluster-scoped resource
+// that nonetheless sets metadata.namespace. The namespace is dropped for indexing
+// (the mapper's scope wins); whether the shape is refused is an M4 acceptance
+// decision. Structure-only analysis never resolves a scope, so never emits it.
+const reasonScopeMismatch manifestedit.DiagReason = "scope-mismatch"
+
+// reasonAmbiguousNamespace marks a build-time diagnostic for a namespace-less,
+// namespaced KRM document that supported kustomization contexts assign more than one
+// namespace (two render roots, or a parent/child namespace override). The store
+// refuses to infer a namespace and leaves the document namespace-less rather than
+// guess by filesystem proximity; the repository-validity layer is expected to fail the
+// GitTarget on this signal.
+const reasonAmbiguousNamespace manifestedit.DiagReason = "ambiguous-namespace"
+
+// CauseKind is the structured kind of a DocumentCause.
+type CauseKind string
+
+const (
+ // CauseNone is a cleanly editable document — no impediment.
+ CauseNone CauseKind = ""
+ // CauseEncrypted is a SOPS-encrypted document: authoritative but never patched
+ // in place.
+ CauseEncrypted CauseKind = "encrypted"
+ // CauseNonEditable is a document using a construct the editor refuses (anchor,
+ // alias, merge key, unusual tag, duplicate key).
+ CauseNonEditable CauseKind = "non-editable"
+)
+
+// DocumentCause is the structured reason a document is not cleanly editable. Kind
+// drives classification; Detail is a short, display-only token (e.g. the offending
+// construct) and is never read to make a decision.
+type DocumentCause struct {
+ Kind CauseKind `json:"kind,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+
+// RecordRef is a stable (file path, document index) reference to one document. It
+// is a plan-level value — the live, mutable store navigates by *DocumentModel
+// pointers — pinned for the lifetime of a single plan.
+type RecordRef struct {
+ FilePath string
+ DocumentIndex int
+}
+
+// buildStore indexes the YAML files into the byte-free structure model. It runs
+// the same manifestedit.IndexFiles scan the analyzer already used, groups the
+// resulting KRM records into managed FileModels, and builds the manifest-identity,
+// resource-identity, and GVK indexes. scanDiags (walk/read/symlink problems)
+// precede the index diagnostics in store.Diagnostics.
+//
+// mapper resolves each document's GVK to a served resource identity. A nil mapper
+// is treated as structure-only, so the analyzer's no-cluster promise holds: no
+// resource identities are resolved and the resource index stays empty.
+//
+// allowlist names the build-directive files (kustomization.yaml and friends) that
+// are retained rather than materialised. The allowlist is filename-based, because a
+// real kustomization.yaml has no metadata.name and so is not a KRM record at all —
+// a GVK-based match would never see it. An allowlisted file never becomes a
+// FileModel, its per-document index diagnostics are suppressed (its nameless build
+// directives must not look like non-KRM refusals), and it is recorded in
+// store.Retained instead. A named KRM record found inside an allowlisted file is
+// retained WITH its identity so the acceptance gate can refuse the mixed file rather
+// than silently un-manage a resource. The empty allowlist (BuildStore / Analyze)
+// materialises every KRM record, the legacy structure-only behaviour.
+func buildStore(
+ ctx context.Context,
+ yamlFiles []manifestedit.FileContent,
+ scanDiags []manifestedit.Diagnostic,
+ lookup typeset.Lookup,
+ allowlist Allowlist,
+) *ManifestStore {
+ if lookup == nil {
+ // A nil lookup is the structure-only mode: an unpublished registry is never
+ // ready, so it judges nothing.
+ lookup = typeset.NewRegistry()
+ }
+ inv, indexDiags := manifestedit.IndexFiles(yamlFiles)
+ nsAssignments := kustomizeNamespaceAssignments(yamlFiles)
+
+ store := &ManifestStore{
+ FilesByPath: map[string]*FileModel{},
+ ByManifestIdentity: map[manifestedit.Identity]*DocumentModel{},
+ ByResourceIdentity: map[types.ResourceIdentifier]*DocumentModel{},
+ ByGVK: map[schema.GroupVersionKind][]*DocumentModel{},
+ Diagnostics: retainedDiagnostics(scanDiags, indexDiags, allowlist),
+ }
+
+ // inv.Records are exactly the KRM documents (editable or not), in stable scan
+ // order (path, then document index), so each managed file's Documents slice is
+ // built in document order and first-occurrence-wins is deterministic.
+ hasNamedRecord := map[string]bool{}
+ for _, r := range inv.Records {
+ if allowlist.Allows(r.Location.Path) {
+ // A named KRM record inside an allowlisted build-directive file (a managed
+ // resource hiding in kustomization.yaml). We must not silently un-manage it,
+ // so retain it WITH its identity for the mixed-file refusal; never materialise.
+ hasNamedRecord[r.Location.Path] = true
+ store.Retained = append(store.Retained, RetainedDocument{
+ Location: r.Location, Identity: r.Identity, GVK: gvkOf(r.Identity),
+ })
+ continue
+ }
+ store.materialize(ctx, r, lookup, nsAssignments)
+ }
+
+ // Record every allowlisted file with no named record as a whole-file retention,
+ // so it is known to acceptance (and shown) but never becomes a FileModel.
+ for _, f := range yamlFiles {
+ if allowlist.Allows(f.Path) && !hasNamedRecord[f.Path] {
+ store.Retained = append(store.Retained, RetainedDocument{Location: manifestedit.Location{Path: f.Path}})
+ }
+ }
+ sortRetained(store.Retained)
+
+ return store
+}
+
+// BuildStoreFromFiles builds the byte-free structure model from already-collected
+// file bytes, rather than walking an fs.FS (BuildStore). It is the live writer's
+// entry point: the writer reads the worktree subtree once at a commit boundary —
+// it needs the bytes anyway, to hydrate and apply — and hands the same FileContent
+// slice here, so the store and the bytes the plan is applied to are one snapshot.
+//
+// lookup resolves each document's GVK to a served resource identity; a nil lookup
+// keeps it structure-only (no resource index), exactly as BuildStore. allowlist
+// names the build-directive files retained outside the model; pass the zero value
+// to materialise every KRM document.
+func BuildStoreFromFiles(
+ ctx context.Context,
+ files []manifestedit.FileContent,
+ lookup typeset.Lookup,
+ allowlist Allowlist,
+) *ManifestStore {
+ return buildStore(ctx, files, nil, lookup, allowlist)
+}
+
+// DocumentLocations returns the (file path, document index) of every managed
+// document in the store. It is the public form of the planner's per-document
+// position reconstruction (record-less diagnostic gaps), computed once so a caller
+// folding many events over one commit-boundary store does not pay the O(store)
+// reconstruction per lookup. Pair it with ByManifestIdentity to resolve an
+// identity to its RecordRef.
+func (s *ManifestStore) DocumentLocations() map[*DocumentModel]RecordRef {
+ return documentLocations(s)
+}
+
+// materialize adds one managed KRM record to the store: its FileModel, the GVK
+// index, the resolved mapping, and the first-occurrence-wins identity indexes.
+func (s *ManifestStore) materialize(
+ ctx context.Context,
+ r manifestedit.DocumentRecord,
+ lookup typeset.Lookup,
+ nsAssignments map[string]namespaceAssignment,
+) {
+ gvk := gvkOf(r.Identity)
+ identity, nsSource, diag := resolveNamespaceContext(ctx, r.Identity, gvk, lookup, r.Location, nsAssignments)
+ if diag != nil {
+ s.Diagnostics = append(s.Diagnostics, *diag)
+ }
+
+ fm := s.FilesByPath[r.Location.Path]
+ if fm == nil {
+ fm = &FileModel{Path: r.Location.Path}
+ s.FilesByPath[r.Location.Path] = fm
+ }
+ dm := &DocumentModel{
+ ManifestIdentity: identity,
+ NamespaceSource: nsSource,
+ Editable: r.Editable && !r.Encrypted,
+ Cause: causeFor(r),
+ }
+ // resolveMapping is the sole owner of dm.Mapping: it sets the followability
+ // outcome on every path, so an un-ready registry is never confused with a
+ // deliberately structure-only document.
+ s.resolveMapping(ctx, dm, gvk, lookup, r.Location)
+ fm.Documents = append(fm.Documents, dm)
+ s.ByGVK[gvk] = append(s.ByGVK[gvk], dm)
+
+ // The manifest-identity index is the duplicate collapse: documents that claim
+ // their identity take it first-occurrence-wins; a later collision is therefore
+ // not the winner and is detectable via IsDuplicate. The resource-identity index
+ // collapses on exactly the same winners, so a resolved winner is reachable by
+ // either identity.
+ if dm.claimsIdentity() {
+ if _, taken := s.ByManifestIdentity[dm.ManifestIdentity]; !taken {
+ s.ByManifestIdentity[dm.ManifestIdentity] = dm
+ if dm.ResourceIdentity != nil {
+ s.ByResourceIdentity[*dm.ResourceIdentity] = dm
+ }
+ }
+ }
+}
+
+// retainedDiagnostics concatenates scan and index diagnostics, dropping the
+// per-document index diagnostics of allowlisted files: their nameless build
+// directives are retained, not classified, so they must not surface as non-KRM or
+// invalid-YAML refusals. Scan diagnostics (file access) are always kept.
+func retainedDiagnostics(
+ scanDiags, indexDiags []manifestedit.Diagnostic,
+ allowlist Allowlist,
+) []manifestedit.Diagnostic {
+ out := append([]manifestedit.Diagnostic(nil), scanDiags...)
+ for _, d := range indexDiags {
+ if allowlist.Allows(d.Path) {
+ continue
+ }
+ out = append(out, d)
+ }
+ return out
+}
+
+// sortRetained orders retained entries by path then document index, for stable
+// output regardless of the order records and files were visited.
+func sortRetained(retained []RetainedDocument) {
+ sort.Slice(retained, func(i, j int) bool {
+ if retained[i].Location.Path != retained[j].Location.Path {
+ return retained[i].Location.Path < retained[j].Location.Path
+ }
+ return retained[i].Location.DocumentIndex < retained[j].Location.DocumentIndex
+ })
+}
+
+// resolveNamespaceContext determines a document's effective namespace and records
+// where it came from. A namespace written in the file is authoritative (Explicit). For
+// a namespace-less, followable, namespaced document it consults the kustomization
+// resources graph: exactly one assigning namespace is inherited (Kustomize); zero or
+// conflicting assignments leave the document namespace-less (None), with an ambiguity
+// diagnostic in the conflict case. It never guesses by filesystem proximity, so a file
+// is only given a namespace by a kustomization that actually references it.
+func resolveNamespaceContext(
+ ctx context.Context,
+ id manifestedit.Identity,
+ gvk schema.GroupVersionKind,
+ lookup typeset.Lookup,
+ loc manifestedit.Location,
+ assignments map[string]namespaceAssignment,
+) (manifestedit.Identity, NamespaceSource, *manifestedit.Diagnostic) {
+ if id.Namespace != "" {
+ return id, NamespaceSource{Kind: NamespaceExplicit}, nil
+ }
+ if ctx.Err() != nil || !lookup.Ready() {
+ return id, NamespaceSource{Kind: NamespaceExplicit}, nil
+ }
+ record, known := lookup.ByGVK(gvk)
+ if !known || !record.Followable() || record.Identity.Scope != typeset.ScopeNamespaced {
+ // Cluster-scoped or unresolved: a namespace context does not apply.
+ return id, NamespaceSource{Kind: NamespaceExplicit}, nil
+ }
+
+ a := assignments[filepathToSlash(loc.Path)]
+ switch len(a.namespaces) {
+ case 0:
+ return id, NamespaceSource{Kind: NamespaceNone}, nil
+ case 1:
+ ns := a.namespaces[0]
+ id.Namespace = ns
+ return id, NamespaceSource{Kind: NamespaceKustomize, Path: a.sourceByNamespace[ns]}, nil
+ default:
+ return id, NamespaceSource{Kind: NamespaceNone}, &manifestedit.Diagnostic{
+ Level: manifestedit.DiagWarning,
+ Reason: reasonAmbiguousNamespace,
+ Message: fmt.Sprintf(
+ "namespace-less %s %q is assigned conflicting namespaces %v by kustomization context; refusing to infer one",
+ gvk.Kind,
+ id.Name,
+ a.namespaces,
+ ),
+ Path: loc.Path,
+ DocumentIndex: loc.DocumentIndex,
+ }
+ }
+}
+
+// namespaceAssignment is the set of distinct namespaces that supported kustomization
+// contexts assign to one resource file, with the kustomization that supplied each.
+// More than one distinct namespace is the ambiguous case resolveNamespaceContext
+// refuses.
+type namespaceAssignment struct {
+ namespaces []string // sorted, distinct
+ sourceByNamespace map[string]string // namespace -> kustomization file path that assigned it
+}
+
+// kustomizationDoc is the parsed, write-relevant view of one kustomization.yaml: its
+// namespace transformer, its resources/bases graph entries, and whether it uses any
+// feature outside the supported contextual-namespace subset (which disqualifies it as
+// a namespace source). See the "Kustomize subset proposal" in
+// docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md.
+type kustomizationDoc struct {
+ path string // kustomization file path (slash)
+ namespace string // the namespace: transformer value
+ resources []string // resources + bases entries, raw and relative to the file's dir
+ unsupported bool // uses generators/patches/components/remote bases/name(pre|suf)fix/...
+}
+
+// kustomizeNamespaceAssignments walks each supported kustomization as a render root and
+// attributes its namespace to every resource file reachable through its resources
+// graph. A file reached from two roots with different namespaces (or via a parent that
+// overrides a child's namespace) accumulates both, which resolveNamespaceContext then
+// refuses as ambiguous. Following the graph — not the nearest kustomization on disk —
+// is the safety property the design doc requires.
+func kustomizeNamespaceAssignments(files []manifestedit.FileContent) map[string]namespaceAssignment {
+ kusts := parseKustomizations(files)
+ resourceFiles := resourceFilePaths(files)
+
+ // nsByFile[file][namespace] = kustomization path that first assigned it.
+ nsByFile := map[string]map[string]string{}
+ for dir, root := range kusts {
+ if root.unsupported || root.namespace == "" {
+ continue
+ }
+ assignFromRoot(dir, root, kusts, resourceFiles, nsByFile)
+ }
+ return collapseAssignments(nsByFile)
+}
+
+// resourceFilePaths is the set of non-kustomization YAML paths (slash) — the resource
+// files a kustomization's resources graph can reference.
+func resourceFilePaths(files []manifestedit.FileContent) map[string]struct{} {
+ out := map[string]struct{}{}
+ for _, f := range files {
+ if !isKustomizationFile(f.Path) {
+ out[filepathToSlash(f.Path)] = struct{}{}
+ }
+ }
+ return out
+}
+
+// assignFromRoot walks one render root, attributing its namespace to every resource
+// file reachable through the resources graph, recursing into directory bases (where the
+// parent namespace still applies, which is exactly why a base with its own namespace
+// becomes ambiguous). The visited set bounds cycles and re-entry.
+func assignFromRoot(
+ dir string,
+ root *kustomizationDoc,
+ kusts map[string]*kustomizationDoc,
+ resourceFiles map[string]struct{},
+ nsByFile map[string]map[string]string,
+) {
+ visited := map[string]struct{}{}
+ var walk func(curDir string, cur *kustomizationDoc)
+ walk = func(curDir string, cur *kustomizationDoc) {
+ if cur == nil || cur.unsupported {
+ return
+ }
+ if _, seen := visited[curDir]; seen {
+ return
+ }
+ visited[curDir] = struct{}{}
+ for _, entry := range cur.resources {
+ target := cleanJoin(curDir, entry)
+ switch {
+ case target == "":
+ // empty, or escapes the scanned root: contributes no context.
+ case mapHasKey(resourceFiles, target):
+ addNamespace(nsByFile, target, root.namespace, root.path)
+ default:
+ walk(target, kusts[target]) // a directory base, or an unknown entry (no-op)
+ }
+ }
+ }
+ walk(dir, root)
+}
+
+func addNamespace(nsByFile map[string]map[string]string, file, namespace, source string) {
+ m := nsByFile[file]
+ if m == nil {
+ m = map[string]string{}
+ nsByFile[file] = m
+ }
+ if _, ok := m[namespace]; !ok {
+ m[namespace] = source
+ }
+}
+
+func mapHasKey(m map[string]struct{}, key string) bool {
+ _, ok := m[key]
+ return ok
+}
+
+// collapseAssignments turns the per-file namespace map into sorted, distinct
+// namespaceAssignments. A file with more than one distinct namespace is the ambiguous
+// case resolveNamespaceContext refuses.
+func collapseAssignments(nsByFile map[string]map[string]string) map[string]namespaceAssignment {
+ out := make(map[string]namespaceAssignment, len(nsByFile))
+ for file, m := range nsByFile {
+ namespaces := make([]string, 0, len(m))
+ for ns := range m {
+ namespaces = append(namespaces, ns)
+ }
+ sort.Strings(namespaces)
+ out[file] = namespaceAssignment{namespaces: namespaces, sourceByNamespace: m}
+ }
+ return out
+}
+
+// parseKustomizations reads every kustomization.yaml into a kustomizationDoc keyed by
+// its directory. An unparseable kustomization, or one using an unsupported feature, is
+// kept but marked unsupported so it never acts as a namespace source.
+func parseKustomizations(files []manifestedit.FileContent) map[string]*kustomizationDoc {
+ out := map[string]*kustomizationDoc{}
+ for _, f := range files {
+ if !isKustomizationFile(f.Path) {
+ continue
+ }
+ doc := &kustomizationDoc{path: filepathToSlash(f.Path)}
+ raw := map[string]interface{}{}
+ if err := yaml.Unmarshal(f.Content, &raw); err != nil {
+ doc.unsupported = true
+ out[slashDir(f.Path)] = doc
+ continue
+ }
+ doc.namespace = strings.TrimSpace(stringField(raw, "namespace"))
+ doc.resources = append(stringList(raw, "resources"), stringList(raw, "bases")...)
+ doc.unsupported = hasUnsupportedKustomizeFeature(raw) || hasRemoteResource(doc.resources)
+ out[slashDir(f.Path)] = doc
+ }
+ return out
+}
+
+// hasUnsupportedKustomizeFeature reports whether a kustomization uses a field that
+// creates resources or mutates resource identity (name/namespace) in ways the
+// contextual-namespace writer cannot map back to an editable source document. Their
+// presence disqualifies a kustomization as a namespace source; benign transformers
+// (labels, annotations, images) do not.
+func hasUnsupportedKustomizeFeature(raw map[string]interface{}) bool {
+ unsupported := []string{
+ "generators", "configMapGenerator", "secretGenerator",
+ "helmCharts", "helmGlobals", "helmChartInflationGenerator",
+ "patches", "patchesStrategicMerge", "patchesJson6902",
+ "replacements", "components", "transformers", "configurations",
+ "namePrefix", "nameSuffix",
+ }
+ for _, key := range unsupported {
+ if v, ok := raw[key]; ok && !isEmptyValue(v) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasRemoteResource(entries []string) bool {
+ for _, e := range entries {
+ if isRemoteResource(e) {
+ return true
+ }
+ }
+ return false
+}
+
+// isRemoteResource reports whether a resources entry is a remote base (a URL or a
+// git/host-qualified path) rather than a local file or directory.
+func isRemoteResource(entry string) bool {
+ e := strings.TrimSpace(entry)
+ if strings.Contains(e, "://") || strings.Contains(e, "git@") {
+ return true
+ }
+ for _, host := range []string{"github.com/", "gitlab.com/", "bitbucket.org/"} {
+ if strings.HasPrefix(e, host) {
+ return true
+ }
+ }
+ return false
+}
+
+func stringField(raw map[string]interface{}, key string) string {
+ if v, ok := raw[key].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func stringList(raw map[string]interface{}, key string) []string {
+ v, ok := raw[key].([]interface{})
+ if !ok {
+ return nil
+ }
+ out := make([]string, 0, len(v))
+ for _, e := range v {
+ if s, ok := e.(string); ok {
+ if s = strings.TrimSpace(s); s != "" {
+ out = append(out, s)
+ }
+ }
+ }
+ return out
+}
+
+func isEmptyValue(v interface{}) bool {
+ switch t := v.(type) {
+ case nil:
+ return true
+ case string:
+ return strings.TrimSpace(t) == ""
+ case []interface{}:
+ return len(t) == 0
+ case map[string]interface{}:
+ return len(t) == 0
+ default:
+ return false
+ }
+}
+
+// cleanJoin resolves a kustomization resources entry against the kustomization's
+// directory into a clean slash path. A trailing slash is dropped so a directory base
+// keys the same as its kustomization dir. An entry that escapes the scanned root (via
+// "..") or resolves to the root itself returns "" — it points outside the subtree and
+// contributes no context.
+func cleanJoin(dir, entry string) string {
+ e := strings.TrimSuffix(filepathToSlash(strings.TrimSpace(entry)), "/")
+ var joined string
+ if dir == "." || dir == "" {
+ joined = path.Clean(e)
+ } else {
+ joined = path.Clean(dir + "/" + e)
+ }
+ if joined == "." || joined == ".." || strings.HasPrefix(joined, "../") {
+ return ""
+ }
+ return joined
+}
+
+func slashDir(filePath string) string {
+ dir := path.Dir(filepathToSlash(filePath))
+ if dir == "/" || dir == "" {
+ return "."
+ }
+ return dir
+}
+
+func filepathToSlash(filePath string) string {
+ return strings.ReplaceAll(filePath, "\\", "/")
+}
+
+func isKustomizationFile(filePath string) bool {
+ switch path.Base(filepathToSlash(filePath)) {
+ case "kustomization.yaml", "kustomization.yml":
+ return true
+ default:
+ return false
+ }
+}
+
+// resolveMapping asks the followability registry whether dm's GVK is followable,
+// recording the outcome on the document and, when followable, its ResourceIdentity.
+// A ready registry that does not find the GVK followable emits a build-time
+// diagnostic; an un-ready registry (structure-only) resolves nothing and emits none.
+// The registry is the single, central owner of why a type is not followable, so this
+// path carries no per-type explanation — it records only the three outcomes.
+func (s *ManifestStore) resolveMapping(
+ ctx context.Context,
+ dm *DocumentModel,
+ gvk schema.GroupVersionKind,
+ lookup typeset.Lookup,
+ loc manifestedit.Location,
+) {
+ if ctx.Err() != nil || !lookup.Ready() {
+ dm.Mapping = MappingNoSource
+ return
+ }
+
+ record, known := lookup.ByGVK(gvk)
+ if known && record.Followable() {
+ dm.Mapping = MappingFollowable
+ namespaced := record.Identity.Scope == typeset.ScopeNamespaced
+ dm.ResourceIdentity = s.resolvedIdentity(dm, gvk, record.Identity.GVR, namespaced, loc)
+ return
+ }
+
+ dm.Mapping = MappingNotFollowable
+ s.Diagnostics = append(s.Diagnostics, manifestedit.Diagnostic{
+ Level: manifestedit.DiagWarning,
+ Reason: reasonUnresolvedMapping,
+ Message: fmt.Sprintf("GVK %s is not a followable resource type", gvk),
+ Path: loc.Path,
+ DocumentIndex: loc.DocumentIndex,
+ })
+}
+
+// resolvedIdentity builds the ResourceIdentity for a followable document. The
+// registry's scope is authoritative: a cluster-scoped resource is keyed with no
+// namespace, so a manifest that nonetheless carries metadata.namespace would otherwise
+// be indexed under a wrong, namespaced resource key (internal/types treats empty
+// namespace as cluster-scoped). The namespace is dropped and the mismatch flagged.
+func (s *ManifestStore) resolvedIdentity(
+ dm *DocumentModel,
+ gvk schema.GroupVersionKind,
+ gvr schema.GroupVersionResource,
+ namespaced bool,
+ loc manifestedit.Location,
+) *types.ResourceIdentifier {
+ namespace := dm.ManifestIdentity.Namespace
+ if !namespaced && namespace != "" {
+ s.Diagnostics = append(s.Diagnostics, manifestedit.Diagnostic{
+ Level: manifestedit.DiagWarning,
+ Reason: reasonScopeMismatch,
+ Message: fmt.Sprintf(
+ "%s is cluster-scoped but the manifest sets metadata.namespace %q; namespace ignored for indexing",
+ gvk, namespace,
+ ),
+ Path: loc.Path,
+ DocumentIndex: loc.DocumentIndex,
+ })
+ namespace = ""
+ }
+ ri := types.NewResourceIdentifier(
+ gvr.Group,
+ gvr.Version,
+ gvr.Resource,
+ namespace,
+ dm.ManifestIdentity.Name,
+ )
+ return &ri
+}
+
+// claimsIdentity reports whether a document claims its manifest identity for the
+// duplicate-collapse contest. It mirrors manifestedit's rule precisely: a document
+// the editor cannot parse safely (a disallowed construct) does not claim an
+// identity, but an encrypted document — authoritative though never patched in
+// place, so Editable is false — still does.
+func (dm *DocumentModel) claimsIdentity() bool {
+ return dm.Cause.Kind != CauseNonEditable
+}
+
+// IsDuplicate reports whether dm is an identity-claiming document that lost the
+// first-occurrence-wins contest for its manifest identity — i.e. a duplicate the
+// GitTarget would refuse. It reads only the collapsed index, never a diagnostic
+// message, and agrees with manifestedit's duplicate detection (encrypted documents
+// included).
+func (s *ManifestStore) IsDuplicate(dm *DocumentModel) bool {
+ return dm.claimsIdentity() && s.ByManifestIdentity[dm.ManifestIdentity] != dm
+}
+
+// causeFor maps a manifestedit record to the structured DocumentCause. It reads
+// the record's boolean signals (and the construct token), never message text.
+func causeFor(r manifestedit.DocumentRecord) DocumentCause {
+ switch {
+ case r.Encrypted:
+ return DocumentCause{Kind: CauseEncrypted}
+ case !r.Editable:
+ return DocumentCause{Kind: CauseNonEditable, Detail: r.Reason}
+ default:
+ return DocumentCause{Kind: CauseNone}
+ }
+}
+
+// gvkOf derives a GroupVersionKind from a manifest identity's apiVersion and kind.
+func gvkOf(id manifestedit.Identity) schema.GroupVersionKind {
+ gvk := ParseGVK(id.APIVersion, id.Kind)
+ return schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}
+}
diff --git a/internal/manifestanalyzer/store_test.go b/internal/manifestanalyzer/store_test.go
new file mode 100644
index 00000000..a0a98e11
--- /dev/null
+++ b/internal/manifestanalyzer/store_test.go
@@ -0,0 +1,602 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestanalyzer
+
+import (
+ "context"
+ "testing"
+ "testing/fstest"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// structureOnlyStore builds the canonical sample tree with no mapper (structure-only).
+func structureOnlyStore() *ManifestStore {
+ return BuildStore(context.Background(), sampleFS(), nil)
+}
+
+// TestBuildStore_ManagedFilesOnly proves the store carries exactly the managed
+// KRM documents the Report projection needs: non-YAML files and YAML files with no
+// KRM document never become FileModels.
+func TestBuildStore_ManagedFilesOnly(t *testing.T) {
+ store := structureOnlyStore()
+
+ // plain.yaml (non-KRM), broken.yaml (invalid), empty.yaml (empty), and
+ // docs/notes.txt (non-yaml) hold no managed document, so they are absent.
+ managed := []string{"cm.yaml", "deploy.yaml", "dup.yaml", "secret.sops.yaml"}
+ if len(store.FilesByPath) != len(managed) {
+ t.Fatalf("managed files = %d, want %d: %v", len(store.FilesByPath), len(managed), keysOf(store.FilesByPath))
+ }
+ for _, p := range managed {
+ if store.FilesByPath[p] == nil {
+ t.Errorf("expected managed file %q in store", p)
+ }
+ }
+ for _, p := range []string{"plain.yaml", "broken.yaml", "empty.yaml", "docs/notes.txt"} {
+ if store.FilesByPath[p] != nil {
+ t.Errorf("unmanaged file %q should not be a FileModel", p)
+ }
+ }
+}
+
+// TestBuildStore_DocumentDetail checks the per-document classification carried by
+// the store: multi-document order, encryption, and the duplicate loser.
+func TestBuildStore_DocumentDetail(t *testing.T) {
+ store := structureOnlyStore()
+
+ cm := store.FilesByPath["cm.yaml"]
+ if len(cm.Documents) != 2 {
+ t.Fatalf("cm.yaml documents = %d, want 2", len(cm.Documents))
+ }
+ if cm.Documents[0].ManifestIdentity.Name != "a" || cm.Documents[1].ManifestIdentity.Name != "b" {
+ t.Errorf(
+ "cm.yaml doc order = %q,%q",
+ cm.Documents[0].ManifestIdentity.Name,
+ cm.Documents[1].ManifestIdentity.Name,
+ )
+ }
+ for _, dm := range cm.Documents {
+ if dm.Mapping != MappingNoSource {
+ t.Errorf(
+ "structure-only analysis should leave Mapping=%v, got %v",
+ MappingNoSource,
+ dm.Mapping,
+ )
+ }
+ if dm.ResourceIdentity != nil {
+ t.Errorf("structure-only analysis should leave ResourceIdentity nil, got %+v", dm.ResourceIdentity)
+ }
+ }
+
+ if secret := store.FilesByPath["secret.sops.yaml"]; secret.Documents[0].Cause.Kind != CauseEncrypted {
+ t.Errorf("secret.sops.yaml document should carry an encrypted cause, got %+v", secret.Documents[0].Cause)
+ }
+ if dup := store.FilesByPath["dup.yaml"]; !store.IsDuplicate(dup.Documents[0]) {
+ t.Errorf("dup.yaml document should be the duplicate loser")
+ }
+}
+
+// TestBuildStore_EncryptedDuplicate covers the edge the duplicate collapse must
+// match manifestedit on: two encrypted documents with the same identity. The first
+// occurrence wins; the second is a duplicate even though encrypted documents are
+// Editable=false (not patchable in place).
+func TestBuildStore_EncryptedDuplicate(t *testing.T) {
+ store := BuildStore(context.Background(), fstest.MapFS{
+ "a.sops.yaml": {Data: []byte(sopsSecretYAML)},
+ "b.sops.yaml": {Data: []byte(sopsSecretYAML)},
+ }, nil)
+ a := store.FilesByPath["a.sops.yaml"].Documents[0]
+ b := store.FilesByPath["b.sops.yaml"].Documents[0]
+
+ if a.Editable || b.Editable {
+ t.Fatalf("encrypted documents should be Editable=false")
+ }
+ if store.IsDuplicate(a) {
+ t.Errorf("first encrypted occurrence should win the identity, not be a duplicate")
+ }
+ if !store.IsDuplicate(b) {
+ t.Errorf("second encrypted occurrence should be detected as a duplicate")
+ }
+}
+
+// TestBuildStore_Indexes checks the collapsed manifest-identity index and the
+// multi-valued GVK index.
+func TestBuildStore_Indexes(t *testing.T) {
+ store := structureOnlyStore()
+
+ // The Deployment appears in deploy.yaml and dup.yaml; the index collapses to the
+ // first occurrence (deploy.yaml sorts first) and dup.yaml's copy is the loser.
+ dep := manifestedit.Identity{APIVersion: "apps/v1", Kind: "Deployment", Namespace: "default", Name: "web"}
+ winner := store.ByManifestIdentity[dep]
+ if winner == nil {
+ t.Fatalf("Deployment identity missing from ByManifestIdentity")
+ }
+ if winner != store.FilesByPath["deploy.yaml"].Documents[0] {
+ t.Errorf("ByManifestIdentity winner should be deploy.yaml's document")
+ }
+
+ // ByGVK groups the two Deployments (winner + loser) under one key.
+ gvk := schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}
+ if got := len(store.ByGVK[gvk]); got != 2 {
+ t.Errorf("ByGVK[%s] = %d documents, want 2", gvk, got)
+ }
+
+ // Structure-only analysis resolves no resource identities.
+ if len(store.ByResourceIdentity) != 0 {
+ t.Errorf("structure-only ByResourceIdentity should be empty, got %d", len(store.ByResourceIdentity))
+ }
+}
+
+// sampleClusterSnapshot is a ready static snapshot that matches sampleFS: apps/v1
+// Deployment and core v1 ConfigMap are served and allowed; core v1 Secret is served
+// but excluded by policy, so it must resolve to Disallowed rather than Resolved.
+func sampleClusterSnapshot() typeset.Snapshot {
+ return typeset.Snapshot{
+ Generation: 1,
+ Entries: []typeset.Entry{
+ {
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
+ Namespaced: true,
+ Allowed: true,
+ },
+ {
+ GVK: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"},
+ GVR: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"},
+ Namespaced: true,
+ Allowed: true,
+ },
+ {
+ GVK: schema.GroupVersionKind{Version: "v1", Kind: "Secret"},
+ GVR: schema.GroupVersionResource{Version: "v1", Resource: "secrets"},
+ Namespaced: true,
+ Allowed: false,
+ },
+ },
+ }
+}
+
+// TestBuildStore_StaticSnapshotMapper is the B3 milestone check: with a
+// static-snapshot mapper, resolved documents carry a ResourceIdentity + Resolved
+// status and populate ByResourceIdentity, while a disallowed kind stays unresolved
+// and surfaces an unresolved-mapping diagnostic.
+func TestBuildStore_StaticSnapshotMapper(t *testing.T) {
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ store := BuildStore(context.Background(), sampleFS(), mapper)
+
+ // The Deployment resolves to apps/v1/deployments and carries both identities.
+ dep := store.FilesByPath["deploy.yaml"].Documents[0]
+ if dep.Mapping != MappingFollowable {
+ t.Fatalf("deploy.yaml Mapping = %q, want Resolved", dep.Mapping)
+ }
+ wantRI := types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")
+ if dep.ResourceIdentity == nil || *dep.ResourceIdentity != wantRI {
+ t.Fatalf("deploy.yaml ResourceIdentity = %+v, want %+v", dep.ResourceIdentity, wantRI)
+ }
+ if store.ByResourceIdentity[wantRI] != dep {
+ t.Errorf("ByResourceIdentity[%s] should point at deploy.yaml's document", wantRI.Key())
+ }
+
+ // Both ConfigMaps resolve; the resource index collapses on the same winners as
+ // the manifest-identity index, so the three resolved winners are all present
+ // (Deployment web, ConfigMap a, ConfigMap b) and the disallowed Secret is not.
+ if got := len(store.ByResourceIdentity); got != 3 {
+ t.Errorf("ByResourceIdentity = %d resolved winners, want 3", got)
+ }
+
+ // The Secret is served but policy-denied: no ResourceIdentity, not followable.
+ secret := store.FilesByPath["secret.sops.yaml"].Documents[0]
+ if secret.Mapping != MappingNotFollowable {
+ t.Errorf("secret Mapping = %v, want NotFollowable", secret.Mapping)
+ }
+ if secret.ResourceIdentity != nil {
+ t.Errorf("disallowed secret should have no ResourceIdentity, got %+v", secret.ResourceIdentity)
+ }
+
+ // Exactly one unresolved-mapping diagnostic (the disallowed Secret).
+ var unresolved int
+ for _, d := range store.Diagnostics {
+ if d.Reason == reasonUnresolvedMapping {
+ unresolved++
+ if d.Path != "secret.sops.yaml" {
+ t.Errorf("unresolved-mapping diagnostic on %q, want secret.sops.yaml", d.Path)
+ }
+ }
+ }
+ if unresolved != 1 {
+ t.Errorf("unresolved-mapping diagnostics = %d, want 1", unresolved)
+ }
+}
+
+// TestBuildStore_KustomizeNamespaceFromResourcesGraph proves the supported case: a
+// namespace-less namespaced resource inherits its namespace from the kustomization that
+// references it through the resources graph — directly (app.yaml) and transitively via
+// a directory base whose own kustomization sets no namespace (base/cm.yaml). The
+// namespace follows the include graph, not filesystem proximity.
+func TestBuildStore_KustomizeNamespaceFromResourcesGraph(t *testing.T) {
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ store := BuildStore(context.Background(), fstest.MapFS{
+ "kustomization.yaml": {Data: []byte(
+ "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" +
+ "namespace: team-a\nresources:\n- app.yaml\n- base\n")},
+ "app.yaml": {Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n")},
+ "base/kustomization.yaml": {Data: []byte(
+ "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" +
+ "resources:\n- cm.yaml\n")},
+ "base/cm.yaml": {Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: nested\n")},
+ }, mapper)
+
+ app := store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "team-a", Name: "app"}]
+ if app == nil {
+ t.Fatalf("ConfigMap app should be indexed under its effective kustomize namespace team-a")
+ }
+ if app.NamespaceSource.Kind != NamespaceKustomize || app.NamespaceSource.Path != "kustomization.yaml" {
+ t.Errorf("app NamespaceSource = %+v, want {Kustomize kustomization.yaml}", app.NamespaceSource)
+ }
+ if !app.NamespaceInheritedFromContext() {
+ t.Errorf("app should report its namespace was inherited from context")
+ }
+ appRI := types.NewResourceIdentifier("", "v1", "configmaps", "team-a", "app")
+ if app.ResourceIdentity == nil || *app.ResourceIdentity != appRI {
+ t.Fatalf("app ResourceIdentity = %+v, want %+v", app.ResourceIdentity, appRI)
+ }
+
+ nested := store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "team-a", Name: "nested"}]
+ if nested == nil {
+ t.Fatalf("base/cm.yaml should inherit team-a through the parent's resources graph")
+ }
+ if nested.NamespaceSource.Kind != NamespaceKustomize || nested.NamespaceSource.Path != "kustomization.yaml" {
+ t.Errorf("nested NamespaceSource = %+v, want {Kustomize kustomization.yaml}", nested.NamespaceSource)
+ }
+}
+
+// TestBuildStore_AmbiguousKustomizeNamespaceRefused proves the safety rule the design
+// doc requires: when two render roots assign different namespaces to the same source
+// file, the store refuses to infer one, leaves the document namespace-less (None), and
+// emits an ambiguous-namespace diagnostic rather than guessing by proximity.
+func TestBuildStore_AmbiguousKustomizeNamespaceRefused(t *testing.T) {
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ store := BuildStore(context.Background(), fstest.MapFS{
+ "kustomization.yaml": {Data: []byte(
+ "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" +
+ "namespace: team-a\nresources:\n- shared.yaml\n")},
+ "other/kustomization.yaml": {Data: []byte(
+ "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" +
+ "namespace: team-b\nresources:\n- ../shared.yaml\n")},
+ "shared.yaml": {Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: shared\n")},
+ }, mapper)
+
+ for _, ns := range []string{"team-a", "team-b"} {
+ if store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: ns, Name: "shared"}] != nil {
+ t.Errorf("shared.yaml must not be indexed under %q when context is ambiguous", ns)
+ }
+ }
+ dm := store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "shared"}]
+ if dm == nil {
+ t.Fatalf("shared.yaml should still be indexed by its raw namespace-less identity")
+ }
+ if dm.NamespaceSource.Kind != NamespaceNone {
+ t.Errorf("ambiguous shared.yaml NamespaceSource = %+v, want None", dm.NamespaceSource)
+ }
+ if dm.NamespaceInheritedFromContext() {
+ t.Errorf("ambiguous document must not be treated as context-namespaced")
+ }
+
+ var ambiguous int
+ for _, d := range store.Diagnostics {
+ if d.Reason == reasonAmbiguousNamespace {
+ ambiguous++
+ if d.Path != "shared.yaml" {
+ t.Errorf("ambiguous-namespace diagnostic on %q, want shared.yaml", d.Path)
+ }
+ }
+ }
+ if ambiguous != 1 {
+ t.Errorf("ambiguous-namespace diagnostics = %d, want 1", ambiguous)
+ }
+}
+
+// TestBuildStore_UnsupportedKustomizeIsNotANamespaceSource proves a kustomization using
+// a feature outside the supported subset (here patches) never supplies a namespace
+// context, so its referenced documents fall back to namespace-less (None).
+func TestBuildStore_UnsupportedKustomizeIsNotANamespaceSource(t *testing.T) {
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ store := BuildStore(context.Background(), fstest.MapFS{
+ "kustomization.yaml": {Data: []byte(
+ "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\n" +
+ "namespace: team-a\nresources:\n- app.yaml\npatches:\n- path: patch.yaml\n")},
+ "app.yaml": {Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n")},
+ }, mapper)
+
+ if store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "team-a", Name: "app"}] != nil {
+ t.Errorf("a kustomization using patches must not supply a namespace context")
+ }
+ dm := store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "app"}]
+ if dm == nil {
+ t.Fatalf("app.yaml should still be indexed by its raw namespace-less identity")
+ }
+ if dm.NamespaceSource.Kind != NamespaceNone {
+ t.Errorf("app.yaml NamespaceSource = %+v, want None (unsupported context)", dm.NamespaceSource)
+ }
+}
+
+// TestBuildStore_NamespaceSourceWithoutKustomize covers the two no-kustomize cases: an
+// explicit metadata.namespace is authoritative (Explicit), and a namespace-less
+// resource that no kustomization references stays namespace-less (None).
+func TestBuildStore_NamespaceSourceWithoutKustomize(t *testing.T) {
+ mapper := typeset.NewSnapshotRegistry(sampleClusterSnapshot())
+ store := BuildStore(context.Background(), fstest.MapFS{
+ "explicit.yaml": {Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a\n namespace: team-x\n")},
+ "loose.yaml": {Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: b\n")},
+ }, mapper)
+
+ explicit := store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Namespace: "team-x", Name: "a"}]
+ if explicit == nil || explicit.NamespaceSource.Kind != NamespaceExplicit {
+ t.Errorf("explicit-namespace document should record NamespaceExplicit, got %+v", explicit)
+ }
+ loose := store.ByManifestIdentity[manifestedit.Identity{APIVersion: "v1", Kind: "ConfigMap", Name: "b"}]
+ if loose == nil {
+ t.Fatalf("namespace-less document with no context should be indexed by its raw identity")
+ }
+ if loose.NamespaceSource.Kind != NamespaceNone {
+ t.Errorf("namespace-less document with no context = %+v, want None", loose.NamespaceSource)
+ }
+ if loose.NamespaceInheritedFromContext() {
+ t.Errorf("no-context document must not be treated as context-namespaced")
+ }
+}
+
+// TestBuildStore_DuplicateLoserResolves proves mapping is per-document: the
+// duplicate Deployment in dup.yaml still resolves to a ResourceIdentity, but it lost
+// the first-occurrence contest, so it is the IsDuplicate loser and never the
+// ByResourceIdentity winner (deploy.yaml's document holds that slot).
+func TestBuildStore_DuplicateLoserResolves(t *testing.T) {
+ store := BuildStore(context.Background(), sampleFS(), typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ wantRI := types.NewResourceIdentifier("apps", "v1", "deployments", "default", "web")
+ dupDep := store.FilesByPath["dup.yaml"].Documents[0]
+ if dupDep.Mapping != MappingFollowable || dupDep.ResourceIdentity == nil {
+ t.Fatalf(
+ "dup.yaml Deployment should still resolve, got Mapping=%q RI=%+v",
+ dupDep.Mapping,
+ dupDep.ResourceIdentity,
+ )
+ }
+ if !store.IsDuplicate(dupDep) {
+ t.Errorf("dup.yaml Deployment should be the duplicate loser")
+ }
+ if store.ByResourceIdentity[wantRI] == dupDep {
+ t.Errorf("ByResourceIdentity winner should be deploy.yaml's document, not dup.yaml's")
+ }
+}
+
+// TestBuildStore_ClusterScopedResolution covers the scope-correct ResourceIdentity:
+// a cluster-scoped resource is keyed with no namespace, and a manifest that
+// accidentally carries metadata.namespace has it dropped for indexing plus a
+// scope-mismatch diagnostic — never indexed under the wrong, namespaced key.
+func TestBuildStore_ClusterScopedResolution(t *testing.T) {
+ snap := typeset.Snapshot{
+ Generation: 1,
+ Entries: []typeset.Entry{
+ {
+ GVK: schema.GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole"},
+ GVR: schema.GroupVersionResource{
+ Group: "rbac.authorization.k8s.io",
+ Version: "v1",
+ Resource: "clusterroles",
+ },
+ Namespaced: false,
+ Allowed: true,
+ },
+ },
+ }
+ const clusterRoleYAML = "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: viewer\n"
+ const clusterRoleWithNS = "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\n" +
+ "metadata:\n name: editor\n namespace: oops\n"
+
+ store := BuildStore(context.Background(), fstest.MapFS{
+ "cr.yaml": {Data: []byte(clusterRoleYAML)},
+ "cr-ns.yaml": {Data: []byte(clusterRoleWithNS)},
+ }, typeset.NewSnapshotRegistry(snap))
+
+ // Clean cluster-scoped: indexed with an empty namespace, no scope diagnostic.
+ clean := store.FilesByPath["cr.yaml"].Documents[0]
+ wantClean := types.NewResourceIdentifier("rbac.authorization.k8s.io", "v1", "clusterroles", "", "viewer")
+ if clean.ResourceIdentity == nil || *clean.ResourceIdentity != wantClean {
+ t.Fatalf("clean ClusterRole RI = %+v, want %+v", clean.ResourceIdentity, wantClean)
+ }
+ if store.ByResourceIdentity[wantClean] != clean {
+ t.Errorf("ByResourceIdentity should key the clean ClusterRole under an empty namespace")
+ }
+
+ // Accidental namespace: dropped for indexing (so it is NOT keyed under "oops"),
+ // and exactly one scope-mismatch diagnostic is emitted, naming the file.
+ dirty := store.FilesByPath["cr-ns.yaml"].Documents[0]
+ wantDirty := types.NewResourceIdentifier("rbac.authorization.k8s.io", "v1", "clusterroles", "", "editor")
+ if dirty.ResourceIdentity == nil || *dirty.ResourceIdentity != wantDirty {
+ t.Fatalf(
+ "accidentally-namespaced ClusterRole RI = %+v, want %+v (namespace dropped)",
+ dirty.ResourceIdentity,
+ wantDirty,
+ )
+ }
+ var scopeMismatch int
+ for _, d := range store.Diagnostics {
+ if d.Reason == reasonScopeMismatch {
+ scopeMismatch++
+ if d.Path != "cr-ns.yaml" {
+ t.Errorf("scope-mismatch diagnostic on %q, want cr-ns.yaml", d.Path)
+ }
+ }
+ }
+ if scopeMismatch != 1 {
+ t.Errorf("scope-mismatch diagnostics = %d, want 1", scopeMismatch)
+ }
+}
+
+// TestBuildStore_CancelledContextIsNoSource covers a cancelled build context: every
+// document is recorded as MappingNoSource (no API source was consulted) — like
+// structure-only analysis — resolves no identity, and emits no mapping diagnostic.
+func TestBuildStore_CancelledContextIsNoSource(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ store := BuildStore(ctx, sampleFS(), typeset.NewSnapshotRegistry(sampleClusterSnapshot()))
+
+ for path, fm := range store.FilesByPath {
+ for _, dm := range fm.Documents {
+ if dm.Mapping != MappingNoSource {
+ t.Errorf("%s: Mapping = %v, want NoSource after cancel", path, dm.Mapping)
+ }
+ if dm.ResourceIdentity != nil {
+ t.Errorf("%s: cancelled lookup should resolve no identity, got %+v", path, dm.ResourceIdentity)
+ }
+ }
+ }
+ if len(store.ByResourceIdentity) != 0 {
+ t.Errorf("cancelled lookups should populate no resource index, got %d", len(store.ByResourceIdentity))
+ }
+ for _, d := range store.Diagnostics {
+ if d.Reason == reasonUnresolvedMapping {
+ t.Errorf("a cancelled (no-source) build should emit no mapping diagnostics, got %+v", d)
+ }
+ }
+}
+
+// TestBuildStore_NotFollowableDiagnoses proves the store records a not-followable
+// outcome for every kind the registry refuses (unserved, denied, ambiguous,
+// subresource-only, degraded) and emits exactly one unresolved-mapping diagnostic,
+// resolving no identity. An unready registry is the no-source case: it judges
+// nothing and emits no diagnostic.
+func TestBuildStore_NotFollowableDiagnoses(t *testing.T) {
+ const widgetYAML = "apiVersion: example.com/v1\nkind: Widget\nmetadata:\n name: w\n namespace: default\n"
+ widgetGVK := schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"}
+ widgetGVR := schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"}
+ widgetGV := schema.GroupVersion{Group: "example.com", Version: "v1"}
+ allowed := typeset.Entry{GVK: widgetGVK, GVR: widgetGVR, Namespaced: true, Allowed: true}
+
+ cases := []struct {
+ name string
+ snap typeset.Snapshot
+ want MappingOutcome
+ wantDiags int
+ }{
+ {"unserved", typeset.Snapshot{}, MappingNotFollowable, 1},
+ {
+ "denied",
+ typeset.Snapshot{
+ Entries: []typeset.Entry{{GVK: widgetGVK, GVR: widgetGVR, Namespaced: true, Allowed: false}},
+ },
+ MappingNotFollowable, 1,
+ },
+ {
+ "ambiguous",
+ typeset.Snapshot{Entries: []typeset.Entry{allowed, {
+ GVK: widgetGVK,
+ GVR: schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgetz"},
+ Namespaced: true,
+ Allowed: true,
+ }}},
+ MappingNotFollowable, 1,
+ },
+ {
+ "subresource-only",
+ typeset.Snapshot{Entries: []typeset.Entry{
+ {
+ GVK: widgetGVK,
+ GVR: schema.GroupVersionResource{
+ Group: "example.com",
+ Version: "v1",
+ Resource: "widgets/status",
+ },
+ Subresource: true,
+ Allowed: true,
+ },
+ }},
+ MappingNotFollowable, 1,
+ },
+ {
+ "degraded",
+ typeset.Snapshot{DegradedGroupVersions: []schema.GroupVersion{widgetGV}},
+ MappingNotFollowable, 1,
+ },
+ {"no-source", typeset.Snapshot{NotReady: true}, MappingNoSource, 0},
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ store := BuildStore(
+ context.Background(),
+ fstest.MapFS{"w.yaml": {Data: []byte(widgetYAML)}},
+ typeset.NewSnapshotRegistry(c.snap),
+ )
+ dm := store.FilesByPath["w.yaml"].Documents[0]
+ if dm.Mapping != c.want {
+ t.Errorf("Mapping = %v, want %v", dm.Mapping, c.want)
+ }
+ if dm.ResourceIdentity != nil {
+ t.Errorf("a refused kind should leave ResourceIdentity nil, got %+v", dm.ResourceIdentity)
+ }
+ var diags int
+ for _, d := range store.Diagnostics {
+ if d.Reason == reasonUnresolvedMapping {
+ diags++
+ }
+ }
+ if diags != c.wantDiags {
+ t.Errorf("unresolved-mapping diagnostics = %d, want %d", diags, c.wantDiags)
+ }
+ })
+ }
+}
+
+// TestFileModel_DirtyDeleted exercises the derived byte-state machine.
+func TestFileModel_DirtyDeleted(t *testing.T) {
+ cases := []struct {
+ name string
+ orig, cur []byte
+ dirty, isDeleted bool
+ }{
+ {"resident", nil, nil, false, false},
+ {"new file", nil, []byte("x"), true, false},
+ {"deleted", []byte("x"), nil, false, true},
+ {"changed", []byte("x"), []byte("y"), true, false},
+ {"unchanged", []byte("x"), []byte("x"), false, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ f := &FileModel{Original: c.orig, Current: c.cur}
+ if f.Dirty() != c.dirty || f.Deleted() != c.isDeleted {
+ t.Errorf("Dirty=%v Deleted=%v, want %v/%v", f.Dirty(), f.Deleted(), c.dirty, c.isDeleted)
+ }
+ })
+ }
+}
+
+func keysOf(m map[string]*FileModel) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/README.md b/internal/manifestanalyzer/testdata/contextual-namespace/README.md
new file mode 100644
index 00000000..49eeaefe
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/README.md
@@ -0,0 +1,17 @@
+# Contextual-namespace example folders
+
+Small, real folder layouts that pin the supported/unsupported boundary for
+contextual (kustomize-inherited) namespace inference. Each folder is one scenario;
+`contextual_namespace_corpus_test.go` builds the manifest store over each and
+asserts the outcome.
+
+- `supported/*` — the store inherits the namespace from the kustomization that
+ references the document through its `resources` graph (`NamespaceSource =
+ Kustomize`), or keeps an explicit `metadata.namespace` as-is (`Explicit`).
+- `unsupported/*` — the store refuses to infer a namespace (`NamespaceSource =
+ None`); the ambiguous case also emits an `ambiguous-namespace` diagnostic. These
+ are the inputs the pending `RepositoryValid` refusal will fail the GitTarget on.
+
+See `docs/design/manifest/contextual-namespace-and-kustomize-folder-editing.md`
+(the "Supported and unsupported example folders" matrix). Add a new folder here
+whenever a new "can we support X?" question comes up.
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/explicit-namespace/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/explicit-namespace/cm.yaml
new file mode 100644
index 00000000..e778a500
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/explicit-namespace/cm.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+ namespace: explicit-ns
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/explicit-namespace/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/explicit-namespace/kustomization.yaml
new file mode 100644
index 00000000..a2577dd1
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/explicit-namespace/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- cm.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/a.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/a.yaml
new file mode 100644
index 00000000..96455bee
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/a.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: a
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/b.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/b.yaml
new file mode 100644
index 00000000..617e9adf
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/b.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: b
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/kustomization.yaml
new file mode 100644
index 00000000..ed1a21a0
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/flat-namespace/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- a.yaml
+- b.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/multi-doc/bundle.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/multi-doc/bundle.yaml
new file mode 100644
index 00000000..2482256d
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/multi-doc/bundle.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: one
+data:
+ k: v
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: two
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/multi-doc/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/multi-doc/kustomization.yaml
new file mode 100644
index 00000000..8047fa94
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/multi-doc/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- bundle.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/base/child.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/base/child.yaml
new file mode 100644
index 00000000..47ff8dda
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/base/child.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: child
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/base/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/base/kustomization.yaml
new file mode 100644
index 00000000..96175e41
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/base/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- child.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/kustomization.yaml
new file mode 100644
index 00000000..1750297e
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- root.yaml
+- base
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/root.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/root.yaml
new file mode 100644
index 00000000..8f89e44b
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/supported/nested-base/root.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: root
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/kustomization.yaml
new file mode 100644
index 00000000..75ffb6d1
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: team-a
+resources:
+- shared.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/other/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/other/kustomization.yaml
new file mode 100644
index 00000000..1e4d6919
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/other/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: team-b
+resources:
+- ../shared.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/shared.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/shared.yaml
new file mode 100644
index 00000000..49da8f31
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/ambiguous-two-roots/shared.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: shared
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/components/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/components/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/components/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/components/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/components/kustomization.yaml
new file mode 100644
index 00000000..d50e035d
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/components/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- cm.yaml
+components:
+- ./comp
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/generators/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/generators/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/generators/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/generators/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/generators/kustomization.yaml
new file mode 100644
index 00000000..3d90b699
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/generators/kustomization.yaml
@@ -0,0 +1,9 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- cm.yaml
+configMapGenerator:
+- name: gen
+ literals:
+ - a=b
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/helm/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/helm/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/helm/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/helm/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/helm/kustomization.yaml
new file mode 100644
index 00000000..b606c9e1
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/helm/kustomization.yaml
@@ -0,0 +1,9 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- cm.yaml
+helmCharts:
+- name: foo
+ repo: https://example.com/charts
+ version: "1.0.0"
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/name-prefix/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/name-prefix/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/name-prefix/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/name-prefix/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/name-prefix/kustomization.yaml
new file mode 100644
index 00000000..f3789874
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/name-prefix/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+namePrefix: dev-
+resources:
+- cm.yaml
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/no-context/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/no-context/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/no-context/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/patches/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/patches/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/patches/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/patches/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/patches/kustomization.yaml
new file mode 100644
index 00000000..df83e1c3
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/patches/kustomization.yaml
@@ -0,0 +1,13 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- cm.yaml
+patches:
+- patch: |-
+ - op: add
+ path: /metadata/labels/x
+ value: y
+ target:
+ kind: ConfigMap
+ name: cm
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/remote-base/cm.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/remote-base/cm.yaml
new file mode 100644
index 00000000..152104f8
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/remote-base/cm.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: cm
+data:
+ k: v
diff --git a/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/remote-base/kustomization.yaml b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/remote-base/kustomization.yaml
new file mode 100644
index 00000000..ad124f70
--- /dev/null
+++ b/internal/manifestanalyzer/testdata/contextual-namespace/unsupported/remote-base/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: app
+resources:
+- github.com/org/repo//base?ref=v1
+- cm.yaml
diff --git a/internal/manifestreport/editinplace_test.go b/internal/manifestreport/editinplace_test.go
new file mode 100644
index 00000000..2dba6967
--- /dev/null
+++ b/internal/manifestreport/editinplace_test.go
@@ -0,0 +1,99 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestreport
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// A nil desired object is not editable: EditInPlace returns not-ok rather than
+// dereferencing it.
+func TestEditInPlace_NilObjectReturnsNotOk(t *testing.T) {
+ existing := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n namespace: default\n")
+ got, ok := EditInPlace("v1/configmaps/default/app.yaml", existing, nil)
+ assert.False(t, ok)
+ assert.Nil(t, got)
+}
+
+// EditInPlace edits an existing hand-authored document so it matches the desired
+// object, preserving the comment and layout of everything it does not change.
+func TestEditInPlace_PreservesCommentsOnChange(t *testing.T) {
+ existing := []byte(`apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ # operator note: keep this across edits
+ color: blue
+`)
+ desired := configMap("app", "green")
+
+ got, ok := EditInPlace("v1/configmaps/default/app.yaml", existing, desired)
+ require.True(t, ok)
+ assert.Contains(t, string(got), "# operator note: keep this across edits",
+ "the hand-authored comment must survive the edit")
+ assert.Contains(t, string(got), "color: green", "the changed field is updated")
+ assert.NotContains(t, string(got), "color: blue")
+}
+
+// A no-op edit returns the file byte-for-byte, comment intact.
+func TestEditInPlace_NoOpPreservesBytes(t *testing.T) {
+ existing := []byte(`apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: app
+ namespace: default
+data:
+ # keep me
+ color: blue
+`)
+ got, ok := EditInPlace("v1/configmaps/default/app.yaml", existing, configMap("app", "blue"))
+ require.True(t, ok)
+ assert.Equal(t, string(existing), string(got), "a no-op edit preserves the file exactly")
+}
+
+// When the file has no document for the desired identity, EditInPlace declines so
+// the caller falls back to writing canonical content.
+func TestEditInPlace_WrongIdentityDeclines(t *testing.T) {
+ existing := []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: other\n namespace: default\n" +
+ "data:\n color: blue\n")
+ _, ok := EditInPlace("v1/configmaps/default/app.yaml", existing, configMap("app", "green"))
+ assert.False(t, ok, "no document for the identity: decline and fall back")
+}
+
+// An encrypted (SOPS) document is never edited in place: EditInPlace declines so
+// the secret goes through the re-encrypt writer instead.
+func TestEditInPlace_EncryptedDeclines(t *testing.T) {
+ existing := []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: db\n namespace: default\n" +
+ "data:\n password: ENC[AES256_GCM,data:abc]\nsops:\n age: []\n")
+ desired := &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1", "kind": "Secret",
+ "metadata": map[string]interface{}{"name": "db", "namespace": "default"},
+ "data": map[string]interface{}{"password": "hunter2"},
+ }}
+ got, ok := EditInPlace("v1/secrets/default/db.sops.yaml", existing, desired)
+ assert.False(t, ok, "encrypted documents must not be edited in place")
+ assert.Nil(t, got)
+}
diff --git a/internal/manifestreport/noneditable_desired_bug_test.go b/internal/manifestreport/noneditable_desired_bug_test.go
new file mode 100644
index 00000000..a851eebc
--- /dev/null
+++ b/internal/manifestreport/noneditable_desired_bug_test.go
@@ -0,0 +1,70 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestreport
+
+// This test guards a BuildReport defect fixed on this branch. It started red (as
+// the executable spec for the fix) and now pins the corrected behavior.
+//
+// Medium: BuildReport must not misclassify a desired resource whose existing
+// Git document is non-editable. Inventory.Location only contains editable
+// records (non-editable ones are skipped while indexing), so a desired object
+// whose only Git copy is an anchor/alias/etc. used to be reported as
+// ActionCreate (no location found) AND, separately, the same identity's
+// non-editable record as ActionSkip — two contradictory verdicts. It must be a
+// single skip.
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// A resource that exists in Git as a non-editable document (here an anchor/alias)
+// AND still exists in the cluster must produce exactly one verdict — a skip —
+// never a contradictory ActionCreate + ActionSkip pair.
+func TestBuildReport_NonEditableDesiredIsNotDoubleClassified(t *testing.T) {
+ anchor := manifestedit.FileContent{Path: "anchor.yaml", Content: []byte(
+ "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: anc\n namespace: default\n" +
+ "data:\n a: &x 1\n b: *x\n")}
+
+ // The cluster still has this exact resource (same identity as the Git doc).
+ desired := configMap("anc", "blue")
+
+ report, _ := BuildReport(
+ []manifestedit.FileContent{anchor},
+ []*unstructured.Unstructured{desired},
+ )
+
+ var actions []Action
+ for _, e := range report.Entries {
+ if e.Identity.Name == "anc" {
+ actions = append(actions, e.Action)
+ }
+ }
+
+ require.Lenf(t, actions, 1,
+ "one identity present in both Git and cluster must yield one verdict, got %v", actions)
+ assert.Equal(t, ActionSkip, actions[0],
+ "a non-editable Git document is a skip, never a create")
+}
diff --git a/internal/manifestreport/render.go b/internal/manifestreport/render.go
new file mode 100644
index 00000000..b71ac486
--- /dev/null
+++ b/internal/manifestreport/render.go
@@ -0,0 +1,119 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+Package manifestreport is the integration layer that drives the cluster-free
+manifestedit library against a real repository and cluster state. It supplies the
+two pieces of policy manifestedit deliberately refuses to own — the Git
+projection and the canonical renderer — and provides a read-only reconcile that
+reports what it would add, remove, or update.
+
+It is the seam described in step 6 of
+docs/design/manifest/manifestedit-abstraction-plan.md and detailed in
+docs/design/manifest/manifestedit-integration-readonly-reconcile.md. It depends on
+internal/sanitize (the projection/renderer) and internal/git/manifestedit (the
+mechanism); manifestedit itself stays free of both.
+*/
+package manifestreport
+
+import (
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/sanitize"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// Project computes the Git projection of a live API object: the clean desired
+// state the reverser would store. This is the "what does clean mean" policy that
+// manifestedit does not own; the integration layer supplies it, and it is exactly
+// the projection the live writer path uses (internal/sanitize).
+func Project(obj *unstructured.Unstructured) *unstructured.Unstructured {
+ return sanitize.Sanitize(obj)
+}
+
+// Render is the house canonical renderer injected into manifestedit for
+// whole-document replacement and new files. It is the same renderer the Git
+// writer uses (sanitize.MarshalToOrderedYAML, see
+// internal/git/content_writer.go buildContentForWrite), so whole-replace and
+// new-file output cannot drift from committed content. The object passed in is
+// the already-projected desired state.
+func Render(obj *unstructured.Unstructured) ([]byte, error) {
+ return sanitize.MarshalToOrderedYAML(obj)
+}
+
+// EditOptions returns the production manifestedit options:
+// - Render: the house renderer above (so canonical output never drifts);
+// - ListMatch: zero value = index-based, deliberately not a global keyed
+// strategy — keyed matching needs a path/GVK-aware policy that does not exist
+// yet, and a blanket KeyField would change every named list's behavior;
+// - Owns: nil = whole-object truth (API-first), the only supported policy
+// (docs/design/manifest/manifestedit-field-ownership-spike.md).
+func EditOptions() manifestedit.EditOptions {
+ return manifestedit.EditOptions{Render: Render}
+}
+
+// identityOf reads the manifest identity from a live API object, matching how
+// manifestedit derives identity from YAML.
+func identityOf(obj *unstructured.Unstructured) manifestedit.Identity {
+ return manifestedit.Identity{
+ APIVersion: obj.GetAPIVersion(),
+ Kind: obj.GetKind(),
+ Namespace: obj.GetNamespace(),
+ Name: obj.GetName(),
+ }
+}
+
+// EditInPlace produces a minimal, formatting-preserving edit of an existing
+// single-file manifest so its document for obj matches the desired projection,
+// instead of rewriting the file wholesale. It finds the document for obj's
+// identity, patches only what changed (preserving comments, key order, and block
+// scalars of everything else), and returns the full new file content.
+//
+// ok is false when there is no editable document for obj in the file — wrong
+// identity, an encrypted (SOPS) document, a disallowed construct, or snapshot
+// drift — so the caller must fall back to writing canonical content. The returned
+// content is never partial: when ok is true it is the whole file.
+//
+// This is the seam that brings the manifestedit comparison into the live writer:
+// the writer hands EditInPlace the bytes already on disk and the desired object,
+// and gets back a faithful in-place edit. It uses Apply (not just Decide), so it
+// is a real edit — but a read-only-safe one: it only transforms the bytes passed
+// in and never touches Git itself.
+func EditInPlace(path string, existing []byte, obj *unstructured.Unstructured) ([]byte, bool) {
+ if obj == nil {
+ return nil, false
+ }
+ inv, _ := manifestedit.IndexFile(path, existing)
+ loc, found := inv.Location(identityOf(obj))
+ if !found {
+ return nil, false
+ }
+
+ doc, _ := manifestedit.NewDocumentAt(path, existing, loc.DocumentIndex)
+ c := manifestedit.Comparison{Git: doc, Desired: Project(obj), Options: EditOptions()}
+ res, _ := manifestedit.Apply(c, manifestedit.Decide(c))
+
+ switch res.Mode {
+ case manifestedit.EditNoChange, manifestedit.EditPatched, manifestedit.EditWholeReplace:
+ return res.Content, true
+ case manifestedit.EditSkipped, manifestedit.EditDeleted:
+ return nil, false
+ default:
+ return nil, false
+ }
+}
diff --git a/internal/manifestreport/render_contract_test.go b/internal/manifestreport/render_contract_test.go
new file mode 100644
index 00000000..d58735a8
--- /dev/null
+++ b/internal/manifestreport/render_contract_test.go
@@ -0,0 +1,87 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestreport
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+ "github.com/ConfigButler/gitops-reverser/internal/sanitize"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// dirtyConfigMap is an API object carrying operational noise the projection
+// strips: a status, a server-set resourceVersion, and an operational annotation.
+func dirtyConfigMap() *unstructured.Unstructured {
+ return &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{
+ "name": "app",
+ "namespace": "default",
+ "resourceVersion": "12345",
+ "annotations": map[string]interface{}{
+ "kubectl.kubernetes.io/last-applied-configuration": "{}",
+ "team": "payments",
+ },
+ },
+ "data": map[string]interface{}{"color": "blue"},
+ "status": map[string]interface{}{"observedGeneration": int64(2)},
+ }}
+}
+
+// The integration renderer must be byte-identical to the renderer the Git writer
+// uses (internal/git/content_writer.go buildContentForWrite calls
+// sanitize.MarshalToOrderedYAML on an already-sanitized object). If these ever
+// diverge, whole-replace/new-file output would no longer match committed content.
+func TestRender_MatchesWriterHouseFormat(t *testing.T) {
+ raw := dirtyConfigMap()
+
+ // What the writer would commit: MarshalToOrderedYAML on the sanitized object.
+ want, err := sanitize.MarshalToOrderedYAML(sanitize.Sanitize(raw))
+ require.NoError(t, err)
+
+ got, err := Render(Project(raw))
+ require.NoError(t, err)
+
+ assert.Equal(t, string(want), string(got), "the integration renderer must match the Git writer")
+}
+
+// Whole-document replacement through manifestedit, using the injected production
+// options, must produce exactly the house format — proving new-file and
+// fallback output stay in lockstep with the writer.
+func TestRender_WholeReplaceMatchesHouseFormat(t *testing.T) {
+ raw := dirtyConfigMap()
+ want, err := sanitize.MarshalToOrderedYAML(sanitize.Sanitize(raw))
+ require.NoError(t, err)
+
+ // A top-level sequence is not a KRM object, forcing manifestedit to fall back
+ // to a canonical whole-document render via the injected Render.
+ res, diags := manifestedit.PatchDocument([]byte("- a\n- b\n"), 0, Project(raw), EditOptions())
+ require.Equal(t, manifestedit.EditWholeReplace, res.Mode)
+ require.NotEmpty(t, diags)
+
+ // The file had a single document, so its whole content is that rendered body.
+ assert.Equal(t, string(want), string(res.Content),
+ "whole-replace output must be the house canonical format")
+}
diff --git a/internal/manifestreport/report.go b/internal/manifestreport/report.go
new file mode 100644
index 00000000..bf615be2
--- /dev/null
+++ b/internal/manifestreport/report.go
@@ -0,0 +1,241 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestreport
+
+import (
+ "sort"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// Action is what the reconcile would do to bring Git in line with the cluster.
+// The report is read-only: these are intents, never executed here.
+type Action string
+
+const (
+ // ActionNoChange means Git already matches the desired projection.
+ ActionNoChange Action = "no-change"
+ // ActionUpdate means an existing document would be edited (patch or whole-replace).
+ ActionUpdate Action = "update"
+ // ActionCreate means a desired resource has no document in Git yet. Placement
+ // is an upstream decision; this report only flags that a file would be created.
+ ActionCreate Action = "create"
+ // ActionDelete means a document exists in Git for a resource the cluster no
+ // longer has (or a duplicate loser). A prune candidate — the prune trigger
+ // lives in the reconcile layer, not here.
+ ActionDelete Action = "delete"
+ // ActionSkip means the document exists but cannot be edited in place
+ // (encrypted, disallowed construct, non-KRM) — reported, never silently acted on.
+ ActionSkip Action = "skip"
+)
+
+// Entry is one resource's verdict in the report.
+type Entry struct {
+ Identity manifestedit.Identity
+ Action Action
+ // Location is the Git document this verdict concerns. It is the zero value for
+ // ActionCreate, which has no existing location.
+ Location manifestedit.Location
+ // Reason is the human-readable explanation, carried straight from the Decide
+ // reason for update/no-op/skip verdicts.
+ Reason string
+}
+
+// Report is the read-only verdict over a (Git folder, cluster state) pair.
+type Report struct {
+ Entries []Entry
+}
+
+// Counts returns the number of entries per action, for a bounded summary.
+func (r Report) Counts() map[Action]int {
+ out := make(map[Action]int)
+ for _, e := range r.Entries {
+ out[e.Action]++
+ }
+ return out
+}
+
+// BuildReport is the read-only, inventory-driven reconcile: it indexes the Git
+// folder, compares it to the desired cluster state, and reports what it would
+// add, remove, or update — without mutating Git or touching the writer. It uses
+// manifestedit.Decide only (never Apply), so it cannot change anything; this is
+// the trust-building step before the comparison is wired into the commit path.
+//
+// The trust model is a single repository transaction: files must be the content
+// of one checked-out commit/worktree, and the resulting verdicts are valid only
+// for that snapshot. See
+// docs/design/manifest/manifestedit-integration-readonly-reconcile.md.
+func BuildReport(
+ files []manifestedit.FileContent,
+ desired []*unstructured.Unstructured,
+) (Report, []manifestedit.Diagnostic) {
+ inv, diags := manifestedit.IndexFiles(files)
+ contentByPath := indexContent(files)
+ opts := EditOptions()
+
+ var entries []Entry
+ desiredSeen := make(map[manifestedit.Identity]bool, len(desired))
+
+ // Desired side: create / update / no-op / skip for every cluster object.
+ for _, obj := range desired {
+ if obj == nil {
+ continue
+ }
+ id := identityOf(obj)
+ desiredSeen[id] = true
+
+ loc, ok := inv.Location(id)
+ if !ok {
+ // Git may still hold this identity as a non-editable document (encrypted,
+ // disallowed construct, …). That is a skip — surfaced exactly once by
+ // gitOnlyEntries below — not a create. Only a truly absent resource is a
+ // create, so don't double-classify it here.
+ if inventoryHasNonEditable(inv, id) {
+ continue
+ }
+ entries = append(entries, Entry{
+ Identity: id,
+ Action: ActionCreate,
+ Reason: "no existing document in Git; placement is an upstream decision",
+ })
+ continue
+ }
+
+ doc, _ := manifestedit.NewDocumentAt(loc.Path, contentByPath[loc.Path], loc.DocumentIndex)
+ c := manifestedit.Comparison{Git: doc, Desired: Project(obj), Options: opts}
+ d := manifestedit.Decide(c)
+ entries = append(entries, Entry{
+ Identity: id,
+ Action: actionFromDecision(d.Action),
+ Location: loc,
+ Reason: d.Reason,
+ })
+ }
+
+ entries = append(entries, gitOnlyEntries(inv, desiredSeen)...)
+
+ sortEntries(entries)
+ return Report{Entries: entries}, diags
+}
+
+// gitOnlyEntries flags documents in Git with no desired counterpart: prune
+// candidates for authoritative records the cluster lacks, every duplicate loser,
+// and a skip for records that are not editable at all.
+func gitOnlyEntries(inv manifestedit.Inventory, desiredSeen map[manifestedit.Identity]bool) []Entry {
+ var entries []Entry
+ for _, rec := range inv.Records {
+ if !rec.Editable {
+ entries = append(entries, Entry{
+ Identity: rec.Identity,
+ Action: ActionSkip,
+ Location: rec.Location,
+ Reason: nonEditableReason(rec),
+ })
+ continue
+ }
+ // Only the authoritative location is a content delete here; a duplicate
+ // loser is handled below regardless of whether the cluster still has it.
+ loc, ok := inv.Location(rec.Identity)
+ if ok && loc == rec.Location && !desiredSeen[rec.Identity] {
+ entries = append(entries, Entry{
+ Identity: rec.Identity,
+ Action: ActionDelete,
+ Location: rec.Location,
+ Reason: "present in Git, absent from the cluster: prune candidate",
+ })
+ }
+ }
+ for _, dup := range inv.Duplicates() {
+ entries = append(entries, Entry{
+ Identity: dup.Identity,
+ Action: ActionDelete,
+ Location: dup.Location,
+ Reason: "duplicate of the authoritative copy: prune candidate",
+ })
+ }
+ return entries
+}
+
+// actionFromDecision maps a manifestedit decision intent to a report action.
+func actionFromDecision(a manifestedit.DecisionAction) Action {
+ switch a {
+ case manifestedit.ActionNoChange:
+ return ActionNoChange
+ case manifestedit.ActionPatch, manifestedit.ActionReplace:
+ return ActionUpdate
+ case manifestedit.ActionDelete:
+ return ActionDelete
+ case manifestedit.ActionSkip:
+ return ActionSkip
+ default:
+ return ActionSkip
+ }
+}
+
+// inventoryHasNonEditable reports whether Git holds a non-editable document for
+// the identity. The desired side uses this to defer to the skip gitOnlyEntries
+// emits, instead of reporting a contradictory create for the same identity.
+func inventoryHasNonEditable(inv manifestedit.Inventory, id manifestedit.Identity) bool {
+ for _, rec := range inv.Records {
+ if rec.Identity == id && !rec.Editable {
+ return true
+ }
+ }
+ return false
+}
+
+// nonEditableReason returns the recorded reason for a non-editable record, or a
+// generic fallback.
+func nonEditableReason(rec manifestedit.DocumentRecord) string {
+ if rec.Reason != "" {
+ return "not editable: " + rec.Reason
+ }
+ return "not editable"
+}
+
+// indexContent maps each file path to its raw bytes for document construction.
+func indexContent(files []manifestedit.FileContent) map[string][]byte {
+ out := make(map[string][]byte, len(files))
+ for _, f := range files {
+ out[f.Path] = f.Content
+ }
+ return out
+}
+
+// sortEntries orders the report deterministically by path, then document index,
+// then identity, so output is stable regardless of map iteration order.
+func sortEntries(entries []Entry) {
+ sort.SliceStable(entries, func(i, j int) bool {
+ a, b := entries[i], entries[j]
+ if a.Location.Path != b.Location.Path {
+ return a.Location.Path < b.Location.Path
+ }
+ if a.Location.DocumentIndex != b.Location.DocumentIndex {
+ return a.Location.DocumentIndex < b.Location.DocumentIndex
+ }
+ return identityString(a.Identity) < identityString(b.Identity)
+ })
+}
+
+// identityString renders an identity for stable sorting and diagnostics.
+func identityString(id manifestedit.Identity) string {
+ return id.APIVersion + "/" + id.Kind + "/" + id.Namespace + "/" + id.Name
+}
diff --git a/internal/manifestreport/report_test.go b/internal/manifestreport/report_test.go
new file mode 100644
index 00000000..227d2c5f
--- /dev/null
+++ b/internal/manifestreport/report_test.go
@@ -0,0 +1,206 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package manifestreport
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// A nil object in the desired set is skipped rather than panicking the reconcile.
+func TestBuildReport_NilDesiredObjectSkipped(t *testing.T) {
+ rep, _ := BuildReport(nil, []*unstructured.Unstructured{nil})
+ assert.Empty(t, rep.Entries, "a nil desired object must be skipped, not classified")
+}
+
+// configMap builds a desired API object for a ConfigMap with one data key.
+func configMap(name, color string) *unstructured.Unstructured {
+ return &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{"name": name, "namespace": "default"},
+ "data": map[string]interface{}{"color": color},
+ }}
+}
+
+// houseFile renders an object to the exact bytes the Git writer would commit, so
+// a "no-change" verdict is genuinely byte-faithful (Git was written this way).
+func houseFile(t *testing.T, path string, obj *unstructured.Unstructured) manifestedit.FileContent {
+ t.Helper()
+ content, err := Render(Project(obj))
+ require.NoError(t, err)
+ return manifestedit.FileContent{Path: path, Content: content}
+}
+
+// entryFor finds the report entry for an identity.
+func entryFor(t *testing.T, r Report, name string) Entry {
+ t.Helper()
+ for _, e := range r.Entries {
+ if e.Identity.Name == name {
+ return e
+ }
+ }
+ t.Fatalf("no report entry for %q", name)
+ return Entry{}
+}
+
+// The read-only reconcile classifies every cell of the comparison: a resource
+// unchanged in Git is a no-op, a changed one is an update, a cluster-only one is
+// a create, and a Git-only one is a prune candidate.
+func TestBuildReport_ClassifiesEveryCell(t *testing.T) {
+ files := []manifestedit.FileContent{
+ houseFile(t, "same.yaml", configMap("same", "blue")), // present in both, identical
+ houseFile(t, "changed.yaml", configMap("changed", "blue")), // present in both, different
+ houseFile(t, "gone.yaml", configMap("gone", "blue")), // only in Git -> delete
+ }
+ desired := []*unstructured.Unstructured{
+ configMap("same", "blue"), // matches Git -> no-change
+ configMap("changed", "green"), // differs -> update
+ configMap("new", "red"), // not in Git -> create
+ }
+
+ report, diags := BuildReport(files, desired)
+ assert.Empty(t, diagErrors(diags), "a clean corpus produces no error diagnostics")
+
+ assert.Equal(t, ActionNoChange, entryFor(t, report, "same").Action)
+ assert.Equal(t, ActionUpdate, entryFor(t, report, "changed").Action)
+ assert.Equal(t, ActionCreate, entryFor(t, report, "new").Action)
+ assert.Equal(t, ActionDelete, entryFor(t, report, "gone").Action)
+
+ counts := report.Counts()
+ assert.Equal(t, 1, counts[ActionNoChange])
+ assert.Equal(t, 1, counts[ActionUpdate])
+ assert.Equal(t, 1, counts[ActionCreate])
+ assert.Equal(t, 1, counts[ActionDelete])
+}
+
+// The report is read-only: building it must not mutate the input file bytes.
+func TestBuildReport_DoesNotMutateInput(t *testing.T) {
+ original := houseFile(t, "cm.yaml", configMap("cm", "blue"))
+ snapshot := append([]byte(nil), original.Content...)
+
+ _, _ = BuildReport([]manifestedit.FileContent{original}, []*unstructured.Unstructured{configMap("cm", "green")})
+
+ assert.Equal(t, string(snapshot), string(original.Content), "BuildReport must not change the input bytes")
+}
+
+// An encrypted document in the cluster's desired set is reported as a skip — it
+// must go through the re-encrypt writer, never an in-place patch.
+func TestBuildReport_EncryptedDocumentSkipped(t *testing.T) {
+ enc := manifestedit.FileContent{Path: "secret.sops.yaml", Content: []byte(
+ "apiVersion: v1\nkind: Secret\nmetadata:\n name: db\n namespace: default\n" +
+ "data:\n password: ENC[AES256_GCM,data:abc]\nsops:\n age: []\n")}
+
+ desired := &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1", "kind": "Secret",
+ "metadata": map[string]interface{}{"name": "db", "namespace": "default"},
+ "data": map[string]interface{}{"password": "hunter2"},
+ }}
+
+ report, _ := BuildReport([]manifestedit.FileContent{enc}, []*unstructured.Unstructured{desired})
+ entry := entryFor(t, report, "db")
+ assert.Equal(t, ActionSkip, entry.Action)
+ assert.Contains(t, entry.Reason, "encrypted")
+}
+
+// A duplicate-identity document (same resource in two files) is reported as a
+// prune candidate for the loser, even when the resource still exists.
+func TestBuildReport_DuplicateIsPruneCandidate(t *testing.T) {
+ cm := configMap("dup", "blue")
+ files := []manifestedit.FileContent{
+ houseFile(t, "apps/dup.yaml", cm), // winner (lexicographically first)
+ houseFile(t, "overlays/dup.yaml", cm), // duplicate loser
+ }
+
+ report, _ := BuildReport(files, []*unstructured.Unstructured{cm})
+
+ var deletes []Entry
+ for _, e := range report.Entries {
+ if e.Action == ActionDelete {
+ deletes = append(deletes, e)
+ }
+ }
+ require.Len(t, deletes, 1, "exactly the duplicate loser is a prune candidate")
+ assert.Equal(t, "overlays/dup.yaml", deletes[0].Location.Path)
+}
+
+// diagErrors filters diagnostics down to errors for assertions.
+func diagErrors(diags []manifestedit.Diagnostic) []manifestedit.Diagnostic {
+ var out []manifestedit.Diagnostic
+ for _, d := range diags {
+ if d.Level == manifestedit.DiagError {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
+// A non-editable Git document (here an anchor) is reported as skip, with the
+// inventory's reason, and never as a delete or edit.
+func TestBuildReport_NonEditableDocumentSkipped(t *testing.T) {
+ anchor := manifestedit.FileContent{Path: "anchor.yaml", Content: []byte(
+ "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: anc\n namespace: default\n" +
+ "data:\n a: &x 1\n b: *x\n")}
+
+ report, _ := BuildReport([]manifestedit.FileContent{anchor}, nil)
+ entry := entryFor(t, report, "anc")
+ assert.Equal(t, ActionSkip, entry.Action)
+ assert.Contains(t, entry.Reason, "not editable")
+}
+
+// Multiple cluster-only resources all become creates; the report stays
+// deterministically ordered even though creates share the zero Location.
+func TestBuildReport_MultipleCreatesAreOrdered(t *testing.T) {
+ report, _ := BuildReport(nil, []*unstructured.Unstructured{
+ configMap("zebra", "x"),
+ configMap("alpha", "y"),
+ })
+ require.Len(t, report.Entries, 2)
+ assert.Equal(t, ActionCreate, report.Entries[0].Action)
+ assert.Equal(t, "alpha", report.Entries[0].Identity.Name, "creates are ordered by identity")
+ assert.Equal(t, "zebra", report.Entries[1].Identity.Name)
+}
+
+func TestActionFromDecision_Mapping(t *testing.T) {
+ assert.Equal(t, ActionNoChange, actionFromDecision(manifestedit.ActionNoChange))
+ assert.Equal(t, ActionUpdate, actionFromDecision(manifestedit.ActionPatch))
+ assert.Equal(t, ActionUpdate, actionFromDecision(manifestedit.ActionReplace))
+ assert.Equal(t, ActionDelete, actionFromDecision(manifestedit.ActionDelete))
+ assert.Equal(t, ActionSkip, actionFromDecision(manifestedit.ActionSkip))
+ assert.Equal(t, ActionSkip, actionFromDecision(manifestedit.DecisionAction("bogus")),
+ "unknown is conservatively a skip")
+}
+
+func TestNonEditableReason_Fallback(t *testing.T) {
+ assert.Equal(t, "not editable", nonEditableReason(manifestedit.DocumentRecord{}))
+ assert.Equal(t, "not editable: anchor", nonEditableReason(manifestedit.DocumentRecord{Reason: "anchor"}))
+}
+
+func TestEditOptions_ProductionDefaults(t *testing.T) {
+ opts := EditOptions()
+ assert.NotNil(t, opts.Render, "the house renderer is injected")
+ assert.Nil(t, opts.Owns, "whole-object truth: Owns must be nil in production")
+ assert.Empty(t, opts.ListMatch.KeyField, "list matching stays index-based")
+}
diff --git a/internal/queue/redis_audit_consumer.go b/internal/queue/redis_audit_consumer.go
index c3023eaf..5dc4740b 100644
--- a/internal/queue/redis_audit_consumer.go
+++ b/internal/queue/redis_audit_consumer.go
@@ -42,6 +42,7 @@ import (
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
"github.com/ConfigButler/gitops-reverser/internal/auditutil"
"github.com/ConfigButler/gitops-reverser/internal/git"
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
"github.com/ConfigButler/gitops-reverser/internal/rulestore"
"github.com/ConfigButler/gitops-reverser/internal/sanitize"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
@@ -421,6 +422,17 @@ func (c *AuditConsumer) routeAuditEvent(
return nil
}
+ id := itypes.NewResourceIdentifier(apiGroup, apiVersion, resourcePlural, namespace, name)
+ userInfo := resolveUserInfo(auditEvent)
+
+ // A subresource event becomes a parent-manifest field patch routed against the SAME
+ // parent-GVR rules — never an object write, because the body is a subresource (an
+ // autoscaling/v1 Scale), not the parent object. Only /scale is translated; every
+ // other subresource is dropped inside routeScaleFieldPatch.
+ if ref.Subresource != "" {
+ return c.routeScaleFieldPatch(ctx, log, auditEvent, op, id, userInfo, gvr, wrRules, cwrRules)
+ }
+
fullAPIVersion := apiVersion
if apiGroup != "" {
fullAPIVersion = apiGroup + "/" + apiVersion
@@ -437,9 +449,6 @@ func (c *AuditConsumer) routeAuditEvent(
return fmt.Errorf("extracting object for %s/%s: %w", namespace, name, err)
}
- id := itypes.NewResourceIdentifier(apiGroup, apiVersion, resourcePlural, namespace, name)
- userInfo := resolveUserInfo(auditEvent)
-
routed := c.routeToMatchedRules(ctx, log, sanitized, id, op, userInfo, wrRules, cwrRules)
if routed > 0 {
@@ -549,8 +558,8 @@ func (c *AuditConsumer) handleExtractObjectError(
}
}
-// routeToMatchedRules dispatches git.Events to all matched WatchRule and ClusterWatchRule targets.
-// It returns the number of successfully routed events.
+// routeToMatchedRules dispatches object-bearing git.Events to all matched WatchRule and
+// ClusterWatchRule targets. It returns the number of successfully routed events.
func (c *AuditConsumer) routeToMatchedRules(
ctx context.Context,
log logr.Logger,
@@ -560,48 +569,98 @@ func (c *AuditConsumer) routeToMatchedRules(
userInfo git.UserInfo,
wrRules []rulestore.CompiledRule,
cwrRules []rulestore.CompiledClusterRule,
+) int {
+ return c.routeEvents(ctx, log, wrRules, cwrRules,
+ func(path, gitTargetRef, gitTargetNamespace string) git.Event {
+ return buildGitEvent(sanitized, id, op, userInfo, path, gitTargetRef, gitTargetNamespace)
+ })
+}
+
+// routeScaleFieldPatch translates a built-in /scale audit event into a parent-manifest
+// replicas field patch and routes it against the matched parent-GVR rules. Only the
+// scale subresource is supported, and only for a built-in scalable parent whose replica
+// path is known: every other subresource, and every scale on a CRD or aggregated API, is
+// dropped with a scale-specific metric and never guessed. There is no live-parent GET and
+// no field-presence gate — the accepted value comes straight from the standardized Scale
+// response. See docs/design/manifest/version2/subresource-scope-reduction.md.
+func (c *AuditConsumer) routeScaleFieldPatch(
+ ctx context.Context,
+ log logr.Logger,
+ auditEvent auditv1.Event,
+ op configv1alpha1.OperationType,
+ id itypes.ResourceIdentifier,
+ userInfo git.UserInfo,
+ gvr pipelineGVR,
+ wrRules []rulestore.CompiledRule,
+ cwrRules []rulestore.CompiledClusterRule,
+) error {
+ assignments, dropOutcome, ok := translateScaleToAssignments(auditEvent, gvr.group, gvr.resource)
+ if !ok {
+ recordPipelineEvent(ctx, gvr, auditEvent.Verb, dropOutcome)
+ log.V(1).Info("Dropping subresource: not a translatable built-in scale event",
+ "resource", id.Resource, "subresource", auditEvent.ObjectRef.Subresource,
+ "verb", auditEvent.Verb, "outcome", dropOutcome)
+ return nil
+ }
+
+ source := id.Resource + "/" + auditEvent.ObjectRef.Subresource
+ routed := c.routeEvents(ctx, log, wrRules, cwrRules,
+ func(path, gitTargetRef, gitTargetNamespace string) git.Event {
+ return buildFieldPatchEvent(assignments, source, id, op, userInfo, path, gitTargetRef, gitTargetNamespace)
+ })
+ if routed > 0 {
+ recordPipelineEvent(ctx, gvr, auditEvent.Verb, pipelineOutcomeRoutedScale)
+ } else {
+ recordPipelineEvent(ctx, gvr, auditEvent.Verb, pipelineOutcomeRouteFailed)
+ }
+ return nil
+}
+
+// routeEvents builds a per-rule git.Event via build and routes it to every matched
+// WatchRule and ClusterWatchRule target, returning the number successfully routed.
+func (c *AuditConsumer) routeEvents(
+ ctx context.Context,
+ log logr.Logger,
+ wrRules []rulestore.CompiledRule,
+ cwrRules []rulestore.CompiledClusterRule,
+ build func(path, gitTargetRef, gitTargetNamespace string) git.Event,
) int {
routed := 0
for _, rule := range wrRules {
- ev := buildGitEvent(sanitized, id, op, userInfo, rule.Path, rule.GitTargetRef, rule.GitTargetNamespace)
- gitDest := itypes.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace)
- if err := c.eventRouter.RouteToGitTargetEventStream(ev, gitDest); err != nil {
- log.V(1).Info("Failed to route audit event via WatchRule", "error", err,
- "gitTarget", gitDest.String())
- recordRouteTarget(
- ctx,
- rule.GitTargetNamespace,
- rule.GitTargetRef,
- ruleKindWatchRule,
- pipelineOutcomeRouteFailed,
- )
- continue
+ if c.routeOne(ctx, log, build, rule.Path, rule.GitTargetRef, rule.GitTargetNamespace, ruleKindWatchRule) {
+ routed++
}
- recordRouteTarget(ctx, rule.GitTargetNamespace, rule.GitTargetRef, ruleKindWatchRule, pipelineOutcomeRouted)
- routed++
}
for _, rule := range cwrRules {
- ev := buildGitEvent(sanitized, id, op, userInfo, rule.Path, rule.GitTargetRef, rule.GitTargetNamespace)
- gitDest := itypes.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace)
- if err := c.eventRouter.RouteToGitTargetEventStream(ev, gitDest); err != nil {
- log.V(1).Info("Failed to route audit event via ClusterWatchRule", "error", err,
- "gitTarget", gitDest.String())
- recordRouteTarget(
- ctx, rule.GitTargetNamespace, rule.GitTargetRef, ruleKindClusterWatchRule, pipelineOutcomeRouteFailed)
- continue
+ if c.routeOne(
+ ctx, log, build, rule.Path, rule.GitTargetRef, rule.GitTargetNamespace, ruleKindClusterWatchRule,
+ ) {
+ routed++
}
- recordRouteTarget(
- ctx,
- rule.GitTargetNamespace,
- rule.GitTargetRef,
- ruleKindClusterWatchRule,
- pipelineOutcomeRouted,
- )
- routed++
}
return routed
}
+// routeOne builds and routes one event to a single GitTarget, recording the per-target
+// outcome. It returns true on success.
+func (c *AuditConsumer) routeOne(
+ ctx context.Context,
+ log logr.Logger,
+ build func(path, gitTargetRef, gitTargetNamespace string) git.Event,
+ path, gitTargetRef, gitTargetNamespace, ruleKind string,
+) bool {
+ ev := build(path, gitTargetRef, gitTargetNamespace)
+ gitDest := itypes.NewResourceReference(gitTargetRef, gitTargetNamespace)
+ if err := c.eventRouter.RouteToGitTargetEventStream(ev, gitDest); err != nil {
+ log.V(1).Info("Failed to route audit event", "error", err,
+ "ruleKind", ruleKind, "gitTarget", gitDest.String())
+ recordRouteTarget(ctx, gitTargetNamespace, gitTargetRef, ruleKind, pipelineOutcomeRouteFailed)
+ return false
+ }
+ recordRouteTarget(ctx, gitTargetNamespace, gitTargetRef, ruleKind, pipelineOutcomeRouted)
+ return true
+}
+
// pipelineGVR carries the bounded group/version/resource labels for the
// audit pipeline consumer metric.
type pipelineGVR struct {
@@ -618,6 +677,20 @@ const (
pipelineOutcomeRouted = "routed"
pipelineOutcomeRouteFailed = "route_failed"
+ // Scale subresource outcomes. Only /scale on a built-in scalable parent routes;
+ // every other subresource and every scale with an unknown parent replica path is
+ // dropped with one of the explicit dropped outcomes so ignored subresources are
+ // visible. See docs/design/manifest/version2/subresource-scope-reduction.md.
+ pipelineOutcomeRoutedScale = "routed_scale_subresource"
+ // pipelineOutcomeDroppedNonScale marks a subresource event that is not /scale.
+ pipelineOutcomeDroppedNonScale = "dropped_non_scale_subresource"
+ // pipelineOutcomeDroppedScaleMissingReplicas marks a scale event whose
+ // responseObject carries no spec.replicas to commit.
+ pipelineOutcomeDroppedScaleMissingReplicas = "dropped_scale_missing_response_replicas"
+ // pipelineOutcomeDroppedScalePathUnresolved marks a scale event whose parent has no
+ // known replica path — a CRD or aggregated API in this pass.
+ pipelineOutcomeDroppedScalePathUnresolved = "dropped_scale_path_unresolved"
+
ruleKindWatchRule = "watchrule"
ruleKindClusterWatchRule = "clusterwatchrule"
)
@@ -674,6 +747,32 @@ func buildGitEvent(
}
}
+// buildFieldPatchEvent constructs a field-patch git.Event for a given rule match. The
+// parent Kind is intentionally left unset: the writer resolves the parent document from
+// the objectRef GVR (it has the live-catalog mapper; the consumer does not), so the
+// translator never needs GVR->GVK resolution.
+func buildFieldPatchEvent(
+ assignments []manifestedit.FieldAssignment,
+ source string,
+ id itypes.ResourceIdentifier,
+ op configv1alpha1.OperationType,
+ userInfo git.UserInfo,
+ path, gitTargetRef, gitTargetNamespace string,
+) git.Event {
+ return git.Event{
+ FieldPatch: &git.FieldPatch{
+ Assignments: assignments,
+ Source: source,
+ },
+ Identifier: id,
+ Operation: string(op),
+ UserInfo: userInfo,
+ Path: path,
+ GitTargetName: gitTargetRef,
+ GitTargetNamespace: gitTargetNamespace,
+ }
+}
+
const (
// displayNameExtraKey is the audit-event user.extra key carrying the OIDC
// "name" claim, when the API server is configured to map it.
diff --git a/internal/queue/redis_audit_consumer_test.go b/internal/queue/redis_audit_consumer_test.go
index 9500b8ce..06b80a00 100644
--- a/internal/queue/redis_audit_consumer_test.go
+++ b/internal/queue/redis_audit_consumer_test.go
@@ -721,6 +721,80 @@ func TestProcessMessage_NoObjectRefIsACKed(t *testing.T) {
assertNoPendingMessages(t, mr)
}
+// A deployments/scale event must be TRANSLATED into a parent-manifest replicas field
+// patch and routed against the matching deployments rule — not written as its Scale
+// body, and not dropped. The routed event carries a FieldPatch (spec.replicas, no
+// object), so the writer patches only that field on the committed Deployment. No live
+// parent GET is involved: the accepted value comes straight from the Scale response.
+func TestProcessMessage_ScaleEventTranslatesToFieldPatch(t *testing.T) {
+ mr := miniredis.RunT(t)
+ er := &fakeEventRouter{}
+
+ rs := rulestore.NewStore()
+ rs.AddOrUpdateWatchRule(
+ makeWatchRule("scale-rule", []string{"deployments"}, []string{"v1"}, []string{"apps"}),
+ "my-target", "default",
+ "my-provider", "default",
+ "main", "state/",
+ )
+
+ c := newTestConsumer(t, mr, rs, er)
+ require.NoError(t, c.ensureConsumerGroup(context.Background()))
+
+ ev := makeAuditEvent("patch", auditv1.StageResponseComplete, "deployments", "default", "web")
+ ev.ObjectRef.APIGroup = "apps"
+ ev.ObjectRef.Subresource = "scale"
+ ev.ResponseObject = &runtime.Unknown{
+ Raw: []byte(`{"kind":"Scale","apiVersion":"autoscaling/v1","spec":{"replicas":3},"status":{"replicas":0}}`),
+ }
+ pushAuditMessage(t, mr, ev)
+
+ require.NoError(t, c.readAndProcessBatch(context.Background()))
+
+ require.Len(t, er.calls, 1, "a scale event must be translated and routed as a field patch")
+ routed := er.calls[0].Event
+ require.NotNil(t, routed.FieldPatch, "the routed event must be a field patch, not an object")
+ assert.Nil(t, routed.Object, "a field patch carries no object body")
+ assert.Equal(t, "deployments/scale", routed.FieldPatch.Source)
+ assert.Equal(t, "UPDATE", routed.Operation)
+ assert.Equal(t, "deployments", routed.Identifier.Resource)
+ require.Len(t, routed.FieldPatch.Assignments, 1, "only spec.replicas, never status")
+ assert.Equal(t, []string{"spec", "replicas"}, routed.FieldPatch.Assignments[0].Path)
+ assert.Equal(t, int64(3), routed.FieldPatch.Assignments[0].Value)
+ assertNoPendingMessages(t, mr)
+}
+
+// A scale on a CRD has no known parent replica path, so it is dropped (path unresolved)
+// rather than defaulting to .spec.replicas: nothing is routed, and the message is ACKed.
+func TestProcessMessage_CRDScaleDroppedPathUnresolved(t *testing.T) {
+ mr := miniredis.RunT(t)
+ er := &fakeEventRouter{}
+
+ rs := rulestore.NewStore()
+ rs.AddOrUpdateWatchRule(
+ makeWatchRule("widget-rule", []string{"widgets"}, []string{"v1"}, []string{"example.com"}),
+ "my-target", "default",
+ "my-provider", "default",
+ "main", "state/",
+ )
+
+ c := newTestConsumer(t, mr, rs, er)
+ require.NoError(t, c.ensureConsumerGroup(context.Background()))
+
+ ev := makeAuditEvent("patch", auditv1.StageResponseComplete, "widgets", "default", "w1")
+ ev.ObjectRef.APIGroup = "example.com"
+ ev.ObjectRef.Subresource = "scale"
+ ev.ResponseObject = &runtime.Unknown{
+ Raw: []byte(`{"kind":"Scale","apiVersion":"autoscaling/v1","spec":{"replicas":3}}`),
+ }
+ pushAuditMessage(t, mr, ev)
+
+ require.NoError(t, c.readAndProcessBatch(context.Background()))
+
+ assert.Empty(t, er.calls, "a CRD scale with no known parent replica path must not be routed")
+ assertNoPendingMessages(t, mr)
+}
+
func TestProcessMessage_NoMatchingRulesIsACKed(t *testing.T) {
mr := miniredis.RunT(t)
er := &fakeEventRouter{}
diff --git a/internal/queue/subresource_translate.go b/internal/queue/subresource_translate.go
new file mode 100644
index 00000000..e4c06c75
--- /dev/null
+++ b/internal/queue/subresource_translate.go
@@ -0,0 +1,90 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package queue
+
+import (
+ "k8s.io/apimachinery/pkg/runtime"
+ utiljson "k8s.io/apimachinery/pkg/util/json"
+ auditv1 "k8s.io/apiserver/pkg/apis/audit/v1"
+
+ "github.com/ConfigButler/gitops-reverser/internal/auditutil"
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
+)
+
+// Scale translation turns a built-in */scale audit event (e.g. deployments/scale) into a
+// single parent-manifest replicas assignment, without hydrating the parent object. It is
+// the only subresource translation GitOps Reverser performs: /scale is the standardized
+// view that writes a parent's desired replica state, and Kubernetes returns the accepted
+// value in responseObject.spec.replicas. Everything else — every other subresource, and
+// every scale on a CRD or aggregated API whose parent replica path is unknown — is
+// dropped with a scale-specific metric, never guessed. See
+// docs/design/manifest/version2/subresource-scope-reduction.md.
+
+// translateScaleToAssignments turns a */scale audit event for parent (group, resource)
+// into a single replicas assignment at the parent's known replica path. It returns
+// (assignments, dropOutcome, ok): on success ok is true, dropOutcome is "", and
+// assignments holds exactly one (replicaPath -> responseObject.spec.replicas) pair; on a
+// drop ok is false and dropOutcome is the scale-specific metric outcome the caller must
+// record. Only the post-mutation responseObject is read — a request body is
+// pre-admission intent, not confirmed accepted state — and only spec.replicas is read,
+// never status, the body's own apiVersion/kind, or any other spec leaf.
+func translateScaleToAssignments(
+ event auditv1.Event,
+ group, resource string,
+) ([]manifestedit.FieldAssignment, string, bool) {
+ if event.ObjectRef == nil || !auditutil.IsScaleSubresource(event.ObjectRef.Subresource) {
+ return nil, pipelineOutcomeDroppedNonScale, false
+ }
+ replicaPath, known := auditutil.BuiltinScaleReplicasPath(group, resource)
+ if !known {
+ return nil, pipelineOutcomeDroppedScalePathUnresolved, false
+ }
+ replicas, ok := scaleReplicasFromResponse(event.ResponseObject)
+ if !ok {
+ return nil, pipelineOutcomeDroppedScaleMissingReplicas, false
+ }
+ return []manifestedit.FieldAssignment{{Path: replicaPath, Value: replicas}}, "", true
+}
+
+// scaleReplicasFromResponse reads responseObject.spec.replicas as an int64. It uses the
+// apimachinery JSON decoder so a JSON integer becomes int64 (matching how a manifest is
+// rendered), and reads nothing else: never status, never the requestObject, never the
+// Scale body's own apiVersion/kind. ok is false when the body is absent, undecodable, or
+// carries no integral spec.replicas.
+func scaleReplicasFromResponse(raw *runtime.Unknown) (int64, bool) {
+ if raw == nil || len(raw.Raw) == 0 {
+ return 0, false
+ }
+ var decoded map[string]interface{}
+ if err := utiljson.Unmarshal(raw.Raw, &decoded); err != nil {
+ return 0, false
+ }
+ spec, ok := decoded["spec"].(map[string]interface{})
+ if !ok {
+ return 0, false
+ }
+ switch replicas := spec["replicas"].(type) {
+ case int64:
+ return replicas, true
+ case float64:
+ return int64(replicas), true
+ default:
+ return 0, false
+ }
+}
diff --git a/internal/queue/subresource_translate_test.go b/internal/queue/subresource_translate_test.go
new file mode 100644
index 00000000..b9d6689f
--- /dev/null
+++ b/internal/queue/subresource_translate_test.go
@@ -0,0 +1,164 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package queue
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ auditv1 "k8s.io/apiserver/pkg/apis/audit/v1"
+)
+
+// scaleEvent builds a deployments/scale audit event with the given response and request
+// bodies. The parent GVR (apps/deployments) is supplied by the caller to
+// translateScaleToAssignments; the objectRef here only needs to carry the subresource.
+func scaleEvent(responseBody, requestBody string) auditv1.Event {
+ ev := auditv1.Event{
+ ObjectRef: &auditv1.ObjectReference{Resource: "deployments", Subresource: "scale"},
+ }
+ if responseBody != "" {
+ ev.ResponseObject = &runtime.Unknown{Raw: []byte(responseBody)}
+ }
+ if requestBody != "" {
+ ev.RequestObject = &runtime.Unknown{Raw: []byte(requestBody)}
+ }
+ return ev
+}
+
+// TestTranslateScale_RealScaleRecording feeds the actual recorded deployments/scale
+// responseObject (the design's reference capture) through the translator and asserts it
+// becomes exactly spec.replicas: 3.
+func TestTranslateScale_RealScaleRecording(t *testing.T) {
+ raw, err := os.ReadFile("../webhook/testdata/audit-events/deployment-scale-subresource.json")
+ require.NoError(t, err)
+
+ var recording map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(raw, &recording))
+ require.Contains(t, recording, "responseObject")
+
+ ev := auditv1.Event{
+ ObjectRef: &auditv1.ObjectReference{Resource: "deployments", Subresource: "scale"},
+ ResponseObject: &runtime.Unknown{Raw: recording["responseObject"]},
+ }
+
+ assignments, dropOutcome, ok := translateScaleToAssignments(ev, "apps", "deployments")
+ require.True(t, ok)
+ assert.Empty(t, dropOutcome)
+ require.Len(t, assignments, 1)
+ assert.Equal(t, []string{"spec", "replicas"}, assignments[0].Path)
+ assert.Equal(t, int64(3), assignments[0].Value)
+}
+
+func TestTranslateScale_UsesResponseIgnoresRequest(t *testing.T) {
+ ev := scaleEvent(`{"spec":{"replicas":5}}`, `{"spec":{"replicas":3}}`)
+
+ assignments, _, ok := translateScaleToAssignments(ev, "apps", "deployments")
+ require.True(t, ok)
+ require.Len(t, assignments, 1)
+ assert.Equal(t, int64(5), assignments[0].Value, "only the post-mutation responseObject is read")
+}
+
+// A scale event carrying only a requestObject is dropped: a request body is
+// pre-admission intent, not confirmed accepted state, so there is no fallback to it.
+func TestTranslateScale_RequestOnlyIsDropped(t *testing.T) {
+ ev := scaleEvent("", `{"spec":{"replicas":3}}`)
+
+ _, dropOutcome, ok := translateScaleToAssignments(ev, "apps", "deployments")
+ assert.False(t, ok, "a request-only scale event must not be translated")
+ assert.Equal(t, pipelineOutcomeDroppedScaleMissingReplicas, dropOutcome)
+}
+
+// Only spec.replicas is read; status (e.g. a Scale's status.replicas) never enters the
+// patch.
+func TestTranslateScale_NeverReadsStatus(t *testing.T) {
+ ev := scaleEvent(`{"spec":{"replicas":3},"status":{"replicas":0,"selector":"app=web"}}`, "")
+
+ assignments, _, ok := translateScaleToAssignments(ev, "apps", "deployments")
+ require.True(t, ok)
+ require.Len(t, assignments, 1, "status is never translated")
+ assert.Equal(t, []string{"spec", "replicas"}, assignments[0].Path)
+}
+
+// A scale response with no spec.replicas is dropped with the missing-replicas outcome.
+func TestTranslateScale_NoReplicasIsDropped(t *testing.T) {
+ ev := scaleEvent(`{"status":{"replicas":0}}`, "")
+
+ _, dropOutcome, ok := translateScaleToAssignments(ev, "apps", "deployments")
+ assert.False(t, ok, "a body with no spec.replicas is not translatable")
+ assert.Equal(t, pipelineOutcomeDroppedScaleMissingReplicas, dropOutcome)
+}
+
+// A non-scale subresource is dropped before any replica lookup: scale is the only
+// supported subresource.
+func TestTranslateScale_NonScaleSubresourceIsDropped(t *testing.T) {
+ ev := auditv1.Event{
+ ObjectRef: &auditv1.ObjectReference{Resource: "deployments", Subresource: "status"},
+ ResponseObject: &runtime.Unknown{Raw: []byte(`{"spec":{"replicas":3}}`)},
+ }
+
+ _, dropOutcome, ok := translateScaleToAssignments(ev, "apps", "deployments")
+ assert.False(t, ok, "only the scale subresource is supported")
+ assert.Equal(t, pipelineOutcomeDroppedNonScale, dropOutcome)
+}
+
+// A scale on a CRD — whose parent has no known replica path — is dropped as
+// path-unresolved rather than defaulting to .spec.replicas, even when the body carries
+// spec.replicas.
+func TestTranslateScale_CRDParentPathUnresolved(t *testing.T) {
+ ev := auditv1.Event{
+ ObjectRef: &auditv1.ObjectReference{Resource: "widgets", Subresource: "scale"},
+ ResponseObject: &runtime.Unknown{Raw: []byte(`{"spec":{"replicas":3}}`)},
+ }
+
+ _, dropOutcome, ok := translateScaleToAssignments(ev, "example.com", "widgets")
+ assert.False(t, ok, "a CRD scale has no known parent replica path")
+ assert.Equal(t, pipelineOutcomeDroppedScalePathUnresolved, dropOutcome)
+}
+
+// An aggregated API scale falls through the same unresolved-path drop as any other
+// non-built-in resource.
+func TestTranslateScale_AggregatedAPIPathUnresolved(t *testing.T) {
+ ev := auditv1.Event{
+ ObjectRef: &auditv1.ObjectReference{Resource: "things", Subresource: "scale"},
+ ResponseObject: &runtime.Unknown{Raw: []byte(`{"spec":{"replicas":2}}`)},
+ }
+
+ _, dropOutcome, ok := translateScaleToAssignments(ev, "metrics.k8s.io", "things")
+ assert.False(t, ok, "an aggregated API scale has no known parent replica path")
+ assert.Equal(t, pipelineOutcomeDroppedScalePathUnresolved, dropOutcome)
+}
+
+// A StatefulSet scale routes to spec.replicas too — every built-in scalable parent
+// shares the path.
+func TestTranslateScale_StatefulSetRoutes(t *testing.T) {
+ ev := auditv1.Event{
+ ObjectRef: &auditv1.ObjectReference{Resource: "statefulsets", Subresource: "scale"},
+ ResponseObject: &runtime.Unknown{Raw: []byte(`{"spec":{"replicas":7}}`)},
+ }
+
+ assignments, _, ok := translateScaleToAssignments(ev, "apps", "statefulsets")
+ require.True(t, ok)
+ require.Len(t, assignments, 1)
+ assert.Equal(t, []string{"spec", "replicas"}, assignments[0].Path)
+ assert.Equal(t, int64(7), assignments[0].Value)
+}
diff --git a/internal/reconcile/folder_reconciler.go b/internal/reconcile/folder_reconciler.go
deleted file mode 100644
index 5645ee48..00000000
--- a/internal/reconcile/folder_reconciler.go
+++ /dev/null
@@ -1,287 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// Package reconcile provides components for cluster-as-source-of-truth reconciliation.
-package reconcile
-
-import (
- "context"
- "fmt"
-
- "github.com/go-logr/logr"
- "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
-
- "github.com/ConfigButler/gitops-reverser/internal/events"
- "github.com/ConfigButler/gitops-reverser/internal/git"
- "github.com/ConfigButler/gitops-reverser/internal/types"
-)
-
-// WriteRequestEmitter emits a complete reconcile write request as a single unit.
-type WriteRequestEmitter interface {
- EmitWriteRequest(request git.WriteRequest) error
-}
-
-// FolderReconciler reconciles Git base folder to match cluster state.
-// It operates without time concerns (delegated to WatchManager) and focuses purely
-// on reconciliation logic.
-type FolderReconciler struct {
- gitDest types.ResourceReference
-
- // Current state snapshots
- clusterResources []types.ResourceIdentifier
- gitResources []types.ResourceIdentifier
- clusterStateSeen bool
- gitStateSeen bool
-
- // Full cluster objects keyed by ResourceIdentifier.Key(), populated alongside
- // clusterResources so that write events can be hydrated with real payloads.
- clusterObjects map[string]unstructured.Unstructured
-
- // Dependencies for event emission
- reconcileEmitter WriteRequestEmitter
- controlEmitter events.ControlEventEmitter
- logger logr.Logger
-
- lastSnapshotStats SnapshotStats
-}
-
-// SnapshotStats captures the latest create/update/delete counts from reconciliation.
-type SnapshotStats struct {
- Created int
- Updated int
- Deleted int
-}
-
-// NewFolderReconciler creates a new FolderReconciler.
-func NewFolderReconciler(
- gitDest types.ResourceReference,
- reconcileEmitter WriteRequestEmitter,
- controlEmitter events.ControlEventEmitter,
- logger logr.Logger,
-) *FolderReconciler {
- return &FolderReconciler{
- gitDest: gitDest,
- reconcileEmitter: reconcileEmitter,
- controlEmitter: controlEmitter,
- logger: logger.WithValues("gitDest", gitDest.String()),
- }
-}
-
-// StartReconciliation initiates the reconciliation process by requesting state.
-func (r *FolderReconciler) StartReconciliation(_ context.Context) error {
- r.ResetState()
-
- r.logger.Info("Starting reconciliation")
-
- // Emit control events to request both cluster and repo state
- if err := r.controlEmitter.EmitControlEvent(events.ControlEvent{
- Type: events.RequestClusterState,
- GitDest: r.gitDest,
- }); err != nil {
- return fmt.Errorf("failed to emit RequestClusterState: %w", err)
- }
-
- if err := r.controlEmitter.EmitControlEvent(events.ControlEvent{
- Type: events.RequestRepoState,
- GitDest: r.gitDest,
- }); err != nil {
- return fmt.Errorf("failed to emit RequestRepoState: %w", err)
- }
-
- return nil
-}
-
-// ResetState clears any previously observed repo/cluster snapshots so the next
-// reconciliation cycle only runs on a fresh pair of state events.
-func (r *FolderReconciler) ResetState() {
- r.clusterResources = nil
- r.gitResources = nil
- r.clusterObjects = nil
- r.clusterStateSeen = false
- r.gitStateSeen = false
-}
-
-// OnClusterState handles cluster state events and triggers reconciliation.
-func (r *FolderReconciler) OnClusterState(event events.ClusterStateEvent) {
- if !event.GitDest.Equal(r.gitDest) {
- return
- }
- r.clusterResources = event.Resources
- r.clusterObjects = event.Objects
- r.clusterStateSeen = true
- r.logger.V(1).Info("Received cluster state", "resourceCount", len(event.Resources))
- r.reconcile()
-}
-
-// OnRepoState handles repository state events and triggers reconciliation.
-func (r *FolderReconciler) OnRepoState(event events.RepoStateEvent) {
- if !event.GitDest.Equal(r.gitDest) {
- return
- }
- r.gitResources = event.Resources
- r.gitStateSeen = true
- r.logger.V(1).Info("Received repo state", "resourceCount", len(event.Resources))
- r.reconcile()
-}
-
-// reconcile performs the reconciliation logic when both states are available.
-// It collects all changes into a single write request and emits it atomically.
-func (r *FolderReconciler) reconcile() {
- // Only reconcile when we have both cluster and Git state.
- //
- // An empty cluster snapshot is treated as authoritative: if the cluster
- // genuinely holds no watched resources, the Git mirror is emptied to match.
- // The trust boundary is the snapshot itself — Manager.GetClusterStateForGitDest
- // fails loudly (returns an error, so no ClusterStateEvent is emitted and this
- // never runs) rather than ever handing back a silently partial cluster view.
- if !r.clusterStateSeen || !r.gitStateSeen {
- return
- }
-
- // Compute reconciliation actions (pure logic, no time concerns)
- toCreate, toDelete, existingInBoth := r.findDifferences(r.clusterResources, r.gitResources)
- r.lastSnapshotStats = SnapshotStats{
- Created: len(toCreate),
- Updated: len(existingInBoth),
- Deleted: len(toDelete),
- }
-
- r.logger.V(1).Info("Reconciliation computed",
- "toCreate", len(toCreate),
- "toDelete", len(toDelete),
- "existingInBoth", len(existingInBoth))
-
- total := len(toCreate) + len(toDelete) + len(existingInBoth)
- if total == 0 {
- r.logger.V(1).Info("No differences found, skipping write request emission")
- return
- }
-
- // Build the complete event list for this reconcile run
- var batchEvents []git.Event
-
- for _, resource := range toCreate {
- obj := r.objectForResource(resource)
- batchEvents = append(batchEvents, git.Event{
- Operation: "CREATE",
- Identifier: resource,
- Object: obj,
- })
- }
-
- for _, resource := range toDelete {
- batchEvents = append(batchEvents, git.Event{
- Operation: "DELETE",
- Identifier: resource,
- })
- }
-
- for _, resource := range existingInBoth {
- obj := r.objectForResource(resource)
- batchEvents = append(batchEvents, git.Event{
- Operation: string(events.ReconcileResource),
- Identifier: resource,
- Object: obj,
- })
- }
-
- request := git.WriteRequest{
- Events: batchEvents,
- CommitMode: git.CommitModeAtomic,
- }
-
- if err := r.reconcileEmitter.EmitWriteRequest(request); err != nil {
- r.logger.Error(err, "Failed to emit reconcile write request")
- }
-}
-
-// GetLastSnapshotStats returns stats from the latest completed reconciliation diff.
-func (r *FolderReconciler) GetLastSnapshotStats() SnapshotStats {
- return r.lastSnapshotStats
-}
-
-// objectForResource returns a pointer to the cached cluster object for the given resource,
-// or nil if the object is not available (e.g. ClusterStateEvent carried no objects).
-func (r *FolderReconciler) objectForResource(resource types.ResourceIdentifier) *unstructured.Unstructured {
- if r.clusterObjects == nil {
- return nil
- }
- obj, ok := r.clusterObjects[resource.Key()]
- if !ok {
- return nil
- }
- return &obj
-}
-
-// findDifferences computes what needs to be created, deleted, and resources that exist in both.
-func (r *FolderReconciler) findDifferences(
- clusterResources, gitResources []types.ResourceIdentifier,
-) ([]types.ResourceIdentifier, []types.ResourceIdentifier, []types.ResourceIdentifier) {
- clusterSet := make(map[string]types.ResourceIdentifier)
- gitSet := make(map[string]types.ResourceIdentifier)
-
- // Build sets for efficient lookup
- for _, resource := range clusterResources {
- clusterSet[resource.Key()] = resource
- }
-
- for _, resource := range gitResources {
- gitSet[resource.Key()] = resource
- }
-
- // Find resources to create (in cluster but not in Git)
- var toCreate []types.ResourceIdentifier
- for _, resource := range clusterResources {
- if _, exists := gitSet[resource.Key()]; !exists {
- toCreate = append(toCreate, resource)
- }
- }
-
- // Find resources to delete (in Git but not in cluster)
- var toDelete []types.ResourceIdentifier
- for _, resource := range gitResources {
- if _, exists := clusterSet[resource.Key()]; !exists {
- toDelete = append(toDelete, resource)
- }
- }
-
- // Find resources that exist in both cluster and Git
- var existingInBoth []types.ResourceIdentifier
- for _, resource := range clusterResources {
- if _, exists := gitSet[resource.Key()]; exists {
- existingInBoth = append(existingInBoth, resource)
- }
- }
-
- return toCreate, toDelete, existingInBoth
-}
-
-// HasBothStates returns true if the reconciler has received both cluster and Git state.
-func (r *FolderReconciler) HasBothStates() bool {
- return r.clusterStateSeen && r.gitStateSeen
-}
-
-// GetGitDest returns the GitDestination reference this reconciler is responsible for.
-func (r *FolderReconciler) GetGitDest() types.ResourceReference {
- return r.gitDest
-}
-
-// String returns a string representation for debugging.
-func (r *FolderReconciler) String() string {
- return fmt.Sprintf("FolderReconciler(gitDest=%s)", r.gitDest.String())
-}
diff --git a/internal/reconcile/folder_reconciler_test.go b/internal/reconcile/folder_reconciler_test.go
deleted file mode 100644
index 8576c548..00000000
--- a/internal/reconcile/folder_reconciler_test.go
+++ /dev/null
@@ -1,475 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package reconcile
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "sigs.k8s.io/controller-runtime/pkg/log"
-
- "github.com/ConfigButler/gitops-reverser/internal/events"
- "github.com/ConfigButler/gitops-reverser/internal/git"
- "github.com/ConfigButler/gitops-reverser/internal/types"
-)
-
-func TestFolderReconciler_FindDifferences(t *testing.T) {
- tests := []struct {
- name string
- clusterResources []types.ResourceIdentifier
- gitResources []types.ResourceIdentifier
- expectedToCreate []types.ResourceIdentifier
- expectedToDelete []types.ResourceIdentifier
- expectedExistingInBoth []types.ResourceIdentifier
- }{
- {
- name: "resources exist in both cluster and Git - no changes needed",
- clusterResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- gitResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- expectedToCreate: []types.ResourceIdentifier{},
- expectedToDelete: []types.ResourceIdentifier{},
- expectedExistingInBoth: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- },
- {
- name: "missing resource in Git - should create",
- clusterResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "services", Name: "app-svc"}, // Missing in Git
- },
- gitResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- expectedToCreate: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "services", Name: "app-svc"},
- },
- expectedToDelete: []types.ResourceIdentifier{},
- expectedExistingInBoth: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- },
- {
- name: "orphaned resource in Git - should delete",
- clusterResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- gitResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "old-config"}, // Orphan
- },
- expectedToCreate: []types.ResourceIdentifier{},
- expectedToDelete: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "configmaps", Name: "old-config"},
- },
- expectedExistingInBoth: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- },
- {
- name: "both create and delete needed",
- clusterResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "new-pod"},
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- gitResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "old-pod"}, // Orphan
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "old-config"}, // Orphan
- },
- expectedToCreate: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "new-pod"},
- },
- expectedToDelete: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "old-pod"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "old-config"},
- },
- expectedExistingInBoth: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- },
- {
- name: "no cluster resources - all Git resources are orphaned",
- clusterResources: []types.ResourceIdentifier{},
- gitResources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "orphan-pod"},
- },
- expectedToCreate: []types.ResourceIdentifier{},
- expectedToDelete: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "orphan-pod"},
- },
- expectedExistingInBoth: []types.ResourceIdentifier{},
- },
- {
- name: "no Git resources - all cluster resources need creation",
- clusterResources: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "new-deployment"},
- },
- gitResources: []types.ResourceIdentifier{},
- expectedToCreate: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "new-deployment"},
- },
- expectedToDelete: []types.ResourceIdentifier{},
- expectedExistingInBoth: []types.ResourceIdentifier{},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
-
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Call findDifferences
- toCreate, toDelete, existingInBoth := reconciler.findDifferences(tt.clusterResources, tt.gitResources)
-
- // Verify results
- assert.Len(t, toCreate, len(tt.expectedToCreate), "Number of resources to create should match")
- for _, expected := range tt.expectedToCreate {
- assert.Contains(t, toCreate, expected, "Should contain resource to create: %s", expected.String())
- }
-
- assert.Len(t, toDelete, len(tt.expectedToDelete), "Number of resources to delete should match")
- for _, expected := range tt.expectedToDelete {
- assert.Contains(t, toDelete, expected, "Should contain resource to delete: %s", expected.String())
- }
-
- assert.Len(t, existingInBoth, len(tt.expectedExistingInBoth), "Number of existing resources should match")
- for _, expected := range tt.expectedExistingInBoth {
- assert.Contains(t, existingInBoth, expected, "Should contain existing resource: %s", expected.String())
- }
- })
- }
-}
-
-func TestFolderReconciler_OnClusterState(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- clusterEvent := events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- }
-
- // Should process matching event
- reconciler.OnClusterState(clusterEvent)
- assert.NotNil(t, reconciler.clusterResources, "Cluster resources should be set")
- assert.Len(t, reconciler.clusterResources, 1, "Should have one cluster resource")
-
- // Should not process non-matching event
- otherGitDest := types.NewResourceReference("other-gitdest", "default")
- otherEvent := events.ClusterStateEvent{
- GitDest: otherGitDest,
- Resources: []types.ResourceIdentifier{},
- }
-
- reconciler.OnClusterState(otherEvent)
- // Should still have original resources
- assert.Len(t, reconciler.clusterResources, 1, "Should not update cluster resources for non-matching event")
-}
-
-func TestFolderReconciler_OnRepoState(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- repoEvent := events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- }
-
- // Should process matching event
- reconciler.OnRepoState(repoEvent)
- assert.NotNil(t, reconciler.gitResources, "Git resources should be set")
- assert.Len(t, reconciler.gitResources, 1, "Should have one Git resource")
-
- // Should not process non-matching event
- otherGitDest := types.NewResourceReference("other-gitdest", "default")
- otherEvent := events.RepoStateEvent{
- GitDest: otherGitDest,
- Resources: []types.ResourceIdentifier{},
- }
-
- reconciler.OnRepoState(otherEvent)
- // Should still have original resources
- assert.Len(t, reconciler.gitResources, 1, "Should not update Git resources for non-matching event")
-}
-
-func TestFolderReconciler_NoDeleteForIdenticalCoreNamespacedResource(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- resource := types.ResourceIdentifier{
- Group: "",
- Version: "v1",
- Resource: "configmaps",
- Namespace: "ns1",
- Name: "oeps3",
- }
-
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{resource},
- })
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{resource},
- })
-
- assert.Empty(t, mockEmitter.GetEventsByOperation("CREATE"))
- assert.Empty(t, mockEmitter.GetEventsByOperation("DELETE"))
- assert.Equal(t,
- []types.ResourceIdentifier{resource},
- mockEmitter.GetIdentifiersByOperation(string(events.ReconcileResource)),
- )
-
- stats := reconciler.GetLastSnapshotStats()
- assert.Equal(t, SnapshotStats{Created: 0, Updated: 1, Deleted: 0}, stats)
-}
-
-func TestFolderReconciler_HasBothStates(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Initially should have no states
- assert.False(t, reconciler.HasBothStates(), "Should not have both states initially")
-
- // Set cluster state
- reconciler.clusterResources = []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- }
- reconciler.clusterStateSeen = true
-
- // Should still not have both states
- assert.False(t, reconciler.HasBothStates(), "Should not have both states with only cluster state")
-
- // Set Git state
- reconciler.gitResources = []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- }
- reconciler.gitStateSeen = true
-
- // Should now have both states
- assert.True(t, reconciler.HasBothStates(), "Should have both states when both are set")
-}
-
-func TestFolderReconciler_HasBothStates_WithEmptyStates(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: nil,
- })
- assert.False(t, reconciler.HasBothStates(), "Should not have both states with only cluster state event")
-
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: nil,
- })
- assert.True(t, reconciler.HasBothStates(), "Should have both states when both events are received, even if empty")
-}
-
-func TestFolderReconciler_GetGitDest(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Test getter method
- result := reconciler.GetGitDest()
- assert.Equal(t, gitDest, result, "GetGitDest should return the GitDest reference")
- assert.Equal(t, "test-gitdest", result.Name, "Name should match")
- assert.Equal(t, "default", result.Namespace, "Namespace should match")
-
- // Test String method
- assert.Contains(t, reconciler.String(), "default/test-gitdest", "String should contain gitDest reference")
-}
-
-func TestFolderReconciler_EmitsSingleBatch(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "new-pod"},
- {Group: "", Version: "v1", Resource: "pods", Name: "existing-pod"},
- },
- })
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "existing-pod"},
- {Group: "", Version: "v1", Resource: "pods", Name: "old-pod"},
- },
- })
-
- // Exactly one batch should be emitted
- assert.Len(t, mockEmitter.Batches, 1, "Should emit exactly one batch")
- batch := mockEmitter.Batches[0]
-
- // Batch should contain all events
- assert.Len(t, batch.Events, 3, "Batch should have 3 events (1 create, 1 delete, 1 reconcile)")
- assert.Equal(t, git.CommitModeAtomic, batch.CommitMode, "reconcile snapshots should be committed atomically")
- assert.Empty(t, batch.CommitMessage, "batch commit message should be resolved from GitProvider commit settings")
- for _, event := range batch.Events {
- assert.Empty(t, event.UserInfo.Username, "reconcile events should not fabricate a user identity")
- }
-}
-
-func TestFolderReconciler_ResetStateRequiresFreshRepoAndClusterSnapshots(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- initialResource := types.ResourceIdentifier{Group: "", Version: "v1", Resource: "configmaps", Name: "old"}
- updatedResource := types.ResourceIdentifier{Group: "", Version: "v1", Resource: "configmaps", Name: "new"}
-
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{initialResource},
- })
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{initialResource},
- })
- assert.Len(t, mockEmitter.Batches, 1, "Initial snapshot should emit one batch")
-
- reconciler.ResetState()
- assert.False(t, reconciler.HasBothStates(), "ResetState should clear observed snapshot flags")
-
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{updatedResource},
- })
- assert.Len(t, mockEmitter.Batches, 1, "Fresh repo state alone should not reconcile against stale cluster state")
-
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{updatedResource},
- })
- assert.Len(t, mockEmitter.Batches, 2, "Fresh cluster+repo snapshots should trigger the next batch")
-}
-
-// TestFolderReconciler_EmptyClusterSnapshotEmptiesGitTree documents a deliberate
-// design decision: a cluster snapshot is authoritative. When the snapshot is empty
-// while Git still holds a mirror, the cluster genuinely has no watched resources,
-// so the reconciler deletes the orphaned Git files to keep the mirror faithful.
-//
-// This is only safe because the snapshot itself is the trust boundary:
-// Manager.GetClusterStateForGitDest fails loudly (returns an error) for any
-// unresolved rule or failed list, so an *incomplete* snapshot never reaches the
-// reconciler disguised as an empty one — see the TestSnapshotAbortsOn* tests in
-// the watch package.
-func TestFolderReconciler_EmptyClusterSnapshotEmptiesGitTree(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Git holds a mirror; the cluster genuinely has no watched resources.
- gitResources := []types.ResourceIdentifier{
- {
- Group: "helm.toolkit.fluxcd.io", Version: "v2", Resource: "helmreleases",
- Namespace: "cozy-system", Name: "cilium",
- },
- {
- Group: "apps.cozystack.io", Version: "v1alpha1", Resource: "tenants",
- Namespace: "tenant-root", Name: "root",
- },
- }
-
- reconciler.OnClusterState(events.ClusterStateEvent{GitDest: gitDest, Resources: nil})
- reconciler.OnRepoState(events.RepoStateEvent{GitDest: gitDest, Resources: gitResources})
-
- assert.Len(t, mockEmitter.GetEventsByOperation("DELETE"), len(gitResources),
- "an authoritative empty cluster snapshot empties the Git mirror to match")
-}
-
-// MockReconcileEmitter is a mock implementation of ReconcileEmitter for testing.
-type MockReconcileEmitter struct {
- Batches []git.WriteRequest
-}
-
-func (m *MockReconcileEmitter) EmitWriteRequest(request git.WriteRequest) error {
- m.Batches = append(m.Batches, request)
- return nil
-}
-
-// GetEventsByOperation returns all events from all batches matching the given operation.
-func (m *MockReconcileEmitter) GetEventsByOperation(op string) []git.Event {
- var result []git.Event
- for _, batch := range m.Batches {
- for _, ev := range batch.Events {
- if ev.Operation == op {
- result = append(result, ev)
- }
- }
- }
- return result
-}
-
-// GetIdentifiersByOperation returns resource identifiers from all batch events with the given operation.
-func (m *MockReconcileEmitter) GetIdentifiersByOperation(op string) []types.ResourceIdentifier {
- var result []types.ResourceIdentifier
- for _, ev := range m.GetEventsByOperation(op) {
- result = append(result, ev.Identifier)
- }
- return result
-}
-
-// MockControlEventEmitter is a mock implementation of ControlEventEmitter for testing.
-type MockControlEventEmitter struct {
- controlEvents []events.ControlEvent
-}
-
-func (m *MockControlEventEmitter) EmitControlEvent(event events.ControlEvent) error {
- m.controlEvents = append(m.controlEvents, event)
- return nil
-}
-
-func (m *MockControlEventEmitter) GetControlEvents() []events.ControlEvent {
- return m.controlEvents
-}
diff --git a/internal/reconcile/git_target_event_stream.go b/internal/reconcile/git_target_event_stream.go
index d2f97e17..5d51beb1 100644
--- a/internal/reconcile/git_target_event_stream.go
+++ b/internal/reconcile/git_target_event_stream.go
@@ -21,6 +21,8 @@ package reconcile
import (
"crypto/sha256"
"fmt"
+ "sort"
+ "strings"
"sync"
"github.com/go-logr/logr"
@@ -61,10 +63,11 @@ type GitTargetEventStream struct {
mu sync.RWMutex
}
-// EventEnqueuer interface for enqueuing events and requests (allows mocking).
+// EventEnqueuer enqueues live watch events onto a branch worker (allows mocking).
+// The streaming-snapshot resync (M8) is driven directly through the worker, so the
+// stream itself only ever forwards individual live events.
type EventEnqueuer interface {
Enqueue(event git.Event)
- EnqueueRequest(request *git.WriteRequest)
}
// NewGitTargetEventStream creates a new event stream for a GitTarget.
@@ -129,17 +132,6 @@ func (s *GitTargetEventStream) OnWatchEvent(event git.Event) {
}
}
-// EmitWriteRequest forwards a complete reconcile write request to the
-// BranchWorker as a single work item. Called while in RECONCILING state by
-// FolderReconciler.
-func (s *GitTargetEventStream) EmitWriteRequest(request git.WriteRequest) error {
- request.GitTargetName = s.gitTargetName
- request.GitTargetNamespace = s.gitTargetNamespace
- request.CommitMode = git.CommitModeAtomic
- s.branchWorker.EnqueueRequest(&request)
- return nil
-}
-
// OnReconciliationComplete signals that reconciliation has finished.
// Transitions to LIVE_PROCESSING and flushes buffered live events.
func (s *GitTargetEventStream) OnReconciliationComplete() {
@@ -175,7 +167,7 @@ func (s *GitTargetEventStream) OnReconciliationComplete() {
// processEvent forwards the event to BranchWorker and updates deduplication state.
func (s *GitTargetEventStream) processEvent(event git.Event, eventHash, resourceKey string) {
- if event.Object == nil && event.Operation != "DELETE" {
+ if event.Object == nil && !event.IsFieldPatch() && event.Operation != "DELETE" {
s.logger.V(1).Info(
"Skipping event with no object payload",
"resource", resourceKey,
@@ -200,6 +192,13 @@ func (s *GitTargetEventStream) processEvent(event git.Event, eventHash, resource
// computeEventHash calculates a hash of the event content that would be written to Git.
func (s *GitTargetEventStream) computeEventHash(event git.Event) string {
+ if event.IsFieldPatch() {
+ // Field-patch events carry no Object. Hash the patch CONTENT (operation,
+ // parent identity, source, and assignments) — never the resourceVersion —
+ // so a redelivered identical patch dedups while two different values for the
+ // same parent stay distinct. This mirrors the object path's content-dedup.
+ return fmt.Sprintf("%x", sha256.Sum256([]byte(fieldPatchHashContent(event))))
+ }
if event.Object == nil {
// Control events - hash the operation and identifier
content := fmt.Sprintf("%s:%s", event.Operation, event.Identifier.String())
@@ -221,6 +220,22 @@ func (s *GitTargetEventStream) computeEventHash(event git.Event) string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(content)))
}
+// fieldPatchHashContent renders a field-patch event into a stable dedup string:
+// operation, parent identity, source, and the (path=value) assignments. Assignment
+// order is normalized so the key does not depend on the translator's emission
+// order, and the value is rendered with %v (which prints map keys in sorted order),
+// so two patches that set the same fields to the same values produce the same key.
+func fieldPatchHashContent(event git.Event) string {
+ patch := event.FieldPatch
+ parts := make([]string, 0, len(patch.Assignments))
+ for _, assignment := range patch.Assignments {
+ parts = append(parts, strings.Join(assignment.Path, ".")+"="+fmt.Sprintf("%v", assignment.Value))
+ }
+ sort.Strings(parts)
+ return fmt.Sprintf("%s:%s:%s:%s",
+ event.Operation, event.Identifier.String(), patch.Source, strings.Join(parts, "|"))
+}
+
// GetState returns the current state of the event stream.
func (s *GitTargetEventStream) GetState() EventStreamState {
s.mu.RLock()
diff --git a/internal/reconcile/git_target_event_stream_test.go b/internal/reconcile/git_target_event_stream_test.go
index 3f20888d..7f5f317d 100644
--- a/internal/reconcile/git_target_event_stream_test.go
+++ b/internal/reconcile/git_target_event_stream_test.go
@@ -27,6 +27,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/ConfigButler/gitops-reverser/internal/git"
+ "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
"github.com/ConfigButler/gitops-reverser/internal/types"
)
@@ -45,7 +46,7 @@ var _ = Describe("GitTargetEventStream", func() {
)
BeforeEach(func() {
- mockWorker = &mockBranchWorker{events: make([]git.Event, 0), batches: make([]*git.WriteRequest, 0)}
+ mockWorker = &mockBranchWorker{events: make([]git.Event, 0)}
logger = logr.Discard()
stream = NewGitTargetEventStream(gitTargetName, gitTargetNS, mockWorker, logger)
})
@@ -160,23 +161,6 @@ var _ = Describe("GitTargetEventStream", func() {
})
})
- Describe("EmitWriteRequest", func() {
- It("should enqueue request to worker and stamp GitTarget info", func() {
- request := git.WriteRequest{
- Events: []git.Event{
- createTestEvent("pod", "pod1", "CREATE"),
- },
- CommitMessage: "reconcile: sync 1 resources",
- }
-
- err := stream.EmitWriteRequest(request)
- Expect(err).NotTo(HaveOccurred())
- Expect(mockWorker.batches).To(HaveLen(1))
- Expect(mockWorker.batches[0].GitTargetName).To(Equal(gitTargetName))
- Expect(mockWorker.batches[0].GitTargetNamespace).To(Equal(gitTargetNS))
- })
- })
-
Describe("Event Hash Deduplication", func() {
It("should treat different operations as different events", func() {
event1 := createTestEvent("pod", "test-pod", "CREATE")
@@ -202,29 +186,45 @@ var _ = Describe("GitTargetEventStream", func() {
Expect(mockWorker.events).To(HaveLen(2))
})
})
+
+ Describe("Field Patch Events", func() {
+ BeforeEach(func() {
+ stream.OnReconciliationComplete() // Live processing
+ })
+
+ It("forwards a field-patch event that carries no object", func() {
+ stream.OnWatchEvent(createTestFieldPatchEvent(3))
+
+ Expect(mockWorker.events).To(HaveLen(1))
+ Expect(mockWorker.events[0].IsFieldPatch()).To(BeTrue())
+ Expect(mockWorker.events[0].Object).To(BeNil())
+ })
+
+ It("deduplicates an identical redelivered field-patch event", func() {
+ stream.OnWatchEvent(createTestFieldPatchEvent(3))
+ stream.OnWatchEvent(createTestFieldPatchEvent(3)) // same content, e.g. Redis redelivery
+
+ Expect(mockWorker.events).To(HaveLen(1))
+ })
+
+ It("treats different field values for the same parent as distinct events", func() {
+ stream.OnWatchEvent(createTestFieldPatchEvent(3))
+ stream.OnWatchEvent(createTestFieldPatchEvent(5))
+
+ Expect(mockWorker.events).To(HaveLen(2))
+ })
+ })
})
-// mockBranchWorker implements EventEnqueuer interface for testing.
+// mockBranchWorker implements the EventEnqueuer interface for testing.
type mockBranchWorker struct {
- events []git.Event
- batches []*git.WriteRequest
+ events []git.Event
}
func (m *mockBranchWorker) Enqueue(event git.Event) {
m.events = append(m.events, event)
}
-func (m *mockBranchWorker) EnqueueRequest(request *git.WriteRequest) {
- if request == nil {
- return
- }
- if request.CommitMode == git.CommitModeAtomic {
- m.batches = append(m.batches, request)
- return
- }
- m.events = append(m.events, request.Events...)
-}
-
// createTestEvent creates a test event with minimal required fields.
func createTestEvent(resourceType, name, operation string) git.Event {
obj := &unstructured.Unstructured{}
@@ -249,3 +249,28 @@ func createTestEvent(resourceType, name, operation string) git.Event {
Path: "test-folder",
}
}
+
+// createTestFieldPatchEvent builds a deployments/scale-shaped field-patch event:
+// no Object, just a spec.replicas assignment against a parent Deployment identity.
+func createTestFieldPatchEvent(replicas int64) git.Event {
+ identifier := types.ResourceIdentifier{
+ Group: "apps",
+ Version: "v1",
+ Resource: "deployments",
+ Name: "web",
+ Namespace: "default",
+ }
+
+ return git.Event{
+ FieldPatch: &git.FieldPatch{
+ Assignments: []manifestedit.FieldAssignment{
+ {Path: []string{"spec", "replicas"}, Value: replicas},
+ },
+ Source: "deployments/scale",
+ },
+ Identifier: identifier,
+ Operation: "UPDATE",
+ UserInfo: git.UserInfo{Username: "test-user", UID: "test-uid"},
+ Path: "test-folder",
+ }
+}
diff --git a/internal/reconcile/integration_test.go b/internal/reconcile/integration_test.go
deleted file mode 100644
index 07afb1ce..00000000
--- a/internal/reconcile/integration_test.go
+++ /dev/null
@@ -1,330 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package reconcile
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "sigs.k8s.io/controller-runtime/pkg/log"
-
- "github.com/ConfigButler/gitops-reverser/internal/events"
- "github.com/ConfigButler/gitops-reverser/internal/types"
-)
-
-// TestFolderReconciler_FullReconciliationCycle tests the complete reconciliation cycle
-// with both cluster and Git state events.
-func TestFolderReconciler_FullReconciliationCycle(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Initial state - should not reconcile yet
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- })
-
- // Should not have reconciled yet (missing Git state)
- assert.False(t, reconciler.HasBothStates(), "Should not have both states")
- assert.Empty(t, mockEmitter.Batches, "Should not emit batch without both states")
-
- // Provide Git state - should now reconcile
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "services", Name: "old-service"}, // Orphan
- },
- })
-
- // Should now have both states and a single batch
- assert.True(t, reconciler.HasBothStates(), "Should have both states")
- assert.Len(t, mockEmitter.Batches, 1, "Should emit exactly one batch")
-
- createEvents := mockEmitter.GetEventsByOperation("CREATE")
- deleteEvents := mockEmitter.GetEventsByOperation("DELETE")
- reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource))
-
- assert.Empty(t, createEvents, "No resources should be created (pod exists in both)")
- assert.Len(t, deleteEvents, 1, "Should delete orphaned service")
- assert.Equal(t, "old-service", deleteEvents[0].Identifier.Name, "Should delete orphaned service")
- assert.Len(t, reconcileEvents, 1, "Should emit reconcile event for existing resource")
-}
-
-// TestFolderReconciler_MissingInGit tests reconciliation when cluster has resources not in Git.
-func TestFolderReconciler_MissingInGit(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Cluster has resources, Git has subset
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "services", Name: "app-svc"}, // Missing in Git
- },
- })
-
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- })
-
- createEvents := mockEmitter.GetEventsByOperation("CREATE")
- reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource))
-
- assert.Len(t, createEvents, 1, "Should emit one create event")
- assert.Equal(t, "app-svc", createEvents[0].Identifier.Name, "Should create missing service")
-
- assert.Len(t, reconcileEvents, 1, "Should emit one reconcile event")
- assert.Equal(t, "app-pod", reconcileEvents[0].Identifier.Name, "Should reconcile existing pod")
-}
-
-// TestFolderReconciler_OrphansInGit tests reconciliation when Git has orphaned resources.
-func TestFolderReconciler_OrphansInGit(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("test-gitdest", "default")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Git has resources not in cluster
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- })
-
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "old-config"}, // Orphan
- },
- })
-
- deleteEvents := mockEmitter.GetEventsByOperation("DELETE")
- reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource))
-
- assert.Len(t, deleteEvents, 1, "Should emit one delete event")
- assert.Equal(t, "old-config", deleteEvents[0].Identifier.Name, "Should delete orphan configmap")
-
- assert.Len(t, reconcileEvents, 1, "Should emit one reconcile event")
- assert.Equal(t, "app-pod", reconcileEvents[0].Identifier.Name, "Should reconcile existing pod")
-}
-
-// TestFolderReconciler_OrderIndependence tests that event order doesn't matter.
-func TestFolderReconciler_OrderIndependence(t *testing.T) {
- // Test 1: Git state first, then cluster state
- mockEmitter1 := &MockReconcileEmitter{}
- mockControlEmitter1 := &MockControlEventEmitter{}
- gitDest1 := types.NewResourceReference("test-gitdest", "default")
- reconciler1 := NewFolderReconciler(gitDest1, mockEmitter1, mockControlEmitter1, log.Log)
-
- reconciler1.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest1,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- })
-
- reconciler1.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest1,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "services", Name: "app-svc"},
- },
- })
-
- // Test 2: Cluster state first, then Git state
- mockEmitter2 := &MockReconcileEmitter{}
- mockControlEmitter2 := &MockControlEventEmitter{}
- gitDest2 := types.NewResourceReference("test-gitdest", "default")
- reconciler2 := NewFolderReconciler(gitDest2, mockEmitter2, mockControlEmitter2, log.Log)
-
- reconciler2.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest2,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- {Group: "", Version: "v1", Resource: "services", Name: "app-svc"},
- },
- })
-
- reconciler2.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest2,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "pods", Name: "app-pod"},
- },
- })
-
- // Both should produce the same results regardless of order
- createEvents1 := mockEmitter1.GetEventsByOperation("CREATE")
- createEvents2 := mockEmitter2.GetEventsByOperation("CREATE")
-
- assert.Len(t, createEvents2, len(createEvents1), "Both orders should produce same number of create events")
- assert.Len(t, createEvents1, 1, "Should have one create event")
-
- if len(createEvents1) > 0 && len(createEvents2) > 0 {
- assert.Equal(t,
- createEvents1[0].Identifier,
- createEvents2[0].Identifier,
- "Both orders should produce same create event",
- )
- }
-}
-
-// TestFolderReconciler_ScopeIsolation tests that different scopes don't interfere.
-func TestFolderReconciler_ScopeIsolation(t *testing.T) {
- mockEmitter1 := &MockReconcileEmitter{}
- mockControlEmitter1 := &MockControlEventEmitter{}
- gitDest1 := types.NewResourceReference("gitdest-apps", "default")
- reconciler1 := NewFolderReconciler(gitDest1, mockEmitter1, mockControlEmitter1, log.Log)
-
- mockEmitter2 := &MockReconcileEmitter{}
- mockControlEmitter2 := &MockControlEventEmitter{}
- gitDest2 := types.NewResourceReference("gitdest-infra", "default")
- reconciler2 := NewFolderReconciler(gitDest2, mockEmitter2, mockControlEmitter2, log.Log)
-
- // Send events to first reconciler (apps)
- reconciler1.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest1,
- Resources: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- })
-
- reconciler1.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest1,
- Resources: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "app-deployment"},
- },
- })
-
- // Send events to second reconciler (infrastructure)
- reconciler2.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest2,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "nodes", Name: "worker-node"},
- },
- })
-
- reconciler2.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest2,
- Resources: []types.ResourceIdentifier{
- {Group: "", Version: "v1", Resource: "nodes", Name: "worker-node"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "orphan-cm"}, // Orphan
- },
- })
-
- // Each reconciler should have its own state and events
- assert.True(t, reconciler1.HasBothStates(), "First reconciler should have both states")
- assert.True(t, reconciler2.HasBothStates(), "Second reconciler should have both states")
-
- reconcileEvents1 := mockEmitter1.GetEventsByOperation(string(events.ReconcileResource))
- assert.Len(t, reconcileEvents1, 1, "First reconciler should have one reconcile event")
- assert.Equal(t, "app-deployment", reconcileEvents1[0].Identifier.Name, "Should reconcile deployment")
-
- deleteEvents2 := mockEmitter2.GetEventsByOperation("DELETE")
- assert.Len(t, deleteEvents2, 1, "Second reconciler should have one delete event")
- assert.Equal(t, "orphan-cm", deleteEvents2[0].Identifier.Name, "Should delete orphan configmap")
-
- // Cross-contamination check
- assert.Empty(t, mockEmitter1.GetEventsByOperation("DELETE"), "First reconciler should not have delete events")
-
- reconcileEvents2 := mockEmitter2.GetEventsByOperation(string(events.ReconcileResource))
- assert.Len(t, reconcileEvents2, 1, "Second reconciler should have one reconcile event")
- assert.Equal(t, "worker-node", reconcileEvents2[0].Identifier.Name, "Should reconcile worker-node")
-}
-
-// TestFolderReconciler_ComplexScenario tests a complex real-world scenario.
-func TestFolderReconciler_ComplexScenario(t *testing.T) {
- mockEmitter := &MockReconcileEmitter{}
- mockControlEmitter := &MockControlEventEmitter{}
- gitDest := types.NewResourceReference("my-app-gitdest", "production")
- reconciler := NewFolderReconciler(gitDest, mockEmitter, mockControlEmitter, log.Log)
-
- // Initial cluster state
- reconciler.OnClusterState(events.ClusterStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "frontend"},
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "backend"},
- {Group: "", Version: "v1", Resource: "services", Name: "frontend-svc"},
- {Group: "", Version: "v1", Resource: "services", Name: "backend-svc"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "app-config"},
- },
- })
-
- // Current Git state (missing some resources, has some orphans)
- reconciler.OnRepoState(events.RepoStateEvent{
- GitDest: gitDest,
- Resources: []types.ResourceIdentifier{
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "frontend"},
- {Group: "", Version: "v1", Resource: "services", Name: "frontend-svc"},
- {Group: "", Version: "v1", Resource: "configmaps", Name: "old-config"}, // Orphan
- {Group: "", Version: "v1", Resource: "secrets", Name: "legacy-secret"}, // Orphan
- {Group: "apps", Version: "v1", Resource: "deployments", Name: "deprecated"}, // Orphan
- },
- })
-
- // Verify complex reconciliation results
- createEvents := mockEmitter.GetEventsByOperation("CREATE")
- deleteEvents := mockEmitter.GetEventsByOperation("DELETE")
- reconcileEvents := mockEmitter.GetEventsByOperation(string(events.ReconcileResource))
-
- // Should create missing resources (backend, backend-svc, app-config)
- assert.Len(t, createEvents, 3, "Should create 3 missing resources")
-
- createNames := make(map[string]bool)
- for _, event := range createEvents {
- createNames[event.Identifier.Name] = true
- }
- assert.True(t, createNames["backend"], "Should create missing backend deployment")
- assert.True(t, createNames["backend-svc"], "Should create missing backend service")
- assert.True(t, createNames["app-config"], "Should create missing configmap")
-
- // Should delete orphaned resources (old-config, legacy-secret, deprecated)
- assert.Len(t, deleteEvents, 3, "Should delete 3 orphaned resources")
-
- deleteNames := make(map[string]bool)
- for _, event := range deleteEvents {
- deleteNames[event.Identifier.Name] = true
- }
- assert.True(t, deleteNames["old-config"], "Should delete old configmap")
- assert.True(t, deleteNames["legacy-secret"], "Should delete legacy secret")
- assert.True(t, deleteNames["deprecated"], "Should delete deprecated deployment")
-
- // Should reconcile existing resources (frontend, frontend-svc)
- assert.Len(t, reconcileEvents, 2, "Should reconcile 2 existing resources")
-
- reconcileNames := make(map[string]bool)
- for _, event := range reconcileEvents {
- reconcileNames[event.Identifier.Name] = true
- }
- assert.True(t, reconcileNames["frontend"], "Should reconcile frontend deployment")
- assert.True(t, reconcileNames["frontend-svc"], "Should reconcile frontend service")
-}
diff --git a/internal/reconcile/reconciler_manager.go b/internal/reconcile/reconciler_manager.go
deleted file mode 100644
index 36eca534..00000000
--- a/internal/reconcile/reconciler_manager.go
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package reconcile
-
-import (
- "context"
- "errors"
- "sync"
-
- "github.com/go-logr/logr"
-
- "github.com/ConfigButler/gitops-reverser/internal/events"
- "github.com/ConfigButler/gitops-reverser/internal/types"
-)
-
-// ReconcilerManager manages the lifecycle of FolderReconciler instances.
-type ReconcilerManager struct {
- mu sync.RWMutex
- reconcilers map[string]*FolderReconciler // key = gitDest.Key() = "namespace/name"
- eventRouter interface {
- ProcessControlEvent(ctx context.Context, event events.ControlEvent) error
- }
- logger logr.Logger
-
- onReconcilerCreated func(context.Context, types.ResourceReference)
-}
-
-// NewReconcilerManager creates a new ReconcilerManager.
-func NewReconcilerManager(
- eventRouter interface {
- ProcessControlEvent(ctx context.Context, event events.ControlEvent) error
- },
- logger logr.Logger,
-) *ReconcilerManager {
- return &ReconcilerManager{
- reconcilers: make(map[string]*FolderReconciler),
- eventRouter: eventRouter,
- logger: logger,
- }
-}
-
-// SetEventRouter sets the control-event processor dependency after construction.
-func (m *ReconcilerManager) SetEventRouter(
- eventRouter interface {
- ProcessControlEvent(ctx context.Context, event events.ControlEvent) error
- },
-) {
- m.eventRouter = eventRouter
-}
-
-// SetOnReconcilerCreated registers a callback fired after a new FolderReconciler is created.
-func (m *ReconcilerManager) SetOnReconcilerCreated(callback func(context.Context, types.ResourceReference)) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.onReconcilerCreated = callback
-}
-
-// CreateReconciler creates or retrieves a FolderReconciler for the given GitDestination.
-// ctx is the caller's reconcile context; it is forwarded to the
-// onReconcilerCreated callback so downstream work (e.g. snapshot replay) is
-// cancellable rather than running on a detached context.Background().
-func (m *ReconcilerManager) CreateReconciler(
- ctx context.Context,
- gitDest types.ResourceReference,
- requestEmitter WriteRequestEmitter,
-) *FolderReconciler {
- key := gitDest.Key()
-
- m.mu.Lock()
- if reconciler, exists := m.reconcilers[key]; exists {
- m.mu.Unlock()
- m.logger.V(1).Info("Reconciler already exists", "gitDest", gitDest.String())
- return reconciler
- }
-
- reconciler := NewFolderReconciler(gitDest, requestEmitter, m, m.logger)
- m.reconcilers[key] = reconciler
- callback := m.onReconcilerCreated
- m.mu.Unlock()
-
- m.logger.Info("Created new FolderReconciler", "gitDest", gitDest.String())
- if callback != nil {
- callback(ctx, gitDest)
- }
- return reconciler
-}
-
-// GetReconciler retrieves a FolderReconciler for the given GitDestination.
-func (m *ReconcilerManager) GetReconciler(gitDest types.ResourceReference) (*FolderReconciler, bool) {
- m.mu.RLock()
- defer m.mu.RUnlock()
- reconciler, exists := m.reconcilers[gitDest.Key()]
- return reconciler, exists
-}
-
-// DeleteReconciler removes a FolderReconciler from management.
-func (m *ReconcilerManager) DeleteReconciler(gitDest types.ResourceReference) bool {
- m.mu.Lock()
- defer m.mu.Unlock()
-
- key := gitDest.Key()
- if _, exists := m.reconcilers[key]; !exists {
- m.logger.V(1).Info("Reconciler not found", "gitDest", gitDest.String())
- return false
- }
- delete(m.reconcilers, key)
- m.logger.Info("Deleted FolderReconciler", "gitDest", gitDest.String())
- return true
-}
-
-// EmitControlEvent implements ControlEventEmitter interface.
-func (m *ReconcilerManager) EmitControlEvent(event events.ControlEvent) error {
- if m.eventRouter == nil {
- return errors.New("eventRouter not set")
- }
- return m.eventRouter.ProcessControlEvent(context.Background(), event)
-}
-
-// ListReconcilers returns all managed reconcilers.
-func (m *ReconcilerManager) ListReconcilers() []*FolderReconciler {
- m.mu.RLock()
- defer m.mu.RUnlock()
-
- var reconcilers []*FolderReconciler
- for _, reconciler := range m.reconcilers {
- reconcilers = append(reconcilers, reconciler)
- }
- return reconcilers
-}
-
-// CountReconcilers returns the number of managed reconcilers.
-func (m *ReconcilerManager) CountReconcilers() int {
- m.mu.RLock()
- defer m.mu.RUnlock()
- return len(m.reconcilers)
-}
diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go
index 2f5288e8..323dc30b 100644
--- a/internal/telemetry/exporter.go
+++ b/internal/telemetry/exporter.go
@@ -63,11 +63,11 @@ var (
RepoBranchQueueDepth metric.Int64UpDownCounter
// TargetReconcileCompletedTotal counts completed rule-set snapshot reconcile
- // passes per GitTarget: each increment marks one pass where the snapshot
- // decision was made and its write request submitted to the branch worker
- // queue. Labelled by {gittarget_namespace, gittarget_name, trigger} where
- // trigger is `rule_change` (the GVR/rule reconcile path) or `startup_replay`
- // (a snapshot replayed once a FolderReconciler is created). A counter, not a
+ // passes per GitTarget: each increment marks one pass where the streaming-snapshot
+ // resync was gathered and ENQUEUED on the branch worker (not waited on to commit —
+ // see Manager.recordTargetReconcileCompleted). Labelled by {gittarget_namespace,
+ // gittarget_name, trigger} where trigger is `rule_change` (the GVR/rule reconcile
+ // path). A counter, not a
// latched gauge, on purpose: a counter resets to 0 on a fresh pod, so a
// per-pod `{pod=""} > 0` check after a rollout proves the new pod did
// its own reconcile — robust to the old pod's stale series that a Prometheus
@@ -92,6 +92,29 @@ var (
// spec's drain wait; treat the name/labels as a public observability contract.
BranchWorkerQueueDepth metric.Int64Gauge
+ // ResyncBackgroundFailuresTotal counts rule-change resyncs whose apply failed or
+ // timed out at the worker AFTER being enqueued. Delivery is marked on enqueue (the
+ // resync is fire-and-forget to avoid an unbounded re-gather loop — see
+ // Manager.recordTargetReconcileCompleted), so a failed background apply is otherwise
+ // only logged. This counter makes those failures observable/alertable without
+ // triggering an immediate re-gather. Labelled by {gittarget_namespace,
+ // gittarget_name}; a sustained increase means snapshots are not committing and the
+ // folder is relying on steady-state events to catch up.
+ ResyncBackgroundFailuresTotal metric.Int64Counter
+
+ // TypeLifecycleReconcileTotal counts M12 per-type reconciles driven by a registry
+ // TypeActivated transition: each increment is one (GitTarget, type) reconcile enqueued
+ // after the type settled into the followable set. Labelled by {gittarget_namespace,
+ // gittarget_name}; an increase tracks types coming online (e.g. a CRD installed) being
+ // mirrored without a whole-GitTarget resync.
+ TypeLifecycleReconcileTotal metric.Int64Counter
+ // TypeLifecycleSweepTotal counts M12 per-type sweeps driven by a registry TypeRemoved
+ // transition (a type whose removal grace elapsed): each increment is one (GitTarget,
+ // type) scoped sweep enqueued. Labelled by {gittarget_namespace, gittarget_name}; an
+ // increase tracks types going away (e.g. a CRD deleted) having only their own documents
+ // pruned.
+ TypeLifecycleSweepTotal metric.Int64Counter
+
// WatchDuplicatesSkippedTotal counts watch events skipped due to duplicate sanitized content.
WatchDuplicatesSkippedTotal metric.Int64Counter
// AuditEventsReceivedTotal counts audit events received from Kubernetes API server.
@@ -156,6 +179,9 @@ var (
APICatalogRefreshDurationSeconds metric.Float64Histogram
// APICatalogGeneration gauges the current APIResourceCatalog generation.
APICatalogGeneration metric.Int64Gauge
+ // WatchedTypes gauges the number of watched types per GitTarget, labelled by
+ // gittarget_namespace and gittarget_name.
+ WatchedTypes metric.Int64Gauge
// SecretEncryptionAttemptsTotal counts total Secret encryption attempts.
SecretEncryptionAttemptsTotal metric.Int64Counter
// SecretEncryptionSuccessTotal counts successful Secret encryptions.
@@ -235,8 +261,21 @@ type (
)
// registerInstruments creates every metric instrument against the current
-// otelMeter and stores it in its package-level variable.
+// otelMeter and stores it in its package-level variable, one kind at a time.
func registerInstruments() error {
+ if err := registerCounters(); err != nil {
+ return err
+ }
+ if err := registerHistograms(); err != nil {
+ return err
+ }
+ if err := registerGauges(); err != nil {
+ return err
+ }
+ return registerUpDownCounters()
+}
+
+func registerCounters() error {
counters := []cSpec{
{"gitopsreverser_git_operations_total", &GitOperationsTotal},
{"gitopsreverser_objects_scanned_total", &ObjectsScannedTotal},
@@ -267,6 +306,9 @@ func registerInstruments() error {
{"gitopsreverser_secret_encryption_cache_hits_total", &SecretEncryptionCacheHitsTotal},
{"gitopsreverser_secret_encryption_marker_skips_total", &SecretEncryptionMarkerSkipsTotal},
{"gitopsreverser_target_reconcile_completed_total", &TargetReconcileCompletedTotal},
+ {"gitopsreverser_resync_background_failures_total", &ResyncBackgroundFailuresTotal},
+ {"gitopsreverser_type_lifecycle_reconcile_total", &TypeLifecycleReconcileTotal},
+ {"gitopsreverser_type_lifecycle_sweep_total", &TypeLifecycleSweepTotal},
}
for _, s := range counters {
v, err := otelMeter.Int64Counter(s.name)
@@ -275,7 +317,10 @@ func registerInstruments() error {
}
*s.dest = v
}
+ return nil
+}
+func registerHistograms() error {
// auditJoinBuckets span the wait budget (sub-second) and the parked-body TTL margin
// (seconds to minutes) so one set of boundaries fits both skew and gate-wait timings.
auditJoinBuckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5, 30, 300}
@@ -304,11 +349,15 @@ func registerInstruments() error {
}
*s.dest = v
}
+ return nil
+}
+func registerGauges() error {
gauges := []gSpec{
{"gitopsreverser_api_catalog_resources", &APICatalogResources},
{"gitopsreverser_api_catalog_group_versions", &APICatalogGroupVersions},
{"gitopsreverser_api_catalog_generation", &APICatalogGeneration},
+ {"gitopsreverser_watched_types", &WatchedTypes},
{"gitopsreverser_audit_queue_stream_length", &AuditQueueStreamLength},
{"gitopsreverser_audit_queue_consumer_lag", &AuditQueueConsumerLag},
{"gitopsreverser_audit_queue_pending_entries", &AuditQueuePendingEntries},
@@ -324,7 +373,10 @@ func registerInstruments() error {
}
*s.dest = v
}
+ return nil
+}
+func registerUpDownCounters() error {
upDowns := []uSpec{
{"gitopsreverser_repo_branch_active_workers", &RepoBranchActiveWorkers},
{"gitopsreverser_repo_branch_queue_depth", &RepoBranchQueueDepth},
diff --git a/internal/typeset/funnel.go b/internal/typeset/funnel.go
new file mode 100644
index 00000000..6a5965bb
--- /dev/null
+++ b/internal/typeset/funnel.go
@@ -0,0 +1,318 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import "strings"
+
+// requiredVerbs are the verbs discovery must advertise for a type to be followable,
+// in the order the missing-verb detail lists them. GitOps Reverser mirrors cluster
+// state into Git, which is a read path, so it needs get/list/watch to enumerate and
+// follow a type. It deliberately does NOT require patch: a read-only type is still
+// mirrorable, and the one write-back GitOps Reverser performs (a /scale replica
+// assignment) is gated on the scale subresource's own verbs via the scale
+// requirement, not on the parent carrying patch. This is a deliberate simplification
+// of the design doc's get/list/watch/patch list — see
+// docs/design/manifest/version2/type-followability-implementation.md.
+func requiredVerbs() []string { return []string{"get", "list", "watch"} }
+
+// Observation is the raw per-type facts the funnel reduces into a Followability. It
+// is built by the scan (discovery + CRD/APIService evidence + the built-in scale
+// registry + product policy) and carries every fact a check needs, so Evaluate is a
+// pure function with no side inputs. The registry owns how observations are built;
+// the funnel owns only how they are judged.
+type Observation struct {
+ Identity Identity
+ Origin Origin
+
+ Preferred bool
+ Verbs []string
+ Subresources Subresources
+
+ // served / trusted / stable facts.
+ Served bool // discovery currently serves this as a top-level resource
+ SubresourceOnly bool // the kind is served only as a subresource
+ Trusted bool // backing group/version came from trusted, non-degraded discovery
+ CatalogReady bool // the catalog has accepted any trusted discovery data
+ AbsenceExpired bool // the type is mid-disappearance and the removal grace has elapsed
+
+ // identity facts.
+ GVKUnique bool // exactly one GVR serves this GVK
+ GVRUnique bool // this GVR resolves back to exactly one Kind
+ GVKConflictDetail string // e.g. "widgets, widgetz" when GVKUnique is false
+ GVRConflictDetail string // e.g. "Widget, Gadget" when GVRUnique is false
+
+ // policy facts (computed by the registry from group/resource).
+ Denied bool
+ DenyDetail string
+ Sensitive bool
+ SensitiveSupported bool
+}
+
+// Evaluate reduces one Observation into its Followability: every requirement check
+// in funnel order, plus the mechanical verdict and one-line summary. It is the
+// single decision point — there is no second "inspect" pass.
+func Evaluate(obs Observation) Followability {
+ checks := []Check{
+ servedCheck(obs),
+ trustedCheck(obs),
+ stableCheck(obs),
+ identityCheck(obs),
+ scopeCheck(obs),
+ verbsCheck(obs),
+ originCheck(obs),
+ policyCheck(obs),
+ sensitivityCheck(obs),
+ scaleCheck(obs),
+ }
+ verdict := deriveVerdict(checks)
+ return Followability{
+ Verdict: verdict,
+ Summary: summarize(verdict, checks),
+ Checks: checks,
+ }
+}
+
+func servedCheck(obs Observation) Check {
+ switch {
+ case obs.Served:
+ return pass(RequirementServed)
+ case obs.SubresourceOnly:
+ return fail(RequirementServed, ReasonSubresourceOnly, "")
+ default:
+ return fail(RequirementServed, ReasonNotServed, "")
+ }
+}
+
+func trustedCheck(obs Observation) Check {
+ switch {
+ case obs.Trusted:
+ return pass(RequirementTrusted)
+ case !obs.CatalogReady:
+ return fail(RequirementTrusted, ReasonCatalogUnavailable, "")
+ default:
+ return fail(RequirementTrusted, ReasonDiscoveryDegraded, "")
+ }
+}
+
+func stableCheck(obs Observation) Check {
+ if obs.AbsenceExpired {
+ return fail(RequirementStable, ReasonAbsenceExpired, "")
+ }
+ return pass(RequirementStable)
+}
+
+func identityCheck(obs Observation) Check {
+ switch {
+ case !obs.GVKUnique:
+ return fail(RequirementIdentity, ReasonGVKNotUnique, obs.GVKConflictDetail)
+ case !obs.GVRUnique:
+ return fail(RequirementIdentity, ReasonGVRNotUnique, obs.GVRConflictDetail)
+ default:
+ return pass(RequirementIdentity)
+ }
+}
+
+func scopeCheck(obs Observation) Check {
+ if obs.Identity.Scope == ScopeUnknown || obs.Identity.Scope == "" {
+ return fail(RequirementScope, ReasonScopeUnknown, "")
+ }
+ return pass(RequirementScope)
+}
+
+func verbsCheck(obs Observation) Check {
+ missing := missingVerbs(obs.Verbs)
+ if len(missing) > 0 {
+ return fail(RequirementVerbs, ReasonMissingVerb, strings.Join(missing, ", "))
+ }
+ return pass(RequirementVerbs)
+}
+
+func originCheck(obs Observation) Check {
+ if obs.Origin.Kind == OriginUnknown || obs.Origin.Kind == "" {
+ return fail(RequirementOrigin, ReasonOriginUnknown, "")
+ }
+ return pass(RequirementOrigin)
+}
+
+func policyCheck(obs Observation) Check {
+ if obs.Denied {
+ return fail(RequirementPolicy, ReasonDeniedByPolicy, obs.DenyDetail)
+ }
+ return pass(RequirementPolicy)
+}
+
+func sensitivityCheck(obs Observation) Check {
+ if obs.Sensitive && !obs.SensitiveSupported {
+ return fail(RequirementSensitivity, ReasonSensitiveUnsupported, "")
+ }
+ return pass(RequirementSensitivity)
+}
+
+func scaleCheck(obs Observation) Check {
+ scale := obs.Subresources.Scale
+ switch {
+ case !scale.Enabled:
+ return skip(RequirementScale)
+ case !scale.Usable:
+ return fail(RequirementScale, ReasonScalePathUnresolved, "")
+ default:
+ return pass(RequirementScale)
+ }
+}
+
+// missingVerbs returns the required verbs absent from advertised, in required order.
+func missingVerbs(advertised []string) []string {
+ have := make(map[string]struct{}, len(advertised))
+ for _, v := range advertised {
+ have[v] = struct{}{}
+ }
+ var missing []string
+ for _, req := range requiredVerbs() {
+ if _, ok := have[req]; !ok {
+ missing = append(missing, req)
+ }
+ }
+ return missing
+}
+
+// deriveVerdict turns the funnel checks into one verdict, mechanically:
+// - no failures -> followable;
+// - a catalog-unavailable failure -> unknown (the whole catalog is down);
+// - only served/trusted fail and the absence has not expired -> retained;
+// - any other failure -> refused.
+func deriveVerdict(checks []Check) Verdict {
+ failures := failedChecks(checks)
+ if len(failures) == 0 {
+ return VerdictFollowable
+ }
+ if hasReason(failures, ReasonCatalogUnavailable) {
+ return VerdictUnknown
+ }
+ if onlyTransientFailures(failures) {
+ return VerdictRetained
+ }
+ return VerdictRefused
+}
+
+// onlyTransientFailures reports whether every failure is a served/trusted blip —
+// the transient checks the removal grace covers — so the type stays retained.
+func onlyTransientFailures(failures []Check) bool {
+ for _, c := range failures {
+ if c.Requirement != RequirementServed && c.Requirement != RequirementTrusted {
+ return false
+ }
+ }
+ return true
+}
+
+func failedChecks(checks []Check) []Check {
+ var out []Check
+ for _, c := range checks {
+ if c.Failed() {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func hasReason(checks []Check, reason Reason) bool {
+ for _, c := range checks {
+ if c.Reason == reason {
+ return true
+ }
+ }
+ return false
+}
+
+// summarize renders the one-line summary from the verdict and the first failing
+// check, so the summary always speaks the same reason vocabulary as the checks.
+func summarize(verdict Verdict, checks []Check) string {
+ if verdict == VerdictFollowable {
+ return "followable"
+ }
+ first, ok := firstFailure(checks)
+ if !ok {
+ return string(verdict)
+ }
+ phrase := reasonPhrase(first)
+ if verdict == VerdictRetained {
+ return "retained — " + phrase
+ }
+ return "not followable — " + phrase
+}
+
+func firstFailure(checks []Check) (Check, bool) {
+ for _, c := range checks {
+ if c.Failed() {
+ return c, true
+ }
+ }
+ return Check{}, false
+}
+
+// reasonPhrase maps a failed check to its human phrase. denied-by-policy substitutes
+// the policy's own detail; the identity and verb reasons append their bounded detail;
+// the rest read straight from the phrase table.
+func reasonPhrase(c Check) string {
+ if c.Reason == ReasonDeniedByPolicy && c.Detail != "" {
+ return c.Detail
+ }
+ phrase, ok := reasonPhrases()[c.Reason]
+ if !ok {
+ return string(c.Reason)
+ }
+ if c.Detail != "" && detailAppendedReason(c.Reason) {
+ return phrase + ": " + c.Detail
+ }
+ return phrase
+}
+
+// reasonPhrases is the base human phrase for every reason code.
+func reasonPhrases() map[Reason]string {
+ return map[Reason]string{
+ ReasonNotServed: "not served",
+ ReasonSubresourceOnly: "served only as a subresource",
+ ReasonDiscoveryDegraded: "discovery degraded for its group/version",
+ ReasonCatalogUnavailable: "API catalog unavailable",
+ ReasonAbsenceExpired: "no longer served (removal grace elapsed)",
+ ReasonGVKNotUnique: "GVK served by more than one resource",
+ ReasonGVRNotUnique: "resource resolves to more than one kind",
+ ReasonScopeUnknown: "scope unknown",
+ ReasonMissingVerb: "missing required verb",
+ ReasonOriginUnknown: "origin could not be classified",
+ ReasonDeniedByPolicy: "denied by policy",
+ ReasonSensitiveUnsupported: "sensitive type without supported write handling",
+ ReasonScalePathUnresolved: "scale parent replica path unresolved",
+ }
+}
+
+// detailAppendedReason reports whether a reason appends its bounded Detail to the
+// base phrase as ": " (rather than substituting or ignoring it).
+func detailAppendedReason(reason Reason) bool {
+ return reason == ReasonGVKNotUnique ||
+ reason == ReasonGVRNotUnique ||
+ reason == ReasonMissingVerb
+}
+
+func pass(req Requirement) Check { return Check{Requirement: req, Result: ResultPass} }
+func skip(req Requirement) Check { return Check{Requirement: req, Result: ResultSkip} }
+
+func fail(req Requirement, reason Reason, detail string) Check {
+ return Check{Requirement: req, Result: ResultFail, Reason: reason, Detail: detail}
+}
diff --git a/internal/typeset/funnel_test.go b/internal/typeset/funnel_test.go
new file mode 100644
index 00000000..1d7756ca
--- /dev/null
+++ b/internal/typeset/funnel_test.go
@@ -0,0 +1,301 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "testing"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// followableObservation is the baseline every funnel test mutates one field of: a
+// served, trusted, unambiguous, namespaced built-in Deployment with full verbs.
+func followableObservation() Observation {
+ return Observation{
+ Identity: Identity{
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
+ Scope: ScopeNamespaced,
+ },
+ Origin: Origin{Kind: OriginBuiltin, Confidence: ConfidenceInferred},
+ Verbs: []string{"get", "list", "watch", "patch", "create", "delete"},
+ Served: true,
+ Trusted: true,
+ CatalogReady: true,
+ GVKUnique: true,
+ GVRUnique: true,
+ }
+}
+
+func TestEvaluate_Followable(t *testing.T) {
+ f := Evaluate(followableObservation())
+ if f.Verdict != VerdictFollowable {
+ t.Fatalf("verdict = %q, want followable", f.Verdict)
+ }
+ if f.Summary != "followable" {
+ t.Fatalf("summary = %q, want followable", f.Summary)
+ }
+ if len(f.Checks) != 10 {
+ t.Fatalf("got %d checks, want 10 (one per requirement)", len(f.Checks))
+ }
+ // Every check passes except scale, which is skipped when scale is unused.
+ for _, c := range f.Checks {
+ if c.Requirement == RequirementScale {
+ if c.Result != ResultSkip {
+ t.Errorf("scale check = %q, want skip", c.Result)
+ }
+ continue
+ }
+ if c.Result != ResultPass {
+ t.Errorf("%s check = %q, want pass", c.Requirement, c.Result)
+ }
+ }
+}
+
+func TestEvaluate_CheckOrderIsFunnelOrder(t *testing.T) {
+ want := []Requirement{
+ RequirementServed, RequirementTrusted, RequirementStable, RequirementIdentity,
+ RequirementScope, RequirementVerbs, RequirementOrigin, RequirementPolicy,
+ RequirementSensitivity, RequirementScale,
+ }
+ got := Evaluate(followableObservation()).Checks
+ for i, req := range want {
+ if got[i].Requirement != req {
+ t.Errorf("check[%d] = %q, want %q", i, got[i].Requirement, req)
+ }
+ }
+}
+
+func TestEvaluate_RequirementFailures(t *testing.T) {
+ tests := []struct {
+ name string
+ mutate func(*Observation)
+ req Requirement
+ reason Reason
+ detail string
+ verdict Verdict
+ summary string
+ }{
+ {
+ name: "not served",
+ mutate: func(o *Observation) { o.Served = false },
+ req: RequirementServed,
+ reason: ReasonNotServed,
+ verdict: VerdictRetained,
+ summary: "retained — not served",
+ },
+ {
+ name: "subresource only",
+ mutate: func(o *Observation) { o.Served = false; o.SubresourceOnly = true },
+ req: RequirementServed,
+ reason: ReasonSubresourceOnly,
+ verdict: VerdictRetained,
+ summary: "retained — served only as a subresource",
+ },
+ {
+ name: "discovery degraded",
+ mutate: func(o *Observation) { o.Trusted = false },
+ req: RequirementTrusted,
+ reason: ReasonDiscoveryDegraded,
+ verdict: VerdictRetained,
+ summary: "retained — discovery degraded for its group/version",
+ },
+ {
+ name: "catalog unavailable",
+ mutate: func(o *Observation) { o.Trusted = false; o.CatalogReady = false },
+ req: RequirementTrusted,
+ reason: ReasonCatalogUnavailable,
+ verdict: VerdictUnknown,
+ summary: "not followable — API catalog unavailable",
+ },
+ {
+ name: "absence expired",
+ mutate: func(o *Observation) { o.Served = false; o.AbsenceExpired = true },
+ req: RequirementStable,
+ reason: ReasonAbsenceExpired,
+ verdict: VerdictRefused,
+ summary: "not followable — not served",
+ },
+ {
+ name: "gvk not unique",
+ mutate: func(o *Observation) { o.GVKUnique = false; o.GVKConflictDetail = "widgets, widgetz" },
+ req: RequirementIdentity,
+ reason: ReasonGVKNotUnique,
+ detail: "widgets, widgetz",
+ verdict: VerdictRefused,
+ summary: "not followable — GVK served by more than one resource: widgets, widgetz",
+ },
+ {
+ name: "gvr not unique",
+ mutate: func(o *Observation) { o.GVRUnique = false; o.GVRConflictDetail = "Widget, Gadget" },
+ req: RequirementIdentity,
+ reason: ReasonGVRNotUnique,
+ detail: "Widget, Gadget",
+ verdict: VerdictRefused,
+ summary: "not followable — resource resolves to more than one kind: Widget, Gadget",
+ },
+ {
+ name: "scope unknown",
+ mutate: func(o *Observation) { o.Identity.Scope = ScopeUnknown },
+ req: RequirementScope,
+ reason: ReasonScopeUnknown,
+ verdict: VerdictRefused,
+ summary: "not followable — scope unknown",
+ },
+ {
+ name: "missing verb",
+ mutate: func(o *Observation) { o.Verbs = []string{"get", "list"} },
+ req: RequirementVerbs,
+ reason: ReasonMissingVerb,
+ detail: "watch",
+ verdict: VerdictRefused,
+ summary: "not followable — missing required verb: watch",
+ },
+ {
+ name: "origin unknown",
+ mutate: func(o *Observation) { o.Origin = Origin{Kind: OriginUnknown} },
+ req: RequirementOrigin,
+ reason: ReasonOriginUnknown,
+ verdict: VerdictRefused,
+ summary: "not followable — origin could not be classified",
+ },
+ {
+ name: "denied by policy",
+ mutate: func(o *Observation) { o.Denied = true; o.DenyDetail = "excluded by default policy" },
+ req: RequirementPolicy,
+ reason: ReasonDeniedByPolicy,
+ detail: "excluded by default policy",
+ verdict: VerdictRefused,
+ summary: "not followable — excluded by default policy",
+ },
+ {
+ name: "sensitive unsupported",
+ mutate: func(o *Observation) { o.Sensitive = true },
+ req: RequirementSensitivity,
+ reason: ReasonSensitiveUnsupported,
+ verdict: VerdictRefused,
+ summary: "not followable — sensitive type without supported write handling",
+ },
+ {
+ name: "scale path unresolved",
+ mutate: func(o *Observation) {
+ o.Subresources.Scale = ScaleBinding{Enabled: true, Usable: false}
+ },
+ req: RequirementScale,
+ reason: ReasonScalePathUnresolved,
+ verdict: VerdictRefused,
+ summary: "not followable — scale parent replica path unresolved",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ obs := followableObservation()
+ tt.mutate(&obs)
+ f := Evaluate(obs)
+ if f.Verdict != tt.verdict {
+ t.Errorf("verdict = %q, want %q", f.Verdict, tt.verdict)
+ }
+ if f.Summary != tt.summary {
+ t.Errorf("summary = %q, want %q", f.Summary, tt.summary)
+ }
+ check, ok := f.Check(tt.req)
+ if !ok {
+ t.Fatalf("missing %s check", tt.req)
+ }
+ if check.Result != ResultFail {
+ t.Errorf("%s result = %q, want fail", tt.req, check.Result)
+ }
+ if check.Reason != tt.reason {
+ t.Errorf("%s reason = %q, want %q", tt.req, check.Reason, tt.reason)
+ }
+ if check.Detail != tt.detail {
+ t.Errorf("%s detail = %q, want %q", tt.req, check.Detail, tt.detail)
+ }
+ })
+ }
+}
+
+func TestEvaluate_ScaleUsablePasses(t *testing.T) {
+ obs := followableObservation()
+ binding, ok := BuiltinScale("apps", "deployments")
+ if !ok {
+ t.Fatal("expected deployments to be built-in scalable")
+ }
+ obs.Subresources.Scale = binding
+ f := Evaluate(obs)
+ if f.Verdict != VerdictFollowable {
+ t.Fatalf("verdict = %q, want followable", f.Verdict)
+ }
+ check, _ := f.Check(RequirementScale)
+ if check.Result != ResultPass {
+ t.Errorf("scale check = %q, want pass", check.Result)
+ }
+}
+
+func TestEvaluate_PermanentFailureWinsOverTransient(t *testing.T) {
+ // A type that is both absent (served fail) and ambiguous (identity fail) is
+ // refused, not retained: a permanent failure is never masked by the grace.
+ obs := followableObservation()
+ obs.Served = false
+ obs.GVKUnique = false
+ obs.GVKConflictDetail = "widgets, widgetz"
+ f := Evaluate(obs)
+ if f.Verdict != VerdictRefused {
+ t.Fatalf("verdict = %q, want refused", f.Verdict)
+ }
+}
+
+func TestEvaluate_FirstFailureDrivesSummary(t *testing.T) {
+ // served fails before verbs in funnel order, so the summary names served.
+ obs := followableObservation()
+ obs.Served = false
+ obs.Verbs = []string{"get"}
+ f := Evaluate(obs)
+ first, ok := f.FirstFailure()
+ if !ok || first.Requirement != RequirementServed {
+ t.Fatalf("first failure = %+v, want served", first)
+ }
+}
+
+func TestEvaluate_EmptyScopeTreatedAsUnknown(t *testing.T) {
+ obs := followableObservation()
+ obs.Identity.Scope = ""
+ check, _ := Evaluate(obs).Check(RequirementScope)
+ if check.Result != ResultFail || check.Reason != ReasonScopeUnknown {
+ t.Errorf("scope check = %+v, want fail/scope-unknown", check)
+ }
+}
+
+func TestEvaluate_EmptyOriginTreatedAsUnknown(t *testing.T) {
+ obs := followableObservation()
+ obs.Origin = Origin{}
+ check, _ := Evaluate(obs).Check(RequirementOrigin)
+ if check.Result != ResultFail || check.Reason != ReasonOriginUnknown {
+ t.Errorf("origin check = %+v, want fail/origin-unknown", check)
+ }
+}
+
+func TestEvaluate_SensitiveSupportedPasses(t *testing.T) {
+ obs := followableObservation()
+ obs.Sensitive = true
+ obs.SensitiveSupported = true
+ if Evaluate(obs).Verdict != VerdictFollowable {
+ t.Error("sensitive-but-supported should stay followable")
+ }
+}
diff --git a/internal/typeset/lifecycle.go b/internal/typeset/lifecycle.go
new file mode 100644
index 00000000..aaa6ccee
--- /dev/null
+++ b/internal/typeset/lifecycle.go
@@ -0,0 +1,248 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "sort"
+ "time"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// SettleWindow is how long a type must stay continuously followable before the registry
+// emits a TypeActivated for it. It governs ACTIVATION, not removal: a flapping or
+// just-appeared type does not drive a per-type reconcile on a state that is about to change
+// again. Like RemovalGrace it is product safety, not tuning, so it is a fixed constant — but
+// it is deliberately short where the grace is long. See
+// docs/design/manifest/version2/type-lifecycle-events-and-wobble-settling.md (Proposal 2).
+const SettleWindow = 5 * time.Second
+
+// EventKind names a per-type lifecycle transition the registry emits. The events are
+// transitions between the existing verdicts (no new verdict vocabulary): the registry is the
+// single component that owns the decision, so it computes the transition once and names it,
+// instead of every consumer re-detecting the same edge by diffing tables.
+type EventKind string
+
+const (
+ // TypeActivated fires when a type has been continuously Followable for the settle
+ // window: it is healthy and stable, so M12 may schedule its (re)reconcile.
+ TypeActivated EventKind = "TypeActivated"
+ // TypeWobbling fires on Followable -> Retained: a transient unserved blip. Do NOT sweep;
+ // postpone the type's reconcile and keep its informers up until it settles or drops.
+ TypeWobbling EventKind = "TypeWobbling"
+ // TypeRecovered fires on Retained -> Followable: the wobble resolved. It collapses into
+ // a fresh TypeActivated once the settle window elapses again.
+ TypeRecovered EventKind = "TypeRecovered"
+ // TypeRemoved fires when a previously-live type leaves the live set because its removal
+ // grace elapsed (absence-expired): it is genuinely gone, so M12 sweeps THIS type only.
+ TypeRemoved EventKind = "TypeRemoved"
+ // TypeRefused fires when a previously-live type fails a permanent check: never watch it,
+ // drop its informers, surface it in status.
+ TypeRefused EventKind = "TypeRefused"
+)
+
+// LifecycleEvent is one named transition between verdicts for a single type. It carries the
+// identity, the verdicts it crossed, the single machine-readable reason for a failure, the
+// scan generation the transition was computed at, and the time it was observed.
+type LifecycleEvent struct {
+ Kind EventKind
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+ From Verdict
+ To Verdict
+ Reason Reason
+ Generation uint64
+ At time.Time
+}
+
+// Observer receives lifecycle events from the registry. It is invoked by Update after the new
+// records are published, in generation order and serialized with other updates, so an
+// observer may read the registry but must NOT block the updater (a real consumer enqueues the
+// event and returns). See Registry.Subscribe.
+type Observer func(LifecycleEvent)
+
+// Subscribe registers an observer for every lifecycle event from subsequent Updates. Register
+// before the first Update to observe cold-start activations. Observers are invoked outside the
+// registry's read/write lock (so they may read the registry) but under the updater's
+// serialization, so a slow observer stalls the updater — keep them non-blocking.
+func (r *Registry) Subscribe(obs Observer) {
+ if obs == nil {
+ return
+ }
+ r.mu.Lock()
+ r.observers = append(r.observers, obs)
+ r.mu.Unlock()
+}
+
+// computeLifecycleLocked sets the settle bookkeeping on every next entry by comparing it with
+// its prior verdict, and returns the lifecycle transitions this Update crosses. It runs while
+// r.entries still holds the PREVIOUS records, before next is published, so the diff is against
+// what each consumer last saw. The caller holds r.mu.
+//
+// Cold start and always-refused types are silent: an event is emitted only for a type that was
+// live (or is entering the live set), never a spurious TypeRemoved/TypeRefused for a type the
+// registry never followed. TypeActivated is gated on the settle window, so a fresh or flapping
+// type does not activate until it has been stably Followable.
+func (r *Registry) computeLifecycleLocked(next map[recordKey]entry, now time.Time, generation uint64) []LifecycleEvent {
+ var events []LifecycleEvent
+ for key, e := range next {
+ prev, had := r.entries[key]
+ newVerdict := e.record.Followability.Verdict
+
+ // Settle bookkeeping: a continuous Followable streak carries its start time and its
+ // activated flag; any other verdict (or a gap) resets the streak so the window restarts.
+ if newVerdict == VerdictFollowable {
+ if had && prev.record.Followability.Verdict == VerdictFollowable {
+ e.followableSince = prev.followableSince
+ e.activated = prev.activated
+ } else {
+ e.followableSince = now
+ e.activated = false
+ }
+ }
+
+ events = appendTransitionEvent(events, had, prev, e, now, generation)
+
+ // Activation: emitted once per Followable streak, only after the settle window, so the
+ // first per-type reconcile only ever runs against a type that has been stably healthy.
+ if newVerdict == VerdictFollowable && !e.activated &&
+ !e.followableSince.IsZero() && now.Sub(e.followableSince) >= r.settle {
+ e.activated = true
+ events = append(events,
+ lifecycleEvent(TypeActivated, e.record, fromVerdict(had, prev), VerdictFollowable, "", now, generation))
+ }
+
+ next[key] = e
+ }
+
+ // Removals: a previously-live key absent from next has dropped past the grace. A
+ // never-live (refused) key that disappears is not a lifecycle removal — no consumer was
+ // acting on it.
+ for key, prev := range r.entries {
+ if _, present := next[key]; present {
+ continue
+ }
+ if !prev.record.Followable() {
+ continue
+ }
+ events = append(events, lifecycleEvent(
+ TypeRemoved, prev.record, prev.record.Followability.Verdict, VerdictRefused,
+ ReasonAbsenceExpired, now, generation))
+ }
+
+ sortLifecycleEvents(events)
+ return events
+}
+
+// appendTransitionEvent appends the named transition (if any) between an entry's prior and new
+// verdict. A newly observed type produces no transition here — its activation is decided by the
+// settle window and a brand-new refused type is silent. TypeActivated and TypeRemoved are
+// handled by the caller (they depend on the settle window and on absence, not on a verdict diff).
+func appendTransitionEvent(
+ events []LifecycleEvent,
+ had bool,
+ prev, cur entry,
+ now time.Time,
+ generation uint64,
+) []LifecycleEvent {
+ if !had {
+ return events
+ }
+ from := prev.record.Followability.Verdict
+ to := cur.record.Followability.Verdict
+ if from == to {
+ return events
+ }
+ switch {
+ case from == VerdictFollowable && to == VerdictRetained:
+ return append(events, lifecycleEvent(TypeWobbling, cur.record, from, to, reasonOf(cur.record), now, generation))
+ case from == VerdictRetained && to == VerdictFollowable:
+ return append(events, lifecycleEvent(TypeRecovered, cur.record, from, to, "", now, generation))
+ case to == VerdictRefused && wasLive(from):
+ return append(events, lifecycleEvent(TypeRefused, cur.record, from, to, reasonOf(cur.record), now, generation))
+ }
+ return events
+}
+
+// dispatchLifecycle delivers each event to every observer in order. It runs after Update has
+// released r.mu, so an observer may read the registry; it stays serialized with other updates
+// by the registry's dispatch mutex so events never interleave or reorder across generations.
+func dispatchLifecycle(observers []Observer, events []LifecycleEvent) {
+ for _, ev := range events {
+ for _, obs := range observers {
+ obs(ev)
+ }
+ }
+}
+
+// sortLifecycleEvents orders a generation's events deterministically (by GVR then kind) so two
+// consumers — and the tests — see the same sequence regardless of map iteration order.
+func sortLifecycleEvents(events []LifecycleEvent) {
+ sort.Slice(events, func(i, j int) bool {
+ if events[i].GVR.String() != events[j].GVR.String() {
+ return events[i].GVR.String() < events[j].GVR.String()
+ }
+ return events[i].Kind < events[j].Kind
+ })
+}
+
+// lifecycleEvent builds a LifecycleEvent from a record and the verdicts it crossed.
+func lifecycleEvent(
+ kind EventKind,
+ rec TypeRecord,
+ from, to Verdict,
+ reason Reason,
+ at time.Time,
+ generation uint64,
+) LifecycleEvent {
+ return LifecycleEvent{
+ Kind: kind,
+ GVK: rec.Identity.GVK,
+ GVR: rec.Identity.GVR,
+ From: from,
+ To: to,
+ Reason: reason,
+ Generation: generation,
+ At: at,
+ }
+}
+
+// fromVerdict returns the prior verdict for an event, or the empty verdict when there was no
+// prior record (a cold-start activation).
+func fromVerdict(had bool, prev entry) Verdict {
+ if had {
+ return prev.record.Followability.Verdict
+ }
+ return ""
+}
+
+// reasonOf returns the single machine-readable reason of a record's first failed check, or the
+// empty reason when nothing failed.
+func reasonOf(rec TypeRecord) Reason {
+ if c, ok := rec.Followability.FirstFailure(); ok {
+ return c.Reason
+ }
+ return ""
+}
+
+// wasLive reports whether a verdict means the type was in the live set (followable or held
+// under the grace).
+func wasLive(v Verdict) bool {
+ return v == VerdictFollowable || v == VerdictRetained
+}
diff --git a/internal/typeset/lifecycle_test.go b/internal/typeset/lifecycle_test.go
new file mode 100644
index 00000000..cccd1ffd
--- /dev/null
+++ b/internal/typeset/lifecycle_test.go
@@ -0,0 +1,262 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "testing"
+ "time"
+)
+
+// eventRecorder is a test Observer that captures every lifecycle event in order.
+type eventRecorder struct{ events []LifecycleEvent }
+
+func (e *eventRecorder) observe(ev LifecycleEvent) { e.events = append(e.events, ev) }
+
+func (e *eventRecorder) count(k EventKind) int {
+ n := 0
+ for _, ev := range e.events {
+ if ev.Kind == k {
+ n++
+ }
+ }
+ return n
+}
+
+func (e *eventRecorder) last(k EventKind) LifecycleEvent {
+ for i := len(e.events) - 1; i >= 0; i-- {
+ if e.events[i].Kind == k {
+ return e.events[i]
+ }
+ }
+ return LifecycleEvent{}
+}
+
+func (e *eventRecorder) reset() { e.events = nil }
+
+func TestRegistry_NoActivationOnFirstObserveThenActivatesAfterWindow(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(1_000, 0)}
+ r := newRegistry(clock.now)
+ rec := &eventRecorder{}
+ r.Subscribe(rec.observe)
+
+ // A just-appeared followable type must NOT activate on first observation — it has not
+ // yet been stably followable for the settle window.
+ r.Update([]Observation{deploymentObs()}, 1)
+ if got := rec.count(TypeActivated); got != 0 {
+ t.Fatalf("first observe must not activate, got %d TypeActivated", got)
+ }
+
+ // Still inside the window: no activation.
+ clock.add(SettleWindow - time.Second)
+ r.Update([]Observation{deploymentObs()}, 2)
+ if got := rec.count(TypeActivated); got != 0 {
+ t.Fatalf("within the settle window must not activate, got %d", got)
+ }
+
+ // Window elapsed: exactly one activation, carrying the type identity and From="" (cold).
+ clock.add(2 * time.Second)
+ r.Update([]Observation{deploymentObs()}, 3)
+ if got := rec.count(TypeActivated); got != 1 {
+ t.Fatalf("after the settle window must activate once, got %d", got)
+ }
+ ev := rec.last(TypeActivated)
+ if ev.To != VerdictFollowable || ev.GVR != deploymentObs().Identity.GVR {
+ t.Errorf("activation event = %+v, want followable deployments", ev)
+ }
+
+ // A subsequent stable Update must NOT re-activate (one per streak).
+ clock.add(10 * time.Second)
+ r.Update([]Observation{deploymentObs()}, 4)
+ if got := rec.count(TypeActivated); got != 1 {
+ t.Errorf("a stable followable streak must activate once, got %d", got)
+ }
+}
+
+func TestRegistry_WobbleThenRecoverThenReactivate(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(2_000, 0)}
+ r := newRegistry(clock.now)
+ rec := &eventRecorder{}
+ r.Subscribe(rec.observe)
+
+ // Settle the type first.
+ r.Update([]Observation{deploymentObs()}, 1)
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{deploymentObs()}, 2)
+ if rec.count(TypeActivated) != 1 {
+ t.Fatalf("setup: expected one activation, got %d", rec.count(TypeActivated))
+ }
+ rec.reset()
+
+ // Vanishes within the grace -> retained -> TypeWobbling, never TypeRemoved.
+ clock.add(5 * time.Second)
+ r.Update(nil, 3)
+ if rec.count(TypeWobbling) != 1 || rec.count(TypeRemoved) != 0 {
+ t.Fatalf("wobble: wobbling=%d removed=%d, want 1/0", rec.count(TypeWobbling), rec.count(TypeRemoved))
+ }
+
+ // Reappears -> TypeRecovered, but not yet re-activated (settle restarts).
+ clock.add(2 * time.Second)
+ r.Update([]Observation{deploymentObs()}, 4)
+ if rec.count(TypeRecovered) != 1 {
+ t.Fatalf("recover: recovered=%d, want 1", rec.count(TypeRecovered))
+ }
+ if rec.count(TypeActivated) != 0 {
+ t.Fatalf("recover must not immediately re-activate, got %d", rec.count(TypeActivated))
+ }
+
+ // After the window from recovery -> a fresh TypeActivated.
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{deploymentObs()}, 5)
+ if rec.count(TypeActivated) != 1 {
+ t.Errorf("re-activation after recovery: got %d, want 1", rec.count(TypeActivated))
+ }
+}
+
+func TestRegistry_FlapInsideWindowActivatesOnce(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(3_000, 0)}
+ r := newRegistry(clock.now)
+ rec := &eventRecorder{}
+ r.Subscribe(rec.observe)
+
+ // Appears.
+ r.Update([]Observation{deploymentObs()}, 1)
+ // Flap inside the window: wobble then recover, all before the settle window elapses.
+ clock.add(time.Second)
+ r.Update(nil, 2) // retained
+ clock.add(time.Second)
+ r.Update([]Observation{deploymentObs()}, 3) // recovered, settle restarts here
+ if rec.count(TypeActivated) != 0 {
+ t.Fatalf("a flap inside the window must not activate, got %d", rec.count(TypeActivated))
+ }
+
+ // Settle from the recovery point -> exactly one activation despite the churn.
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{deploymentObs()}, 4)
+ if got := rec.count(TypeActivated); got != 1 {
+ t.Errorf("flapping then settling must activate exactly once, got %d", got)
+ }
+}
+
+func TestRegistry_GraceExpiryEmitsTypeRemoved(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(4_000, 0)}
+ r := newRegistry(clock.now)
+ rec := &eventRecorder{}
+ r.Subscribe(rec.observe)
+
+ r.Update([]Observation{deploymentObs()}, 1)
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{deploymentObs()}, 2) // activated
+ rec.reset()
+
+ // Vanishes; within grace it is retained (a wobble), not removed.
+ clock.add(10 * time.Second)
+ r.Update(nil, 3)
+ if rec.count(TypeRemoved) != 0 {
+ t.Fatalf("within grace must not remove, got %d", rec.count(TypeRemoved))
+ }
+
+ // Grace elapses (absence began at +SettleWindow+1+10; advance past 60s of absence).
+ clock.add(RemovalGrace)
+ r.Update(nil, 4)
+ if got := rec.count(TypeRemoved); got != 1 {
+ t.Fatalf("after the grace must emit one TypeRemoved, got %d", got)
+ }
+ ev := rec.last(TypeRemoved)
+ if ev.Reason != ReasonAbsenceExpired || ev.To != VerdictRefused {
+ t.Errorf("removal event = %+v, want absence-expired -> refused", ev)
+ }
+}
+
+func TestRegistry_LivePermanentRefuseEmitsTypeRefused(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(5_000, 0)}
+ r := newRegistry(clock.now)
+ rec := &eventRecorder{}
+ r.Subscribe(rec.observe)
+
+ r.Update([]Observation{deploymentObs()}, 1)
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{deploymentObs()}, 2) // activated
+ rec.reset()
+
+ // A second resource starts serving the same GVK: the kind becomes gvk-not-unique, a
+ // PERMANENT refusal, for the previously-followable deployments record.
+ dep := deploymentObs()
+ dep.GVKUnique = false
+ dep.GVKConflictDetail = "deployments, deploymentz"
+ other := deploymentObs()
+ other.Identity.GVR.Resource = "deploymentz"
+ other.GVKUnique = false
+ other.GVKConflictDetail = "deployments, deploymentz"
+
+ clock.add(time.Second)
+ r.Update([]Observation{dep, other}, 3)
+ if got := rec.count(TypeRefused); got != 1 {
+ t.Fatalf("a live type failing a permanent check must emit one TypeRefused, got %d", got)
+ }
+ ev := rec.last(TypeRefused)
+ if ev.Reason != ReasonGVKNotUnique || ev.GVR.Resource != "deployments" {
+ t.Errorf("refused event = %+v, want gvk-not-unique on deployments", ev)
+ }
+ // The brand-new deploymentz record, born refused, must be silent.
+ if rec.count(TypeActivated) != 0 {
+ t.Errorf("a born-refused type must not activate, got %d", rec.count(TypeActivated))
+ }
+}
+
+func TestRegistry_BrandNewRefusedIsSilent(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(6_000, 0)}
+ r := newRegistry(clock.now)
+ rec := &eventRecorder{}
+ r.Subscribe(rec.observe)
+
+ denied := deploymentObs()
+ denied.Denied = true
+ denied.DenyDetail = "excluded by default policy"
+ r.Update([]Observation{denied}, 1)
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{denied}, 2)
+
+ if len(rec.events) != 0 {
+ t.Errorf("a type that is refused from birth must emit no lifecycle events, got %+v", rec.events)
+ }
+}
+
+func TestRegistry_MultipleSubscribersAndDeterministicOrder(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(7_000, 0)}
+ r := newRegistry(clock.now)
+ a, b := &eventRecorder{}, &eventRecorder{}
+ r.Subscribe(a.observe)
+ r.Subscribe(b.observe)
+ r.Subscribe(nil) // no-op, must not panic
+
+ // Two followable types appear together and settle together.
+ r.Update([]Observation{deploymentObs(), widgetObs()}, 1)
+ clock.add(SettleWindow + time.Second)
+ r.Update([]Observation{deploymentObs(), widgetObs()}, 2)
+
+ gotA, gotB := a.count(TypeActivated), b.count(TypeActivated)
+ if gotA != 2 || gotB != 2 {
+ t.Fatalf("both subscribers must see both activations: a=%d b=%d", gotA, gotB)
+ }
+ // Deterministic order: sorted by GVR string, so apps/v1 deployments precedes
+ // example.com/v1 widgets.
+ if a.events[0].GVR.Resource != "deployments" || a.events[1].GVR.Resource != "widgets" {
+ t.Errorf("events not in deterministic GVR order: %+v", a.events)
+ }
+}
diff --git a/internal/typeset/lookup.go b/internal/typeset/lookup.go
new file mode 100644
index 00000000..37ce0803
--- /dev/null
+++ b/internal/typeset/lookup.go
@@ -0,0 +1,106 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import "k8s.io/apimachinery/pkg/runtime/schema"
+
+// Lookup is the minimal followability surface every consumer reads: "is this type
+// followable, and what is its resolved identity?". It replaces the old
+// mapping.ResourceMapper contract — there is one notion of followable, and callers
+// gate on TypeRecord.Followable() rather than interpreting a status vocabulary.
+//
+// Ready reports whether the backing scan holds trusted data. A not-ready Lookup is
+// the "structure-only / no API source" mode: it cannot judge followability, so a
+// consumer must not draw a watched/unwatched (or destructive) conclusion from it.
+type Lookup interface {
+ Ready() bool
+ ByGVK(gvk schema.GroupVersionKind) (TypeRecord, bool)
+}
+
+// Registry is the live, refreshable Lookup.
+var _ Lookup = (*Registry)(nil)
+
+// Snapshot is a serialized, scan-shaped fixture for a non-live Lookup. It is an
+// explicit test/review input, not live discovery: it can model old clusters, partial
+// catalogs, policy exclusions, and ambiguity on purpose, but must not be mistaken for
+// proof about a running cluster.
+type Snapshot struct {
+ // Entries are the served resources the snapshot declares. Allowed defaults to
+ // false on a zero Entry, so fixtures opt resources in explicitly.
+ Entries []Entry
+ // DegradedGroupVersions mark group/versions whose discovery is modeled as failed;
+ // their entries are observed as untrusted (retained rather than freshly followable).
+ DegradedGroupVersions []schema.GroupVersion
+ // NotReady models a scan with no trusted data yet — the structure-only mode — so
+ // the resulting Lookup is never ready and judges nothing.
+ NotReady bool
+ // Generation is the reported scan generation.
+ Generation uint64
+}
+
+// NewSnapshotRegistry builds a Registry from a Snapshot. A NotReady snapshot yields an
+// unpublished (structure-only) registry; otherwise the entries are projected into
+// observations and published at the snapshot's generation.
+//
+// As a fixture convenience, a top-level entry that declares no Verbs is assumed to
+// advertise the verbs a followable type needs — a snapshot opts a resource in by
+// setting Allowed, and spelling out get/list/watch on every fixture would be noise.
+// Set Verbs explicitly to model a verb-poor resource.
+func NewSnapshotRegistry(snap Snapshot) *Registry {
+ r := NewRegistry()
+ if snap.NotReady {
+ return r
+ }
+ entries := markDegraded(assumeFollowableVerbs(snap.Entries), snap.DegradedGroupVersions)
+ r.Update(ObservationsFromEntries(entries, true), snap.Generation)
+ return r
+}
+
+// assumeFollowableVerbs fills the required verbs on any top-level entry that declares
+// none, so fixtures need only set Allowed to opt a resource in.
+func assumeFollowableVerbs(entries []Entry) []Entry {
+ out := make([]Entry, len(entries))
+ for i, e := range entries {
+ if !e.Subresource && len(e.Verbs) == 0 {
+ e.Verbs = requiredVerbs()
+ }
+ out[i] = e
+ }
+ return out
+}
+
+// markDegraded flags entries whose group/version the snapshot models as degraded, so
+// the funnel observes them as untrusted.
+func markDegraded(entries []Entry, degraded []schema.GroupVersion) []Entry {
+ if len(degraded) == 0 {
+ return entries
+ }
+ degradedSet := make(map[schema.GroupVersion]struct{}, len(degraded))
+ for _, gv := range degraded {
+ degradedSet[gv] = struct{}{}
+ }
+ out := make([]Entry, len(entries))
+ for i, e := range entries {
+ if _, ok := degradedSet[e.GVR.GroupVersion()]; ok {
+ e.Degraded = true
+ }
+ out[i] = e
+ }
+ return out
+}
diff --git a/internal/typeset/lookup_test.go b/internal/typeset/lookup_test.go
new file mode 100644
index 00000000..99958bfe
--- /dev/null
+++ b/internal/typeset/lookup_test.go
@@ -0,0 +1,102 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "testing"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+func TestNewSnapshotRegistry_NotReadyIsStructureOnly(t *testing.T) {
+ r := NewSnapshotRegistry(Snapshot{NotReady: true, Entries: []Entry{
+ mkEntry("apps", "Deployment", "deployments"),
+ }})
+ if r.Ready() {
+ t.Fatal("a NotReady snapshot must yield an un-ready (structure-only) registry")
+ }
+ if _, ok := r.ByGVK(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}); ok {
+ t.Error("a structure-only registry must know no kind")
+ }
+}
+
+func TestNewSnapshotRegistry_ResolvesAndAssumesVerbs(t *testing.T) {
+ // The entry sets Allowed but no Verbs: the snapshot assumes followable verbs.
+ r := NewSnapshotRegistry(Snapshot{
+ Generation: 7,
+ Entries: []Entry{{
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
+ Namespaced: true,
+ Allowed: true,
+ }},
+ })
+ if !r.Ready() || r.Generation() != 7 {
+ t.Fatalf("ready=%v generation=%d, want ready at 7", r.Ready(), r.Generation())
+ }
+ rec, ok := r.ByGVK(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"})
+ if !ok || !rec.Followable() {
+ t.Fatalf(
+ "a snapshot entry with no verbs should be assumed followable, got ok=%v rec=%+v",
+ ok,
+ rec.Followability,
+ )
+ }
+}
+
+func TestNewSnapshotRegistry_VerbPoorEntryStaysRefused(t *testing.T) {
+ // An explicit verb-poor entry (missing watch) is honored, not assumed followable.
+ r := NewSnapshotRegistry(Snapshot{Entries: []Entry{{
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"},
+ Namespaced: true,
+ Allowed: true,
+ Verbs: []string{"get", "list"},
+ }}})
+ rec, ok := r.ByGVK(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"})
+ if !ok || rec.Followable() {
+ t.Fatalf("a verb-poor entry must stay refused, got ok=%v followable=%v", ok, rec.Followable())
+ }
+ check, _ := rec.Followability.Check(RequirementVerbs)
+ if check.Reason != ReasonMissingVerb || check.Detail != "watch" {
+ t.Errorf("verbs check = %+v, want missing-verb: watch", check)
+ }
+}
+
+func TestNewSnapshotRegistry_DegradedMarksUntrusted(t *testing.T) {
+ gv := schema.GroupVersion{Group: "shop.example.com", Version: "v1"}
+ r := NewSnapshotRegistry(Snapshot{
+ DegradedGroupVersions: []schema.GroupVersion{gv},
+ Entries: []Entry{
+ mkEntry("shop.example.com", "Widget", "widgets"),
+ },
+ })
+ rec, ok := r.ByGVK(schema.GroupVersionKind{Group: "shop.example.com", Version: "v1", Kind: "Widget"})
+ if !ok {
+ t.Fatal("the widget should be known")
+ }
+ // A degraded group/version makes trusted fail; within grace that is retained.
+ if rec.Followability.Verdict != VerdictRetained {
+ t.Errorf("a degraded type should be retained, got %q", rec.Followability.Verdict)
+ }
+ trusted, _ := rec.Followability.Check(RequirementTrusted)
+ if trusted.Reason != ReasonDiscoveryDegraded {
+ t.Errorf("trusted check = %+v, want discovery-degraded", trusted)
+ }
+}
diff --git a/internal/typeset/model.go b/internal/typeset/model.go
new file mode 100644
index 00000000..f071411d
--- /dev/null
+++ b/internal/typeset/model.go
@@ -0,0 +1,274 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package typeset is GitOps Reverser's single decision surface for "is this
+// resource type followable, and if not, what is the one reason it is not?".
+//
+// It is the greenfield model from docs/design/manifest/version2/type-followability.md.
+// Every served type carries one TypeRecord with one Followability: a verdict, a
+// one-line summary, and the full funnel-ordered list of requirement checks. That
+// single value replaces the old split between a "requirements" table and a "health
+// conditions" table — the failing check is the explanation.
+//
+// The package is deliberately a leaf: it depends only on apimachinery schema, never
+// on a Kubernetes client, controller runtime, or the watch manager. That lets both
+// the live cluster path (internal/watch) and the no-cluster manifest analyzer share
+// one decision surface and one reason-code vocabulary.
+package typeset
+
+import "k8s.io/apimachinery/pkg/runtime/schema"
+
+// Scope is whether a type is namespaced, cluster-scoped, or not yet known. Unknown
+// feeds the scope requirement's scope-unknown reason; discovery normally resolves
+// it, so Unknown is reserved for synthetic or un-enriched observations.
+type Scope string
+
+const (
+ // ScopeNamespaced is a namespaced resource (lives inside a namespace).
+ ScopeNamespaced Scope = "Namespaced"
+ // ScopeCluster is a cluster-scoped resource.
+ ScopeCluster Scope = "ClusterScoped"
+ // ScopeUnknown is a type whose scope discovery has not established.
+ ScopeUnknown Scope = "Unknown"
+)
+
+// Identity is the one true name of a type. For a followable type the GVK <-> GVR
+// bijection is closed, so GVK and GVR always round-trip.
+type Identity struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+ Scope Scope
+}
+
+// OriginKind classifies where a served type comes from.
+type OriginKind string
+
+const (
+ // OriginBuiltin is a core or built-in Kubernetes API group/version.
+ OriginBuiltin OriginKind = "builtin"
+ // OriginCRD is a type backed by a CustomResourceDefinition.
+ OriginCRD OriginKind = "crd"
+ // OriginAggregated is a type served by an aggregated API server (APIService).
+ OriginAggregated OriginKind = "aggregated"
+ // OriginUnknown is a served type the scan could not classify; it fails the
+ // origin requirement.
+ OriginUnknown OriginKind = "unknown"
+)
+
+// Confidence records how strongly the origin classification is held: observed from
+// direct CRD/APIService evidence, inferred from the group/version alone, or unknown.
+type Confidence string
+
+const (
+ // ConfidenceObserved is backed by direct evidence (a CRD or APIService object).
+ ConfidenceObserved Confidence = "observed"
+ // ConfidenceInferred is derived from the group/version shape, without an object.
+ ConfidenceInferred Confidence = "inferred"
+ // ConfidenceUnknown is no basis for classification.
+ ConfidenceUnknown Confidence = "unknown"
+)
+
+// Origin is the provenance of a served type plus how strongly it is held.
+type Origin struct {
+ Kind OriginKind
+ Confidence Confidence
+ // Evidence is bounded human detail, e.g. crontabs.stable.example.com.
+ Evidence string
+}
+
+// StatusFact records whether a type exposes a /status subresource. It is reporting
+// only — GitOps Reverser never writes /status — so it carries no write path.
+type StatusFact struct {
+ Enabled bool
+}
+
+// ScaleBinding is the only subresource fact the writer needs: where a /scale
+// mutation lands on the parent's desired state. SpecReplicasPath drives the scale
+// write path; the selector facts are for reporting. See
+// docs/design/manifest/version2/type-followability.md.
+type ScaleBinding struct {
+ Enabled bool
+ Source string // discovery | crd | builtin-registry | aggregated | unknown
+ ResponseGVK schema.GroupVersionKind
+
+ SpecReplicasPath string
+ StatusReplicasPath string
+ SelectorPath string
+ SelectorKind string // serialized-string | label-selector | unknown
+
+ // Usable is true only when a /scale audit event can be mapped back to a durable
+ // parent field. False feeds the scale requirement's scale-path-unresolved reason.
+ Usable bool
+}
+
+// Subresources are folded into the parent record, never followed as their own
+// types.
+type Subresources struct {
+ Status StatusFact
+ Scale ScaleBinding
+}
+
+// Verdict is the top-level answer for one type. Every other surface (health level,
+// status condition, "why ignored?" diagnostic) is a rendering of this.
+type Verdict string
+
+const (
+ // VerdictFollowable means every required check passed; the type is in the live set.
+ VerdictFollowable Verdict = "followable"
+ // VerdictRetained means a transient check (served/trusted) is failing now but the
+ // removal grace has not elapsed; the type is still treated as live.
+ VerdictRetained Verdict = "retained"
+ // VerdictRefused means a permanent check failed; the type will not be followed.
+ VerdictRefused Verdict = "refused"
+ // VerdictUnknown means the registry could not assess the type (catalog unavailable).
+ VerdictUnknown Verdict = "unknown"
+)
+
+// Requirement is one named check in the followability funnel.
+type Requirement string
+
+const (
+ // RequirementServed — discovery serves this as a top-level resource.
+ RequirementServed Requirement = "served"
+ // RequirementTrusted — the backing group/version came from trusted discovery.
+ RequirementTrusted Requirement = "trusted"
+ // RequirementStable — the type is not mid-disappearance, or is inside the grace.
+ RequirementStable Requirement = "stable"
+ // RequirementIdentity — GVK <-> GVR is 1:1 in both directions.
+ RequirementIdentity Requirement = "identity"
+ // RequirementScope — the type is known namespaced or cluster-scoped.
+ RequirementScope Requirement = "scope"
+ // RequirementVerbs — discovery advertises get, list, watch, patch.
+ RequirementVerbs Requirement = "verbs"
+ // RequirementOrigin — classified builtin, crd, or aggregated with evidence.
+ RequirementOrigin Requirement = "origin"
+ // RequirementPolicy — product policy permits mirroring this type.
+ RequirementPolicy Requirement = "policy"
+ // RequirementSensitivity — not sensitive, or sensitivity is supported.
+ RequirementSensitivity Requirement = "sensitivity"
+ // RequirementScale — scale is unused, or its parent replica path is known.
+ RequirementScale Requirement = "scale"
+)
+
+// Result is one requirement's outcome.
+type Result string
+
+const (
+ // ResultPass — the requirement is satisfied.
+ ResultPass Result = "pass"
+ // ResultFail — the requirement is not satisfied; Reason names the single cause.
+ ResultFail Result = "fail"
+ // ResultSkip — the requirement does not apply (e.g. scale when scale is unused).
+ ResultSkip Result = "skip"
+ // ResultUnknown — the requirement could not be assessed.
+ ResultUnknown Result = "unknown"
+)
+
+// Reason is the single machine-readable cause of a failed check. It is the one
+// vocabulary used everywhere a type is turned away — lookups, the live-set report,
+// and operator status — so "why isn't this picked up?" always has the same answer.
+type Reason string
+
+const (
+ // ReasonNotServed — trusted discovery has no top-level resource for this kind.
+ ReasonNotServed Reason = "not-served"
+ // ReasonSubresourceOnly — the kind is served only as a subresource.
+ ReasonSubresourceOnly Reason = "subresource-only"
+ // ReasonDiscoveryDegraded — discovery currently fails for the backing group/version.
+ ReasonDiscoveryDegraded Reason = "discovery-degraded"
+ // ReasonCatalogUnavailable — no trusted catalog data exists yet.
+ ReasonCatalogUnavailable Reason = "catalog-unavailable"
+ // ReasonAbsenceExpired — the type disappeared and the removal grace has elapsed.
+ ReasonAbsenceExpired Reason = "absence-expired"
+ // ReasonGVKNotUnique — the GVK is served by more than one GVR.
+ ReasonGVKNotUnique Reason = "gvk-not-unique"
+ // ReasonGVRNotUnique — the GVR resolves to more than one Kind.
+ ReasonGVRNotUnique Reason = "gvr-not-unique"
+ // ReasonScopeUnknown — discovery did not establish a namespaced/cluster scope.
+ ReasonScopeUnknown Reason = "scope-unknown"
+ // ReasonMissingVerb — discovery does not advertise a required verb (Detail names it).
+ ReasonMissingVerb Reason = "missing-verb"
+ // ReasonOriginUnknown — the served type could not be classified.
+ ReasonOriginUnknown Reason = "origin-unknown"
+ // ReasonDeniedByPolicy — product policy refuses to mirror this type.
+ ReasonDeniedByPolicy Reason = "denied-by-policy"
+ // ReasonSensitiveUnsupported — sensitive type without supported write handling.
+ ReasonSensitiveUnsupported Reason = "sensitive-unsupported"
+ // ReasonScalePathUnresolved — scale is used but the parent replica path is unknown.
+ ReasonScalePathUnresolved Reason = "scale-path-unresolved"
+)
+
+// Check is one requirement's evaluated result in the funnel.
+type Check struct {
+ Requirement Requirement
+ Result Result
+ Reason Reason // empty on pass/skip; otherwise the single reason code
+ Detail string // bounded human detail, e.g. "patch"
+}
+
+// Failed reports whether the check is a hard fail.
+func (c Check) Failed() bool { return c.Result == ResultFail }
+
+// Followability answers "can I act on this, and why not?" in one value.
+type Followability struct {
+ Verdict Verdict
+ Summary string // one line, e.g. "not followable — missing required verb: patch"
+ Checks []Check
+}
+
+// Check returns the evaluated check for a requirement, if present.
+func (f Followability) Check(req Requirement) (Check, bool) {
+ for _, c := range f.Checks {
+ if c.Requirement == req {
+ return c, true
+ }
+ }
+ return Check{}, false
+}
+
+// FirstFailure returns the first failed check in funnel order, if any.
+func (f Followability) FirstFailure() (Check, bool) {
+ for _, c := range f.Checks {
+ if c.Failed() {
+ return c, true
+ }
+ }
+ return Check{}, false
+}
+
+// TypeRecord is the unit everything passes around. It answers "can I act on this?"
+// and "why not?" in one object, so the safe path and the diagnostic path are the
+// same call.
+type TypeRecord struct {
+ Identity Identity
+ Origin Origin
+ Preferred bool
+ Verbs []string
+ Subresources Subresources
+ Sensitive bool
+
+ Followability Followability
+ Generation uint64
+}
+
+// Followable is the safe-path helper. Most callers never inspect Verdict directly:
+// a followable or retained type is live, everything else is not.
+func (r TypeRecord) Followable() bool {
+ return r.Followability.Verdict == VerdictFollowable ||
+ r.Followability.Verdict == VerdictRetained
+}
diff --git a/internal/typeset/model_test.go b/internal/typeset/model_test.go
new file mode 100644
index 00000000..eb3b1257
--- /dev/null
+++ b/internal/typeset/model_test.go
@@ -0,0 +1,83 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import "testing"
+
+func TestTypeRecord_Followable(t *testing.T) {
+ tests := []struct {
+ verdict Verdict
+ want bool
+ }{
+ {VerdictFollowable, true},
+ {VerdictRetained, true},
+ {VerdictRefused, false},
+ {VerdictUnknown, false},
+ {Verdict(""), false},
+ }
+ for _, tt := range tests {
+ t.Run(string(tt.verdict), func(t *testing.T) {
+ rec := TypeRecord{Followability: Followability{Verdict: tt.verdict}}
+ if got := rec.Followable(); got != tt.want {
+ t.Errorf("Followable() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFollowability_Check(t *testing.T) {
+ f := Followability{Checks: []Check{
+ {Requirement: RequirementServed, Result: ResultPass},
+ {Requirement: RequirementVerbs, Result: ResultFail, Reason: ReasonMissingVerb},
+ }}
+ if c, ok := f.Check(RequirementVerbs); !ok || c.Reason != ReasonMissingVerb {
+ t.Errorf("Check(verbs) = %+v, %v", c, ok)
+ }
+ if _, ok := f.Check(RequirementScale); ok {
+ t.Error("Check(scale) should report missing")
+ }
+}
+
+func TestFollowability_FirstFailure(t *testing.T) {
+ f := Followability{Checks: []Check{
+ {Requirement: RequirementServed, Result: ResultPass},
+ {Requirement: RequirementScope, Result: ResultFail, Reason: ReasonScopeUnknown},
+ {Requirement: RequirementVerbs, Result: ResultFail, Reason: ReasonMissingVerb},
+ }}
+ c, ok := f.FirstFailure()
+ if !ok || c.Requirement != RequirementScope {
+ t.Errorf("FirstFailure() = %+v, %v, want scope", c, ok)
+ }
+
+ none := Followability{Checks: []Check{{Requirement: RequirementServed, Result: ResultPass}}}
+ if _, ok := none.FirstFailure(); ok {
+ t.Error("FirstFailure() on all-pass should report no failure")
+ }
+}
+
+func TestCheck_Failed(t *testing.T) {
+ if !(Check{Result: ResultFail}).Failed() {
+ t.Error("fail check should report Failed")
+ }
+ for _, r := range []Result{ResultPass, ResultSkip, ResultUnknown} {
+ if (Check{Result: r}).Failed() {
+ t.Errorf("%s check should not report Failed", r)
+ }
+ }
+}
diff --git a/internal/typeset/observe.go b/internal/typeset/observe.go
new file mode 100644
index 00000000..c7029ed1
--- /dev/null
+++ b/internal/typeset/observe.go
@@ -0,0 +1,260 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "sort"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// Entry is one served API resource's raw facts — the neutral input to the scan. Both
+// the live discovery catalog and a serialized snapshot convert their resources to
+// this shape, so observation-building (identity uniqueness, origin, scale, policy)
+// lives in exactly one place and the live and fixture paths agree on every verdict.
+type Entry struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+ Namespaced bool
+ Verbs []string
+ Preferred bool
+ Subresource bool
+ Allowed bool // product policy permits mirroring this resource
+ PolicyReason string // why it is not allowed, when Allowed is false
+ Degraded bool // the backing group/version is currently degraded
+ // Sensitive reports whether this resource must use the encrypted Git write path.
+ // It is a startup-known policy fact, applied by the entry builder (the catalog
+ // applies the configured SensitiveResourcePolicy), not inferred inside typeset.
+ Sensitive bool
+}
+
+// ObservationsFromEntries projects served entries into one Observation per top-level
+// type — the "Scan -> Observation" reduction. Subresources are folded into their
+// parent's record, never emitted as their own observation. catalogReady reports
+// whether the backing scan holds trusted data, feeding the trusted requirement's
+// catalog-unavailable distinction.
+func ObservationsFromEntries(entries []Entry, catalogReady bool) []Observation {
+ gvrsByGVK := distinctGVRsByGVK(entries)
+ gvksByGVR := distinctGVKsByGVR(entries)
+ scaleParents := subresourceParents(entries, "scale")
+ statusParents := subresourceParents(entries, "status")
+
+ out := make([]Observation, 0, len(entries))
+ for _, e := range entries {
+ if e.Subresource {
+ continue
+ }
+ out = append(out, observationFromEntry(
+ e, catalogReady, gvrsByGVK[e.GVK], gvksByGVR[e.GVR], scaleParents, statusParents,
+ ))
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].Identity.GVR.String() < out[j].Identity.GVR.String()
+ })
+ return out
+}
+
+// observationFromEntry builds one served type's observation from its entry plus the
+// cross-entry facts (identity uniqueness in both directions, parent subresources).
+// servingGVRs are the distinct resources serving this kind; servingGVKs are the
+// distinct kinds this resource resolves to — a closed bijection requires both to be 1.
+func observationFromEntry(
+ e Entry,
+ catalogReady bool,
+ servingGVRs []schema.GroupVersionResource,
+ servingGVKs []schema.GroupVersionKind,
+ scaleParents, statusParents map[schema.GroupVersionResource]struct{},
+) Observation {
+ gvkUnique := len(servingGVRs) == 1
+ gvrUnique := len(servingGVKs) == 1
+ return Observation{
+ Identity: Identity{GVK: e.GVK, GVR: e.GVR, Scope: scopeFor(e.Namespaced)},
+ Origin: classifyOrigin(e.GVR.Group),
+ Preferred: e.Preferred,
+ Verbs: append([]string(nil), e.Verbs...),
+ Subresources: subresourcesFor(e.GVR, scaleParents, statusParents),
+ Served: true,
+ Trusted: !e.Degraded,
+ CatalogReady: catalogReady,
+ GVKUnique: gvkUnique,
+ GVRUnique: gvrUnique,
+ GVKConflictDetail: gvkConflictDetail(gvkUnique, servingGVRs),
+ GVRConflictDetail: gvrConflictDetail(gvrUnique, servingGVKs),
+ Denied: !e.Allowed,
+ DenyDetail: e.PolicyReason,
+ Sensitive: e.Sensitive,
+ // Sensitive types route through the encrypted Git write path, a supported
+ // handling, so sensitivity never refuses a followable type today.
+ SensitiveSupported: true,
+ }
+}
+
+// distinctGVRsByGVK indexes each kind to the distinct served top-level resources for
+// it, so a kind served by more than one resource is recognised as non-unique identity
+// (gvk-not-unique). Exact duplicates (same GVR) collapse, so a doubly-listed resource
+// is not mistaken for a conflict.
+func distinctGVRsByGVK(entries []Entry) map[schema.GroupVersionKind][]schema.GroupVersionResource {
+ seen := map[schema.GroupVersionKind]map[schema.GroupVersionResource]struct{}{}
+ for _, e := range entries {
+ if e.Subresource {
+ continue
+ }
+ if seen[e.GVK] == nil {
+ seen[e.GVK] = map[schema.GroupVersionResource]struct{}{}
+ }
+ seen[e.GVK][e.GVR] = struct{}{}
+ }
+ out := make(map[schema.GroupVersionKind][]schema.GroupVersionResource, len(seen))
+ for gvk, set := range seen {
+ for gvr := range set {
+ out[gvk] = append(out[gvk], gvr)
+ }
+ }
+ return out
+}
+
+// distinctGVKsByGVR indexes each resource to the distinct kinds it resolves to, so a
+// resource that resolves to more than one kind is recognised as non-unique identity
+// (gvr-not-unique) — the reverse half of the GVK<->GVR bijection. Real discovery keeps
+// a resource name unique per group/version, so this only fires for a malformed or
+// hand-crafted (snapshot) surface; modelling it keeps the bijection honest in both
+// directions rather than silently picking a winner.
+func distinctGVKsByGVR(entries []Entry) map[schema.GroupVersionResource][]schema.GroupVersionKind {
+ seen := map[schema.GroupVersionResource]map[schema.GroupVersionKind]struct{}{}
+ for _, e := range entries {
+ if e.Subresource {
+ continue
+ }
+ if seen[e.GVR] == nil {
+ seen[e.GVR] = map[schema.GroupVersionKind]struct{}{}
+ }
+ seen[e.GVR][e.GVK] = struct{}{}
+ }
+ out := make(map[schema.GroupVersionResource][]schema.GroupVersionKind, len(seen))
+ for gvr, set := range seen {
+ for gvk := range set {
+ out[gvr] = append(out[gvr], gvk)
+ }
+ }
+ return out
+}
+
+// subresourceParents returns the set of parent GVRs that expose the named
+// subresource (e.g. "scale"), so a parent type can fold its subresource facts in.
+func subresourceParents(entries []Entry, name string) map[schema.GroupVersionResource]struct{} {
+ out := map[schema.GroupVersionResource]struct{}{}
+ for _, e := range entries {
+ parent, sub, ok := splitSubresource(e.GVR.Resource)
+ if !ok || sub != name {
+ continue
+ }
+ out[schema.GroupVersionResource{Group: e.GVR.Group, Version: e.GVR.Version, Resource: parent}] = struct{}{}
+ }
+ return out
+}
+
+// subresourcesFor folds a parent's /scale and /status facts into its record. Scale is
+// enabled when the parent exposes a /scale subresource; its write binding comes from
+// the built-in scale registry, and is left unusable for a CRD or aggregated parent
+// whose replica path is not yet enriched (the scale requirement then refuses it rather
+// than guessing .spec.replicas).
+func subresourcesFor(
+ gvr schema.GroupVersionResource,
+ scaleParents, statusParents map[schema.GroupVersionResource]struct{},
+) Subresources {
+ var subs Subresources
+ if _, ok := statusParents[gvr]; ok {
+ subs.Status = StatusFact{Enabled: true}
+ }
+ if _, ok := scaleParents[gvr]; ok {
+ if binding, known := BuiltinScale(gvr.Group, gvr.Resource); known {
+ subs.Scale = binding
+ } else {
+ subs.Scale = ScaleBinding{Enabled: true, Source: "unknown", Usable: false}
+ }
+ }
+ return subs
+}
+
+// splitSubresource splits "deployments/scale" into ("deployments", "scale", true). A
+// name without a slash is a top-level resource and reports false.
+func splitSubresource(resource string) (string, string, bool) {
+ idx := strings.IndexByte(resource, '/')
+ if idx < 0 {
+ return "", "", false
+ }
+ return resource[:idx], resource[idx+1:], true
+}
+
+func scopeFor(namespaced bool) Scope {
+ if namespaced {
+ return ScopeNamespaced
+ }
+ return ScopeCluster
+}
+
+func gvkConflictDetail(unique bool, serving []schema.GroupVersionResource) string {
+ if unique {
+ return ""
+ }
+ resources := make([]string, 0, len(serving))
+ for _, gvr := range serving {
+ resources = append(resources, gvr.Resource)
+ }
+ sort.Strings(resources)
+ return strings.Join(resources, ", ")
+}
+
+func gvrConflictDetail(unique bool, serving []schema.GroupVersionKind) string {
+ if unique {
+ return ""
+ }
+ kinds := make([]string, 0, len(serving))
+ for _, gvk := range serving {
+ kinds = append(kinds, gvk.Kind)
+ }
+ sort.Strings(kinds)
+ return strings.Join(kinds, ", ")
+}
+
+// builtinGroupSuffix marks the Kubernetes built-in API groups by their shared
+// suffix; the core group is empty, and a handful of legacy groups have no suffix.
+const builtinGroupSuffix = ".k8s.io"
+
+// classifyOrigin infers a served type's origin from its API group. It is a shape
+// heuristic, not evidence: the core group, the *.k8s.io groups, and the legacy
+// built-in groups are builtin; everything else is treated as a CRD. Confidence is
+// inferred, and it never returns unknown for a served type, so the origin requirement
+// passes for every served type until real CRD/APIService evidence is wired in.
+func classifyOrigin(group string) Origin {
+ if group == "" || strings.HasSuffix(group, builtinGroupSuffix) || legacyBuiltinGroup(group) {
+ return Origin{Kind: OriginBuiltin, Confidence: ConfidenceInferred}
+ }
+ return Origin{Kind: OriginCRD, Confidence: ConfidenceInferred}
+}
+
+func legacyBuiltinGroup(group string) bool {
+ switch group {
+ case "apps", "batch", "autoscaling", "policy", "extensions":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/typeset/observe_test.go b/internal/typeset/observe_test.go
new file mode 100644
index 00000000..ed1ddcab
--- /dev/null
+++ b/internal/typeset/observe_test.go
@@ -0,0 +1,238 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "testing"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// mkEntry builds a namespaced, policy-allowed, followable-verbed served entry at v1 —
+// the common fixture shape. Tests needing a cluster-scoped, denied, or other-version
+// entry build it inline.
+func mkEntry(group, kind, resource string) Entry {
+ return Entry{
+ GVK: schema.GroupVersionKind{Group: group, Version: "v1", Kind: kind},
+ GVR: schema.GroupVersionResource{Group: group, Version: "v1", Resource: resource},
+ Namespaced: true,
+ Verbs: []string{"get", "list", "watch"},
+ Allowed: true,
+ }
+}
+
+func observationByGVR(obs []Observation, resource string) (Observation, bool) {
+ for _, o := range obs {
+ if o.Identity.GVR.Resource == resource {
+ return o, true
+ }
+ }
+ return Observation{}, false
+}
+
+func TestObservationsFromEntries_BuiltinAndCRDAndPolicy(t *testing.T) {
+ entries := []Entry{
+ mkEntry("apps", "Deployment", "deployments"),
+ mkEntry("shop.example.com", "Widget", "widgets"),
+ {
+ GVK: schema.GroupVersionKind{Version: "v1", Kind: "Pod"},
+ GVR: schema.GroupVersionResource{Version: "v1", Resource: "pods"},
+ Namespaced: true,
+ Verbs: []string{"get", "list", "watch"},
+ Allowed: false,
+ PolicyReason: "excluded by default policy",
+ },
+ }
+ obs := ObservationsFromEntries(entries, true)
+ if len(obs) != 3 {
+ t.Fatalf("got %d observations, want 3", len(obs))
+ }
+
+ dep, _ := observationByGVR(obs, "deployments")
+ if dep.Origin.Kind != OriginBuiltin {
+ t.Errorf("apps Deployment origin = %q, want builtin", dep.Origin.Kind)
+ }
+ if dep.Identity.Scope != ScopeNamespaced || !dep.Served || !dep.Trusted || !dep.CatalogReady {
+ t.Errorf("deployment observation facts wrong: %+v", dep)
+ }
+ if Evaluate(dep).Verdict != VerdictFollowable {
+ t.Errorf("deployment should evaluate followable, got %q", Evaluate(dep).Verdict)
+ }
+
+ widget, _ := observationByGVR(obs, "widgets")
+ if widget.Origin.Kind != OriginCRD {
+ t.Errorf("widget origin = %q, want crd", widget.Origin.Kind)
+ }
+
+ pod, _ := observationByGVR(obs, "pods")
+ if !pod.Denied || pod.DenyDetail != "excluded by default policy" {
+ t.Errorf("pod should carry the deny reason, got %+v", pod)
+ }
+}
+
+func TestObservationsFromEntries_AmbiguousGVK(t *testing.T) {
+ entries := []Entry{
+ mkEntry("shop.example.com", "Widget", "widgets"),
+ mkEntry("shop.example.com", "Widget", "widgetz"),
+ }
+ obs := ObservationsFromEntries(entries, true)
+ for _, o := range obs {
+ if o.GVKUnique {
+ t.Errorf("%s should be marked non-unique", o.Identity.GVR.Resource)
+ }
+ if o.GVKConflictDetail != "widgets, widgetz" {
+ t.Errorf("conflict detail = %q, want 'widgets, widgetz'", o.GVKConflictDetail)
+ }
+ }
+}
+
+func TestObservationsFromEntries_AmbiguousGVR(t *testing.T) {
+ // One resource resolving to two Kinds is the reverse half of the bijection. Real
+ // discovery keeps a resource name unique per group/version, so this only arises on
+ // a malformed or hand-crafted surface — the model must still refuse it rather than
+ // silently pick a winner, so two manifest Kinds cannot resolve to one identity.
+ gvr := schema.GroupVersionResource{Group: "shop.example.com", Version: "v1", Resource: "things"}
+ entries := []Entry{
+ {
+ GVK: schema.GroupVersionKind{Group: "shop.example.com", Version: "v1", Kind: "Thing"},
+ GVR: gvr, Namespaced: true, Verbs: []string{"get", "list", "watch"}, Allowed: true,
+ },
+ {
+ GVK: schema.GroupVersionKind{Group: "shop.example.com", Version: "v1", Kind: "Gadget"},
+ GVR: gvr, Namespaced: true, Verbs: []string{"get", "list", "watch"}, Allowed: true,
+ },
+ }
+ obs := ObservationsFromEntries(entries, true)
+ if len(obs) != 2 {
+ t.Fatalf("got %d observations, want 2", len(obs))
+ }
+ for _, o := range obs {
+ if o.GVRUnique {
+ t.Errorf("%s should be marked GVR-non-unique", o.Identity.GVK.Kind)
+ }
+ if o.GVRConflictDetail != "Gadget, Thing" {
+ t.Errorf("gvr conflict detail = %q, want 'Gadget, Thing'", o.GVRConflictDetail)
+ }
+ // GVK uniqueness is unaffected: each kind still has exactly one resource.
+ if !o.GVKUnique {
+ t.Errorf("%s should remain GVK-unique (one resource per kind)", o.Identity.GVK.Kind)
+ }
+ if Evaluate(o).Verdict != VerdictRefused {
+ t.Errorf("an ambiguous-GVR type must be refused, got %q", Evaluate(o).Verdict)
+ }
+ check, _ := Evaluate(o).Check(RequirementIdentity)
+ if check.Reason != ReasonGVRNotUnique {
+ t.Errorf("identity reason = %q, want gvr-not-unique", check.Reason)
+ }
+ }
+}
+
+func TestObservationsFromEntries_ExactDuplicateIsNotAConflict(t *testing.T) {
+ // The same resource listed twice (identical GVR+GVK) must collapse, not be mistaken
+ // for an ambiguity in either direction.
+ e := mkEntry("apps", "Deployment", "deployments")
+ for _, o := range ObservationsFromEntries([]Entry{e, e}, true) {
+ if !o.GVKUnique || !o.GVRUnique {
+ t.Errorf("an exact duplicate must stay unique both ways, got gvk=%v gvr=%v",
+ o.GVKUnique, o.GVRUnique)
+ }
+ }
+}
+
+func TestObservationsFromEntries_SubresourcesFolded(t *testing.T) {
+ entries := []Entry{
+ mkEntry("apps", "Deployment", "deployments"),
+ {
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Scale"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments/scale"},
+ Subresource: true,
+ },
+ {
+ GVK: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ GVR: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments/status"},
+ Subresource: true,
+ },
+ // A CRD with a /scale subresource whose replica path is unknown.
+ mkEntry("shop.example.com", "Widget", "widgets"),
+ {
+ GVK: schema.GroupVersionKind{Group: "shop.example.com", Version: "v1", Kind: "Scale"},
+ GVR: schema.GroupVersionResource{
+ Group: "shop.example.com",
+ Version: "v1",
+ Resource: "widgets/scale",
+ },
+ Subresource: true,
+ },
+ }
+ obs := ObservationsFromEntries(entries, true)
+
+ // Subresources never become their own observation.
+ if _, ok := observationByGVR(obs, "deployments/scale"); ok {
+ t.Error("a subresource must not produce its own observation")
+ }
+
+ dep, _ := observationByGVR(obs, "deployments")
+ if !dep.Subresources.Status.Enabled {
+ t.Error("deployment should fold in its /status subresource")
+ }
+ if !dep.Subresources.Scale.Enabled || !dep.Subresources.Scale.Usable {
+ t.Errorf("deployment /scale should be enabled and usable (built-in), got %+v", dep.Subresources.Scale)
+ }
+
+ widget, _ := observationByGVR(obs, "widgets")
+ if !widget.Subresources.Scale.Enabled || widget.Subresources.Scale.Usable {
+ t.Errorf("CRD /scale should be enabled but not usable (unknown path), got %+v", widget.Subresources.Scale)
+ }
+}
+
+func TestObservationsFromEntries_ScopeAndSensitivityAndDegraded(t *testing.T) {
+ entries := []Entry{
+ { // cluster-scoped built-in
+ GVK: schema.GroupVersionKind{Version: "v1", Kind: "Namespace"},
+ GVR: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"},
+ Namespaced: false,
+ Verbs: []string{"get", "list", "watch"},
+ Allowed: true,
+ },
+ { // sensitive resource (the entry builder applied the policy), on a degraded GV
+ GVK: schema.GroupVersionKind{Version: "v1", Kind: "Secret"},
+ GVR: schema.GroupVersionResource{Version: "v1", Resource: "secrets"},
+ Namespaced: true,
+ Verbs: []string{"get", "list", "watch"},
+ Allowed: true,
+ Degraded: true,
+ Sensitive: true,
+ },
+ }
+ obs := ObservationsFromEntries(entries, true)
+
+ ns, _ := observationByGVR(obs, "namespaces")
+ if ns.Identity.Scope != ScopeCluster {
+ t.Errorf("namespace scope = %q, want ClusterScoped", ns.Identity.Scope)
+ }
+
+ secret, _ := observationByGVR(obs, "secrets")
+ if !secret.Sensitive || !secret.SensitiveSupported {
+ t.Errorf("core Secret should be sensitive-but-supported, got sensitive=%v supported=%v",
+ secret.Sensitive, secret.SensitiveSupported)
+ }
+ if secret.Trusted {
+ t.Error("a degraded group/version must observe its types as untrusted")
+ }
+}
diff --git a/internal/typeset/registry.go b/internal/typeset/registry.go
new file mode 100644
index 00000000..7d35a0b0
--- /dev/null
+++ b/internal/typeset/registry.go
@@ -0,0 +1,327 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "sort"
+ "sync"
+ "time"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// RemovalGrace is how long a previously-live type that stops being observed is held
+// as retained before it leaves the live set. It is product safety, not tuning, so it
+// is a fixed constant: it stops a short discovery blink from turning into a large Git
+// sweep. See the "Live set and the 60-second grace" section of the design.
+const RemovalGrace = 60 * time.Second
+
+// Registry is the single decision surface: it turns observations plus the live-set
+// grace into one TypeRecord per known type, and answers the lookups every consumer
+// reads. It owns identity, the live set, and the removal grace; consumers never
+// recompute followability, they read it.
+//
+// Additions are fast and removals are slow: a newly observed followable type enters
+// the live set immediately, while a previously-live type that stops being observed is
+// held as retained for RemovalGrace before it drops. The clock is injectable so the
+// grace is deterministic in tests.
+//
+// Registry is safe for concurrent readers and a single updater.
+type Registry struct {
+ // dispatchMu serializes whole Updates and the lifecycle dispatch that follows each, so
+ // event batches are delivered to observers in generation order and never interleave. It
+ // is taken before mu; observers run after mu is released (so they may read the registry).
+ dispatchMu sync.Mutex
+ mu sync.RWMutex
+ grace time.Duration
+ settle time.Duration
+ now func() time.Time
+
+ entries map[recordKey]entry
+ byGVK map[schema.GroupVersionKind][]recordKey
+ byGVR map[schema.GroupVersionResource]recordKey
+ observers []Observer
+ generation uint64
+ // revision is the registry's own change-of-decision signal: it bumps whenever the
+ // followable membership changes (a type appears, drops after the grace, or flips
+ // followable<->refused) or the backing scan generation moves. Consumers that cache
+ // a projection of the registry (the per-GitTarget watched-type set) gate on this,
+ // not on the catalog generation — so a retention-grace drop at a stable generation
+ // still invalidates their cache. See docs/.../discovery-catalog-typeset-boundary.md.
+ revision uint64
+ ready bool
+}
+
+// recordKey is a record's stable identity for the live set: the (GVK, GVR, scope)
+// triple. Two resources serving the same kind are distinct records under the same
+// GVK index entry, which is exactly the gvk-not-unique case.
+type recordKey struct {
+ gvk schema.GroupVersionKind
+ gvr schema.GroupVersionResource
+ scope Scope
+}
+
+// entry is one record plus the facts, grace, and settle bookkeeping needed to re-judge it
+// when it stops being observed and to debounce its activation.
+type entry struct {
+ obs Observation
+ record TypeRecord
+ absentSince time.Time // zero while currently observed
+ // followableSince marks the start of the current continuous Followable streak (zero when
+ // not Followable); activated records whether TypeActivated has already been emitted for
+ // that streak. Together they implement the settle window and its flap coalescing.
+ followableSince time.Time
+ activated bool
+}
+
+// NewRegistry builds an empty registry with the fixed removal grace and a real clock.
+func NewRegistry() *Registry {
+ return newRegistry(time.Now)
+}
+
+// newRegistry is the test seam: it injects the clock so the grace is deterministic.
+// The grace itself is always the fixed RemovalGrace — it is product safety, not
+// tuning.
+func newRegistry(now func() time.Time) *Registry {
+ return &Registry{
+ grace: RemovalGrace,
+ settle: SettleWindow,
+ now: now,
+ entries: map[recordKey]entry{},
+ byGVK: map[schema.GroupVersionKind][]recordKey{},
+ byGVR: map[schema.GroupVersionResource]recordKey{},
+ }
+}
+
+// Update replaces the observation set for a new catalog generation and applies the
+// live-set grace. Every observation becomes a record at this generation; a
+// previously-live type missing from the set is re-judged as retained (within the
+// grace) or dropped (once the grace elapses). The first Update marks the registry
+// ready.
+func (r *Registry) Update(observations []Observation, generation uint64) {
+ // dispatchMu serializes the whole Update plus its post-publish dispatch, so concurrent
+ // updaters cannot interleave event batches and observers see transitions in generation
+ // order. The records themselves are still guarded by mu for concurrent readers.
+ r.dispatchMu.Lock()
+ defer r.dispatchMu.Unlock()
+
+ r.mu.Lock()
+ now := r.now()
+ prevFollowable := r.followableKeysLocked()
+ prevGeneration := r.generation
+ wasReady := r.ready
+
+ next := make(map[recordKey]entry, len(observations))
+ for _, obs := range observations {
+ key := observationKey(obs)
+ rec := recordFromObservation(obs, generation)
+ next[key] = entry{obs: obs, record: rec}
+ }
+
+ r.retainAbsentLocked(next, now, generation)
+
+ // Compute the lifecycle transitions while r.entries still holds the previous records, and
+ // finalize each next entry's settle bookkeeping, before publishing next.
+ events := r.computeLifecycleLocked(next, now, generation)
+
+ r.entries = next
+ r.rebuildIndexesLocked()
+ r.generation = generation
+ r.ready = true
+
+ // Bump the change-of-decision signal when the followable set changes (covers the
+ // time-based grace drop at a stable generation) or the scan generation moves.
+ if !wasReady || generation != prevGeneration || !sameKeySet(prevFollowable, r.followableKeysLocked()) {
+ r.revision++
+ }
+ observers := r.observers
+ r.mu.Unlock()
+
+ // Dispatch outside mu (so an observer may read the registry) but still under dispatchMu
+ // (so batches stay ordered). Observers must not block — a real consumer enqueues and returns.
+ dispatchLifecycle(observers, events)
+}
+
+// followableKeysLocked returns the identity keys of the records that are currently
+// followable. Caller holds r.mu.
+func (r *Registry) followableKeysLocked() map[recordKey]struct{} {
+ out := make(map[recordKey]struct{}, len(r.entries))
+ for key, e := range r.entries {
+ if e.record.Followable() {
+ out[key] = struct{}{}
+ }
+ }
+ return out
+}
+
+func sameKeySet(a, b map[recordKey]struct{}) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for key := range a {
+ if _, ok := b[key]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+// retainAbsentLocked folds previously-live types missing from the next set back in as
+// retained records, until the removal grace elapses. A type that was already refused
+// (never live) is not retained — it simply drops with its observation.
+func (r *Registry) retainAbsentLocked(next map[recordKey]entry, now time.Time, generation uint64) {
+ for key, prev := range r.entries {
+ if _, present := next[key]; present {
+ continue // freshly observed; the new record wins
+ }
+ if !prev.record.Followable() {
+ continue // was not live, nothing to hold
+ }
+ absentSince := prev.absentSince
+ if absentSince.IsZero() {
+ absentSince = now
+ }
+ expired := now.Sub(absentSince) >= r.grace
+ obs := prev.obs
+ obs.Served = false
+ obs.AbsenceExpired = expired
+ rec := recordFromObservation(obs, generation)
+ if !rec.Followable() {
+ continue // grace elapsed; the absence is trusted, let it drop
+ }
+ next[key] = entry{obs: obs, record: rec, absentSince: absentSince}
+ }
+}
+
+func (r *Registry) rebuildIndexesLocked() {
+ r.byGVK = make(map[schema.GroupVersionKind][]recordKey, len(r.entries))
+ r.byGVR = make(map[schema.GroupVersionResource]recordKey, len(r.entries))
+ for key := range r.entries {
+ r.byGVK[key.gvk] = append(r.byGVK[key.gvk], key)
+ r.byGVR[key.gvr] = key
+ }
+ for gvk := range r.byGVK {
+ sort.Slice(r.byGVK[gvk], func(i, j int) bool {
+ return r.byGVK[gvk][i].gvr.String() < r.byGVK[gvk][j].gvr.String()
+ })
+ }
+}
+
+// Ready reports whether the registry has accepted any observation set.
+func (r *Registry) Ready() bool {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.ready
+}
+
+// Generation reports the catalog generation the current records were resolved at.
+func (r *Registry) Generation() uint64 {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.generation
+}
+
+// Revision reports the registry's change-of-decision counter. It bumps whenever the
+// followable membership changes or the scan generation moves, so a consumer that caches
+// a projection of the registry can gate its rebuild on this value and still react to a
+// retention-grace drop that happens without any discovery change.
+func (r *Registry) Revision() uint64 {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.revision
+}
+
+// ByGVK returns the record for a kind. The bool reports whether the kind is known to
+// the registry at all; callers gate behaviour on record.Followable(). When a kind is
+// served by more than one resource every such record is refused with gvk-not-unique,
+// and the deterministic first (by GVR) is returned.
+func (r *Registry) ByGVK(gvk schema.GroupVersionKind) (TypeRecord, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ keys := r.byGVK[gvk]
+ if len(keys) == 0 {
+ return TypeRecord{}, false
+ }
+ return r.entries[keys[0]].record, true
+}
+
+// ByGVR returns the record for a resource. The bool reports whether the resource is
+// known to the registry at all.
+func (r *Registry) ByGVR(gvr schema.GroupVersionResource) (TypeRecord, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ key, ok := r.byGVR[gvr]
+ if !ok {
+ return TypeRecord{}, false
+ }
+ return r.entries[key].record, true
+}
+
+// Followable returns every live record (verdict followable or retained), sorted by
+// identity. It is the inventory the informer set and snapshot scope derive from.
+func (r *Registry) Followable() []TypeRecord {
+ return r.records(func(rec TypeRecord) bool { return rec.Followable() })
+}
+
+// All returns every known record — followable, retained, and refused — for inventory
+// and "why not" views.
+func (r *Registry) All() []TypeRecord {
+ return r.records(func(TypeRecord) bool { return true })
+}
+
+func (r *Registry) records(keep func(TypeRecord) bool) []TypeRecord {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ out := make([]TypeRecord, 0, len(r.entries))
+ for _, e := range r.entries {
+ if keep(e.record) {
+ out = append(out, e.record)
+ }
+ }
+ sortRecords(out)
+ return out
+}
+
+func sortRecords(records []TypeRecord) {
+ sort.Slice(records, func(i, j int) bool {
+ return identitySortKey(records[i].Identity) < identitySortKey(records[j].Identity)
+ })
+}
+
+func identitySortKey(id Identity) string {
+ return id.GVK.Group + "|" + id.GVK.Version + "|" + id.GVK.Kind + "|" + id.GVR.Resource
+}
+
+func observationKey(obs Observation) recordKey {
+ return recordKey{gvk: obs.Identity.GVK, gvr: obs.Identity.GVR, scope: obs.Identity.Scope}
+}
+
+// recordFromObservation evaluates an observation into a full record at a generation.
+func recordFromObservation(obs Observation, generation uint64) TypeRecord {
+ return TypeRecord{
+ Identity: obs.Identity,
+ Origin: obs.Origin,
+ Preferred: obs.Preferred,
+ Verbs: obs.Verbs,
+ Subresources: obs.Subresources,
+ Sensitive: obs.Sensitive,
+ Followability: Evaluate(obs),
+ Generation: generation,
+ }
+}
diff --git a/internal/typeset/registry_test.go b/internal/typeset/registry_test.go
new file mode 100644
index 00000000..e1142c5f
--- /dev/null
+++ b/internal/typeset/registry_test.go
@@ -0,0 +1,253 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "testing"
+ "time"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// fakeClock is a manually advanced clock for deterministic grace tests.
+type fakeClock struct{ t time.Time }
+
+func (c *fakeClock) now() time.Time { return c.t }
+func (c *fakeClock) add(d time.Duration) { c.t = c.t.Add(d) }
+
+func deploymentObs() Observation {
+ return followableObservation()
+}
+
+func widgetObs() Observation {
+ obs := Observation{
+ Identity: Identity{
+ GVK: schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"},
+ GVR: schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"},
+ Scope: ScopeNamespaced,
+ },
+ Origin: Origin{Kind: OriginCRD, Confidence: ConfidenceObserved, Evidence: "widgets.example.com"},
+ Verbs: []string{"get", "list", "watch", "patch"},
+ Served: true,
+ Trusted: true,
+ CatalogReady: true,
+ GVKUnique: true,
+ GVRUnique: true,
+ }
+ return obs
+}
+
+func TestRegistry_EmptyIsNotReady(t *testing.T) {
+ r := NewRegistry()
+ if r.Ready() {
+ t.Error("a fresh registry must not be ready")
+ }
+ if _, ok := r.ByGVK(deploymentObs().Identity.GVK); ok {
+ t.Error("empty registry should not know any kind")
+ }
+}
+
+func TestRegistry_UpdateMakesReadyAndLooksUp(t *testing.T) {
+ r := NewRegistry()
+ r.Update([]Observation{deploymentObs()}, 1)
+ if !r.Ready() {
+ t.Fatal("registry should be ready after Update")
+ }
+ if r.Generation() != 1 {
+ t.Errorf("generation = %d, want 1", r.Generation())
+ }
+ rec, ok := r.ByGVK(deploymentObs().Identity.GVK)
+ if !ok || !rec.Followable() {
+ t.Fatalf("deployment should be followable: ok=%v rec=%+v", ok, rec.Followability)
+ }
+ byGVR, ok := r.ByGVR(deploymentObs().Identity.GVR)
+ if !ok || byGVR.Identity.GVK != rec.Identity.GVK {
+ t.Errorf("ByGVR should round-trip to the same record")
+ }
+}
+
+func TestRegistry_FollowableAndAll(t *testing.T) {
+ denied := deploymentObs()
+ denied.Identity.GVK.Kind = "Pod"
+ denied.Identity.GVR.Resource = "pods"
+ denied.Denied = true
+ denied.DenyDetail = "excluded by default policy"
+
+ r := NewRegistry()
+ r.Update([]Observation{deploymentObs(), denied}, 1)
+
+ if got := len(r.Followable()); got != 1 {
+ t.Errorf("Followable() = %d records, want 1 (deployment only)", got)
+ }
+ if got := len(r.All()); got != 2 {
+ t.Errorf("All() = %d records, want 2 (deployment + refused pod)", got)
+ }
+ pod, ok := r.ByGVR(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "pods"})
+ if !ok || pod.Followable() {
+ t.Errorf("refused pod should be known but not followable: ok=%v", ok)
+ }
+}
+
+func TestRegistry_AmbiguousGVKRefused(t *testing.T) {
+ // Two resources serve the same kind; both observations carry GVKUnique=false.
+ a := widgetObs()
+ a.GVKUnique = false
+ a.GVKConflictDetail = "widgets, widgetz"
+ b := widgetObs()
+ b.Identity.GVR.Resource = "widgetz"
+ b.GVKUnique = false
+ b.GVKConflictDetail = "widgets, widgetz"
+
+ r := NewRegistry()
+ r.Update([]Observation{a, b}, 1)
+
+ rec, ok := r.ByGVK(a.Identity.GVK)
+ if !ok {
+ t.Fatal("ambiguous kind should still be known")
+ }
+ if rec.Followable() {
+ t.Error("ambiguous kind must not be followable")
+ }
+ check, _ := rec.Followability.Check(RequirementIdentity)
+ if check.Reason != ReasonGVKNotUnique {
+ t.Errorf("identity reason = %q, want gvk-not-unique", check.Reason)
+ }
+ // Deterministic: ByGVK returns the first resource by GVR sort ("widgets" < "widgetz").
+ if rec.Identity.GVR.Resource != "widgets" {
+ t.Errorf("ByGVK returned %q, want the sorted-first widgets", rec.Identity.GVR.Resource)
+ }
+}
+
+func TestRegistry_GraceRetainsThenDrops(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(1_000, 0)}
+ r := newRegistry(clock.now)
+
+ // Generation 1: deployment is live.
+ r.Update([]Observation{deploymentObs()}, 1)
+ gvk := deploymentObs().Identity.GVK
+
+ // Generation 2: deployment vanishes. Within the grace it is retained and still live.
+ clock.add(10 * time.Second)
+ r.Update(nil, 2)
+ rec, ok := r.ByGVK(gvk)
+ if !ok || rec.Followability.Verdict != VerdictRetained {
+ t.Fatalf("within grace: verdict = %q, want retained (ok=%v)", rec.Followability.Verdict, ok)
+ }
+ if !rec.Followable() {
+ t.Error("a retained type must still be followable")
+ }
+ if got := len(r.Followable()); got != 1 {
+ t.Errorf("retained type should still count as followable, got %d", got)
+ }
+
+ // Still absent, still within grace: stays retained, absentSince does not reset.
+ clock.add(40 * time.Second)
+ r.Update(nil, 3)
+ if rec, ok := r.ByGVK(gvk); !ok || rec.Followability.Verdict != VerdictRetained {
+ t.Fatalf("still within grace: verdict = %q, want retained", rec.Followability.Verdict)
+ }
+
+ // Grace elapses: absence began at +10s, so +40s +25s = 65s of absence >= 60s and
+ // the type drops entirely.
+ clock.add(25 * time.Second)
+ r.Update(nil, 4)
+ if _, ok := r.ByGVK(gvk); ok {
+ t.Error("after the grace the absent type must drop from the registry")
+ }
+ if got := len(r.All()); got != 0 {
+ t.Errorf("All() = %d, want 0 after drop", got)
+ }
+}
+
+// TestRegistry_RevisionBumpsOnGraceDropAtStableGeneration is the regression that
+// motivated Revision(): a type whose retention grace elapses leaves the followable set
+// without any discovery change, so the catalog generation does not move. A consumer
+// gated on the generation would never notice; the revision must bump so it re-projects.
+func TestRegistry_RevisionBumpsOnGraceDropAtStableGeneration(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(5_000, 0)}
+ r := newRegistry(clock.now)
+
+ // Generation stays 5 for the whole sequence — only time passes.
+ r.Update([]Observation{deploymentObs()}, 5)
+ afterFirst := r.Revision()
+ if afterFirst == 0 {
+ t.Fatal("the first ready Update must bump the revision")
+ }
+
+ // Within the grace: retained, same generation, followable set unchanged -> no bump.
+ clock.add(10 * time.Second)
+ r.Update(nil, 5)
+ clock.add(20 * time.Second)
+ r.Update(nil, 5)
+ if r.Revision() != afterFirst {
+ t.Errorf("a retained type within grace must not move the revision: %d != %d", r.Revision(), afterFirst)
+ }
+
+ // Grace elapses (70s absence >= 60s): the type drops at the SAME generation, so only
+ // the followable-set change can move the revision.
+ clock.add(40 * time.Second)
+ r.Update(nil, 5)
+ if r.Generation() != 5 {
+ t.Fatalf("generation must be stable at 5, got %d", r.Generation())
+ }
+ if r.Revision() <= afterFirst {
+ t.Errorf("the grace drop must bump the revision even at a stable generation: %d <= %d",
+ r.Revision(), afterFirst)
+ }
+}
+
+func TestRegistry_ReappearanceClearsGrace(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(2_000, 0)}
+ r := newRegistry(clock.now)
+ gvk := deploymentObs().Identity.GVK
+
+ r.Update([]Observation{deploymentObs()}, 1)
+ clock.add(30 * time.Second)
+ r.Update(nil, 2) // absent, retained
+ clock.add(5 * time.Second)
+ r.Update([]Observation{deploymentObs()}, 3) // reappears
+
+ rec, ok := r.ByGVK(gvk)
+ if !ok || rec.Followability.Verdict != VerdictFollowable {
+ t.Fatalf("reappeared type should be followable again, got %q", rec.Followability.Verdict)
+ }
+
+ // And it should not immediately drop on the next absence — the grace restarts.
+ clock.add(40 * time.Second)
+ r.Update(nil, 4)
+ if rec, ok := r.ByGVK(gvk); !ok || rec.Followability.Verdict != VerdictRetained {
+ t.Errorf("grace should restart after reappearance, got %q (ok=%v)", rec.Followability.Verdict, ok)
+ }
+}
+
+func TestRegistry_RefusedTypeNotRetained(t *testing.T) {
+ clock := &fakeClock{t: time.Unix(3_000, 0)}
+ r := newRegistry(clock.now)
+
+ denied := deploymentObs()
+ denied.Denied = true
+ r.Update([]Observation{denied}, 1)
+
+ // It was never live, so its disappearance is immediate — no grace hold.
+ clock.add(1 * time.Second)
+ r.Update(nil, 2)
+ if _, ok := r.ByGVK(denied.Identity.GVK); ok {
+ t.Error("a refused (never-live) type should drop immediately, not be retained")
+ }
+}
diff --git a/internal/typeset/scale.go b/internal/typeset/scale.go
new file mode 100644
index 00000000..79a13d9a
--- /dev/null
+++ b/internal/typeset/scale.go
@@ -0,0 +1,89 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "strings"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// ScaleSourceBuiltinRegistry labels a ScaleBinding resolved from the built-in
+// registry below, so it reads identically to a CRD-sourced binding for the writer.
+const ScaleSourceBuiltinRegistry = "builtin-registry"
+
+// builtinSpecReplicasPath is the parent replica path every currently-served
+// built-in scalable resource maps scale.spec.replicas onto. The version is
+// intentionally ignored: this path is stable for these resources across their
+// served versions.
+const builtinSpecReplicasPath = ".spec.replicas"
+
+// builtinScaleResponseGVK is the standardized object Kubernetes returns for a
+// built-in /scale call.
+func builtinScaleResponseGVK() schema.GroupVersionKind {
+ return schema.GroupVersionKind{Group: "autoscaling", Version: "v1", Kind: "Scale"}
+}
+
+// builtinScalable is the closed set of currently-served built-in scalable
+// resources, keyed by API group and plural resource. Each maps scale.spec.replicas
+// to the parent's .spec.replicas. CRDs draw the path from the CRD definition and
+// aggregated APIs have no generic discovery field, so neither is listed here; both
+// must resolve their own binding rather than default to .spec.replicas.
+func builtinScalable(group, resource string) bool {
+ switch {
+ case group == "apps" && resource == "deployments",
+ group == "apps" && resource == "statefulsets",
+ group == "apps" && resource == "replicasets",
+ group == "" && resource == "replicationcontrollers":
+ return true
+ default:
+ return false
+ }
+}
+
+// BuiltinScale returns the /scale binding for a currently-served built-in scalable
+// resource identified by API group and plural resource. ok is false for any other
+// resource — a CRD, an aggregated API, or a non-scalable built-in — in which case
+// the scale event must be resolved elsewhere or dropped, never defaulted to
+// .spec.replicas. It is the single source of built-in scale facts, shared by the
+// cluster registry (origin/scale enrichment) and the audit consumer (scale write).
+func BuiltinScale(group, resource string) (ScaleBinding, bool) {
+ if !builtinScalable(group, resource) {
+ return ScaleBinding{}, false
+ }
+ return ScaleBinding{
+ Enabled: true,
+ Source: ScaleSourceBuiltinRegistry,
+ ResponseGVK: builtinScaleResponseGVK(),
+ SpecReplicasPath: builtinSpecReplicasPath,
+ Usable: true,
+ }, true
+}
+
+// SplitFieldPath turns a dotted JSONPath like ".spec.replicas" (or "spec.replicas")
+// into the segment slice the manifest writer applies. An empty or whitespace-only
+// path yields nil so a caller can tell "no path" from a real one. This is the
+// bridge from a ScaleBinding's string path to the writer's []string assignment.
+func SplitFieldPath(path string) []string {
+ trimmed := strings.TrimPrefix(strings.TrimSpace(path), ".")
+ if trimmed == "" {
+ return nil
+ }
+ return strings.Split(trimmed, ".")
+}
diff --git a/internal/typeset/scale_test.go b/internal/typeset/scale_test.go
new file mode 100644
index 00000000..6853e9c3
--- /dev/null
+++ b/internal/typeset/scale_test.go
@@ -0,0 +1,100 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package typeset
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestBuiltinScale_Scalable(t *testing.T) {
+ scalable := []struct{ group, resource string }{
+ {"apps", "deployments"},
+ {"apps", "statefulsets"},
+ {"apps", "replicasets"},
+ {"", "replicationcontrollers"},
+ }
+ for _, tt := range scalable {
+ t.Run(tt.group+"/"+tt.resource, func(t *testing.T) {
+ binding, ok := BuiltinScale(tt.group, tt.resource)
+ if !ok {
+ t.Fatalf("BuiltinScale(%q,%q) should be a built-in scalable type", tt.group, tt.resource)
+ }
+ assertBuiltinScaleBinding(t, binding)
+ })
+ }
+}
+
+func assertBuiltinScaleBinding(t *testing.T, binding ScaleBinding) {
+ t.Helper()
+ if !binding.Enabled || !binding.Usable {
+ t.Errorf("built-in scalable binding should be enabled and usable: %+v", binding)
+ }
+ if binding.Source != ScaleSourceBuiltinRegistry {
+ t.Errorf("source = %q, want %q", binding.Source, ScaleSourceBuiltinRegistry)
+ }
+ if binding.SpecReplicasPath != ".spec.replicas" {
+ t.Errorf("specReplicasPath = %q, want .spec.replicas", binding.SpecReplicasPath)
+ }
+ if binding.ResponseGVK.Kind != "Scale" || binding.ResponseGVK.Group != "autoscaling" {
+ t.Errorf("responseGVK = %+v, want autoscaling/v1 Scale", binding.ResponseGVK)
+ }
+}
+
+func TestBuiltinScale_NotScalable(t *testing.T) {
+ notScalable := []struct{ group, resource string }{
+ {"apps", "daemonsets"}, // not scalable via the standardized path
+ {"batch", "jobs"}, // not scalable
+ {"example.com", "widgets"}, // CRD: resolved from the CRD, not here
+ {"metrics.k8s.io", "pods"}, // aggregated: no generic path
+ }
+ for _, tt := range notScalable {
+ t.Run(tt.group+"/"+tt.resource, func(t *testing.T) {
+ binding, ok := BuiltinScale(tt.group, tt.resource)
+ if ok {
+ t.Fatalf("BuiltinScale(%q,%q) should not be a built-in scalable type", tt.group, tt.resource)
+ }
+ if binding != (ScaleBinding{}) {
+ t.Errorf("non-scalable should return zero binding, got %+v", binding)
+ }
+ })
+ }
+}
+
+func TestSplitFieldPath(t *testing.T) {
+ tests := []struct {
+ path string
+ want []string
+ }{
+ {".spec.replicas", []string{"spec", "replicas"}},
+ {"spec.replicas", []string{"spec", "replicas"}},
+ {" .spec.replicas ", []string{"spec", "replicas"}},
+ {".status.replicas", []string{"status", "replicas"}},
+ {"", nil},
+ {" ", nil},
+ {".", nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ if got := SplitFieldPath(tt.path); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("SplitFieldPath(%q) = %v, want %v", tt.path, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/watch/api_resource_catalog.go b/internal/watch/api_resource_catalog.go
index c694c594..658ee278 100644
--- a/internal/watch/api_resource_catalog.go
+++ b/internal/watch/api_resource_catalog.go
@@ -41,16 +41,6 @@ type APIResourceEntry struct {
PolicyReason string
}
-// Supports reports whether discovery advertised all requested verbs.
-func (e APIResourceEntry) Supports(verbs ...string) bool {
- for _, verb := range verbs {
- if _, ok := e.Verbs[verb]; !ok {
- return false
- }
- }
- return true
-}
-
type apiResourceDiscovery interface {
ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error)
}
@@ -72,8 +62,6 @@ type APIResourceCatalog struct {
mu sync.RWMutex
byGVR map[schema.GroupVersionResource]APIResourceEntry
- byResource map[string][]APIResourceEntry
- byGroupRes map[string][]APIResourceEntry
byGroupVer map[schema.GroupVersion][]APIResourceEntry
groupVersion map[schema.GroupVersion]catalogGroupVersionState
generation uint64
@@ -84,8 +72,6 @@ type APIResourceCatalog struct {
func NewAPIResourceCatalog() *APIResourceCatalog {
return &APIResourceCatalog{
byGVR: make(map[schema.GroupVersionResource]APIResourceEntry),
- byResource: make(map[string][]APIResourceEntry),
- byGroupRes: make(map[string][]APIResourceEntry),
byGroupVer: make(map[schema.GroupVersion][]APIResourceEntry),
groupVersion: make(map[schema.GroupVersion]catalogGroupVersionState),
}
@@ -189,7 +175,7 @@ func (c *APIResourceCatalog) Refresh(disco apiResourceDiscovery) (bool, error) {
changed = true
}
if changed {
- c.rebuildIndexesLocked()
+ c.rebuildGVRIndexLocked()
c.generation++
}
return changed, nil
@@ -275,74 +261,10 @@ func (c *APIResourceCatalog) removeUndiscoveredGroupVersions(supported map[schem
return changed
}
-// Entry returns one concrete catalog entry.
-func (c *APIResourceCatalog) Entry(gvr schema.GroupVersionResource) (APIResourceEntry, bool) {
- c.mu.RLock()
- defer c.mu.RUnlock()
- entry, ok := c.byGVR[gvr]
- return cloneAPIResourceEntry(entry), ok
-}
-
-func (c *APIResourceCatalog) entriesForResource(resource string) []APIResourceEntry {
- c.mu.RLock()
- defer c.mu.RUnlock()
- return cloneAPIResourceEntries(c.byResource[resource])
-}
-
-func (c *APIResourceCatalog) entriesForGroup(group string) []APIResourceEntry {
- c.mu.RLock()
- defer c.mu.RUnlock()
- var out []APIResourceEntry
- for gv, entries := range c.byGroupVer {
- if gv.Group == group {
- out = append(out, entries...)
- }
- }
- sortCatalogEntries(out)
- return cloneAPIResourceEntries(out)
-}
-
-func (c *APIResourceCatalog) entriesForGroupResource(group, resource string) []APIResourceEntry {
- c.mu.RLock()
- defer c.mu.RUnlock()
- return cloneAPIResourceEntries(c.byGroupRes[groupResourceKey(group, resource)])
-}
-
-func (c *APIResourceCatalog) allEntries() []APIResourceEntry {
- c.mu.RLock()
- defer c.mu.RUnlock()
- out := make([]APIResourceEntry, 0, len(c.byGVR))
- for _, entry := range c.byGVR {
- out = append(out, entry)
- }
- sortCatalogEntries(out)
- return cloneAPIResourceEntries(out)
-}
-
-func (c *APIResourceCatalog) hasDegradedLookup(groups, versions []string) bool {
- c.mu.RLock()
- defer c.mu.RUnlock()
- for gv, state := range c.groupVersion {
- if !state.degraded {
- continue
- }
- if matchLookupValue(groups, gv.Group) && matchLookupValue(versions, gv.Version) {
- return true
- }
- }
- return false
-}
-
func (c *APIResourceCatalog) initializeMaps() {
if c.byGVR == nil {
c.byGVR = make(map[schema.GroupVersionResource]APIResourceEntry)
}
- if c.byResource == nil {
- c.byResource = make(map[string][]APIResourceEntry)
- }
- if c.byGroupRes == nil {
- c.byGroupRes = make(map[string][]APIResourceEntry)
- }
if c.byGroupVer == nil {
c.byGroupVer = make(map[schema.GroupVersion][]APIResourceEntry)
}
@@ -351,24 +273,16 @@ func (c *APIResourceCatalog) initializeMaps() {
}
}
-func (c *APIResourceCatalog) rebuildIndexesLocked() {
+// rebuildGVRIndexLocked rebuilds the by-GVR index from the group/version-keyed scan.
+// The catalog keeps only this one raw index now; followability lookups (by GVK, by
+// resource, selector expansion) all live on the typeset registry the catalog feeds.
+func (c *APIResourceCatalog) rebuildGVRIndexLocked() {
c.byGVR = make(map[schema.GroupVersionResource]APIResourceEntry)
- c.byResource = make(map[string][]APIResourceEntry)
- c.byGroupRes = make(map[string][]APIResourceEntry)
for _, entries := range c.byGroupVer {
for _, entry := range entries {
c.byGVR[entry.GVR] = entry
- c.byResource[entry.GVR.Resource] = append(c.byResource[entry.GVR.Resource], entry)
- key := groupResourceKey(entry.GVR.Group, entry.GVR.Resource)
- c.byGroupRes[key] = append(c.byGroupRes[key], entry)
}
}
- for key := range c.byResource {
- sortCatalogEntries(c.byResource[key])
- }
- for key := range c.byGroupRes {
- sortCatalogEntries(c.byGroupRes[key])
- }
}
func preferredVersions(groups []*metav1.APIGroup) map[string]string {
@@ -475,27 +389,6 @@ func verbSetsEqual(left, right map[string]struct{}) bool {
return true
}
-func cloneAPIResourceEntries(entries []APIResourceEntry) []APIResourceEntry {
- out := make([]APIResourceEntry, len(entries))
- for i := range entries {
- out[i] = cloneAPIResourceEntry(entries[i])
- }
- return out
-}
-
-func cloneAPIResourceEntry(entry APIResourceEntry) APIResourceEntry {
- entry.Verbs = resourceVerbs(metav1.Verbs(mapKeys(entry.Verbs)))
- return entry
-}
-
-func mapKeys(values map[string]struct{}) []string {
- out := make([]string, 0, len(values))
- for value := range values {
- out = append(out, value)
- }
- return out
-}
-
func groupResourceKey(group, resource string) string {
return group + "|" + resource
}
diff --git a/internal/watch/api_resource_catalog_test.go b/internal/watch/api_resource_catalog_test.go
index 5958d759..e1286f7e 100644
--- a/internal/watch/api_resource_catalog_test.go
+++ b/internal/watch/api_resource_catalog_test.go
@@ -52,14 +52,8 @@ func TestAPIResourceCatalog_RefreshPicksUpNewlyServedResource(t *testing.T) {
require.True(t, catalog.Ready())
gen1 := catalog.Generation()
- resolver := NewRuleGVRResolver(catalog)
- gvrs, misses := resolver.Resolve(
- []string{"shop.example.com"}, []string{"v1"},
- []string{"icecreamorders"}, configv1alpha1.ResourceScopeNamespaced,
- )
- assert.Empty(t, gvrs)
- require.Len(t, misses, 1)
- assert.Equal(t, ResolveMissNotServed, misses[0].Reason)
+ iceCream := schema.GroupVersionResource{Group: "shop.example.com", Version: "v1", Resource: "icecreamorders"}
+ assert.False(t, catalogServes(catalog, iceCream), "resource is not served yet")
// Generation 2: the CRD is now served.
changed, err := catalog.Refresh(staticCatalogDiscovery{
@@ -78,13 +72,15 @@ func TestAPIResourceCatalog_RefreshPicksUpNewlyServedResource(t *testing.T) {
assert.True(t, changed)
assert.Greater(t, catalog.Generation(), gen1)
- gvrs, misses = resolver.Resolve(
- []string{"shop.example.com"}, []string{"v1"},
- []string{"icecreamorders"}, configv1alpha1.ResourceScopeNamespaced,
- )
- require.Empty(t, misses)
- require.Len(t, gvrs, 1)
- assert.Equal(t, "icecreamorders", gvrs[0].Resource)
+ assert.True(t, catalogServes(catalog, iceCream), "the newly-served resource is now in the raw scan")
+}
+
+// catalogServes reports whether the catalog's raw scan holds the exact resource.
+func catalogServes(catalog *APIResourceCatalog, gvr schema.GroupVersionResource) bool {
+ catalog.mu.RLock()
+ defer catalog.mu.RUnlock()
+ _, ok := catalog.byGVR[gvr]
+ return ok
}
// TestAPIResourceCatalog_PartialRefreshPreservesFailedGroupVersion verifies that
@@ -115,17 +111,13 @@ func TestAPIResourceCatalog_PartialRefreshPreservesFailedGroupVersion(t *testing
})
require.NoError(t, err)
- entry, ok := catalog.Entry(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"})
- require.True(t, ok)
- assert.Equal(t, "Deployment", entry.GVK.Kind)
-
- gvrs, misses := NewRuleGVRResolver(catalog).Resolve(
- []string{"apps"}, nil,
- []string{"statefulsets"}, configv1alpha1.ResourceScopeNamespaced,
- )
- assert.Empty(t, gvrs)
- require.Len(t, misses, 1)
- assert.Equal(t, ResolveMissDiscoveryDegraded, misses[0].Reason)
+ // The failed apps group/version keeps its previously-trusted entries (deployments
+ // is still in the raw scan), and the group/version is marked degraded.
+ catalog.mu.RLock()
+ _, kept := catalog.byGVR[schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}]
+ catalog.mu.RUnlock()
+ assert.True(t, kept, "a degraded group/version retains its last trusted entries")
+ assert.Equal(t, []schema.GroupVersion{appsGV}, catalog.DegradedGroupVersions())
}
// TestNotServedResourceProducesNoGVR verifies catalog-backed resolution does not
diff --git a/internal/watch/catalog_observe.go b/internal/watch/catalog_observe.go
new file mode 100644
index 00000000..5c0be8a3
--- /dev/null
+++ b/internal/watch/catalog_observe.go
@@ -0,0 +1,69 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "sort"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// Observations projects the catalog scan into typeset.Observation values — the
+// "Scan -> Observation" layer of the followability model. It converts the discovery
+// facts the catalog already holds (verbs, scope, preferred, trust state, resource
+// policy) into the neutral typeset.Entry shape and lets typeset own the reduction
+// (identity uniqueness, origin, scale), so the live and snapshot paths build
+// observations identically.
+//
+// sensitive is the operator-configured SensitiveResourcePolicy (core Secrets plus any
+// additional types). Sensitivity is a startup-known policy applied here, when each
+// entry is built, exactly like the served-resource (allow/deny) policy — typeset never
+// infers it.
+func (c *APIResourceCatalog) Observations(sensitive types.SensitiveResourcePolicy) []typeset.Observation {
+ c.mu.RLock()
+ entries := make([]typeset.Entry, 0, len(c.byGVR))
+ for _, e := range c.byGVR {
+ entries = append(entries, typeset.Entry{
+ GVK: e.GVK,
+ GVR: e.GVR,
+ Namespaced: e.Namespaced,
+ Verbs: sortedVerbSet(e.Verbs),
+ Preferred: e.Preferred,
+ Subresource: e.Subresource,
+ Allowed: e.Allowed,
+ PolicyReason: e.PolicyReason,
+ Degraded: c.groupVersion[e.GVR.GroupVersion()].degraded,
+ Sensitive: sensitive.IsSensitive(e.GVR.Group, e.GVR.Resource),
+ })
+ }
+ ready := c.ready
+ c.mu.RUnlock()
+
+ return typeset.ObservationsFromEntries(entries, ready)
+}
+
+func sortedVerbSet(verbs map[string]struct{}) []string {
+ out := make([]string, 0, len(verbs))
+ for verb := range verbs {
+ out = append(out, verb)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/watch/catalog_observe_test.go b/internal/watch/catalog_observe_test.go
new file mode 100644
index 00000000..d85fefa6
--- /dev/null
+++ b/internal/watch/catalog_observe_test.go
@@ -0,0 +1,197 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// followableDiscovery serves a realistic mix the funnel must judge: a followable
+// built-in (deployments, with a /scale subresource), a followable CRD, a
+// policy-denied built-in (pods), and a built-in missing the watch verb (nodes).
+func followableDiscovery() staticCatalogDiscovery {
+ full := metav1.Verbs{"get", "list", "watch", "patch", "create", "delete"}
+ // getList lacks watch, so a type with these verbs cannot be followed.
+ getList := metav1.Verbs{"get", "list"}
+ return staticCatalogDiscovery{
+ groups: []*metav1.APIGroup{
+ testAPIGroup("", "v1"),
+ testAPIGroup("apps", "v1"),
+ testAPIGroup("shop.example.com", "v1alpha1"),
+ },
+ resources: []*metav1.APIResourceList{
+ {
+ GroupVersion: "v1",
+ APIResources: []metav1.APIResource{
+ {Name: "configmaps", Kind: "ConfigMap", Namespaced: true, Verbs: full},
+ {Name: "secrets", Kind: "Secret", Namespaced: true, Verbs: full},
+ {Name: "pods", Kind: "Pod", Namespaced: true, Verbs: full},
+ {Name: "nodes", Kind: "Node", Verbs: getList},
+ },
+ },
+ {
+ GroupVersion: "apps/v1",
+ APIResources: []metav1.APIResource{
+ {Name: "deployments", Kind: "Deployment", Namespaced: true, Verbs: full},
+ {Name: "deployments/scale", Kind: "Scale", Namespaced: true, Verbs: metav1.Verbs{"get", "patch"}},
+ },
+ },
+ {
+ GroupVersion: "shop.example.com/v1alpha1",
+ APIResources: []metav1.APIResource{
+ {Name: "icecreamorders", Kind: "IceCreamOrder", Namespaced: true, Verbs: full},
+ },
+ },
+ },
+ }
+}
+
+func recordByGVR(t *testing.T, r *typeset.Registry, group, version, resource string) typeset.TypeRecord {
+ t.Helper()
+ rec, ok := r.ByGVR(schema.GroupVersionResource{Group: group, Version: version, Resource: resource})
+ require.True(t, ok, "registry should know %s/%s/%s", group, version, resource)
+ return rec
+}
+
+func TestObservations_FollowabilityFromCatalog(t *testing.T) {
+ catalog := NewAPIResourceCatalog()
+ _, err := catalog.Refresh(followableDiscovery())
+ require.NoError(t, err)
+
+ reg := typeset.NewRegistry()
+ reg.Update(catalog.Observations(types.SensitiveResourcePolicy{}), catalog.Generation())
+
+ // Deployment: followable built-in with a usable scale binding folded in.
+ dep := recordByGVR(t, reg, "apps", "v1", "deployments")
+ assert.True(t, dep.Followable(), "deployment should be followable: %s", dep.Followability.Summary)
+ assert.Equal(t, typeset.OriginBuiltin, dep.Origin.Kind)
+ assert.True(t, dep.Subresources.Scale.Enabled, "deployment exposes /scale")
+ assert.True(t, dep.Subresources.Scale.Usable, "built-in scale binding is usable")
+ assert.Equal(t, ".spec.replicas", dep.Subresources.Scale.SpecReplicasPath)
+
+ // CRD: followable, classified crd by group shape.
+ order := recordByGVR(t, reg, "shop.example.com", "v1alpha1", "icecreamorders")
+ assert.True(t, order.Followable())
+ assert.Equal(t, typeset.OriginCRD, order.Origin.Kind)
+
+ // Pod: served and fully verbed but denied by default watch policy.
+ pod := recordByGVR(t, reg, "", "v1", "pods")
+ assert.False(t, pod.Followable())
+ policy, _ := pod.Followability.Check(typeset.RequirementPolicy)
+ assert.Equal(t, typeset.ReasonDeniedByPolicy, policy.Reason)
+
+ // Node: built-in lacking watch -> refused for the missing verb (cannot follow it).
+ node := recordByGVR(t, reg, "", "v1", "nodes")
+ assert.False(t, node.Followable())
+ verbs, _ := node.Followability.Check(typeset.RequirementVerbs)
+ assert.Equal(t, typeset.ReasonMissingVerb, verbs.Reason)
+ assert.Equal(t, "watch", verbs.Detail)
+
+ // Secret is sensitive but supported, so it stays followable.
+ secret := recordByGVR(t, reg, "", "v1", "secrets")
+ assert.True(t, secret.Sensitive)
+ assert.True(t, secret.Followable())
+
+ // The scale subresource itself is folded into the parent, never its own record.
+ _, ok := reg.ByGVR(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments/scale"})
+ assert.False(t, ok, "subresources must not enter the registry as their own types")
+}
+
+func TestObservations_AppliesConfiguredSensitivePolicy(t *testing.T) {
+ catalog := NewAPIResourceCatalog()
+ _, err := catalog.Refresh(followableDiscovery())
+ require.NoError(t, err)
+
+ // The operator additionally marks configmaps sensitive; core secrets stay sensitive
+ // by default. The registry record must reflect the configured policy, not just the
+ // built-in core-secret rule.
+ policy, err := types.ParseSensitiveResourcePolicy("configmaps")
+ require.NoError(t, err)
+
+ reg := typeset.NewRegistry()
+ reg.Update(catalog.Observations(policy), catalog.Generation())
+
+ cm := recordByGVR(t, reg, "", "v1", "configmaps")
+ assert.True(t, cm.Sensitive, "an operator-configured sensitive type must be marked sensitive")
+ secret := recordByGVR(t, reg, "", "v1", "secrets")
+ assert.True(t, secret.Sensitive, "core secrets stay sensitive")
+ dep := recordByGVR(t, reg, "apps", "v1", "deployments")
+ assert.False(t, dep.Sensitive, "an unlisted type is not sensitive")
+}
+
+func TestObservations_AmbiguousGVKMarkedNonUnique(t *testing.T) {
+ full := metav1.Verbs{"get", "list", "watch", "patch"}
+ catalog := NewAPIResourceCatalog()
+ _, err := catalog.Refresh(staticCatalogDiscovery{
+ groups: []*metav1.APIGroup{testAPIGroup("shop.example.com", "v1")},
+ resources: []*metav1.APIResourceList{{
+ GroupVersion: "shop.example.com/v1",
+ APIResources: []metav1.APIResource{
+ // Two resources serving the same kind: a pathological cluster.
+ {Name: "widgets", Kind: "Widget", Namespaced: true, Verbs: full},
+ {Name: "widgetz", Kind: "Widget", Namespaced: true, Verbs: full},
+ },
+ }},
+ })
+ require.NoError(t, err)
+
+ reg := typeset.NewRegistry()
+ reg.Update(catalog.Observations(types.SensitiveResourcePolicy{}), catalog.Generation())
+
+ rec, ok := reg.ByGVK(schema.GroupVersionKind{Group: "shop.example.com", Version: "v1", Kind: "Widget"})
+ require.True(t, ok)
+ assert.False(t, rec.Followable(), "an ambiguous kind must be refused")
+ id, _ := rec.Followability.Check(typeset.RequirementIdentity)
+ assert.Equal(t, typeset.ReasonGVKNotUnique, id.Reason)
+ assert.Equal(t, "widgets, widgetz", id.Detail)
+}
+
+func TestManager_RefreshPopulatesTypeRegistry(t *testing.T) {
+ m := &Manager{Log: logr.Discard(), discoveryClient: func() (apiResourceDiscovery, error) {
+ return followableDiscovery(), nil
+ }}
+ require.NoError(t, m.RefreshAPIResourceCatalog(context.Background()))
+
+ followable := m.FollowableTypeRecords()
+ all := m.TypeRecords()
+ assert.NotEmpty(t, followable, "deployments/configmaps/secrets/icecreamorders should be followable")
+ assert.Greater(t, len(all), len(followable), "All() must also include refused types (pods, nodes)")
+
+ // The registry tracks the catalog generation.
+ assert.Equal(t, m.apiResourceCatalog().Generation(), m.typeRegistryInstance().Generation())
+
+ gvks := map[string]bool{}
+ for _, rec := range followable {
+ gvks[rec.Identity.GVK.Kind] = true
+ }
+ assert.True(t, gvks["Deployment"], "Deployment should be followable")
+ assert.True(t, gvks["IceCreamOrder"], "the CRD should be followable")
+ assert.False(t, gvks["Pod"], "Pod is denied by policy")
+ assert.False(t, gvks["Node"], "Node is missing the watch verb")
+}
diff --git a/internal/watch/event_router.go b/internal/watch/event_router.go
index 6037abc7..aac03aa9 100644
--- a/internal/watch/event_router.go
+++ b/internal/watch/event_router.go
@@ -22,16 +22,19 @@ import (
"context"
"fmt"
"sync"
- "sync/atomic"
"time"
"github.com/go-logr/logr"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+ "k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
- "github.com/ConfigButler/gitops-reverser/internal/events"
"github.com/ConfigButler/gitops-reverser/internal/git"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
"github.com/ConfigButler/gitops-reverser/internal/reconcile"
+ "github.com/ConfigButler/gitops-reverser/internal/telemetry"
"github.com/ConfigButler/gitops-reverser/internal/types"
)
@@ -40,54 +43,41 @@ import (
// rides the worker's event queue, so a healthy worker replies promptly.
const finalizeSignalTimeout = 30 * time.Second
-// EventRouter orchestrates control flow between components.
-// It dispatches events to BranchWorkers, calls services synchronously,
-// and routes state events to reconcilers.
+// resyncSignalTimeout bounds how long a resync waits for the worker to apply and
+// commit the snapshot. It is generous because the first resync can clone/pull the
+// repository before committing; the reconcile context cancels sooner if it must.
+const resyncSignalTimeout = 5 * time.Minute
+
+// EventRouter orchestrates control flow between components. It dispatches live events
+// to BranchWorkers, routes them through per-GitTarget event streams for buffering and
+// deduplication, and drives the synchronous streaming-snapshot resync (M8).
type EventRouter struct {
- WorkerManager *git.WorkerManager
- ReconcilerManager *reconcile.ReconcilerManager
- WatchManager *Manager
- Client client.Client
- Log logr.Logger
+ WorkerManager *git.WorkerManager
+ WatchManager *Manager
+ Client client.Client
+ Log logr.Logger
// Registry of GitTargetEventStreams by gitDest key
gitTargetStreams map[string]*reconcile.GitTargetEventStream
streamsMu sync.RWMutex
-
- // snapshotDeliveryDrops counts how many cluster/repo state events were
- // produced but had no registered FolderReconciler to receive them. This
- // happens, for example, when WatchManager.ReconcileForRuleChange fires its
- // snapshot before the GitTargetReconciler has had a chance to create a
- // FolderReconciler. Each drop is a silently-missed backfill. Exposed for
- // tests and will be wired to a Prometheus gauge later.
- snapshotDeliveryDrops atomic.Int64
}
// NewEventRouter creates a new event router.
func NewEventRouter(
workerManager *git.WorkerManager,
- reconcilerManager *reconcile.ReconcilerManager,
watchManager *Manager,
client client.Client,
log logr.Logger,
) *EventRouter {
return &EventRouter{
- WorkerManager: workerManager,
- ReconcilerManager: reconcilerManager,
- WatchManager: watchManager,
- Client: client,
- Log: log,
- gitTargetStreams: make(map[string]*reconcile.GitTargetEventStream),
+ WorkerManager: workerManager,
+ WatchManager: watchManager,
+ Client: client,
+ Log: log,
+ gitTargetStreams: make(map[string]*reconcile.GitTargetEventStream),
}
}
-// SnapshotDeliveryDrops returns the number of state events that were emitted
-// for a GitDest that had no registered FolderReconciler at the time. A
-// non-zero value indicates a missed snapshot delivery.
-func (r *EventRouter) SnapshotDeliveryDrops() int64 {
- return r.snapshotDeliveryDrops.Load()
-}
-
// RouteEvent sends an event to the worker for (provider, branch).
// The target info is used to lookup the worker, then the event is queued.
// Returns an error if no worker exists for the given (provider, branch) combination.
@@ -182,102 +172,250 @@ func (r *EventRouter) FinalizeGitTargetWindow(
}
}
-// ProcessControlEvent handles control events from reconcilers.
-func (r *EventRouter) ProcessControlEvent(ctx context.Context, event events.ControlEvent) error {
- r.Log.V(1).Info("Processing control event", "type", event.Type, "gitDest", event.GitDest.String())
-
- switch event.Type {
- case events.RequestClusterState:
- return r.handleRequestClusterState(ctx, event)
- case events.RequestRepoState:
- return r.handleRequestRepoState(ctx, event)
- case events.ReconcileResource:
- return r.handleReconcileResource(ctx, event)
- default:
- return fmt.Errorf("unknown control event type: %s", event.Type)
+// EmitResyncForGitDest runs one content-derived, mark-and-sweep resync for gitDest and
+// blocks until the worker has applied it (M8). It is the replacement for the old
+// two-snapshot handshake: it gathers the GitTarget's complete watched resource set via
+// the streaming-list watch, hands that revision-pinned snapshot to the branch worker as
+// a synchronous resync request, and returns the change counts the worker computed.
+//
+// The gather fails closed on a partial stream (StreamClusterSnapshotForGitDest aborts),
+// so a resync is enqueued only for a complete snapshot — the worker can never sweep on
+// partial knowledge. The call is synchronous so the caller (the GitTarget snapshot gate
+// or ReconcileForRuleChange) learns the outcome and can order live-event flushing after
+// the snapshot commit.
+func (r *EventRouter) EmitResyncForGitDest(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+) (git.ResyncStats, error) {
+ resultCh, err := r.gatherAndEnqueueResync(ctx, gitDest)
+ if err != nil {
+ return git.ResyncStats{}, err
+ }
+
+ select {
+ case result := <-resultCh:
+ if result.Err != nil {
+ return git.ResyncStats{}, result.Err
+ }
+ r.logResyncApplied(gitDest, result.Stats)
+ return result.Stats, nil
+ case <-ctx.Done():
+ return git.ResyncStats{}, ctx.Err()
+ case <-time.After(resyncSignalTimeout):
+ return git.ResyncStats{}, fmt.Errorf("timed out resyncing %s", gitDest.String())
+ }
+}
+
+// TriggerResyncForGitDest gathers and enqueues a resync without blocking on the commit.
+// It is the rule-change path's entry point: that path only needs each affected target's
+// resync STARTED (not its stats), so many targets' commits proceed in parallel at their
+// own workers instead of serializing on the single reconcile goroutine — matching the
+// old fire-and-forget snapshot behaviour. The synchronous gather still fails closed, so
+// an unobservable API surface is returned as an error before anything is enqueued.
+//
+// Delivery is marked by the caller as soon as the resync is ENQUEUED (not when it
+// commits). An earlier version gated delivery on the apply completing, but that turned a
+// slow or failed apply into an unbounded re-resync loop: the target stayed pending, so
+// every subsequent reconcile re-gathered the whole snapshot synchronously, starving the
+// reconcile goroutine and piling resync requests onto the worker. A failed resync is
+// instead recovered by the steady-state live-event path (which writes any subsequent
+// change) and by the next genuine rule-set change, not by re-running the whole snapshot
+// on a tight loop. The worker reply is drained in the background to log the outcome and,
+// on failure/timeout, increment ResyncBackgroundFailuresTotal so the silently-recovered
+// failures are observable/alertable without re-firing the gather.
+func (r *EventRouter) TriggerResyncForGitDest(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+) error {
+ resultCh, err := r.gatherAndEnqueueResync(ctx, gitDest)
+ if err != nil {
+ return err
+ }
+ go func() {
+ select {
+ case result := <-resultCh:
+ if result.Err != nil {
+ r.Log.Error(result.Err, "background resync failed", "gitDest", gitDest.String())
+ r.recordBackgroundResyncFailure(gitDest)
+ return
+ }
+ r.logResyncApplied(gitDest, result.Stats)
+ case <-time.After(resyncSignalTimeout):
+ r.Log.Error(nil, "background resync timed out", "gitDest", gitDest.String())
+ r.recordBackgroundResyncFailure(gitDest)
+ }
+ }()
+ return nil
+}
+
+// recordBackgroundResyncFailure counts a fire-and-forget resync whose apply failed or
+// timed out at the worker, so the failure is observable even though delivery was already
+// marked on enqueue. No-op until the counter is registered.
+func (r *EventRouter) recordBackgroundResyncFailure(gitDest types.ResourceReference) {
+ if telemetry.ResyncBackgroundFailuresTotal == nil {
+ return
}
+ telemetry.ResyncBackgroundFailuresTotal.Add(context.Background(), 1, metric.WithAttributes(
+ attribute.String("gittarget_namespace", gitDest.Namespace),
+ attribute.String("gittarget_name", gitDest.Name),
+ ))
}
-// handleRequestClusterState processes RequestClusterState control events.
-func (r *EventRouter) handleRequestClusterState(ctx context.Context, event events.ControlEvent) error {
- // Call WatchManager service (synchronous)
- resources, objects, err := r.WatchManager.GetClusterStateForGitDest(ctx, event.GitDest)
+// gatherAndEnqueueResync resolves the GitTarget's worker, gathers the revision-pinned
+// streaming snapshot, and enqueues the resync request, returning the buffered reply
+// channel. It does not wait for the commit. A missing GitTarget or worker, or an
+// unobservable API surface, is returned as an error before anything is enqueued.
+func (r *EventRouter) gatherAndEnqueueResync(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+) (chan git.ResyncResult, error) {
+ worker, err := r.resolveWorkerForGitDest(ctx, gitDest)
if err != nil {
- return fmt.Errorf("failed to get cluster state: %w", err)
+ return nil, err
}
- // Wrap in event and route
- return r.RouteClusterStateEvent(events.ClusterStateEvent{
- GitDest: event.GitDest,
- Resources: resources,
- Objects: objects,
+ snapshot, err := r.WatchManager.StreamClusterSnapshotForGitDest(ctx, gitDest)
+ if err != nil {
+ return nil, err
+ }
+
+ resultCh := make(chan git.ResyncResult, 1)
+ worker.EnqueueResync(&git.ResyncRequest{
+ Desired: snapshot.Desired,
+ Revision: snapshot.Revision,
+ GitTargetName: gitDest.Name,
+ GitTargetNamespace: gitDest.Namespace,
+ Result: resultCh,
})
+ return resultCh, nil
}
-// handleRequestRepoState processes RequestRepoState control events.
-func (r *EventRouter) handleRequestRepoState(ctx context.Context, event events.ControlEvent) error {
- // Look up GitTarget
+// resolveWorkerForGitDest looks up the branch worker that owns a GitTarget's provider/branch.
+// A missing GitTarget (a rule briefly outliving its target during deletion) or a worker that
+// is not yet live is returned as an error, before anything is gathered or enqueued.
+func (r *EventRouter) resolveWorkerForGitDest(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+) (*git.BranchWorker, error) {
var gitTarget configv1alpha1.GitTarget
if err := r.Client.Get(ctx, client.ObjectKey{
- Name: event.GitDest.Name,
- Namespace: event.GitDest.Namespace,
+ Name: gitDest.Name,
+ Namespace: gitDest.Namespace,
}, &gitTarget); err != nil {
- return fmt.Errorf("failed to get GitTarget: %w", err)
+ return nil, fmt.Errorf("get GitTarget %s: %w", gitDest.String(), err)
}
-
- // Get BranchWorker
worker, exists := r.WorkerManager.GetWorkerForTarget(
gitTarget.Spec.ProviderRef.Name,
- gitTarget.Namespace, // Provider is in same namespace
+ gitTarget.Namespace, // provider is in the same namespace as the target
gitTarget.Spec.Branch,
)
if !exists {
- return fmt.Errorf("no worker for %s", event.GitDest.String())
+ return nil, fmt.Errorf("no worker for %s", gitDest.String())
}
+ return worker, nil
+}
- // Call BranchWorker service (synchronous)
- resources, err := worker.ListResourcesInPath(gitTarget.Spec.Path)
+// EmitTypeReconcileForGitDest runs one M12 per-type reconcile: it streams just gvr's resources
+// for the GitTarget and enqueues a type-scoped resync (upserts that type's objects, sweeps only
+// that type's orphans). It is fire-and-forget — the worker reply is drained in the background,
+// like the rule-change resync — so the registry's event-drain goroutine never blocks on a
+// commit. A type this GitTarget does not watch, or an unobservable surface, is returned as an
+// error before anything is enqueued.
+func (r *EventRouter) EmitTypeReconcileForGitDest(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+ gvr schema.GroupVersionResource,
+) error {
+ snapshot, err := r.WatchManager.StreamSnapshotForType(ctx, gitDest, gvr)
if err != nil {
- return fmt.Errorf("failed to list resources: %w", err)
+ return err
}
-
- // Wrap in event and route
- return r.RouteRepoStateEvent(events.RepoStateEvent{
- GitDest: event.GitDest,
- Resources: resources,
- })
+ resultCh, err := r.enqueueScopedResync(ctx, gitDest, gvr, snapshot.Desired, snapshot.Revision)
+ if err != nil {
+ return err
+ }
+ go r.drainScopedResync(gitDest, gvr, "reconcile", resultCh)
+ return nil
}
-// handleReconcileResource processes ReconcileResource control events.
-func (r *EventRouter) handleReconcileResource(_ context.Context, event events.ControlEvent) error {
- // This would handle individual resource reconciliation
- // For now, just log it
- r.Log.V(1).Info("ReconcileResource event", "gitDest", event.GitDest.String(), "resource", event.Resource)
+// EmitTypeSweepForGitDest runs one M12 per-type sweep: a type-scoped resync with an EMPTY
+// desired set, so a removed type's managed documents are dropped and no sibling type is
+// touched. It does NOT stream — the type is gone from the API, so its desired set is
+// definitionally empty. Like the reconcile it is fire-and-forget. A GitTarget that holds no
+// documents of the type produces a no-op commit.
+func (r *EventRouter) EmitTypeSweepForGitDest(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+ gvr schema.GroupVersionResource,
+) error {
+ resultCh, err := r.enqueueScopedResync(ctx, gitDest, gvr, nil, "")
+ if err != nil {
+ return err
+ }
+ go r.drainScopedResync(gitDest, gvr, "sweep", resultCh)
return nil
}
-// RouteRepoStateEvent routes RepoStateEvents to the appropriate FolderReconciler.
-func (r *EventRouter) RouteRepoStateEvent(event events.RepoStateEvent) error {
- reconciler, exists := r.ReconcilerManager.GetReconciler(event.GitDest)
- if !exists {
- r.snapshotDeliveryDrops.Add(1)
- r.Log.V(1).Info("No reconciler found", "gitDest", event.GitDest.String())
- return nil
+// enqueueScopedResync resolves the GitTarget's worker and enqueues a type-scoped resync,
+// returning the buffered reply channel. The ScopeGVR restricts the worker's mark-and-sweep to
+// the one type, so desired must carry only that type's objects (empty for a sweep).
+func (r *EventRouter) enqueueScopedResync(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+ gvr schema.GroupVersionResource,
+ desired []manifestanalyzer.DesiredResource,
+ revision string,
+) (chan git.ResyncResult, error) {
+ worker, err := r.resolveWorkerForGitDest(ctx, gitDest)
+ if err != nil {
+ return nil, err
}
- reconciler.OnRepoState(event)
- return nil
+ scope := gvr
+ resultCh := make(chan git.ResyncResult, 1)
+ worker.EnqueueResync(&git.ResyncRequest{
+ Desired: desired,
+ Revision: revision,
+ GitTargetName: gitDest.Name,
+ GitTargetNamespace: gitDest.Namespace,
+ ScopeGVR: &scope,
+ Result: resultCh,
+ })
+ return resultCh, nil
}
-// RouteClusterStateEvent routes ClusterStateEvents to the appropriate FolderReconciler.
-func (r *EventRouter) RouteClusterStateEvent(event events.ClusterStateEvent) error {
- reconciler, exists := r.ReconcilerManager.GetReconciler(event.GitDest)
- if !exists {
- r.snapshotDeliveryDrops.Add(1)
- r.Log.V(1).Info("No reconciler found", "gitDest", event.GitDest.String())
- return nil
+// drainScopedResync logs a per-type reconcile/sweep's outcome and, on failure or timeout,
+// counts it as a background resync failure so a silently-recovered fault stays observable. The
+// steady-state live-event path and the next type transition recover a failed apply, so this
+// never re-fires the gather.
+func (r *EventRouter) drainScopedResync(
+ gitDest types.ResourceReference,
+ gvr schema.GroupVersionResource,
+ kind string,
+ resultCh chan git.ResyncResult,
+) {
+ select {
+ case result := <-resultCh:
+ if result.Err != nil {
+ r.Log.Error(result.Err, "per-type "+kind+" failed", "gitDest", gitDest.String(), "gvr", gvr.String())
+ r.recordBackgroundResyncFailure(gitDest)
+ return
+ }
+ r.Log.V(1).Info("per-type "+kind+" applied",
+ "gitDest", gitDest.String(), "gvr", gvr.String(),
+ "created", result.Stats.Created, "updated", result.Stats.Updated, "deleted", result.Stats.Deleted)
+ case <-time.After(resyncSignalTimeout):
+ r.Log.Error(nil, "per-type "+kind+" timed out", "gitDest", gitDest.String(), "gvr", gvr.String())
+ r.recordBackgroundResyncFailure(gitDest)
}
- reconciler.OnClusterState(event)
- return nil
+}
+
+func (r *EventRouter) logResyncApplied(gitDest types.ResourceReference, stats git.ResyncStats) {
+ r.Log.V(1).Info("Resync applied",
+ "gitDest", gitDest.String(),
+ "created", stats.Created,
+ "updated", stats.Updated,
+ "deleted", stats.Deleted,
+ "skipped", stats.Skipped)
}
// RegisterGitTargetEventStream registers a GitTargetEventStream with the router.
diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go
index fc6533c4..7e4ac448 100644
--- a/internal/watch/event_router_test.go
+++ b/internal/watch/event_router_test.go
@@ -49,7 +49,7 @@ func TestFinalizeGitTargetWindow_GitTargetNotFound(t *testing.T) {
client := fake.NewClientBuilder().WithScheme(scheme).Build()
workerManager := git.NewWorkerManager(client, logr.Discard(), 0, types.SensitiveResourcePolicy{})
- router := NewEventRouter(workerManager, nil, nil, client, logr.Discard())
+ router := NewEventRouter(workerManager, nil, client, logr.Discard())
_, err := router.FinalizeGitTargetWindow(context.Background(), "alice", "missing", "team-a", "")
require.Error(t, err)
@@ -68,7 +68,7 @@ func TestFinalizeGitTargetWindow_NoWorkerYieldsNoOpenWindow(t *testing.T) {
client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gitTarget).Build()
workerManager := git.NewWorkerManager(client, logr.Discard(), 0, types.SensitiveResourcePolicy{})
- router := NewEventRouter(workerManager, nil, nil, client, logr.Discard())
+ router := NewEventRouter(workerManager, nil, client, logr.Discard())
result, err := router.FinalizeGitTargetWindow(context.Background(), "alice", "team-a-config", "team-a", "")
require.NoError(t, err)
@@ -99,7 +99,7 @@ func TestFinalizeGitTargetWindow_RegisteredWorkerProcessesSignal(t *testing.T) {
require.NoError(t, workerManager.EnsureWorker(ctx, "team-a-provider", "team-a", "main"))
- router := NewEventRouter(workerManager, nil, nil, client, logr.Discard())
+ router := NewEventRouter(workerManager, nil, client, logr.Discard())
// No events were routed, so the worker has no open window: the signal is
// enqueued, processed by the worker loop, and reported as NoOpenWindow.
diff --git a/internal/watch/gvr.go b/internal/watch/gvr.go
index 6275efc3..6d48251f 100644
--- a/internal/watch/gvr.go
+++ b/internal/watch/gvr.go
@@ -19,13 +19,9 @@ limitations under the License.
package watch
import (
- "fmt"
"strings"
- "k8s.io/apimachinery/pkg/runtime/schema"
-
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
- "github.com/ConfigButler/gitops-reverser/internal/rulestore"
)
// GVR represents a concrete Group/Version/Resource target with a scope.
@@ -37,66 +33,24 @@ type GVR struct {
Scope configv1alpha1.ResourceScope
}
-func (g GVR) schema() schema.GroupVersionResource {
- return schema.GroupVersionResource{Group: g.Group, Version: g.Version, Resource: g.Resource}
-}
-
-// ComputeRequestedGVRs aggregates resolved GVRs from the active RuleStore.
+// ComputeRequestedGVRs aggregates the watched GVRs from the active RuleStore: the union
+// of every GitTarget's watched types, read from the resident tables.
func (m *Manager) ComputeRequestedGVRs() []GVR {
- out, _ := m.computeRequestedGVRs()
- return out
-}
-
-func (m *Manager) computeRequestedGVRs() ([]GVR, []ResolveMiss) {
if m.RuleStore == nil {
- return nil, nil
+ return nil
}
-
var out []GVR
- var misses []ResolveMiss
- resolver := m.ruleGVRResolver()
-
- // From WatchRule (namespaced-only)
- for _, cr := range m.RuleStore.SnapshotWatchRules() {
- gvrs, ruleMisses := gvrFromCompiledRule(resolver, cr, configv1alpha1.ResourceScopeNamespaced)
- out = append(out, gvrs...)
- misses = append(misses, ruleMisses...)
- }
-
- // From ClusterWatchRule (scope per rule)
- for _, ccr := range m.RuleStore.SnapshotClusterWatchRules() {
- for _, rr := range ccr.Rules {
- gvrs, ruleMisses := gvrFromClusterRule(resolver, rr)
- out = append(out, gvrs...)
- misses = append(misses, ruleMisses...)
+ for _, table := range m.allWatchedTypeTables() {
+ for _, wt := range table.Types {
+ out = append(out, GVR{
+ Group: wt.GVR.Group,
+ Version: wt.GVR.Version,
+ Resource: wt.GVR.Resource,
+ Scope: wt.Scope,
+ })
}
}
-
- return dedupeGVRs(out), misses
-}
-
-// gvrFromCompiledRule extracts GVR entries from a compiled namespaced rule set.
-func gvrFromCompiledRule(
- resolver *RuleGVRResolver,
- cr rulestore.CompiledRule,
- scope configv1alpha1.ResourceScope,
-) ([]GVR, []ResolveMiss) {
- var out []GVR
- var misses []ResolveMiss
- for _, rr := range cr.ResourceRules {
- gvrs, ruleMisses := resolver.Resolve(rr.APIGroups, rr.APIVersions, rr.Resources, scope)
- out = append(out, gvrs...)
- misses = append(misses, ruleMisses...)
- }
- return out, misses
-}
-
-// gvrFromClusterRule extracts GVR entries from a single cluster rule with scope.
-func gvrFromClusterRule(
- resolver *RuleGVRResolver,
- rr rulestore.CompiledClusterResourceRule,
-) ([]GVR, []ResolveMiss) {
- return resolver.Resolve(rr.APIGroups, rr.APIVersions, rr.Resources, rr.Scope)
+ return dedupeGVRs(out)
}
// normalizeResource lowercases the resource for consistent matching.
@@ -104,18 +58,16 @@ func normalizeResource(r string) string {
return strings.ToLower(strings.TrimSpace(r))
}
-// FormatResolveMisses produces an actionable summary for status and logs.
-func FormatResolveMisses(misses []ResolveMiss) string {
- if len(misses) == 0 {
- return "all rule resources resolved"
- }
- parts := make([]string, 0, len(misses))
- for _, miss := range misses {
- detail := miss.Detail
- if detail == "" {
- detail = string(miss.Reason)
+// dedupeGVRs removes duplicate GVRs, preserving first-seen order.
+func dedupeGVRs(in []GVR) []GVR {
+ seen := make(map[GVR]struct{}, len(in))
+ out := make([]GVR, 0, len(in))
+ for _, gvr := range in {
+ if _, ok := seen[gvr]; ok {
+ continue
}
- parts = append(parts, fmt.Sprintf("%q: %s", miss.Resource, detail))
+ seen[gvr] = struct{}{}
+ out = append(out, gvr)
}
- return strings.Join(uniqueStrings(parts), "; ")
+ return out
}
diff --git a/internal/watch/manager.go b/internal/watch/manager.go
index 8974d95c..e9fe35d8 100644
--- a/internal/watch/manager.go
+++ b/internal/watch/manager.go
@@ -30,13 +30,12 @@ import (
"time"
corev1 "k8s.io/api/core/v1"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
k8stypes "k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/dynamic/dynamicinformer"
- "k8s.io/client-go/tools/cache"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/cespare/xxhash/v2"
@@ -46,11 +45,11 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
- "github.com/ConfigButler/gitops-reverser/internal/events"
"github.com/ConfigButler/gitops-reverser/internal/rulestore"
"github.com/ConfigButler/gitops-reverser/internal/sanitize"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
"github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
)
// RBAC permissions for dynamic watch manager - read-only access to watch all (also future ones!) resource types
@@ -71,6 +70,11 @@ type Manager struct {
// AuditLiveEventsEnabled makes the audit pipeline authoritative for live mutating events.
// Watchers still support discovery and snapshot/reconcile flows.
AuditLiveEventsEnabled bool
+ // SensitiveResources is the startup-configured policy classifying which types must
+ // use the encrypted Git write path. It is applied when the followability registry
+ // builds its observations, so each TypeRecord carries the right Sensitive fact. The
+ // zero value still treats core Secrets as sensitive.
+ SensitiveResources types.SensitiveResourcePolicy
// Deduplication: tracks last seen content hash per resource to skip status-only changes
lastSeenMu sync.RWMutex
lastSeenHash map[string]uint64 // resourceKey → content hash (key uses types.ResourceIdentifier.Key)
@@ -110,6 +114,32 @@ type Manager struct {
ruleSetSnapshotMu sync.Mutex
lastDeliveredRuleSetHash map[string]uint64
pendingRuleSetHash map[string]uint64
+
+ // watchedTypes is the resident, per-GitTarget watched-type table set: the single
+ // source of "what each GitTarget watches", a projection of the type registry's
+ // followable set onto each target's rules, read by the snapshot, informer, and
+ // plan-hash paths instead of each re-resolving inline. watchedTypeInit guards its
+ // lazy construction for zero-value Managers in tests.
+ watchedTypeInit sync.Once
+ watchedTypes *watchedTypeStore
+
+ // typeRegistry is the followability decision surface (see
+ // docs/design/manifest/version2/type-followability.md): one typeset.TypeRecord
+ // per served type, refreshed from the catalog scan on every catalog refresh. It
+ // is the inventory/status surface ("is this type followable, and if not, why?");
+ // typeRegistryInit guards its lazy construction for zero-value Managers in tests.
+ typeRegistryInit sync.Once
+ typeRegistry *typeset.Registry
+ // typeRefusalsLogged is the GVK->summary of every type the registry currently
+ // refuses, so the central "why is this not followable?" log is edge-triggered: a
+ // stable refusal is logged once, not on every refresh. Guarded by resourceCatalogMu.
+ typeRefusalsLogged map[string]string
+
+ // lifecycleEvents carries per-type registry transitions (TypeActivated / TypeRemoved /
+ // …) from the registry's updater to the drain goroutine that drives the M12 per-type
+ // reconcile/sweep. lifecycleConsumerOnce guards the one-time subscribe + goroutine start.
+ lifecycleEvents chan typeset.LifecycleEvent
+ lifecycleConsumerOnce sync.Once
}
// SnapshotEmitCount returns the number of times the manager has emitted a
@@ -198,6 +228,10 @@ func (m *Manager) Start(ctx context.Context) error {
m.initializeManagerState()
+ // Subscribe to the registry's per-type transitions before the first reconcile drives a
+ // registry Update, so cold-start activations drive the M12 per-type reconcile path.
+ m.startTypeLifecycleConsumer(ctx, log.WithName("type-lifecycle"))
+
if err := m.bootstrapRuleStore(ctx, log.WithName("bootstrap")); err != nil {
log.Error(err, "RuleStore bootstrap failed, continuing with current in-memory state")
}
@@ -387,348 +421,81 @@ func (m *Manager) dynamicClientFromConfig(log logr.Logger) dynamic.Interface {
return dc
}
-// getNamespacesForGVR returns the list of namespaces to list for a given GVR.
-// Returns empty slice for cluster-scoped resources or ClusterWatchRules (meaning cluster-wide list).
-// Returns specific namespace(s) for namespaced resources from WatchRules.
-func (m *Manager) getNamespacesForGVR(g GVR) []string {
- // Cluster-scoped resources always list cluster-wide
- if g.Scope == configv1alpha1.ResourceScopeCluster {
- return nil
- }
-
- // Collect namespaces from WatchRules
- namespacesSet := m.collectWatchRuleNamespaces(g)
-
- // Convert set to slice
- namespaces := make([]string, 0, len(namespacesSet))
- for ns := range namespacesSet {
- namespaces = append(namespaces, ns)
- }
-
- // Check ClusterWatchRules if no WatchRules matched
- if len(namespaces) == 0 && m.hasMatchingClusterWatchRule(g) {
- return nil // ClusterWatchRule with Namespaced scope - list cluster-wide
- }
-
- return namespaces
-}
-
-// collectWatchRuleNamespaces collects namespaces from WatchRules that match the given GVR.
-func (m *Manager) collectWatchRuleNamespaces(g GVR) map[string]struct{} {
- wrRules := m.RuleStore.SnapshotWatchRules()
- namespacesSet := make(map[string]struct{})
-
- for _, rule := range wrRules {
- if m.compiledRuleMatchesGVR(rule.ResourceRules, g) {
- namespacesSet[rule.Source.Namespace] = struct{}{}
- }
- }
-
- return namespacesSet
-}
-
-// hasMatchingClusterWatchRule checks if any ClusterWatchRule with Namespaced scope matches the GVR.
-func (m *Manager) hasMatchingClusterWatchRule(g GVR) bool {
- cwrRules := m.RuleStore.SnapshotClusterWatchRules()
-
- for _, cwrRule := range cwrRules {
- for _, rr := range cwrRule.Rules {
- if rr.Scope != configv1alpha1.ResourceScopeNamespaced {
+// desiredInformerScope computes, in a single pass over the resident watched-type tables,
+// the informer surface every GitTarget wants: each GVR mapped to the namespaces to watch,
+// where the empty string is a cluster-wide stream. A cluster-wide selection wins over any
+// named namespace for the same GVR (the snapshot's collapse), so the informer scope and
+// the snapshot agree. It is a pure read — the caller refreshes the tables once per
+// reconcile — which keeps re-resolution off the per-type path the old per-GVR
+// getNamespacesForGVR walked once for every requested GVR.
+func (m *Manager) desiredInformerScope() map[GVR]map[string]struct{} {
+ desired := map[GVR]map[string]struct{}{}
+ clusterWide := map[GVR]struct{}{}
+ for _, table := range m.residentWatchedTypeTables() {
+ for _, wt := range table.Types {
+ gvr := GVR{Group: wt.GVR.Group, Version: wt.GVR.Version, Resource: wt.GVR.Resource, Scope: wt.Scope}
+ if desired[gvr] == nil {
+ desired[gvr] = map[string]struct{}{}
+ }
+ if wt.ClusterWide() {
+ clusterWide[gvr] = struct{}{}
continue
}
- if m.clusterResourceRuleMatchesGVR(rr, g) {
- return true
+ for _, ns := range wt.SnapshotNamespaces() {
+ desired[gvr][ns] = struct{}{}
}
}
}
-
- return false
-}
-
-// compiledRuleMatchesGVR checks if any CompiledResourceRule in the slice matches the given GVR.
-func (m *Manager) compiledRuleMatchesGVR(resourceRules []rulestore.CompiledResourceRule, g GVR) bool {
- for _, rr := range resourceRules {
- if m.compiledResourceRuleMatchesGVR(rr, g) {
- return true
- }
- }
- return false
-}
-
-// compiledResourceRuleMatchesGVR checks if a CompiledResourceRule matches the given GVR.
-func (m *Manager) compiledResourceRuleMatchesGVR(rr rulestore.CompiledResourceRule, g GVR) bool {
- if !m.matchesAPIGroups(rr.APIGroups, g.Group) {
- return false
- }
- if !m.matchesAPIVersions(rr.APIVersions, g.Version) {
- return false
- }
- return m.matchesResources(rr.Resources, g.Resource)
-}
-
-// clusterResourceRuleMatchesGVR checks if a CompiledClusterResourceRule matches the given GVR.
-func (m *Manager) clusterResourceRuleMatchesGVR(rr rulestore.CompiledClusterResourceRule, g GVR) bool {
- if !m.matchesAPIGroups(rr.APIGroups, g.Group) {
- return false
- }
- if !m.matchesAPIVersions(rr.APIVersions, g.Version) {
- return false
- }
- return m.matchesResources(rr.Resources, g.Resource)
-}
-
-// matchesAPIGroups checks if the rule's API groups match the target group.
-func (m *Manager) matchesAPIGroups(groups []string, targetGroup string) bool {
- if len(groups) == 0 {
- return true
- }
- for _, grp := range groups {
- if grp == "*" || grp == targetGroup {
- return true
- }
- }
- return false
-}
-
-// matchesAPIVersions checks if the rule's API versions match the target version.
-func (m *Manager) matchesAPIVersions(versions []string, targetVersion string) bool {
- if len(versions) == 0 {
- return true
- }
- for _, ver := range versions {
- if ver == "*" || ver == targetVersion {
- return true
- }
- }
- return false
-}
-
-// matchesResources checks if the rule's resources match the target resource.
-func (m *Manager) matchesResources(resources []string, targetResource string) bool {
- for _, res := range resources {
- normalized := normalizeResource(res)
- if normalized == "*" || normalized == targetResource {
- return true
- }
+ // A cluster-wide selection for a GVR subsumes every named namespace for it.
+ for gvr := range clusterWide {
+ desired[gvr] = map[string]struct{}{"": {}}
}
- return false
+ return desired
}
-// GetClusterStateForGitDest returns cluster resources for a GitTarget.
-// This is a synchronous service method called by EventRouter.
-// It returns both resource identifiers (for diff logic) and sanitized full objects
-// (keyed by ResourceIdentifier.Key()) for hydrating initial snapshot write events.
-//
-//nolint:gocognit,cyclop,funlen
-func (m *Manager) GetClusterStateForGitDest(
- ctx context.Context,
- gitDest types.ResourceReference,
-) ([]types.ResourceIdentifier, map[string]unstructured.Unstructured, error) {
- log := m.Log.WithValues("gitDest", gitDest.String())
-
- // Look up GitTarget to get path
- var gitTargetObj configv1alpha1.GitTarget
- if err := m.Client.Get(ctx, client.ObjectKey{
- Name: gitDest.Name,
- Namespace: gitDest.Namespace,
- }, &gitTargetObj); err != nil {
- return nil, nil, fmt.Errorf("failed to get GitTarget: %w", err)
- }
-
- path := gitTargetObj.Spec.Path
- log = log.WithValues("path", path)
-
- // Get matching rules
- wrRules := m.RuleStore.SnapshotWatchRules()
- cwrRules := m.RuleStore.SnapshotClusterWatchRules()
-
- // Build a map from GVR to the namespaces that should be listed for it.
- // WatchRules are namespace-scoped: only list within rule.Source.Namespace.
- // ClusterWatchRules are cluster-wide: clusterWide=true overrides any namespace set.
- type gvrEntry struct {
- namespaces map[string]struct{}
- clusterWide bool
- }
- gvrMap := make(map[schema.GroupVersionResource]*gvrEntry)
-
- if err := m.RefreshAPIResourceCatalog(ctx); err != nil {
- return nil, nil, fmt.Errorf("refresh API resource catalog for %s: %w", gitDest.String(), err)
- }
- resolver := m.ruleGVRResolver()
- var blockingMisses []ResolveMiss
-
- for _, rule := range wrRules {
- if rule.GitTargetRef == gitTargetObj.Name &&
- rule.GitTargetNamespace == gitTargetObj.Namespace {
- ns := rule.Source.Namespace
- for _, rr := range rule.ResourceRules {
- gvrs, miss := m.gvrsFromResourceRule(rr, resolver)
- blockingMisses = append(blockingMisses, blockingSnapshotMisses(miss)...)
- for _, gvr := range gvrs {
- entry := gvrMap[gvr.schema()]
- if entry == nil {
- entry = &gvrEntry{namespaces: make(map[string]struct{})}
- gvrMap[gvr.schema()] = entry
- }
- if !entry.clusterWide {
- entry.namespaces[ns] = struct{}{}
- }
- }
- }
- }
- }
-
- for _, cwrRule := range cwrRules {
- if cwrRule.GitTargetRef == gitTargetObj.Name &&
- cwrRule.GitTargetNamespace == gitTargetObj.Namespace {
- gvrs, miss := m.gvrsFromClusterRule(cwrRule, resolver)
- blockingMisses = append(blockingMisses, blockingSnapshotMisses(miss)...)
- for _, gvr := range gvrs {
- entry := gvrMap[gvr.schema()]
- if entry == nil {
- entry = &gvrEntry{namespaces: make(map[string]struct{})}
- gvrMap[gvr.schema()] = entry
- }
- entry.clusterWide = true
- }
- }
- }
-
- if len(blockingMisses) > 0 {
- return nil, nil, fmt.Errorf(
- "aborting cluster snapshot for %s: %s; refusing to snapshot a partial cluster view",
- gitDest.String(), FormatResolveMisses(blockingMisses),
- )
- }
-
- // Query cluster for these GVRs
- dc := m.dynamicClientFromConfig(log)
- if dc == nil {
- return nil, nil, errors.New("no dynamic client available")
- }
-
- var resources []types.ResourceIdentifier
- objects := make(map[string]unstructured.Unstructured)
- for gvr, entry := range gvrMap {
- var namespaces []string
- if !entry.clusterWide {
- for ns := range entry.namespaces {
- namespaces = append(namespaces, ns)
+// informersToStart returns the (GVR, namespace) informers in the desired scope that are
+// not yet active.
+func informersToStart(
+ active map[GVR]map[string]context.CancelFunc,
+ desired map[GVR]map[string]struct{},
+) []gvrNamespace {
+ var toStart []gvrNamespace
+ for gvr, namespaces := range desired {
+ for ns := range namespaces {
+ if _, ok := active[gvr][ns]; !ok {
+ toStart = append(toStart, gvrNamespace{gvr: gvr, ns: ns})
}
}
- gvrResources, err := m.listResourcesForGVR(ctx, dc, gvr, namespaces, objects)
- if err != nil {
- // A failed list yields a partial cluster view. Abort rather than
- // return it: a missing resource is indistinguishable from a deleted
- // one and would wipe its tracked files on the next reconcile.
- return nil, nil, fmt.Errorf(
- "aborting cluster snapshot for %s: failed to list %s: %w",
- gitDest.String(), gvr.String(), err)
- }
- resources = append(resources, gvrResources...)
- }
-
- log.Info("Retrieved cluster state", "resourceCount", len(resources))
- return resources, objects, nil
-}
-
-// gvrsFromResourceRule returns the GVRs implied by a CompiledResourceRule.
-func (m *Manager) gvrsFromResourceRule(
- rr rulestore.CompiledResourceRule,
- resolver *RuleGVRResolver,
-) ([]GVR, []ResolveMiss) {
- return resolver.Resolve(rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha1.ResourceScopeNamespaced)
-}
-
-// gvrsFromClusterRule returns the GVRs implied by a CompiledClusterRule.
-func (m *Manager) gvrsFromClusterRule(
- cwrRule rulestore.CompiledClusterRule,
- resolver *RuleGVRResolver,
-) ([]GVR, []ResolveMiss) {
- var gvrs []GVR
- var misses []ResolveMiss
- for _, rr := range cwrRule.Rules {
- ruleGVRs, ruleMiss := resolver.Resolve(rr.APIGroups, rr.APIVersions, rr.Resources, rr.Scope)
- gvrs = append(gvrs, ruleGVRs...)
- misses = append(misses, ruleMiss...)
- }
- return dedupeGVRs(gvrs), misses
-}
-
-func blockingSnapshotMisses(misses []ResolveMiss) []ResolveMiss {
- var blocking []ResolveMiss
- for _, miss := range misses {
- switch miss.Reason {
- case ResolveMissNotServed, ResolveMissAmbiguous, ResolveMissDisallowed:
- continue
- case ResolveMissCatalogUnavailable,
- ResolveMissDiscoveryDegraded:
- blocking = append(blocking, miss)
- }
}
- return blocking
-}
-
-// uniqueStrings returns the input with duplicates removed, preserving order.
-func uniqueStrings(in []string) []string {
- seen := make(map[string]struct{}, len(in))
- out := make([]string, 0, len(in))
- for _, s := range in {
- if _, ok := seen[s]; ok {
- continue
- }
- seen[s] = struct{}{}
- out = append(out, s)
- }
- return out
+ return toStart
}
-// listResourcesForGVR lists resources for a GVR, scoped to the given namespaces.
-// If namespaces is empty, a cluster-wide list is performed (for ClusterWatchRules).
-// Identifiers are returned; sanitized full objects are written into the provided objects map
-// (keyed by ResourceIdentifier.Key()) for hydrating initial snapshot write events.
-func (m *Manager) listResourcesForGVR(
- ctx context.Context,
- dc dynamic.Interface,
- gvr schema.GroupVersionResource,
- namespaces []string,
- objects map[string]unstructured.Unstructured,
-) ([]types.ResourceIdentifier, error) {
- var allItems []unstructured.Unstructured
-
- if len(namespaces) == 0 {
- // ClusterWatchRule or cluster-scoped resource: list cluster-wide
- list, err := dc.Resource(gvr).List(ctx, metav1.ListOptions{})
- if err != nil {
- return nil, fmt.Errorf("failed to list %v: %w", gvr, err)
- }
- allItems = list.Items
- } else {
- // WatchRule: list only in the namespaces that have a matching rule
- for _, ns := range namespaces {
- list, err := dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
- if err != nil {
- return nil, fmt.Errorf("failed to list %v in namespace %s: %w", gvr, ns, err)
+// informersObsolete returns the active (GVR, namespace) informers no longer in the
+// desired scope — a whole GVR removed, or just a namespace scope narrowed.
+func informersObsolete(
+ active map[GVR]map[string]context.CancelFunc,
+ desired map[GVR]map[string]struct{},
+) []gvrNamespace {
+ var obsolete []gvrNamespace
+ for gvr, activeNS := range active {
+ want := desired[gvr]
+ for ns := range activeNS {
+ if _, ok := want[ns]; !ok {
+ obsolete = append(obsolete, gvrNamespace{gvr: gvr, ns: ns})
}
- allItems = append(allItems, list.Items...)
}
}
+ return obsolete
+}
- var resources []types.ResourceIdentifier
- for i := range allItems {
- obj := &allItems[i]
- id := types.NewResourceIdentifier(
- gvr.Group,
- gvr.Version,
- gvr.Resource,
- obj.GetNamespace(),
- obj.GetName(),
- )
- resources = append(resources, id)
- objects[id.Key()] = *sanitize.Sanitize(obj)
- }
-
- return resources, nil
+// compareInformerScope diffs the desired informer scope against the active informers
+// under informersMu, returning the (GVR, namespace) informers to start, then those to
+// retire.
+func (m *Manager) compareInformerScope(desired map[GVR]map[string]struct{}) ([]gvrNamespace, []gvrNamespace) {
+ m.informersMu.Lock()
+ defer m.informersMu.Unlock()
+ m.initializeInformerMaps()
+ return informersToStart(m.activeInformers, desired), informersObsolete(m.activeInformers, desired)
}
// ReconcileForRuleChange reconciles the watch manager when rules change.
@@ -744,18 +511,19 @@ func (m *Manager) ReconcileForRuleChange(ctx context.Context) error {
return err
}
- // Compute desired GVRs from current rules. The resolver only emits GVRs that
- // the trusted catalog confirms are served, listable, watchable and in scope,
- // so the requested set is already the discoverable set.
- requestedGVRs, misses := m.computeRequestedGVRs()
- if len(misses) > 0 {
- log.Info("rule resources were not planned", "misses", FormatResolveMisses(misses))
- }
-
- log.V(1).Info("Computed GVRs for reconciliation", "requested", len(requestedGVRs))
+ // Re-resolve the resident watched-type tables (M10) now that the catalog is
+ // fresh. This is gated on a rule-set change or catalog generation bump, so a
+ // periodic reconcile with neither reuses the resolved tables. Every consumer
+ // below (informer set, snapshot gather, plan hash) reads these tables.
+ m.refreshWatchedTypeTables()
- // Determine what changed
- added, removed := m.compareGVRs(requestedGVRs)
+ // The informer surface is read from the resident watched-type tables in a single
+ // pass (refreshed once above), then diffed against the active informers at
+ // (GVR, namespace) granularity: toStart needs starting, obsolete needs retiring so
+ // a narrowed scope (a namespace dropped, or a switch between namespaced and
+ // cluster-wide) does not leave the old informer running alongside the new one.
+ desired := m.desiredInformerScope()
+ toStart, obsolete := m.compareInformerScope(desired)
// Log current active count for debugging
m.informersMu.Lock()
@@ -763,49 +531,48 @@ func (m *Manager) ReconcileForRuleChange(ctx context.Context) error {
m.informersMu.Unlock()
targets := m.snapshotTargetsNeedingDelivery()
- if len(added) == 0 && len(removed) == 0 && len(targets) == 0 {
+ if len(toStart) == 0 && len(obsolete) == 0 && len(targets) == 0 {
log.V(1).Info("No GVR changes detected, skipping reconciliation",
"activeGVRs", activeCount)
return nil
}
log.Info("GVR changes detected",
- "added", len(added),
- "removed", len(removed),
+ "toStart", len(toStart),
+ "obsolete", len(obsolete),
"activeGVRs", activeCount)
- // Stop informers for removed GVRs
- for _, gvr := range removed {
- m.stopInformer(gvr)
+ // Stop obsolete (GVR, namespace) informers.
+ for _, gn := range obsolete {
+ m.stopInformerNamespace(gn.gvr, gn.ns)
}
// Put affected GitTarget event streams into RECONCILING state BEFORE starting new
- // informers. This ensures informer ADDED events fired during cache sync are buffered
- // rather than processed as N individual [CREATE] commits.
+ // informers, so live events arriving during the snapshot are buffered rather than
+ // interleaved with the mark-and-sweep commit. Informer start is non-blocking now
+ // (no synchronous cache-sync wait), so an ADDED event may instead arrive after the
+ // flush below; it is then processed live and deduped against the snapshot content.
m.beginReconciliationForTargets(targets, log)
- // Start informers for added GVRs
- if len(added) > 0 {
- if err := m.startInformersForGVRs(ctx, added); err != nil {
- log.Error(err, "Failed to start informers for new GVRs")
- return err
- }
+ // Start the new (GVR, namespace) informers.
+ if err := m.startInformerScope(ctx, toStart); err != nil {
+ log.Error(err, "Failed to start informers for new GVRs")
+ return err
}
// Clear deduplication cache for changed GVRs to prevent false duplicates
- m.clearDeduplicationCacheForGVRs(append(added, removed...))
+ m.clearDeduplicationCacheForGVRs(changedInformerGVRs(toStart, obsolete))
- // Emit RequestClusterState for each affected GitTarget so that a single
+ // Run one streaming-snapshot resync per affected GitTarget so a single
// "reconcile: sync N resources" commit is produced instead of N individual
// [CREATE] commits from the informer ADDED events buffered above.
deliveryErr := m.emitSnapshotForRuleChange(ctx, log, targets, "rule_change")
- // Transition streams back to LIVE_PROCESSING and flush buffered events.
- // startInformersForGVRs already waited for cache sync (WaitForCacheSync),
- // so all initial ADDED events are guaranteed to be buffered before this point.
- // The flushed events are no-ops at the git level because the snapshot batch
- // just wrote those files. Run this even when a target failed delivery so the
- // streams that did snapshot leave the buffering state.
+ // Transition streams back to LIVE_PROCESSING and flush events buffered during the
+ // snapshot. Informers sync in the background, so an initial ADDED event may not have
+ // arrived yet; when it does it is a no-op at the git level because the snapshot batch
+ // just wrote that file (content dedup). Run this even when a target failed delivery so
+ // the streams that did snapshot leave the buffering state.
m.completeReconciliationForTargets(targets, log)
if deliveryErr != nil {
@@ -816,133 +583,60 @@ func (m *Manager) ReconcileForRuleChange(ctx context.Context) error {
}
log.V(1).Info("Watch manager reconciliation completed",
- "addedGVRs", len(added),
- "removedGVRs", len(removed))
+ "startedInformers", len(toStart),
+ "obsoleteInformers", len(obsolete))
return nil
}
-// compareGVRs returns (added, removed) GVRs compared to current active set.
-// Now handles GVR+namespace combinations properly.
-func (m *Manager) compareGVRs(desired []GVR) ([]GVR, []GVR) {
- m.informersMu.Lock()
- defer m.informersMu.Unlock()
-
- // Initialize if needed
- if m.activeInformers == nil {
- m.activeInformers = make(map[GVR]map[string]context.CancelFunc)
- }
-
- // Build map of desired GVR -> namespaces
- desiredGVRNamespaces := m.buildDesiredGVRNamespaces(desired)
-
- // Find added and removed GVRs
- added := m.findAddedGVRs(desiredGVRNamespaces)
- removed := m.findRemovedGVRs(desiredGVRNamespaces)
-
- return added, removed
-}
-
-// buildDesiredGVRNamespaces constructs a map of desired GVR to their namespaces.
-func (m *Manager) buildDesiredGVRNamespaces(desired []GVR) map[GVR]map[string]bool {
- desiredGVRNamespaces := make(map[GVR]map[string]bool)
-
- for _, gvr := range desired {
- namespaces := m.getNamespacesForGVRUnlocked(gvr)
-
- if desiredGVRNamespaces[gvr] == nil {
- desiredGVRNamespaces[gvr] = make(map[string]bool)
- }
-
- if len(namespaces) == 0 {
- // Cluster-wide
- desiredGVRNamespaces[gvr][""] = true
- } else {
- for _, ns := range namespaces {
- desiredGVRNamespaces[gvr][ns] = true
- }
- }
- }
-
- return desiredGVRNamespaces
-}
-
-// findAddedGVRs identifies GVRs that need informers added.
-func (m *Manager) findAddedGVRs(desiredGVRNamespaces map[GVR]map[string]bool) []GVR {
- var added []GVR
- seenGVRs := make(map[GVR]bool)
-
- for gvr, desiredNS := range desiredGVRNamespaces {
- if m.hasNewNamespaces(gvr, desiredNS) && !seenGVRs[gvr] {
- added = append(added, gvr)
- seenGVRs[gvr] = true
- }
- }
-
- return added
-}
-
-// hasNewNamespaces checks if a GVR has new namespaces compared to active informers.
-func (m *Manager) hasNewNamespaces(gvr GVR, desiredNS map[string]bool) bool {
- activeNS, gvrExists := m.activeInformers[gvr]
-
- if !gvrExists {
- return true
- }
-
- for ns := range desiredNS {
- if _, nsExists := activeNS[ns]; !nsExists {
- return true
+// changedInformerGVRs is the deduplicated set of GVRs touched by a reconcile (started or
+// torn down), used to clear the content-dedup cache for exactly those types.
+func changedInformerGVRs(toStart, obsolete []gvrNamespace) []GVR {
+ seen := make(map[GVR]struct{}, len(toStart)+len(obsolete))
+ out := make([]GVR, 0, len(toStart)+len(obsolete))
+ for _, gn := range append(append([]gvrNamespace{}, toStart...), obsolete...) {
+ if _, ok := seen[gn.gvr]; !ok {
+ seen[gn.gvr] = struct{}{}
+ out = append(out, gn.gvr)
}
}
-
- return false
-}
-
-// findRemovedGVRs identifies GVRs that should be removed.
-func (m *Manager) findRemovedGVRs(desiredGVRNamespaces map[GVR]map[string]bool) []GVR {
- var removed []GVR
-
- for gvr := range m.activeInformers {
- if _, exists := desiredGVRNamespaces[gvr]; !exists {
- removed = append(removed, gvr)
- }
- }
-
- return removed
-}
-
-// getNamespacesForGVRUnlocked is like getNamespacesForGVR but assumes informersMu is already held.
-func (m *Manager) getNamespacesForGVRUnlocked(g GVR) []string {
- // Temporarily unlock to call getNamespacesForGVR which doesn't need the lock
- m.informersMu.Unlock()
- defer m.informersMu.Lock()
- return m.getNamespacesForGVR(g)
+ return out
}
-// stopInformer cancels and removes all informers for a specific GVR (across all namespaces).
-func (m *Manager) stopInformer(gvr GVR) {
+// stopInformerNamespace cancels one (GVR, namespace) informer and drops it from the
+// active set, removing the GVR entry once its last namespace stops. It is idempotent: a
+// concurrent reconcile may have already stopped the same informer.
+func (m *Manager) stopInformerNamespace(gvr GVR, ns string) {
m.informersMu.Lock()
defer m.informersMu.Unlock()
- if nsMap, exists := m.activeInformers[gvr]; exists {
- for ns, cancel := range nsMap {
- cancel() // Stop the informer
- m.Log.V(1).Info("Stopped informer",
- "group", gvr.Group,
- "version", gvr.Version,
- "resource", gvr.Resource,
- "namespace", ns)
- }
+ nsMap, exists := m.activeInformers[gvr]
+ if !exists {
+ return
+ }
+ if cancel, ok := nsMap[ns]; ok {
+ cancel() // Stop the informer
+ delete(nsMap, ns)
+ m.Log.V(1).Info("Stopped informer",
+ "group", gvr.Group,
+ "version", gvr.Version,
+ "resource", gvr.Resource,
+ "namespace", ns)
+ }
+ if len(nsMap) == 0 {
delete(m.activeInformers, gvr)
}
}
-// startInformersForGVRs starts watching specific GVRs.
-// Creates namespace-scoped factories for WatchRule GVRs and cluster-wide factory for ClusterWatchRule GVRs.
-func (m *Manager) startInformersForGVRs(ctx context.Context, gvrs []GVR) error {
+// startInformerScope starts the given (GVR, namespace) informers that are not already
+// running. It is the namespace-granular replacement for the old GVR-list start path:
+// the caller passes the exact (GVR, namespace) pairs computed once from the desired
+// scope, so there is no per-GVR namespace re-resolution here.
+func (m *Manager) startInformerScope(ctx context.Context, toStart []gvrNamespace) error {
+ if len(toStart) == 0 {
+ return nil
+ }
log := m.Log.WithName("reconcile")
- log.V(1).Info("startInformersForGVRs called", "gvrCount", len(gvrs))
cfg := m.restConfig()
if cfg == nil {
@@ -961,15 +655,24 @@ func (m *Manager) startInformersForGVRs(ctx context.Context, gvrs []GVR) error {
m.initializeInformerMaps()
- toStart := m.collectInformersToStart(gvrs)
-
- if len(toStart) == 0 {
+ // Re-check under the lock: a concurrent reconcile may have started some already.
+ actual := make([]gvrNamespace, 0, len(toStart))
+ for _, gn := range toStart {
+ if m.activeInformers[gn.gvr] == nil {
+ m.activeInformers[gn.gvr] = make(map[string]context.CancelFunc)
+ }
+ if _, exists := m.activeInformers[gn.gvr][gn.ns]; !exists {
+ actual = append(actual, gn)
+ }
+ }
+ if len(actual) == 0 {
log.V(1).Info("All informers already running")
return nil
}
- log.Info("Starting new informers", "count", len(toStart))
- return m.startCollectedInformers(ctx, client, toStart)
+ log.Info("Starting new informers", "count", len(actual))
+ m.startCollectedInformers(ctx, client, actual)
+ return nil
}
// initializeInformerMaps ensures informer tracking maps are initialized.
@@ -988,50 +691,22 @@ type gvrNamespace struct {
ns string
}
-// collectInformersToStart identifies which informers need to be started.
-func (m *Manager) collectInformersToStart(gvrs []GVR) []gvrNamespace {
- var toStart []gvrNamespace
-
- for _, gvr := range gvrs {
- namespaces := m.getNamespacesForGVR(gvr)
-
- if m.activeInformers[gvr] == nil {
- m.activeInformers[gvr] = make(map[string]context.CancelFunc)
- }
-
- if len(namespaces) == 0 {
- // Cluster-wide informer
- if _, exists := m.activeInformers[gvr][""]; !exists {
- toStart = append(toStart, gvrNamespace{gvr: gvr, ns: ""})
- }
- } else {
- // Namespace-scoped informers
- for _, ns := range namespaces {
- if _, exists := m.activeInformers[gvr][ns]; !exists {
- toStart = append(toStart, gvrNamespace{gvr: gvr, ns: ns})
- }
- }
- }
- }
-
- return toStart
-}
-
// startCollectedInformers starts all the collected informers.
-func (m *Manager) startCollectedInformers(ctx context.Context, client dynamic.Interface, toStart []gvrNamespace) error {
+func (m *Manager) startCollectedInformers(ctx context.Context, client dynamic.Interface, toStart []gvrNamespace) {
for _, item := range toStart {
- if err := m.startSingleInformer(ctx, client, item.gvr, item.ns); err != nil {
- return err
- }
+ m.startSingleInformer(ctx, client, item.gvr, item.ns)
}
- m.Log.WithName("reconcile").V(1).Info("All informers started and synced")
- return nil
+ m.Log.WithName("reconcile").V(1).Info("All informers started")
}
// startSingleInformer starts a single informer for a GVR in a specific namespace (or cluster-wide if ns is empty).
-// Must be called with informersMu held.
-func (m *Manager) startSingleInformer(ctx context.Context, client dynamic.Interface, gvr GVR, ns string) error {
+// Must be called with informersMu held. It does NOT wait for the informer's cache to sync: a fresh
+// CRD whose API endpoint is briefly unservable would otherwise block the whole reconcile (and
+// informersMu) on WaitForCacheSync, which is the bootstrap deadlock M12 removes. The informer syncs
+// in the background; content for each type is materialised by its per-type reconcile, and live
+// events flow once the cache is up.
+func (m *Manager) startSingleInformer(ctx context.Context, client dynamic.Interface, gvr GVR, ns string) {
log := m.Log.WithName("reconcile").WithValues(
"group", gvr.Group,
"version", gvr.Version,
@@ -1074,16 +749,9 @@ func (m *Manager) startSingleInformer(ctx context.Context, client dynamic.Interf
log.V(1).Info("Registered new informer")
- // Start the factory (idempotent - starts new informers if factory already running)
+ // Start the factory (idempotent - starts new informers if factory already running). This
+ // does not block on cache sync; the informer syncs in the background.
factory.Start(ctx.Done())
-
- // ALWAYS wait for this specific informer to sync
- if !cache.WaitForCacheSync(ctx.Done(), informer.HasSynced) {
- return fmt.Errorf("failed to sync cache for %v in namespace %s", resource, ns)
- }
- log.V(1).Info("Informer cache synced")
-
- return nil
}
// clearDeduplicationCacheForGVRs removes hash entries for resources of the specified GVRs.
@@ -1230,66 +898,18 @@ func (m *Manager) snapshotTargetsNeedingDelivery() []ruleSetSnapshotTarget {
// rulestore TestGetMatchingRules_OverlappingRulesUnionOperations). A target with
// rules that currently resolve to nothing is kept as an empty plan so transient
// discovery gaps do not look like rule removal.
+//
+// As of M10 the resolved surface is read from the resident watched-type tables
+// rather than re-resolved here; watchPlanFromTable reconstructs the identical
+// effective-plan entries (and hash) from a table, so snapshot selection is
+// unchanged.
func (m *Manager) currentRuleSetSnapshots() []ruleSetSnapshotTarget {
- plans := make(map[string]*targetWatchPlan)
- resolver := m.ruleGVRResolver()
-
- plan := func(ref types.ResourceReference, providerNS, provider, branch, path string) *targetWatchPlan {
- key := ref.Key()
- p := plans[key]
- if p == nil {
- p = &targetWatchPlan{gitDest: ref, entries: make(map[string]map[string]struct{})}
- plans[key] = p
- }
- // Destination is a property of the GitTarget, so it is identical across
- // that target's rules; recording it on each rule is harmless.
- p.dest = fmt.Sprintf("provider=%s/%s|branch=%q|path=%q", providerNS, provider, branch, path)
- return p
- }
-
- // Namespaced WatchRules watch their own namespace (rule.Source.Namespace),
- // so the namespace is part of the resolved scope even though the rule name is
- // not part of the plan.
- for _, rule := range m.RuleStore.SnapshotWatchRules() {
- p := plan(
- types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace),
- rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path,
- )
- for _, rr := range rule.ResourceRules {
- gvrs, _ := resolver.Resolve(rr.APIGroups, rr.APIVersions, rr.Resources,
- configv1alpha1.ResourceScopeNamespaced)
- for _, gvr := range gvrs {
- p.addEntry(gvr, rule.Source.Namespace, rr.Operations)
- }
- }
- }
-
- // ClusterWatchRules carry per-rule scope and watch all namespaces (or are
- // cluster-scoped), so the plan namespace is empty.
- for _, rule := range m.RuleStore.SnapshotClusterWatchRules() {
- p := plan(
- types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace),
- rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path,
- )
- for _, rr := range rule.Rules {
- gvrs, _ := resolver.Resolve(rr.APIGroups, rr.APIVersions, rr.Resources, rr.Scope)
- for _, gvr := range gvrs {
- p.addEntry(gvr, "", rr.Operations)
- }
- }
- }
-
- keys := make([]string, 0, len(plans))
- for key := range plans {
- keys = append(keys, key)
- }
- sort.Strings(keys)
-
- targets := make([]ruleSetSnapshotTarget, 0, len(keys))
- for _, key := range keys {
- p := plans[key]
+ tables := m.allWatchedTypeTables()
+ targets := make([]ruleSetSnapshotTarget, 0, len(tables))
+ for _, table := range tables {
+ p := watchPlanFromTable(table)
targets = append(targets, ruleSetSnapshotTarget{
- gitDest: p.gitDest,
+ gitDest: table.GitDest,
hash: p.hash(),
hasEntries: len(p.entries) > 0,
})
@@ -1317,36 +937,6 @@ func (m *Manager) markRuleSetSnapshotDelivered(target ruleSetSnapshotTarget) {
}
}
-// MaybeReplaySnapshot emits a pending rule-change snapshot once a FolderReconciler
-// exists for gitDest. It is called by ReconcilerManager when a reconciler is
-// created; ctx is the originating reconcile context so the replay is cancellable.
-func (m *Manager) MaybeReplaySnapshot(ctx context.Context, gitDest types.ResourceReference) {
- if m == nil {
- return
- }
-
- key := gitDest.Key()
- m.ruleSetSnapshotMu.Lock()
- m.ensureRuleSetSnapshotMapsLocked()
- hash, pending := m.pendingRuleSetHash[key]
- lastDelivered := m.lastDeliveredRuleSetHash[key]
- m.ruleSetSnapshotMu.Unlock()
-
- if !pending || lastDelivered == hash || m.EventRouter == nil {
- return
- }
-
- target := ruleSetSnapshotTarget{gitDest: gitDest, hash: hash}
- log := m.Log.WithName("reconcile")
- m.EventRouter.BeginReconciliationForStream(gitDest)
- if err := m.emitSnapshotForRuleChange(ctx, log, []ruleSetSnapshotTarget{target}, "startup_replay"); err != nil {
- // The target stays pending (not marked delivered), so the next reconcile
- // or replay retries it; just record why this attempt did not land.
- log.Error(err, "snapshot replay did not complete, leaving target pending", "gitDest", gitDest.String())
- }
- m.EventRouter.CompleteReconciliationForStream(gitDest)
-}
-
func (m *Manager) beginReconciliationForTargets(targets []ruleSetSnapshotTarget, log logr.Logger) {
if m.EventRouter == nil {
return
@@ -1357,18 +947,20 @@ func (m *Manager) beginReconciliationForTargets(targets []ruleSetSnapshotTarget,
}
}
-// emitSnapshotForRuleChange emits fresh repo and cluster state requests for every affected
-// GitTarget so FolderReconciler diffs against current repository contents rather than a
-// stale cached repo snapshot from an earlier reconcile.
+// emitSnapshotForRuleChange runs one streaming-snapshot resync for every affected
+// GitTarget (M8), replacing the old repo+cluster two-snapshot handshake. Each target's
+// complete watched set is gathered via the streaming-list watch and applied at the
+// worker as a content-derived mark-and-sweep.
//
-// A target is marked delivered and counted only once *both* its repo- and
-// cluster-state requests land on the worker queue: a partial emission would have
-// the reconciler diff against an incomplete cluster view. A transient failure on
-// either request leaves that target pending and is returned as an error so the
-// caller requeues with backoff and retries it promptly, rather than waiting out
-// the 30s periodic reconcile — which matters for the per-pod restart gate that
-// blocks on the reconcile counter reaching the new pod. Other targets in the
-// batch are still attempted so one bad target cannot starve the rest.
+// A target is marked delivered and counted once its resync has been ENQUEUED at the
+// worker (the rule-change resync is fire-and-forget). Delivery is deliberately NOT gated
+// on the apply committing: doing so turned a slow or failed apply into an unbounded
+// re-resync loop that re-gathered the whole snapshot every reconcile and starved the
+// reconcile goroutine (see TriggerResyncForGitDest). A gather failure is returned
+// synchronously so the caller requeues promptly — which matters for the per-pod restart
+// gate that blocks on the reconcile counter reaching the new pod — and leaves the target
+// pending for retry. Other targets in the batch are still attempted so one bad target
+// cannot starve the rest.
func (m *Manager) emitSnapshotForRuleChange(
ctx context.Context,
log logr.Logger,
@@ -1382,37 +974,31 @@ func (m *Manager) emitSnapshotForRuleChange(
}
return nil
}
- log.Info("Emitting fresh repo and cluster state for affected GitTargets after rule change", "count", len(targets))
+ log.Info("Resyncing affected GitTargets after rule change", "count", len(targets))
emitted := false
var errs []error
for _, target := range targets {
gitDest := target.gitDest
- if m.EventRouter.ReconcilerManager == nil {
- log.V(1).Info("ReconcilerManager not set, leaving snapshot pending", "gitDest", gitDest.String())
- continue
- }
- reconciler, exists := m.EventRouter.ReconcilerManager.GetReconciler(gitDest)
- if !exists {
- log.V(1).Info("No reconciler registered, leaving snapshot pending", "gitDest", gitDest.String())
- continue
- }
- reconciler.ResetState()
- if err := m.EventRouter.ProcessControlEvent(ctx, events.ControlEvent{
- Type: events.RequestRepoState,
- GitDest: gitDest,
- }); err != nil {
- log.Error(err, "failed to emit RequestRepoState for rule change", "gitDest", gitDest)
- errs = append(errs, fmt.Errorf("emit RequestRepoState for %s: %w", gitDest, err))
- continue
- }
- if err := m.EventRouter.ProcessControlEvent(ctx, events.ControlEvent{
- Type: events.RequestClusterState,
- GitDest: gitDest,
- }); err != nil {
- log.Error(err, "failed to emit RequestClusterState for rule change", "gitDest", gitDest)
- errs = append(errs, fmt.Errorf("emit RequestClusterState for %s: %w", gitDest, err))
+ // One content-derived, mark-and-sweep resync per target: gather the streaming
+ // snapshot and enqueue it at the worker without blocking on the commit, so many
+ // targets' commits proceed in parallel. A target whose GitTarget no longer exists
+ // is skipped benignly (a rule may briefly outlive its GitTarget during deletion);
+ // it must not poison the batch into a requeue storm. A target whose worker is not
+ // yet live, or whose snapshot could not be gathered, is left pending and retried
+ // by the next reconcile, exactly as the old two-snapshot path left it pending.
+ if err := m.EventRouter.TriggerResyncForGitDest(ctx, gitDest); err != nil {
+ if apierrors.IsNotFound(err) {
+ log.V(1).Info("GitTarget no longer exists; skipping resync", "gitDest", gitDest.String())
+ continue
+ }
+ log.Error(err, "failed to resync GitTarget for rule change", "gitDest", gitDest)
+ errs = append(errs, fmt.Errorf("resync %s: %w", gitDest, err))
continue
}
+ // Mark delivered + count the reconcile once the resync is ENQUEUED. Gating this
+ // on the apply completing caused an unbounded re-resync loop (see
+ // TriggerResyncForGitDest); a failed apply is recovered by steady-state events and
+ // the next rule-set change, not by re-running the whole snapshot every reconcile.
m.markRuleSetSnapshotDelivered(target)
m.recordTargetReconcileCompleted(gitDest, trigger)
emitted = true
@@ -1424,13 +1010,17 @@ func (m *Manager) emitSnapshotForRuleChange(
}
// recordTargetReconcileCompleted increments the per-GitTarget reconcile counter
-// once its snapshot decision has been made and the resulting write request
-// submitted to the branch worker, tagged with the trigger that drove the pass.
-// On a controller restart the new pod's counter starts at 0, so a per-pod
-// `{pod=""} > 0` reading is the signal that the new pod's snapshot
-// reconcile reached the git write path — paired with a drained
-// BranchWorkerQueueDepth it proves the post-restart snapshot has fully landed.
-// No-op until the counter is registered.
+// once its snapshot decision has been made and the resync ENQUEUED on the branch
+// worker, tagged with the trigger that drove the pass. It deliberately does NOT wait
+// for the commit (see emitSnapshotForRuleChange / TriggerResyncForGitDest), so the
+// counter measures "the new pod gathered the snapshot and submitted it to the worker
+// queue", not "the commit landed in git". On a controller restart the new pod's counter
+// starts at 0, so a per-pod `{pod=""} > 0` reading shows the new pod reached the
+// submit step; paired with a drained BranchWorkerQueueDepth it shows the worker then
+// processed everything it was handed. The apply itself can still fail (the worker logs
+// it and the steady-state path / next rule change recovers), so a rollout gate built on
+// this must accept "gathered + enqueued + queue drained", not "every snapshot commit
+// succeeded". No-op until the counter is registered.
func (m *Manager) recordTargetReconcileCompleted(gitDest types.ResourceReference, trigger string) {
if telemetry.TargetReconcileCompletedTotal == nil {
return
diff --git a/internal/watch/manager_catalog.go b/internal/watch/manager_catalog.go
index c4e4ba26..174f16a3 100644
--- a/internal/watch/manager_catalog.go
+++ b/internal/watch/manager_catalog.go
@@ -39,6 +39,7 @@ import (
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
"github.com/ConfigButler/gitops-reverser/internal/telemetry"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
)
// restConfig acquires the controller runtime REST config.
@@ -81,6 +82,9 @@ func (m *Manager) RefreshAPIResourceCatalog(ctx context.Context) error {
changed, refreshErr := catalog.Refresh(disco)
recordCatalogRefresh(ctx, changed, refreshErr, time.Since(start))
if refreshErr == nil {
+ // Re-derive the followability records from the fresh scan before logging, so
+ // the ready line can report how many served types are followable.
+ m.refreshTypeRegistry()
stats := catalog.Stats()
recordCatalogStats(ctx, stats)
m.logCatalogTransitions(catalog, stats)
@@ -102,6 +106,8 @@ func (m *Manager) logCatalogTransitions(catalog *APIResourceCatalog, stats Catal
"excludedResources", stats.ExcludedResources,
"trustedGroupVersions", stats.TrustedGroupVersions,
"degradedGroupVersions", stats.DegradedGroupVersions,
+ "followableTypes", len(m.FollowableTypeRecords()),
+ "knownTypes", len(m.TypeRecords()),
"generation", stats.Generation)
})
}
@@ -208,8 +214,79 @@ func (m *Manager) apiResourceCatalog() *APIResourceCatalog {
return m.resourceCatalog
}
-func (m *Manager) ruleGVRResolver() *RuleGVRResolver {
- return NewRuleGVRResolver(m.apiResourceCatalog())
+// typeRegistryInstance returns the lazily-built followability registry, so a
+// zero-value Manager (used widely in tests) needs no explicit setup.
+func (m *Manager) typeRegistryInstance() *typeset.Registry {
+ m.typeRegistryInit.Do(func() {
+ if m.typeRegistry == nil {
+ m.typeRegistry = typeset.NewRegistry()
+ }
+ })
+ return m.typeRegistry
+}
+
+// refreshTypeRegistry re-derives the followability records from the current catalog
+// scan and republishes them at the catalog's generation. It runs after every catalog
+// refresh, so the registry tracks discovery (and the 60-second removal grace advances
+// on the same cadence the catalog does). It is the "Scan -> Observation -> Registry"
+// pipeline of docs/design/manifest/version2/type-followability.md.
+func (m *Manager) refreshTypeRegistry() {
+ catalog := m.apiResourceCatalog()
+ // Only publish once the catalog holds trusted data, so the registry's readiness
+ // tracks the catalog's: an unready catalog must leave the registry unready, which
+ // is what makes the live mapper fall closed (CatalogUnavailable) rather than treat
+ // an empty scan as a trusted "nothing is served".
+ if !catalog.Ready() {
+ return
+ }
+ reg := m.typeRegistryInstance()
+ reg.Update(catalog.Observations(m.SensitiveResources), catalog.Generation())
+ m.logTypeRefusals(reg)
+}
+
+// logTypeRefusals is the single central place that explains why a served type is not
+// followed. It emits one V(1) line per refused type, edge-triggered: keyed by GVK and
+// summary, so a stable refusal (a policy-excluded kind, a verb-poor type) is logged
+// once rather than on every refresh. The full machine-readable answer always lives on
+// the registry record (TypeRecords / FollowableTypeRecords), so callers that need it
+// read there rather than parse logs.
+func (m *Manager) logTypeRefusals(reg *typeset.Registry) {
+ log := m.Log.WithName("followability")
+ m.resourceCatalogMu.Lock()
+ defer m.resourceCatalogMu.Unlock()
+ current := map[string]string{}
+ for _, rec := range reg.All() {
+ if rec.Followable() {
+ continue
+ }
+ key := rec.Identity.GVK.String()
+ current[key] = rec.Followability.Summary
+ if prev, known := m.typeRefusalsLogged[key]; !known || prev != rec.Followability.Summary {
+ log.V(1).Info("type is not followable",
+ "gvk", key, "gvr", rec.Identity.GVR.String(), "reason", rec.Followability.Summary)
+ }
+ }
+ m.typeRefusalsLogged = current
+}
+
+// TypeRegistry returns the live followability registry, the single decision surface
+// (a typeset.Lookup). The git worker reads it to resolve manifest GVKs; the manager
+// refreshes it in place, so the returned pointer tracks discovery updates.
+func (m *Manager) TypeRegistry() *typeset.Registry {
+ return m.typeRegistryInstance()
+}
+
+// FollowableTypeRecords returns every currently-followable type record (verdict
+// followable or retained), sorted by identity. It is the inventory the status and
+// visibility surfaces read; it never recomputes followability.
+func (m *Manager) FollowableTypeRecords() []typeset.TypeRecord {
+ return m.typeRegistryInstance().Followable()
+}
+
+// TypeRecords returns every known type record — followable, retained, and refused —
+// for inventory and "why is this type not picked up?" views.
+func (m *Manager) TypeRecords() []typeset.TypeRecord {
+ return m.typeRegistryInstance().All()
}
func (m *Manager) apiResourceDiscovery() (apiResourceDiscovery, error) {
@@ -227,70 +304,65 @@ func (m *Manager) apiResourceDiscovery() (apiResourceDiscovery, error) {
return disco, nil
}
-// ResolveWatchRuleResources resolves one WatchRule for controller status feedback.
+// ruleResourceSelector is one rule's (apiGroups, apiVersions, resources, scope) tuple,
+// the unit ResolveWatchRuleResources / ResolveClusterWatchRuleResources match against the
+// followable set.
+type ruleResourceSelector struct {
+ groups, versions, resources []string
+ scope configv1alpha1.ResourceScope
+}
+
+// ResolveWatchRuleResources reports one WatchRule's resource-resolution status for
+// controller feedback. See resolveRuleResourceStatus.
func (m *Manager) ResolveWatchRuleResources(
_ context.Context,
rule configv1alpha1.WatchRule,
) (bool, string) {
- var gvrs []GVR
- var misses []ResolveMiss
- wildcard := false
- resolver := m.ruleGVRResolver()
- for _, resourceRule := range rule.Spec.Rules {
- ruleGVRs, ruleMisses := resolver.Resolve(
- resourceRule.APIGroups,
- resourceRule.APIVersions,
- resourceRule.Resources,
- configv1alpha1.ResourceScopeNamespaced,
- )
- gvrs = append(gvrs, ruleGVRs...)
- misses = append(misses, ruleMisses...)
- wildcard = wildcard || ruleSelectorsContainWildcard(
- resourceRule.APIGroups,
- resourceRule.APIVersions,
- resourceRule.Resources,
- )
- }
- return len(misses) == 0, formatResolutionStatus(dedupeGVRs(gvrs), misses, wildcard)
+ selectors := make([]ruleResourceSelector, 0, len(rule.Spec.Rules))
+ for _, rr := range rule.Spec.Rules {
+ selectors = append(selectors, ruleResourceSelector{
+ groups: rr.APIGroups, versions: rr.APIVersions, resources: rr.Resources,
+ scope: configv1alpha1.ResourceScopeNamespaced,
+ })
+ }
+ return m.resolveRuleResourceStatus(selectors)
}
-// ResolveClusterWatchRuleResources resolves one ClusterWatchRule for status feedback.
+// ResolveClusterWatchRuleResources reports one ClusterWatchRule's resource-resolution
+// status for controller feedback. See resolveRuleResourceStatus.
func (m *Manager) ResolveClusterWatchRuleResources(
_ context.Context,
rule configv1alpha1.ClusterWatchRule,
) (bool, string) {
- var gvrs []GVR
- var misses []ResolveMiss
- wildcard := false
- resolver := m.ruleGVRResolver()
- for _, resourceRule := range rule.Spec.Rules {
- ruleGVRs, ruleMisses := resolver.Resolve(
- resourceRule.APIGroups,
- resourceRule.APIVersions,
- resourceRule.Resources,
- resourceRule.Scope,
- )
- gvrs = append(gvrs, ruleGVRs...)
- misses = append(misses, ruleMisses...)
- wildcard = wildcard || ruleSelectorsContainWildcard(
- resourceRule.APIGroups,
- resourceRule.APIVersions,
- resourceRule.Resources,
- )
- }
- return len(misses) == 0, formatResolutionStatus(dedupeGVRs(gvrs), misses, wildcard)
-}
-
-func ruleSelectorsContainWildcard(groups, versions, resources []string) bool {
- return hasWildcard(groups) || hasWildcard(versions) || hasWildcard(resources)
+ selectors := make([]ruleResourceSelector, 0, len(rule.Spec.Rules))
+ for _, rr := range rule.Spec.Rules {
+ selectors = append(selectors, ruleResourceSelector{
+ groups: rr.APIGroups, versions: rr.APIVersions, resources: rr.Resources, scope: rr.Scope,
+ })
+ }
+ return m.resolveRuleResourceStatus(selectors)
}
-func formatResolutionStatus(gvrs []GVR, misses []ResolveMiss, wildcard bool) string {
- message := FormatResolveMisses(misses)
- if !wildcard {
- return message
+// resolveRuleResourceStatus reports a rule's resource-resolution status from the type
+// registry's followable set — the exact records the watcher follows, so the status a rule
+// reports can never drift from what is actually mirrored. The app deliberately does not
+// explain why an individual selector matched nothing: absent, refused, and not-yet-served
+// are all the same to a mirror. Status only reports catalog readiness and how many distinct
+// followable types the rule currently watches.
+func (m *Manager) resolveRuleResourceStatus(selectors []ruleResourceSelector) (bool, string) {
+ m.refreshTypeRegistry()
+ reg := m.typeRegistryInstance()
+ if !reg.Ready() {
+ return false, "API resource catalog is not ready"
+ }
+ records := reg.Followable()
+ watched := map[schema.GroupVersionResource]struct{}{}
+ for _, s := range selectors {
+ for _, rec := range matchFollowableRecords(records, s.groups, s.versions, s.resources, s.scope) {
+ watched[rec.Identity.GVR] = struct{}{}
+ }
}
- return fmt.Sprintf("wildcard expanded to %d GVRs; %s", len(gvrs), message)
+ return true, fmt.Sprintf("watching %d resource type(s)", len(watched))
}
func (m *Manager) signalCatalogRefresh() {
diff --git a/internal/watch/manager_snapshot_test.go b/internal/watch/manager_snapshot_test.go
index 700a2441..fc9937e3 100644
--- a/internal/watch/manager_snapshot_test.go
+++ b/internal/watch/manager_snapshot_test.go
@@ -20,27 +20,33 @@ package watch
import (
"context"
- "errors"
"sort"
"testing"
"github.com/go-logr/logr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- appsv1 "k8s.io/api/apps/v1"
- corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/watch"
dynamicfake "k8s.io/client-go/dynamic/fake"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
k8stesting "k8s.io/client-go/testing"
fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
"github.com/ConfigButler/gitops-reverser/internal/rulestore"
itypes "github.com/ConfigButler/gitops-reverser/internal/types"
)
+var (
+ secretsGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}
+ nodesGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"}
+)
+
// makeScheme returns a scheme with core Kubernetes types registered.
func makeScheme(t *testing.T) *runtime.Scheme {
t.Helper()
@@ -50,590 +56,312 @@ func makeScheme(t *testing.T) *runtime.Scheme {
return s
}
-// makeSecret creates a minimal Secret suitable for the fake dynamic client.
-func makeSecret(name, namespace string) *corev1.Secret {
- return &corev1.Secret{
- ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
- }
-}
-
-// makeConfigMap creates a minimal ConfigMap suitable for the fake dynamic client.
-func makeConfigMap(name, namespace string) *corev1.ConfigMap {
- return &corev1.ConfigMap{
- ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
- }
-}
-
-// makeDeployment creates a minimal Deployment suitable for the fake dynamic client.
-func makeDeployment(name, namespace string) *appsv1.Deployment {
- return &appsv1.Deployment{
- ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
+// uns builds a core/v1 unstructured object an initial-events stream would replay.
+func uns(kind, namespace, name string) *unstructured.Unstructured {
+ u := &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": map[string]interface{}{"name": name},
+ }}
+ if namespace != "" {
+ u.SetNamespace(namespace)
}
+ return u
}
-// makeNode creates a minimal Node (cluster-scoped) for the fake dynamic client.
-func makeNode(name string) *corev1.Node {
- return &corev1.Node{
- ObjectMeta: metav1.ObjectMeta{Name: name},
- }
-}
-
-// setupManager creates a Manager wired with fake clients and a pre-populated RuleStore.
-func setupManager(
+// streamingManager builds a Manager whose dynamic client serves a streaming-list watch
+// from objectsByGVR: every Watch replays the matching objects (filtered to the watched
+// namespace) as initial ADDED events, then an initial-events-end bookmark. This is the
+// fake that lets StreamClusterSnapshotForGitDest run end to end without a cluster.
+func streamingManager(
t *testing.T,
- scheme *runtime.Scheme,
gitTarget *configv1alpha1.GitTarget,
- ruleStore *rulestore.RuleStore,
- clusterObjects ...runtime.Object,
+ store *rulestore.RuleStore,
+ objectsByGVR map[schema.GroupVersionResource][]*unstructured.Unstructured,
) *Manager {
t.Helper()
-
- // Controller-runtime fake client: used by m.Client.Get to resolve the GitTarget.
- fakeK8s := fakeclient.NewClientBuilder().
- WithScheme(scheme).
- WithObjects(gitTarget).
- Build()
-
- // Fake dynamic client: used by listResourcesForGVR.
- fakeDyn := dynamicfake.NewSimpleDynamicClient(scheme, clusterObjects...)
-
+ scheme := makeScheme(t)
+ fakeK8s := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(gitTarget).Build()
+ fakeDyn := dynamicfake.NewSimpleDynamicClient(scheme)
+ fakeDyn.PrependWatchReactor("*", func(action k8stesting.Action) (bool, watch.Interface, error) {
+ wa := action.(k8stesting.WatchActionImpl)
+ fw := watch.NewFakeWithChanSize(64, false)
+ for _, obj := range objectsByGVR[wa.Resource] {
+ if wa.Namespace == "" || obj.GetNamespace() == wa.Namespace {
+ fw.Add(obj.DeepCopy())
+ }
+ }
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("1"))
+ return true, fw, nil
+ })
return &Manager{
Client: fakeK8s,
Log: logr.Discard(),
- RuleStore: ruleStore,
+ RuleStore: store,
dynamicClient: fakeDyn,
resourceCatalog: newCommonTestCatalog(t),
discoveryClient: commonTestDiscoveryClient(),
}
}
-// resourceNames extracts sorted Name fields from a slice of ResourceIdentifiers for easy assertion.
-func resourceNames(ids []itypes.ResourceIdentifier) []string {
- names := make([]string, len(ids))
- for i, id := range ids {
- names[i] = id.Name
- }
- sort.Strings(names)
- return names
-}
-
-// resourceNamespaces extracts the unique namespaces from a slice of ResourceIdentifiers.
-func resourceNamespaces(ids []itypes.ResourceIdentifier) []string {
- seen := map[string]struct{}{}
- var out []string
- for _, id := range ids {
- if _, ok := seen[id.Namespace]; !ok {
- seen[id.Namespace] = struct{}{}
- out = append(out, id.Namespace)
- }
- }
- sort.Strings(out)
- return out
-}
-
-// TestSnapshotScopedToWatchRuleNamespace verifies that a WatchRule in namespace ns-a
-// causes GetClusterStateForGitDest to list only resources in ns-a, not in ns-b.
-func TestSnapshotScopedToWatchRuleNamespace(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
+// gitTargetFixture is the GitTarget the snapshot tests resolve rules against.
+func gitTargetFixture() *configv1alpha1.GitTarget {
+ return &configv1alpha1.GitTarget{
ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
Spec: configv1alpha1.GitTargetSpec{Path: "live"},
}
-
- store := rulestore.NewStore()
- store.AddOrUpdateWatchRule(
- configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "wr-ns-a", Namespace: "ns-a"},
- Spec: configv1alpha1.WatchRuleSpec{
- TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"},
- Rules: []configv1alpha1.ResourceRule{{
- APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"},
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
-
- m := setupManager(t, scheme, gitTarget, store,
- makeSecret("secret-a1", "ns-a"),
- makeSecret("secret-a2", "ns-a"),
- makeSecret("secret-b1", "ns-b"), // should NOT appear
- )
-
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
-
- require.NoError(t, err)
- assert.Equal(t, []string{"secret-a1", "secret-a2"}, resourceNames(resources),
- "only secrets from ns-a should be returned")
- assert.Equal(t, []string{"ns-a"}, resourceNamespaces(resources),
- "no resources from ns-b should leak into the snapshot")
}
-func TestSnapshotBareDeploymentRuleResolvesAppsGVR(t *testing.T) {
- scheme := makeScheme(t)
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
- }
- store := rulestore.NewStore()
+// addWatchRule registers a namespaced WatchRule for my-target watching one resource.
+func addWatchRule(store *rulestore.RuleStore, name, namespace, resource string) {
store.AddOrUpdateWatchRule(
configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "deployment-rule", Namespace: "ns-a"},
+ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: configv1alpha1.WatchRuleSpec{
TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"},
Rules: []configv1alpha1.ResourceRule{{
- Resources: []string{"deployments"},
+ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{resource},
}},
},
},
"my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
)
- manager := setupManager(t, scheme, gitTarget, store, makeDeployment("api", "ns-a"))
-
- resources, _, err := manager.GetClusterStateForGitDest(
- context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"),
- )
-
- require.NoError(t, err)
- assert.Equal(t, []string{"api"}, resourceNames(resources))
-}
-
-// TestSnapshotTwoWatchRulesInDifferentNamespaces verifies that when two WatchRules
-// target the same GitTarget from different namespaces, both are included and a third
-// namespace is excluded.
-func TestSnapshotTwoWatchRulesInDifferentNamespaces(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
- }
-
- store := rulestore.NewStore()
- for _, ns := range []string{"ns-a", "ns-b"} {
- store.AddOrUpdateWatchRule(
- configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "wr-" + ns, Namespace: ns},
- Spec: configv1alpha1.WatchRuleSpec{
- TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"},
- Rules: []configv1alpha1.ResourceRule{{
- APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"configmaps"},
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
- }
-
- m := setupManager(t, scheme, gitTarget, store,
- makeConfigMap("cm-a", "ns-a"),
- makeConfigMap("cm-b", "ns-b"),
- makeConfigMap("cm-c", "ns-c"), // should NOT appear
- )
-
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
-
- require.NoError(t, err)
- assert.Equal(t, []string{"cm-a", "cm-b"}, resourceNames(resources),
- "configmaps from both ns-a and ns-b should be returned")
- assert.Equal(t, []string{"ns-a", "ns-b"}, resourceNamespaces(resources),
- "ns-c should be excluded")
}
-// TestSnapshotClusterWatchRuleIsClusterWide verifies that a ClusterWatchRule causes
-// GetClusterStateForGitDest to list resources cluster-wide (nodes are cluster-scoped).
-func TestSnapshotClusterWatchRuleIsClusterWide(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
- }
-
- store := rulestore.NewStore()
+// addClusterWatchRule registers a cluster-scoped ClusterWatchRule for my-target.
+func addClusterWatchRule(store *rulestore.RuleStore, name, resource string) {
store.AddOrUpdateClusterWatchRule(
configv1alpha1.ClusterWatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "cwr-nodes"},
+ ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: configv1alpha1.ClusterWatchRuleSpec{
- TargetRef: configv1alpha1.NamespacedTargetReference{
- Name: "my-target",
- Namespace: "gitops-reverser",
- },
+ TargetRef: configv1alpha1.NamespacedTargetReference{Name: "my-target", Namespace: "gitops-reverser"},
Rules: []configv1alpha1.ClusterResourceRule{{
- APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"nodes"},
+ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{resource},
Scope: configv1alpha1.ResourceScopeCluster,
}},
},
},
"my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
)
-
- m := setupManager(t, scheme, gitTarget, store,
- makeNode("node-1"),
- makeNode("node-2"),
- makeNode("node-3"),
- )
-
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
-
- require.NoError(t, err)
- assert.Equal(t, []string{"node-1", "node-2", "node-3"}, resourceNames(resources),
- "all nodes should be returned from cluster-wide list")
}
-// TestSnapshotEmptyNamespaceReturnsNoResources verifies that when a WatchRule points
-// to a namespace with no matching resources, the result is empty (not an error, and no
-// resources from other namespaces bleed in).
-func TestSnapshotEmptyNamespaceReturnsNoResources(t *testing.T) {
- scheme := makeScheme(t)
+func myTargetRef() itypes.ResourceReference {
+ return itypes.NewResourceReference("my-target", "gitops-reverser")
+}
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
+// desiredNames returns the sorted resource names in a snapshot, for stable assertions.
+func desiredNames(desired []manifestanalyzer.DesiredResource) []string {
+ names := make([]string, len(desired))
+ for i, d := range desired {
+ names[i] = d.Resource.Name
}
-
- store := rulestore.NewStore()
- store.AddOrUpdateWatchRule(
- configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "wr-empty", Namespace: "ns-empty"},
- Spec: configv1alpha1.WatchRuleSpec{
- TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"},
- Rules: []configv1alpha1.ResourceRule{{
- APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"configmaps"},
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
-
- m := setupManager(t, scheme, gitTarget, store,
- makeConfigMap("cm-other", "ns-other"), // other namespace, should not appear
- )
-
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
-
- require.NoError(t, err)
- assert.Empty(t, resources, "no resources expected when watched namespace is empty")
+ sort.Strings(names)
+ return names
}
-// TestSnapshotRegressionNoFluxSystemLeakage directly reproduces the observed failure:
-// a WatchRule in a test namespace must not cause secrets from flux-system or other
-// namespaces to appear in the snapshot result.
-func TestSnapshotRegressionNoFluxSystemLeakage(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "bi-target", Namespace: "test-ns"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
+// desiredNamespaces returns the unique namespaces present in a snapshot.
+func desiredNamespaces(desired []manifestanalyzer.DesiredResource) []string {
+ seen := map[string]struct{}{}
+ var out []string
+ for _, d := range desired {
+ if _, ok := seen[d.Resource.Namespace]; !ok {
+ seen[d.Resource.Namespace] = struct{}{}
+ out = append(out, d.Resource.Namespace)
+ }
}
+ sort.Strings(out)
+ return out
+}
+// A namespaced WatchRule scopes the streaming snapshot to its own namespace: objects in
+// other namespaces never leak into the desired set.
+func TestStreamSnapshot_ScopedToWatchRuleNamespace(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateWatchRule(
- configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "bi-secret-watchrule", Namespace: "test-ns"},
- Spec: configv1alpha1.WatchRuleSpec{
- TargetRef: configv1alpha1.LocalTargetReference{Name: "bi-target"},
- Rules: []configv1alpha1.ResourceRule{{
- APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"},
- }},
- },
- },
- "bi-target", "test-ns", "provider", "test-ns", "main", "live",
- )
-
- // Cluster has secrets in the test namespace AND in system namespaces that
- // should never be touched by this WatchRule.
- m := setupManager(t, scheme, gitTarget, store,
- makeSecret("bi-secret", "test-ns"),
- makeSecret("git-creds", "test-ns"),
- makeSecret("bi-controller-sops", "test-ns"),
- makeSecret("cert-manager-webhook-ca", "cert-manager"), // must NOT appear
- makeSecret("bi-flux-auth", "flux-system"), // must NOT appear
- makeSecret("bi-sops", "flux-system"), // must NOT appear
- makeSecret("k3s-serving", "kube-system"), // must NOT appear
- makeSecret("prometheus-shared", "prometheus-operator"), // must NOT appear
- )
+ addWatchRule(store, "wr-ns-a", "ns-a", "secrets")
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("bi-target", "test-ns"))
+ m := streamingManager(t, gitTargetFixture(), store, map[schema.GroupVersionResource][]*unstructured.Unstructured{
+ secretsGVR: {
+ uns("Secret", "ns-a", "secret-a1"),
+ uns("Secret", "ns-a", "secret-a2"),
+ uns("Secret", "ns-b", "secret-b1"), // out of scope
+ },
+ })
+ snap, err := m.StreamClusterSnapshotForGitDest(context.Background(), myTargetRef())
require.NoError(t, err)
- assert.Equal(t, []string{"bi-controller-sops", "bi-secret", "git-creds"}, resourceNames(resources),
- "only secrets in test-ns should be returned")
- assert.Equal(t, []string{"test-ns"}, resourceNamespaces(resources),
- "secrets from cert-manager, flux-system, kube-system, prometheus-operator must not appear")
+ assert.Equal(t, []string{"secret-a1", "secret-a2"}, desiredNames(snap.Desired))
+ assert.Equal(t, []string{"ns-a"}, desiredNamespaces(snap.Desired), "ns-b must not leak in")
}
-// TestSnapshotClusterWatchRuleWildcardVersionNotSilentlyEmpty is a regression guard for
-// the "startup reconcile snapshots an empty cluster and deletes the tracked git tree"
-// data-loss bug.
-//
-// apiVersions: ["*"] is the documented "match all versions" form for a ClusterWatchRule
-// (see api/v1alpha1/clusterwatchrule_types.go). The live audit path honours it, so the
-// git mirror builds up normally. The startup snapshot path (gvrsFromClusterRule) skips
-// every "*" version, so it resolves zero GVRs, lists nothing, and GetClusterStateForGitDest
-// returns (empty, nil) — a silent empty snapshot that looks authoritative. On a controller
-// restart the FolderReconciler then diffs "cluster has 0" against the full git mirror and
-// deletes everything.
-//
-// The snapshot must NOT silently return an empty result for a wildcard rule while the
-// cluster has matching resources: it must either resolve the wildcard and list them, or
-// fail loudly with an error so the reconcile aborts.
-func TestSnapshotClusterWatchRuleWildcardVersionNotSilentlyEmpty(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
- }
-
+// Two WatchRules for the same target in different namespaces union their namespaces into
+// one snapshot.
+func TestStreamSnapshot_TwoNamespacesUnion(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateClusterWatchRule(
- configv1alpha1.ClusterWatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "cwr-wildcard"},
- Spec: configv1alpha1.ClusterWatchRuleSpec{
- TargetRef: configv1alpha1.NamespacedTargetReference{
- Name: "my-target",
- Namespace: "gitops-reverser",
- },
- Rules: []configv1alpha1.ClusterResourceRule{{
- APIGroups: []string{""},
- APIVersions: []string{"*"}, // documented "all versions" wildcard
- Resources: []string{"nodes"},
- Scope: configv1alpha1.ResourceScopeCluster,
- }},
- },
+ addWatchRule(store, "wr-ns-a", "ns-a", "secrets")
+ addWatchRule(store, "wr-ns-b", "ns-b", "secrets")
+
+ m := streamingManager(t, gitTargetFixture(), store, map[schema.GroupVersionResource][]*unstructured.Unstructured{
+ secretsGVR: {
+ uns("Secret", "ns-a", "secret-a"),
+ uns("Secret", "ns-b", "secret-b"),
+ uns("Secret", "ns-c", "secret-c"), // no rule for ns-c
},
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
+ })
- m := setupManager(t, scheme, gitTarget, store,
- makeNode("node-1"),
- makeNode("node-2"),
- makeNode("node-3"),
- )
-
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
-
- silentlyEmpty := err == nil && len(resources) == 0
- require.False(t, silentlyEmpty,
- "wildcard ClusterWatchRule produced a silent empty snapshot while the cluster has 3 nodes; "+
- "on a controller restart this empty snapshot makes the FolderReconciler delete the whole git tree")
+ snap, err := m.StreamClusterSnapshotForGitDest(context.Background(), myTargetRef())
+ require.NoError(t, err)
+ assert.Equal(t, []string{"secret-a", "secret-b"}, desiredNames(snap.Desired))
}
-// TestSnapshotWatchRuleWildcardVersionNotSilentlyEmpty is the namespaced WatchRule
-// counterpart of the wildcard regression above: gvrsFromResourceRule skips "*" versions
-// just like gvrsFromClusterRule, so a wildcard WatchRule also yields a silent empty
-// snapshot.
-func TestSnapshotWatchRuleWildcardVersionNotSilentlyEmpty(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
- }
-
+// A ClusterWatchRule streams a cluster-scoped resource cluster-wide.
+func TestStreamSnapshot_ClusterWatchRuleIsClusterWide(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateWatchRule(
- configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "wr-wildcard", Namespace: "ns-a"},
- Spec: configv1alpha1.WatchRuleSpec{
- TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"},
- Rules: []configv1alpha1.ResourceRule{{
- APIGroups: []string{""},
- APIVersions: []string{"*"}, // documented "all versions" wildcard
- Resources: []string{"configmaps"},
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
-
- m := setupManager(t, scheme, gitTarget, store,
- makeConfigMap("cm-a1", "ns-a"),
- makeConfigMap("cm-a2", "ns-a"),
- )
+ addClusterWatchRule(store, "cwr-nodes", "nodes")
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
+ m := streamingManager(t, gitTargetFixture(), store, map[schema.GroupVersionResource][]*unstructured.Unstructured{
+ nodesGVR: {uns("Node", "", "node-1"), uns("Node", "", "node-2")},
+ })
- silentlyEmpty := err == nil && len(resources) == 0
- require.False(t, silentlyEmpty,
- "wildcard WatchRule produced a silent empty snapshot while the namespace has 2 configmaps; "+
- "on a controller restart this empty snapshot makes the FolderReconciler delete the whole git tree")
+ snap, err := m.StreamClusterSnapshotForGitDest(context.Background(), myTargetRef())
+ require.NoError(t, err)
+ assert.Equal(t, []string{"node-1", "node-2"}, desiredNames(snap.Desired))
+ assert.Equal(t, "1", snap.Revision, "the snapshot is pinned to the bookmark revision")
}
-// TestSnapshotWildcardGroupExpands verifies that a ClusterWatchRule with a "*"
-// apiGroups wildcard can enumerate the catalog and snapshot matching resources.
-func TestSnapshotWildcardGroupExpands(t *testing.T) {
- scheme := makeScheme(t)
-
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
- }
-
+// An empty cluster (all streams reach their bookmark with no objects) yields an empty,
+// authoritative snapshot — the basis for sweeping the mirror clean.
+func TestStreamSnapshot_EmptyClusterYieldsEmptySnapshot(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateClusterWatchRule(
- configv1alpha1.ClusterWatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "cwr-wildcard-group"},
- Spec: configv1alpha1.ClusterWatchRuleSpec{
- TargetRef: configv1alpha1.NamespacedTargetReference{
- Name: "my-target",
- Namespace: "gitops-reverser",
- },
- Rules: []configv1alpha1.ClusterResourceRule{{
- APIGroups: []string{"*"},
- APIVersions: []string{"v1"},
- Resources: []string{"configmaps"},
- Scope: configv1alpha1.ResourceScopeNamespaced,
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
+ addWatchRule(store, "wr-ns-a", "ns-a", "secrets")
- m := setupManager(t, scheme, gitTarget, store, makeConfigMap("cm-a", "ns-a"))
+ m := streamingManager(t, gitTargetFixture(), store, nil)
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
+ snap, err := m.StreamClusterSnapshotForGitDest(context.Background(), myTargetRef())
require.NoError(t, err)
- assert.Equal(t, []string{"cm-a"}, resourceNames(resources))
+ assert.Empty(t, snap.Desired, "no objects streamed, but the snapshot is complete")
}
-// TestSnapshotWildcardResourceExpands verifies that a "*" resources wildcard can
-// enumerate listable/watchable catalog entries and snapshot the resources found.
-func TestSnapshotWildcardResourceExpands(t *testing.T) {
- scheme := makeScheme(t)
+// If any type's stream fails before its bookmark, the whole snapshot aborts and returns
+// an error — a partial mark must never drive a sweep.
+func TestStreamSnapshot_PartialStreamAborts(t *testing.T) {
+ store := rulestore.NewStore()
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+ addWatchRule(store, "wr-configmaps", "ns-a", "configmaps")
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
+ scheme := makeScheme(t)
+ fakeK8s := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(gitTargetFixture()).Build()
+ fakeDyn := dynamicfake.NewSimpleDynamicClient(scheme)
+ fakeDyn.PrependWatchReactor("*", func(action k8stesting.Action) (bool, watch.Interface, error) {
+ wa := action.(k8stesting.WatchActionImpl)
+ fw := watch.NewFakeWithChanSize(8, false)
+ if wa.Resource.Resource == "secrets" {
+ fw.Add(uns("Secret", "ns-a", "ok"))
+ fw.Stop() // closes before any bookmark
+ return true, fw, nil
+ }
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("1"))
+ return true, fw, nil
+ })
+ m := &Manager{
+ Client: fakeK8s, Log: logr.Discard(), RuleStore: store,
+ dynamicClient: fakeDyn, resourceCatalog: newCommonTestCatalog(t),
+ discoveryClient: commonTestDiscoveryClient(),
}
+ _, err := m.StreamClusterSnapshotForGitDest(context.Background(), myTargetRef())
+ require.Error(t, err, "a stream that never reaches its bookmark must abort the snapshot")
+}
+
+// A snapshot fails closed while the cluster API surface has not been observed yet (an
+// empty/unready discovery leaves the type registry unready): sweeping a mark over an
+// unobserved surface would delete the mirror.
+func TestResolveSnapshotGVRs_FailsClosedWhenRegistryNotReady(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateClusterWatchRule(
- configv1alpha1.ClusterWatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "cwr-wildcard-resource"},
- Spec: configv1alpha1.ClusterWatchRuleSpec{
- TargetRef: configv1alpha1.NamespacedTargetReference{
- Name: "my-target",
- Namespace: "gitops-reverser",
- },
- Rules: []configv1alpha1.ClusterResourceRule{{
- APIGroups: []string{""},
- APIVersions: []string{"v1"},
- Resources: []string{"*"},
- Scope: configv1alpha1.ResourceScopeNamespaced,
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+ empty := apiResourceDiscovery(staticCatalogDiscovery{})
+ m := &Manager{
+ Log: logr.Discard(),
+ RuleStore: store,
+ resourceCatalog: NewAPIResourceCatalog(),
+ discoveryClient: func() (apiResourceDiscovery, error) { return empty, nil },
+ }
- m := setupManager(t, scheme, gitTarget, store, makeConfigMap("cm-a", "ns-a"))
+ _, err := m.resolveSnapshotGVRs(context.Background(), myTargetRef())
+ require.Error(t, err, "an unobserved API surface must abort the gather rather than sweep")
+ assert.Contains(t, err.Error(), "has not been observed yet")
+}
- resources, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
- require.NoError(t, err)
- assert.Equal(t, []string{"cm-a"}, resourceNames(resources))
+// A normally-served target has no retained types, so the snapshot is not blocked.
+func TestRetainedWatchedTypes_NoneWhenAllServed(t *testing.T) {
+ store := rulestore.NewStore()
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+ m := streamingManager(t, gitTargetFixture(), store, nil)
+ require.NoError(t, m.RefreshAPIResourceCatalog(context.Background()))
+ m.refreshWatchedTypeTables()
+ table := m.residentWatchedTypeTable(myTargetRef())
+ require.NotEmpty(t, table.Types)
+ assert.Empty(t, m.retainedWatchedTypes(table), "served types are not retained")
}
-// TestSnapshotAbortsOnListError verifies that a failed List() call aborts the
-// snapshot with an error. A swallowed list error would drop that resource from
-// the snapshot, and the reconciler would then delete its tracked Git files.
-func TestSnapshotAbortsOnListError(t *testing.T) {
- scheme := makeScheme(t)
+func TestGVKListSummary(t *testing.T) {
+ one := []schema.GroupVersionKind{{Group: "apps", Version: "v1", Kind: "Deployment"}}
+ assert.Equal(t, "watched type apps/v1, Kind=Deployment", gvkListSummary(one))
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
+ two := []schema.GroupVersionKind{
+ {Version: "v1", Kind: "ConfigMap"},
+ {Group: "apps", Version: "v1", Kind: "Deployment"},
}
+ got := gvkListSummary(two)
+ assert.Contains(t, got, "2 watched types")
+ assert.Contains(t, got, "Kind=ConfigMap")
+ assert.Contains(t, got, "Kind=Deployment")
+}
+// resolveSnapshotGVRs scopes a namespaced resource to its rule namespace and a
+// cluster-scoped resource cluster-wide (no namespaces).
+func TestResolveSnapshotGVRs_ScopesNamespacedAndClusterWide(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateClusterWatchRule(
- configv1alpha1.ClusterWatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "cwr-nodes"},
- Spec: configv1alpha1.ClusterWatchRuleSpec{
- TargetRef: configv1alpha1.NamespacedTargetReference{
- Name: "my-target",
- Namespace: "gitops-reverser",
- },
- Rules: []configv1alpha1.ClusterResourceRule{{
- APIGroups: []string{""},
- APIVersions: []string{"v1"},
- Resources: []string{"nodes"},
- Scope: configv1alpha1.ResourceScopeCluster,
- }},
- },
- },
- "my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
- )
-
- m := setupManager(t, scheme, gitTarget, store, makeNode("node-1"))
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+ addClusterWatchRule(store, "cwr-nodes", "nodes")
- fakeDyn, ok := m.dynamicClient.(*dynamicfake.FakeDynamicClient)
- require.True(t, ok, "expected the fake dynamic client from setupManager")
- fakeDyn.PrependReactor("list", "*",
- func(k8stesting.Action) (bool, runtime.Object, error) {
- return true, nil, errors.New("simulated API server outage")
- })
-
- _, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
- require.Error(t, err,
- "a failed List() must abort the snapshot, not silently drop the resource")
-}
-
-func TestSnapshotWildcardResourceAbortsOnAnyListError(t *testing.T) {
- scheme := makeScheme(t)
+ m := streamingManager(t, gitTargetFixture(), store, nil)
+ gvrs, err := m.resolveSnapshotGVRs(context.Background(), myTargetRef())
+ require.NoError(t, err)
- gitTarget := &configv1alpha1.GitTarget{
- ObjectMeta: metav1.ObjectMeta{Name: "my-target", Namespace: "gitops-reverser"},
- Spec: configv1alpha1.GitTargetSpec{Path: "live"},
+ byGVR := map[schema.GroupVersionResource][]string{}
+ for _, sg := range gvrs {
+ byGVR[sg.gvr] = sg.namespaces
}
+ assert.Equal(t, []string{"ns-a"}, byGVR[secretsGVR], "namespaced resource scoped to its rule namespace")
+ assert.Empty(t, byGVR[nodesGVR], "cluster-scoped resource has no namespace scope (cluster-wide)")
+}
+// A wildcard resource pattern expands to every served namespaced resource in the group,
+// so the snapshot is not silently narrowed.
+func TestResolveSnapshotGVRs_WildcardResourceExpands(t *testing.T) {
store := rulestore.NewStore()
- store.AddOrUpdateClusterWatchRule(
- configv1alpha1.ClusterWatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "cwr-wildcard-resource"},
- Spec: configv1alpha1.ClusterWatchRuleSpec{
- TargetRef: configv1alpha1.NamespacedTargetReference{
- Name: "my-target",
- Namespace: "gitops-reverser",
- },
- Rules: []configv1alpha1.ClusterResourceRule{{
- APIGroups: []string{""},
- APIVersions: []string{"v1"},
- Resources: []string{"*"},
- Scope: configv1alpha1.ResourceScopeNamespaced,
+ store.AddOrUpdateWatchRule(
+ configv1alpha1.WatchRule{
+ ObjectMeta: metav1.ObjectMeta{Name: "wr-all", Namespace: "ns-a"},
+ Spec: configv1alpha1.WatchRuleSpec{
+ TargetRef: configv1alpha1.LocalTargetReference{Name: "my-target"},
+ Rules: []configv1alpha1.ResourceRule{{
+ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"*"},
}},
},
},
"my-target", "gitops-reverser", "provider", "gitops-reverser", "main", "live",
)
- m := setupManager(t, scheme, gitTarget, store, makeConfigMap("cm-a", "ns-a"))
-
- fakeDyn, ok := m.dynamicClient.(*dynamicfake.FakeDynamicClient)
- require.True(t, ok, "expected the fake dynamic client from setupManager")
- fakeDyn.PrependReactor("list", "services",
- func(action k8stesting.Action) (bool, runtime.Object, error) {
- if action.GetResource().Resource != "services" {
- return false, nil, nil
- }
- return true, nil, errors.New("simulated service list failure")
- })
+ m := streamingManager(t, gitTargetFixture(), store, nil)
+ gvrs, err := m.resolveSnapshotGVRs(context.Background(), myTargetRef())
+ require.NoError(t, err)
- _, _, err := m.GetClusterStateForGitDest(context.Background(),
- itypes.NewResourceReference("my-target", "gitops-reverser"))
- require.Error(t, err,
- "a wildcard snapshot still aborts on a failed GVR list rather than emitting a partial view")
+ resources := map[string]struct{}{}
+ for _, sg := range gvrs {
+ resources[sg.gvr.Resource] = struct{}{}
+ }
+ assert.Contains(t, resources, "configmaps")
+ assert.Contains(t, resources, "secrets")
+ assert.Contains(t, resources, "services")
}
diff --git a/internal/watch/rule_change_snapshot_test.go b/internal/watch/rule_change_snapshot_test.go
index d97876fe..4bd675bd 100644
--- a/internal/watch/rule_change_snapshot_test.go
+++ b/internal/watch/rule_change_snapshot_test.go
@@ -266,11 +266,11 @@ func TestCurrentRuleSetSnapshots_NamespacedWatchRulePlanByNamespace(t *testing.T
"a redundant duplicate WatchRule in an already-watched namespace must not change the plan hash")
}
-// TestReconcileForRuleChange_NoReconcilerLeavesSnapshotPending verifies that
-// rule-change reconciliation does not emit state events until a FolderReconciler
-// exists. The target remains pending so MaybeReplaySnapshot can retry when the
-// GitTarget reconciler registers the receiver.
-func TestReconcileForRuleChange_NoReconcilerLeavesSnapshotPending(t *testing.T) {
+// TestReconcileForRuleChange_NoWorkerReturnsErrorAndStaysPending verifies that
+// rule-change reconciliation cannot resync a target whose branch worker is not yet
+// live: the resync errors (so the caller requeues and retries promptly), and the
+// target stays pending so the next reconcile resyncs it once the worker exists.
+func TestReconcileForRuleChange_NoWorkerReturnsErrorAndStaysPending(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, clientgoscheme.AddToScheme(scheme))
require.NoError(t, configv1alpha1.AddToScheme(scheme))
@@ -313,23 +313,20 @@ func TestReconcileForRuleChange_NoReconcilerLeavesSnapshotPending(t *testing.T)
},
}
- // Wire EventRouter with no registered FolderReconciler. This is the state
- // before the GitTarget controller has created the per-target receiver.
+ // The EventRouter has a worker manager but no worker registered for this target,
+ // the state before the GitTarget controller has ensured its branch worker.
manager.EventRouter = &EventRouter{
- WorkerManager: git.NewWorkerManager(fakeK8s, logr.Discard(), 0, types.SensitiveResourcePolicy{}),
- ReconcilerManager: reconcile.NewReconcilerManager(nil, logr.Discard()),
- WatchManager: manager,
- Client: fakeK8s,
- Log: logr.Discard(),
- gitTargetStreams: map[string]*reconcile.GitTargetEventStream{},
+ WorkerManager: git.NewWorkerManager(fakeK8s, logr.Discard(), 0, types.SensitiveResourcePolicy{}),
+ WatchManager: manager,
+ Client: fakeK8s,
+ Log: logr.Discard(),
+ gitTargetStreams: map[string]*reconcile.GitTargetEventStream{},
}
- require.NoError(t, manager.ReconcileForRuleChange(ctx()))
-
+ require.Error(t, manager.ReconcileForRuleChange(ctx()),
+ "a resync with no live worker must be returned as an error so the caller requeues")
assert.True(t, targetPending(manager, "test-target"),
- "target must stay pending until a FolderReconciler exists")
- assert.Zero(t, manager.EventRouter.SnapshotDeliveryDrops(),
- "state events must not be emitted before a FolderReconciler exists")
+ "the target must stay pending until its worker exists")
}
// makeTwoTargetRuleChangeManager builds a Manager with two GitTargets,
@@ -603,11 +600,11 @@ func TestSnapshotTargets_RuleRemovalPrunesDeliveredHash(t *testing.T) {
"when the target truly has no rules, delivered state is pruned")
}
-// A transient emit failure (here: RequestRepoState fails because no worker is
-// registered) must NOT mark the target delivered or bump the reconcile counter,
-// and must be returned to the caller so it requeues and retries promptly. Before
-// the fix the error was swallowed (counter and delivery were skipped silently),
-// so the per-pod restart gate could wait out its 90s timeout on a one-off error.
+// A transient resync failure (here: no BranchWorker is registered) must NOT mark the
+// target delivered or bump the reconcile counter, and must be returned to the caller
+// so it requeues and retries promptly. Before the fix the error was swallowed (counter
+// and delivery were skipped silently), so the per-pod restart gate could wait out its
+// 90s timeout on a one-off error.
func TestEmitSnapshotForRuleChange_TransientFailureReturnsErrorAndStaysPending(t *testing.T) {
reader, err := telemetry.InitTestExporter()
require.NoError(t, err)
@@ -629,17 +626,13 @@ func TestEmitSnapshotForRuleChange_TransientFailureReturnsErrorAndStaysPending(t
manager := &Manager{Client: fakeK8s, Log: logr.Discard()}
gitDest := types.NewResourceReference("test-target", "test-ns")
- // A FolderReconciler exists (so emission is attempted), but no BranchWorker is
- // registered, so RequestRepoState fails with "no worker".
- reconcilerManager := reconcile.NewReconcilerManager(nil, logr.Discard())
- reconcilerManager.CreateReconciler(ctx(), gitDest, nil)
+ // No BranchWorker is registered, so the resync fails with "no worker".
manager.EventRouter = &EventRouter{
- WorkerManager: git.NewWorkerManager(fakeK8s, logr.Discard(), 0, types.SensitiveResourcePolicy{}),
- ReconcilerManager: reconcilerManager,
- WatchManager: manager,
- Client: fakeK8s,
- Log: logr.Discard(),
- gitTargetStreams: map[string]*reconcile.GitTargetEventStream{},
+ WorkerManager: git.NewWorkerManager(fakeK8s, logr.Discard(), 0, types.SensitiveResourcePolicy{}),
+ WatchManager: manager,
+ Client: fakeK8s,
+ Log: logr.Discard(),
+ gitTargetStreams: map[string]*reconcile.GitTargetEventStream{},
}
target := ruleSetSnapshotTarget{gitDest: gitDest, hash: 0xABCD}
@@ -659,5 +652,37 @@ func TestEmitSnapshotForRuleChange_TransientFailureReturnsErrorAndStaysPending(t
assert.False(t, counted, "the reconcile counter must not fire when emission failed")
}
+// A rule can briefly outlive its GitTarget during deletion, leaving a pending target
+// whose GitTarget no longer exists. That must be skipped benignly — NOT returned as an
+// error — so one deleted target cannot poison the whole rule-change reconcile into a
+// requeue storm that starves healthy targets.
+func TestEmitSnapshotForRuleChange_DeletedGitTargetIsSkippedNotErrored(t *testing.T) {
+ scheme := runtime.NewScheme()
+ require.NoError(t, clientgoscheme.AddToScheme(scheme))
+ require.NoError(t, configv1alpha1.AddToScheme(scheme))
+
+ // No GitTarget object exists, so the resync's GitTarget lookup returns NotFound.
+ fakeK8s := fake.NewClientBuilder().WithScheme(scheme).Build()
+ manager := &Manager{Client: fakeK8s, Log: logr.Discard()}
+ gitDest := types.NewResourceReference("gone-target", "test-ns")
+ manager.EventRouter = &EventRouter{
+ WorkerManager: git.NewWorkerManager(fakeK8s, logr.Discard(), 0, types.SensitiveResourcePolicy{}),
+ WatchManager: manager,
+ Client: fakeK8s,
+ Log: logr.Discard(),
+ gitTargetStreams: map[string]*reconcile.GitTargetEventStream{},
+ }
+
+ target := ruleSetSnapshotTarget{gitDest: gitDest, hash: 0x1234}
+ emitErr := manager.emitSnapshotForRuleChange(ctx(), logr.Discard(), []ruleSetSnapshotTarget{target}, "rule_change")
+
+ require.NoError(t, emitErr, "a deleted GitTarget must be skipped, not surfaced as a requeue-inducing error")
+
+ manager.ruleSetSnapshotMu.Lock()
+ _, ok := manager.lastDeliveredRuleSetHash[gitDest.Key()]
+ manager.ruleSetSnapshotMu.Unlock()
+ assert.False(t, ok, "a skipped (gone) target must not be marked delivered")
+}
+
// ctx returns a background context. Wrapped for terse use in test setup.
func ctx() context.Context { return context.Background() }
diff --git a/internal/watch/rule_gvr_resolver.go b/internal/watch/rule_gvr_resolver.go
deleted file mode 100644
index e30c8483..00000000
--- a/internal/watch/rule_gvr_resolver.go
+++ /dev/null
@@ -1,317 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package watch
-
-import (
- "fmt"
- "sort"
- "strings"
-
- configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
-)
-
-// ResolveMissReason describes why one declared resource did not become a GVR.
-type ResolveMissReason string
-
-const (
- // ResolveMissNotServed means trusted catalog data has no matching resource.
- ResolveMissNotServed ResolveMissReason = "NotServed"
- // ResolveMissAmbiguous means an omitted apiGroups selector found multiple groups.
- ResolveMissAmbiguous ResolveMissReason = "Ambiguous"
- // ResolveMissDisallowed means resource policy excludes a served resource.
- ResolveMissDisallowed ResolveMissReason = "Disallowed"
- // ResolveMissCatalogUnavailable means discovery has not populated a catalog yet.
- ResolveMissCatalogUnavailable ResolveMissReason = "CatalogUnavailable"
- // ResolveMissDiscoveryDegraded means failed discovery can change the result.
- ResolveMissDiscoveryDegraded ResolveMissReason = "DiscoveryDegraded"
-)
-
-// ResolveMiss captures one declared resource that could not be planned.
-type ResolveMiss struct {
- Resource string
- Reason ResolveMissReason
- Detail string
-}
-
-// RuleGVRResolver applies WatchRule resource semantics to APIResourceCatalog.
-type RuleGVRResolver struct {
- catalog *APIResourceCatalog
-}
-
-// NewRuleGVRResolver creates a resolver over a catalog.
-func NewRuleGVRResolver(catalog *APIResourceCatalog) *RuleGVRResolver {
- return &RuleGVRResolver{catalog: catalog}
-}
-
-// Resolve maps one rule shape to concrete watchable GVRs.
-func (r *RuleGVRResolver) Resolve(
- groups, versions, resources []string,
- scope configv1alpha1.ResourceScope,
-) ([]GVR, []ResolveMiss) {
- var gvrs []GVR
- var misses []ResolveMiss
- for _, resource := range resources {
- resolved, miss := r.resolveResource(groups, versions, normalizeResource(resource), scope)
- gvrs = append(gvrs, resolved...)
- misses = append(misses, miss...)
- }
- return dedupeGVRs(gvrs), misses
-}
-
-func (r *RuleGVRResolver) resolveResource(
- groups, versions []string,
- resource string,
- scope configv1alpha1.ResourceScope,
-) ([]GVR, []ResolveMiss) {
- if misses, stop := r.preflightMisses(resource); stop {
- return nil, misses
- }
-
- candidates := r.resourceCandidates(groups, resource)
- candidates = filterCandidateVersions(candidates, versions)
- candidates = filterScope(candidates, scope)
- if len(candidates) == 0 {
- return nil, []ResolveMiss{r.emptyCandidateMiss(groups, versions, resource)}
- }
-
- if miss := ambiguityMiss(groups, resource, candidates); miss != nil {
- return nil, []ResolveMiss{*miss}
- }
- candidates = choosePreferredVersions(candidates, versions)
- if miss := disallowedMiss(resource, candidates); miss != nil {
- return nil, []ResolveMiss{*miss}
- }
-
- return gvrsForCandidates(resource, candidates, scope)
-}
-
-func (r *RuleGVRResolver) preflightMisses(resource string) ([]ResolveMiss, bool) {
- switch {
- case resource == "":
- return nil, true
- case strings.Contains(resource, "/"):
- return []ResolveMiss{
- newResolveMiss(resource, ResolveMissNotServed, "subresource planning is unsupported"),
- }, true
- case r.catalog == nil || !r.catalog.Ready():
- return []ResolveMiss{
- newResolveMiss(resource, ResolveMissCatalogUnavailable, "API resource catalog is not ready"),
- }, true
- default:
- return nil, false
- }
-}
-
-func (r *RuleGVRResolver) emptyCandidateMiss(groups, versions []string, resource string) ResolveMiss {
- if r.catalog.hasDegradedLookup(groups, versions) {
- return newResolveMiss(resource, ResolveMissDiscoveryDegraded,
- "discovery is degraded for a lookup scope that may serve this resource")
- }
- return newResolveMiss(resource, ResolveMissNotServed, "resource is not served")
-}
-
-func ambiguityMiss(groups []string, resource string, candidates []APIResourceEntry) *ResolveMiss {
- if len(groups) != 0 || resource == "*" {
- return nil
- }
- servedGroups := candidateGroups(candidates)
- if len(servedGroups) <= 1 {
- return nil
- }
- miss := newResolveMiss(resource, ResolveMissAmbiguous,
- fmt.Sprintf("set apiGroups to one of [%s]", strings.Join(quoteStrings(servedGroups), ", ")))
- return &miss
-}
-
-func gvrsForCandidates(
- resource string,
- candidates []APIResourceEntry,
- scope configv1alpha1.ResourceScope,
-) ([]GVR, []ResolveMiss) {
- var out []GVR
- for _, candidate := range candidates {
- if !candidate.Allowed || candidate.Subresource || !candidate.Supports("list", "watch") {
- continue
- }
- out = append(out, GVR{
- Group: candidate.GVR.Group,
- Version: candidate.GVR.Version,
- Resource: candidate.GVR.Resource,
- Scope: scope,
- })
- }
- if len(out) == 0 {
- return nil, []ResolveMiss{newResolveMiss(resource, ResolveMissNotServed,
- "resource does not support GitOps Reverser list and watch planning")}
- }
- return out, nil
-}
-
-func (r *RuleGVRResolver) resourceCandidates(groups []string, resource string) []APIResourceEntry {
- if resource == "*" {
- return r.wildcardResourceCandidates(groups)
- }
- if hasWildcard(groups) {
- return r.catalog.entriesForResource(resource)
- }
- if len(groups) == 0 {
- return r.catalog.entriesForResource(resource)
- }
- var out []APIResourceEntry
- for _, group := range groups {
- out = append(out, r.catalog.entriesForGroupResource(strings.TrimSpace(group), resource)...)
- }
- return out
-}
-
-func (r *RuleGVRResolver) wildcardResourceCandidates(groups []string) []APIResourceEntry {
- if len(groups) == 0 || hasWildcard(groups) {
- return r.catalog.allEntries()
- }
- var out []APIResourceEntry
- for _, group := range groups {
- out = append(out, r.catalog.entriesForGroup(strings.TrimSpace(group))...)
- }
- return out
-}
-
-func filterCandidateVersions(entries []APIResourceEntry, versions []string) []APIResourceEntry {
- if len(versions) == 0 || hasWildcard(versions) {
- return entries
- }
- var out []APIResourceEntry
- for _, entry := range entries {
- if matchLookupValue(versions, entry.GVR.Version) {
- out = append(out, entry)
- }
- }
- return out
-}
-
-func filterScope(entries []APIResourceEntry, scope configv1alpha1.ResourceScope) []APIResourceEntry {
- var out []APIResourceEntry
- for _, entry := range entries {
- if matchesScope(entry.Namespaced, scope) {
- out = append(out, entry)
- }
- }
- return out
-}
-
-func choosePreferredVersions(entries []APIResourceEntry, versions []string) []APIResourceEntry {
- if len(versions) != 0 {
- return entries
- }
- byGroupResource := make(map[string][]APIResourceEntry)
- for _, entry := range entries {
- key := groupResourceKey(entry.GVR.Group, entry.GVR.Resource)
- byGroupResource[key] = append(byGroupResource[key], entry)
- }
- var out []APIResourceEntry
- for _, candidates := range byGroupResource {
- sortCatalogEntries(candidates)
- selected := candidates[0]
- for _, candidate := range candidates {
- if candidate.Preferred {
- selected = candidate
- break
- }
- }
- out = append(out, selected)
- }
- sortCatalogEntries(out)
- return out
-}
-
-func disallowedMiss(resource string, entries []APIResourceEntry) *ResolveMiss {
- for _, entry := range entries {
- if entry.Allowed {
- return nil
- }
- }
- entry := entries[0]
- detail := fmt.Sprintf(
- "%s/%s is served but %s",
- entry.GVR.GroupVersion().String(),
- entry.GVR.Resource,
- entry.PolicyReason,
- )
- miss := newResolveMiss(resource, ResolveMissDisallowed, detail)
- return &miss
-}
-
-func newResolveMiss(resource string, reason ResolveMissReason, detail string) ResolveMiss {
- return ResolveMiss{Resource: resource, Reason: reason, Detail: detail}
-}
-
-func candidateGroups(entries []APIResourceEntry) []string {
- groups := make(map[string]struct{})
- for _, entry := range entries {
- groups[entry.GVR.Group] = struct{}{}
- }
- out := make([]string, 0, len(groups))
- for group := range groups {
- out = append(out, group)
- }
- sort.Strings(out)
- return out
-}
-
-func quoteStrings(values []string) []string {
- out := make([]string, len(values))
- for i, value := range values {
- out[i] = fmt.Sprintf("%q", value)
- }
- return out
-}
-
-// matchesScope reports whether a discovery namespaced flag aligns with a
-// declared resource scope.
-func matchesScope(namespaced bool, scope configv1alpha1.ResourceScope) bool {
- switch scope {
- case configv1alpha1.ResourceScopeNamespaced:
- return namespaced
- case configv1alpha1.ResourceScopeCluster:
- return !namespaced
- default:
- return false
- }
-}
-
-func hasWildcard(values []string) bool {
- for _, value := range values {
- if strings.TrimSpace(value) == "*" {
- return true
- }
- }
- return false
-}
-
-func dedupeGVRs(in []GVR) []GVR {
- seen := make(map[GVR]struct{}, len(in))
- out := make([]GVR, 0, len(in))
- for _, gvr := range in {
- if _, ok := seen[gvr]; ok {
- continue
- }
- seen[gvr] = struct{}{}
- out = append(out, gvr)
- }
- return out
-}
diff --git a/internal/watch/rule_gvr_resolver_test.go b/internal/watch/rule_gvr_resolver_test.go
deleted file mode 100644
index b002be60..00000000
--- a/internal/watch/rule_gvr_resolver_test.go
+++ /dev/null
@@ -1,269 +0,0 @@
-/*
-SPDX-License-Identifier: Apache-2.0
-
-Copyright 2025 ConfigButler
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package watch
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-
- configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
- "github.com/ConfigButler/gitops-reverser/internal/rulestore"
-)
-
-func TestRuleGVRResolver_OmittedGroupResolvesDeployment(t *testing.T) {
- resolver := NewRuleGVRResolver(newCommonTestCatalog(t))
-
- gvrs, misses := resolver.Resolve(nil, nil, []string{"deployments"}, configv1alpha1.ResourceScopeNamespaced)
-
- require.Empty(t, misses)
- require.Len(t, gvrs, 1)
- assert.Equal(t, "apps", gvrs[0].Group)
- assert.Equal(t, "v1", gvrs[0].Version)
- assert.Equal(t, "deployments", gvrs[0].Resource)
-}
-
-func TestRuleGVRResolver_AmbiguousOmittedGroupReturnsMiss(t *testing.T) {
- disco := newCommonTestDiscovery()
- disco.groups = append(disco.groups, testAPIGroup("team.example.com", "v1"))
- disco.resources = append(disco.resources, &metav1.APIResourceList{
- GroupVersion: "team.example.com/v1",
- APIResources: []metav1.APIResource{{
- Name: "deployments",
- Kind: "Deployment",
- Namespaced: true,
- Verbs: metav1.Verbs{"list", "watch"},
- }},
- })
- catalog := NewAPIResourceCatalog()
- _, err := catalog.Refresh(disco)
- require.NoError(t, err)
-
- gvrs, misses := NewRuleGVRResolver(catalog).Resolve(
- nil,
- nil,
- []string{"deployments"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- assert.Empty(t, gvrs)
- require.Len(t, misses, 1)
- assert.Equal(t, ResolveMissAmbiguous, misses[0].Reason)
- assert.Contains(t, misses[0].Detail, `"apps"`)
- assert.Contains(t, misses[0].Detail, `"team.example.com"`)
-}
-
-func TestRuleGVRResolver_WildcardGroupResolvesNamedResource(t *testing.T) {
- resolver := NewRuleGVRResolver(newCommonTestCatalog(t))
-
- gvrs, misses := resolver.Resolve(
- []string{"*"},
- nil,
- []string{"deployments"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- require.Empty(t, misses)
- require.Len(t, gvrs, 1)
- assert.Equal(t, GVR{
- Group: "apps",
- Version: "v1",
- Resource: "deployments",
- Scope: configv1alpha1.ResourceScopeNamespaced,
- }, gvrs[0])
-}
-
-func TestRuleGVRResolver_WildcardResourceExpandsAllowedNamespacedResources(t *testing.T) {
- resolver := NewRuleGVRResolver(newCommonTestCatalog(t))
-
- gvrs, misses := resolver.Resolve(
- []string{"", "apps", "shop.example.com"},
- nil,
- []string{"*"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- require.Empty(t, misses)
- assert.ElementsMatch(t, []GVR{
- {Group: "", Version: "v1", Resource: "configmaps", Scope: configv1alpha1.ResourceScopeNamespaced},
- {Group: "", Version: "v1", Resource: "secrets", Scope: configv1alpha1.ResourceScopeNamespaced},
- {Group: "", Version: "v1", Resource: "services", Scope: configv1alpha1.ResourceScopeNamespaced},
- {Group: "apps", Version: "v1", Resource: "deployments", Scope: configv1alpha1.ResourceScopeNamespaced},
- {
- Group: "shop.example.com",
- Version: "v1alpha1",
- Resource: "customresources",
- Scope: configv1alpha1.ResourceScopeNamespaced,
- },
- {
- Group: "shop.example.com",
- Version: "v1alpha1",
- Resource: "icecreamorders",
- Scope: configv1alpha1.ResourceScopeNamespaced,
- },
- }, gvrs)
-}
-
-func TestRuleGVRResolver_WildcardVersionKeepsAllServedVersions(t *testing.T) {
- disco := newCommonTestDiscovery()
- disco.groups = append(disco.groups, &metav1.APIGroup{
- Name: "multi.example.com",
- Versions: []metav1.GroupVersionForDiscovery{
- {GroupVersion: "multi.example.com/v1", Version: "v1"},
- {GroupVersion: "multi.example.com/v1beta1", Version: "v1beta1"},
- },
- PreferredVersion: metav1.GroupVersionForDiscovery{
- GroupVersion: "multi.example.com/v1",
- Version: "v1",
- },
- })
- for _, version := range []string{"v1", "v1beta1"} {
- disco.resources = append(disco.resources, &metav1.APIResourceList{
- GroupVersion: "multi.example.com/" + version,
- APIResources: []metav1.APIResource{{
- Name: "widgets",
- Kind: "Widget",
- Namespaced: true,
- Verbs: metav1.Verbs{"list", "watch"},
- }},
- })
- }
- catalog := NewAPIResourceCatalog()
- _, err := catalog.Refresh(disco)
- require.NoError(t, err)
-
- gvrs, misses := NewRuleGVRResolver(catalog).Resolve(
- []string{"multi.example.com"},
- []string{"*"},
- []string{"widgets"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- require.Empty(t, misses)
- assert.ElementsMatch(t, []GVR{
- {Group: "multi.example.com", Version: "v1", Resource: "widgets", Scope: configv1alpha1.ResourceScopeNamespaced},
- {
- Group: "multi.example.com",
- Version: "v1beta1",
- Resource: "widgets",
- Scope: configv1alpha1.ResourceScopeNamespaced,
- },
- }, gvrs)
-}
-
-func TestRuleGVRResolver_OmittedVersionKeepsPreferredVersion(t *testing.T) {
- disco := newCommonTestDiscovery()
- disco.groups = append(disco.groups, &metav1.APIGroup{
- Name: "multi.example.com",
- Versions: []metav1.GroupVersionForDiscovery{
- {GroupVersion: "multi.example.com/v1", Version: "v1"},
- {GroupVersion: "multi.example.com/v1beta1", Version: "v1beta1"},
- },
- PreferredVersion: metav1.GroupVersionForDiscovery{
- GroupVersion: "multi.example.com/v1",
- Version: "v1",
- },
- })
- for _, version := range []string{"v1", "v1beta1"} {
- disco.resources = append(disco.resources, &metav1.APIResourceList{
- GroupVersion: "multi.example.com/" + version,
- APIResources: []metav1.APIResource{{
- Name: "widgets",
- Kind: "Widget",
- Namespaced: true,
- Verbs: metav1.Verbs{"list", "watch"},
- }},
- })
- }
- catalog := NewAPIResourceCatalog()
- _, err := catalog.Refresh(disco)
- require.NoError(t, err)
-
- gvrs, misses := NewRuleGVRResolver(catalog).Resolve(
- []string{"multi.example.com"},
- nil,
- []string{"widgets"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- require.Empty(t, misses)
- require.Len(t, gvrs, 1)
- assert.Equal(t, "v1", gvrs[0].Version)
-}
-
-func TestRuleGVRResolver_DisallowedResourceReturnsPolicyMiss(t *testing.T) {
- disco := staticCatalogDiscovery{
- groups: []*metav1.APIGroup{testAPIGroup("batch", "v1")},
- resources: []*metav1.APIResourceList{{
- GroupVersion: "batch/v1",
- APIResources: []metav1.APIResource{{
- Name: "jobs",
- Kind: "Job",
- Namespaced: true,
- Verbs: metav1.Verbs{"list", "watch"},
- }},
- }},
- }
- catalog := NewAPIResourceCatalog()
- _, err := catalog.Refresh(disco)
- require.NoError(t, err)
-
- gvrs, misses := NewRuleGVRResolver(catalog).Resolve(
- []string{"batch"},
- []string{"v1"},
- []string{"jobs"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- assert.Empty(t, gvrs)
- require.Len(t, misses, 1)
- assert.Equal(t, ResolveMissDisallowed, misses[0].Reason)
- assert.Contains(t, misses[0].Detail, defaultResourceExclusionReason)
-}
-
-func TestRuleGVRResolver_CatalogUnavailableFailsClosed(t *testing.T) {
- gvrs, misses := NewRuleGVRResolver(NewAPIResourceCatalog()).Resolve(
- nil,
- nil,
- []string{"deployments"},
- configv1alpha1.ResourceScopeNamespaced,
- )
-
- assert.Empty(t, gvrs)
- require.Len(t, misses, 1)
- assert.Equal(t, ResolveMissCatalogUnavailable, misses[0].Reason)
-}
-
-func TestManager_NamespacesFollowResolvedNonCoreWatchRule(t *testing.T) {
- store := rulestore.NewStore()
- store.AddOrUpdateWatchRule(configv1alpha1.WatchRule{
- ObjectMeta: metav1.ObjectMeta{Name: "deployment-rule", Namespace: "apps-ns"},
- Spec: configv1alpha1.WatchRuleSpec{Rules: []configv1alpha1.ResourceRule{{
- Resources: []string{"deployments"},
- }}},
- }, "target", "apps-ns", "provider", "apps-ns", "main", "live")
- manager := &Manager{RuleStore: store, resourceCatalog: newCommonTestCatalog(t)}
-
- requested := manager.ComputeRequestedGVRs()
-
- require.Len(t, requested, 1)
- assert.Equal(t, []string{"apps-ns"}, manager.getNamespacesForGVR(requested[0]))
-}
diff --git a/internal/watch/rule_status_test.go b/internal/watch/rule_status_test.go
new file mode 100644
index 00000000..5da7cf29
--- /dev/null
+++ b/internal/watch/rule_status_test.go
@@ -0,0 +1,90 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+)
+
+// The rule-resource status reports only what the rule actually watches — the registry's
+// followable set, the same records the informer/snapshot path follows. It never explains
+// why an individual selector matched nothing (absent, refused, and not-yet-served are all
+// the same to a mirror), so the only False case is a catalog that has not been observed.
+
+func watchRule(rules ...configv1alpha1.ResourceRule) configv1alpha1.WatchRule {
+ return configv1alpha1.WatchRule{Spec: configv1alpha1.WatchRuleSpec{Rules: rules}}
+}
+
+func TestResolveWatchRuleResources_ReportsFollowableMatchCount(t *testing.T) {
+ manager := &Manager{Log: logr.Discard(), resourceCatalog: newCommonTestCatalog(t)}
+
+ resolved, message := manager.ResolveWatchRuleResources(context.Background(),
+ watchRule(configv1alpha1.ResourceRule{Resources: []string{"deployments"}}))
+
+ assert.True(t, resolved)
+ assert.Equal(t, "watching 1 resource type(s)", message)
+}
+
+func TestResolveWatchRuleResources_UnmatchedResourceStillResolvesWhenCatalogReady(t *testing.T) {
+ manager := &Manager{Log: logr.Discard(), resourceCatalog: newCommonTestCatalog(t)}
+
+ // "ghosts" is not served. The app does not flag that as a problem: a ready catalog is
+ // resolved, it just watches nothing for this rule.
+ resolved, message := manager.ResolveWatchRuleResources(context.Background(),
+ watchRule(configv1alpha1.ResourceRule{Resources: []string{"ghosts"}}))
+
+ assert.True(t, resolved)
+ assert.Equal(t, "watching 0 resource type(s)", message)
+}
+
+func TestResolveWatchRuleResources_NotReadyFailsClosed(t *testing.T) {
+ // A catalog that has never observed discovery leaves the registry unready, the one
+ // case the status reports as unresolved.
+ manager := &Manager{Log: logr.Discard(), resourceCatalog: NewAPIResourceCatalog()}
+
+ resolved, message := manager.ResolveWatchRuleResources(context.Background(),
+ watchRule(configv1alpha1.ResourceRule{Resources: []string{"deployments"}}))
+
+ assert.False(t, resolved)
+ assert.Equal(t, "API resource catalog is not ready", message)
+}
+
+func TestResolveClusterWatchRuleResources_WildcardWatchesManyTypes(t *testing.T) {
+ manager := &Manager{Log: logr.Discard(), resourceCatalog: newCommonTestCatalog(t)}
+
+ resolved, message := manager.ResolveClusterWatchRuleResources(context.Background(),
+ configv1alpha1.ClusterWatchRule{Spec: configv1alpha1.ClusterWatchRuleSpec{
+ Rules: []configv1alpha1.ClusterResourceRule{{
+ APIGroups: []string{"*"},
+ APIVersions: []string{"*"},
+ Resources: []string{"*"},
+ Scope: configv1alpha1.ResourceScopeNamespaced,
+ }},
+ }})
+
+ assert.True(t, resolved)
+ assert.NotEqual(t, "watching 0 resource type(s)", message,
+ "a wildcard rule watches the followable namespaced types")
+}
diff --git a/internal/watch/snapshot_stream.go b/internal/watch/snapshot_stream.go
new file mode 100644
index 00000000..52162090
--- /dev/null
+++ b/internal/watch/snapshot_stream.go
@@ -0,0 +1,537 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/client-go/dynamic"
+
+ "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer"
+ "github.com/ConfigButler/gitops-reverser/internal/sanitize"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// ClusterSnapshot is one consistent, revision-pinned view of every watched resource a
+// GitTarget tracks. It is produced by the streaming-list watch and consumed by the
+// resync mark-and-sweep: Desired is the complete set the worker folds over the git
+// folder; Revision is the joined initial-events-end bookmark resourceVersion the whole
+// snapshot is pinned to.
+type ClusterSnapshot struct {
+ Desired []manifestanalyzer.DesiredResource
+ Revision string
+}
+
+// StreamClusterSnapshotForGitDest gathers the GitTarget's complete watched resource set
+// via the Kubernetes streaming-list watch (WATCH with sendInitialEvents=true,
+// resourceVersionMatch=NotOlderThan, allowWatchBookmarks=true), described in
+// docs/design/manifest/reconcile-via-watchlist-mark-and-sweep.md.
+//
+// Each watched (GVR, namespace scope) opens its own stream: every initial ADDED event
+// is folded into the desired set, and the stream is read until its initial-events-end
+// bookmark, which pins that type's resourceVersion. The snapshot is the JOIN of all
+// streams' bookmarks — and only the join. If any stream errors or closes before its
+// bookmark, the whole gather ABORTS and returns nothing: a partial mark must never
+// drive a sweep (the same fail-closed rule the old LIST snapshot used).
+//
+// Streaming is the primary path. The one concession is a per-type consistent LIST
+// fallback (streamInitialEvents → listInitialEvents) for a server that cannot stream at
+// all — aggregated apiservers reject sendInitialEvents — so a single non-streaming type
+// no longer aborts the whole snapshot. This is NOT a return to the old LIST+WATCH
+// steady state (the informers still own live events); a transient watch error still
+// aborts, never silently turning an unobservable surface into an empty (destructive)
+// snapshot.
+//
+// An empty desired set is authoritative only because it can only be produced when
+// every stream reached its bookmark — the cluster genuinely holds no watched
+// resources, so the mirror is swept clean to match.
+func (m *Manager) StreamClusterSnapshotForGitDest(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+) (ClusterSnapshot, error) {
+ log := m.Log.WithValues("gitDest", gitDest.String())
+
+ gvrs, err := m.resolveSnapshotGVRs(ctx, gitDest)
+ if err != nil {
+ return ClusterSnapshot{}, err
+ }
+
+ dc := m.dynamicClientFromConfig(log)
+ if dc == nil {
+ return ClusterSnapshot{}, errors.New("no dynamic client available")
+ }
+
+ tasks := snapshotStreamTasks(gvrs)
+ if len(tasks) == 0 {
+ log.Info("Streamed cluster snapshot", "resources", 0, "streams", 0)
+ return ClusterSnapshot{}, nil
+ }
+
+ desired, revision, err := m.joinSnapshotStreams(ctx, dc, tasks)
+ if err != nil {
+ return ClusterSnapshot{}, fmt.Errorf(
+ "aborting streaming snapshot for %s: %w; refusing to sweep on a partial stream",
+ gitDest.String(), err)
+ }
+
+ log.Info("Streamed cluster snapshot", "resources", len(desired), "streams", len(tasks), "revision", revision)
+ return ClusterSnapshot{Desired: desired, Revision: revision}, nil
+}
+
+// StreamSnapshotForType gathers ONE watched type's complete resource set for a GitTarget via
+// the streaming-list watch — the M12 per-type reconcile's desired side. It is the per-type
+// twin of StreamClusterSnapshotForGitDest: it refreshes the catalog/registry/table and fails
+// closed on an unobserved surface, but it resolves and streams only the named GVR (scoped to
+// the namespaces the resident table watches it under). A type that is not watched by this
+// GitTarget yields an empty snapshot (the caller no-ops); a type currently held `retained` (a
+// discovery wobble) fails closed, so a per-type reconcile never streams — or sweeps — a
+// reduced view.
+func (m *Manager) StreamSnapshotForType(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+ gvr schema.GroupVersionResource,
+) (ClusterSnapshot, error) {
+ log := m.Log.WithValues("gitDest", gitDest.String(), "gvr", gvr.String())
+
+ sg, watched, err := m.resolveSnapshotGVRForType(ctx, gitDest, gvr)
+ if err != nil {
+ return ClusterSnapshot{}, err
+ }
+ if !watched {
+ return ClusterSnapshot{}, nil
+ }
+
+ dc := m.dynamicClientFromConfig(log)
+ if dc == nil {
+ return ClusterSnapshot{}, errors.New("no dynamic client available")
+ }
+
+ tasks := snapshotStreamTasks([]snapshotGVR{sg})
+ if len(tasks) == 0 {
+ return ClusterSnapshot{}, nil
+ }
+ desired, revision, err := m.joinSnapshotStreams(ctx, dc, tasks)
+ if err != nil {
+ return ClusterSnapshot{}, fmt.Errorf(
+ "aborting per-type snapshot for %s %s: %w; refusing to reconcile on a partial stream",
+ gitDest.String(), gvr.String(), err)
+ }
+ log.Info("Streamed per-type snapshot", "resources", len(desired), "streams", len(tasks), "revision", revision)
+ return ClusterSnapshot{Desired: desired, Revision: revision}, nil
+}
+
+// resolveSnapshotGVRForType resolves one watched type's (GVR, namespace-scope) stream set for
+// a GitTarget, with the same fail-closed discipline as resolveSnapshotGVRs but scoped to the
+// single type. The bool is false when this GitTarget does not watch the type (so there is
+// nothing to reconcile). It refuses (error) when the surface is unobserved or the type is
+// currently `retained` (a wobble) — the per-type expression of the anti-sweep invariant.
+func (m *Manager) resolveSnapshotGVRForType(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+ gvr schema.GroupVersionResource,
+) (snapshotGVR, bool, error) {
+ if err := m.RefreshAPIResourceCatalog(ctx); err != nil {
+ return snapshotGVR{}, false, fmt.Errorf("refresh API resource catalog for %s: %w", gitDest.String(), err)
+ }
+ m.refreshWatchedTypeTables()
+
+ if !m.typeRegistryInstance().Ready() {
+ return snapshotGVR{}, false, fmt.Errorf(
+ "aborting per-type snapshot for %s: the cluster API surface has not been observed yet",
+ gitDest.String())
+ }
+
+ table := m.residentWatchedTypeTable(gitDest)
+ var watched *WatchedType
+ for i := range table.Types {
+ if table.Types[i].GVR == gvr {
+ watched = &table.Types[i]
+ break
+ }
+ }
+ if watched == nil {
+ return snapshotGVR{}, false, nil
+ }
+
+ if m.typeWobbling(gvr) {
+ return snapshotGVR{}, false, fmt.Errorf(
+ "aborting per-type snapshot for %s: %s within the removal grace (currently unserved); "+
+ "refusing to reconcile a reduced view",
+ gitDest.String(), gvr.String())
+ }
+
+ return snapshotGVR{gvr: watched.GVR, namespaces: watched.SnapshotNamespaces()}, true, nil
+}
+
+// joinSnapshotStreams runs every stream concurrently and joins them at their bookmarks.
+// The first stream to fail cancels the rest (so a doomed gather stops promptly) and the
+// failure is returned; otherwise the desired sets are unioned and the revision is the
+// max bookmark resourceVersion across types.
+func (m *Manager) joinSnapshotStreams(
+ ctx context.Context,
+ dc dynamic.Interface,
+ tasks []snapshotStreamTask,
+) ([]manifestanalyzer.DesiredResource, string, error) {
+ streamCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ var (
+ mu sync.Mutex
+ desired []manifestanalyzer.DesiredResource
+ maxRV string
+ firstErr error
+ wg sync.WaitGroup
+ )
+ for _, task := range tasks {
+ wg.Add(1)
+ go func(task snapshotStreamTask) {
+ defer wg.Done()
+ items, rv, err := m.streamInitialEvents(streamCtx, dc, task.gvr, task.namespace)
+ mu.Lock()
+ defer mu.Unlock()
+ if err != nil {
+ if firstErr == nil {
+ firstErr = err
+ cancel() // abort peers: a partial mark must never drive a sweep
+ }
+ return
+ }
+ desired = append(desired, items...)
+ if maxResourceVersion(rv, maxRV) == rv {
+ maxRV = rv
+ }
+ }(task)
+ }
+ wg.Wait()
+
+ if firstErr != nil {
+ return nil, "", firstErr
+ }
+ return desired, maxRV, nil
+}
+
+// snapshotStreamTask is one stream to open: a resolved GVR scoped either cluster-wide
+// (namespace == "") or to a single namespace.
+type snapshotStreamTask struct {
+ gvr schema.GroupVersionResource
+ namespace string
+}
+
+// snapshotStreamTasks expands the resolved watched types into one task per stream: a
+// cluster-wide type is one stream; a namespaced type is one stream per watched
+// namespace, matching how the resolver scoped it.
+func snapshotStreamTasks(gvrs []snapshotGVR) []snapshotStreamTask {
+ var tasks []snapshotStreamTask
+ for _, sg := range gvrs {
+ if len(sg.namespaces) == 0 {
+ tasks = append(tasks, snapshotStreamTask{gvr: sg.gvr})
+ continue
+ }
+ for _, ns := range sg.namespaces {
+ tasks = append(tasks, snapshotStreamTask{gvr: sg.gvr, namespace: ns})
+ }
+ }
+ return tasks
+}
+
+// streamInitialEvents opens one streaming-list watch and folds its initial ADDED events
+// into desired resources, returning once the initial-events-end bookmark arrives with
+// the resourceVersion the set is consistent at. A closed channel or watch.Error before
+// the bookmark is a failure — the initial sync did not complete — so the caller aborts
+// rather than treating a truncated set as the cluster's full state.
+//
+// Streaming is the primary path, but a server that cannot stream initial events (most
+// commonly an aggregated apiserver, which rejects sendInitialEvents outright) falls back
+// to one consistent LIST at the latest revision for THIS type only. This is the design's
+// per-type "availability fallback": it is not a return to LIST+WATCH steady state — the
+// informers still own live events — just a consistent initial snapshot for a type that
+// cannot stream one.
+func (m *Manager) streamInitialEvents(
+ ctx context.Context,
+ dc dynamic.Interface,
+ gvr schema.GroupVersionResource,
+ namespace string,
+) ([]manifestanalyzer.DesiredResource, string, error) {
+ ri := resourceInterfaceFor(dc, gvr, namespace)
+
+ w, err := ri.Watch(ctx, streamingListOptions())
+ if err != nil {
+ if isStreamingWatchUnsupported(err) {
+ m.Log.V(1).Info("server cannot stream initial events; falling back to a consistent list",
+ "gvr", gvr.String(), "namespace", namespace, "reason", err.Error())
+ return listInitialEvents(ctx, ri, gvr)
+ }
+ return nil, "", fmt.Errorf("open streaming watch for %s: %w", gvr.String(), err)
+ }
+ defer w.Stop()
+
+ var desired []manifestanalyzer.DesiredResource
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, "", ctx.Err()
+ case event, ok := <-w.ResultChan():
+ if !ok {
+ return nil, "", fmt.Errorf(
+ "streaming watch for %s closed before the initial-events-end bookmark", gvr.String())
+ }
+ switch event.Type {
+ case watch.Added:
+ if dr, ok := desiredFromObject(gvr, event.Object); ok {
+ desired = append(desired, dr)
+ }
+ case watch.Bookmark:
+ if rv, done := initialEventsEndRevision(event.Object); done {
+ return desired, rv, nil
+ }
+ case watch.Error:
+ return nil, "", fmt.Errorf(
+ "streaming watch for %s returned an error event: %v", gvr.String(), event.Object)
+ case watch.Modified, watch.Deleted:
+ // Live changes that race the initial sync arrive after the bookmark; any
+ // before it would only refine an object we are about to re-list, so the
+ // pre-bookmark set stays the authoritative initial view.
+ }
+ }
+ }
+}
+
+// streamingListOptions is the watch request that asks the API server to replay every
+// existing object as a synthetic ADDED event, then a bookmark, then live changes.
+func streamingListOptions() metav1.ListOptions {
+ sendInitialEvents := true
+ return metav1.ListOptions{
+ AllowWatchBookmarks: true,
+ SendInitialEvents: &sendInitialEvents,
+ ResourceVersionMatch: metav1.ResourceVersionMatchNotOlderThan,
+ ResourceVersion: "",
+ }
+}
+
+// resourceInterfaceFor scopes a dynamic resource client to a namespace, or leaves it
+// cluster-wide when namespace is empty.
+func resourceInterfaceFor(
+ dc dynamic.Interface,
+ gvr schema.GroupVersionResource,
+ namespace string,
+) dynamic.ResourceInterface {
+ if namespace != "" {
+ return dc.Resource(gvr).Namespace(namespace)
+ }
+ return dc.Resource(gvr)
+}
+
+// isStreamingWatchUnsupported reports whether a Watch error means the server cannot
+// serve a streaming-list watch (so a consistent LIST is the right per-type fallback),
+// as opposed to a transient failure that must abort the snapshot. The options carry
+// nothing but the streaming fields, so an Invalid/BadRequest from this call is about
+// sendInitialEvents; the message check is a version-robust backstop.
+func isStreamingWatchUnsupported(err error) bool {
+ if apierrors.IsInvalid(err) || apierrors.IsBadRequest(err) {
+ return true
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "sendInitialEvents") || strings.Contains(msg, "WatchList")
+}
+
+// listInitialEvents is the per-type fallback: one consistent LIST at the server's latest
+// revision, folded into the desired set the same way the stream's ADDED events are. The
+// list's own resourceVersion pins this type's contribution to the snapshot.
+func listInitialEvents(
+ ctx context.Context,
+ ri dynamic.ResourceInterface,
+ gvr schema.GroupVersionResource,
+) ([]manifestanalyzer.DesiredResource, string, error) {
+ list, err := ri.List(ctx, metav1.ListOptions{})
+ if err != nil {
+ return nil, "", fmt.Errorf("fallback list for %s: %w", gvr.String(), err)
+ }
+ var desired []manifestanalyzer.DesiredResource
+ for i := range list.Items {
+ if dr, ok := desiredFromObject(gvr, &list.Items[i]); ok {
+ desired = append(desired, dr)
+ }
+ }
+ return desired, list.GetResourceVersion(), nil
+}
+
+// desiredFromObject converts a streamed object into a desired resource, pairing the
+// GVR-derived API identity with the sanitized object the writer will materialise.
+func desiredFromObject(
+ gvr schema.GroupVersionResource,
+ obj interface{},
+) (manifestanalyzer.DesiredResource, bool) {
+ u, ok := obj.(*unstructured.Unstructured)
+ if !ok || u == nil {
+ return manifestanalyzer.DesiredResource{}, false
+ }
+ id := types.NewResourceIdentifier(gvr.Group, gvr.Version, gvr.Resource, u.GetNamespace(), u.GetName())
+ return manifestanalyzer.DesiredResource{Resource: id, Object: sanitize.Sanitize(u)}, true
+}
+
+// initialEventsEndRevision reports whether a bookmark event marks the end of the
+// initial sync, and the resourceVersion it pins. Bookmarks without the annotation are
+// ordinary progress notifications and are ignored.
+func initialEventsEndRevision(obj interface{}) (string, bool) {
+ u, ok := obj.(*unstructured.Unstructured)
+ if !ok || u == nil {
+ return "", false
+ }
+ if u.GetAnnotations()[metav1.InitialEventsAnnotationKey] != "true" {
+ return "", false
+ }
+ return u.GetResourceVersion(), true
+}
+
+// maxResourceVersion returns the larger of two resourceVersions, comparing numerically
+// when both parse (etcd resourceVersions are monotonic integers) and falling back to
+// the non-empty one otherwise. The pinned revision is informational here — the plan is
+// applied at the worktree commit — so a best-effort max is sufficient.
+func maxResourceVersion(a, b string) string {
+ if b == "" {
+ return a
+ }
+ if a == "" {
+ return b
+ }
+ ai, aerr := strconv.ParseUint(a, 10, 64)
+ bi, berr := strconv.ParseUint(b, 10, 64)
+ if aerr == nil && berr == nil {
+ if ai >= bi {
+ return a
+ }
+ return b
+ }
+ if a >= b {
+ return a
+ }
+ return b
+}
+
+// snapshotGVR is one resolved watched resource type with the namespace scope to gather
+// it under: an empty namespaces slice means cluster-wide.
+type snapshotGVR struct {
+ gvr schema.GroupVersionResource
+ namespaces []string
+}
+
+// resolveSnapshotGVRs returns the GitTarget's watched (GVR, namespace-scope) set to
+// stream, read from the resident watched-type table rather than re-resolved inline on
+// every gather. It refreshes the trusted API catalog, the registry, and the table first,
+// then fails closed if the registry is not ready — a snapshot must never be built from
+// an unobserved API surface, and a mark-and-sweep over a reduced view would delete KRM
+// from git. A type that briefly leaves discovery stays followable (and so stays in the
+// table) for the registry's removal grace, so a transient wobble never sweeps git.
+func (m *Manager) resolveSnapshotGVRs(
+ ctx context.Context,
+ gitDest types.ResourceReference,
+) ([]snapshotGVR, error) {
+ if err := m.RefreshAPIResourceCatalog(ctx); err != nil {
+ return nil, fmt.Errorf("refresh API resource catalog for %s: %w", gitDest.String(), err)
+ }
+ m.refreshWatchedTypeTables()
+
+ if !m.typeRegistryInstance().Ready() {
+ return nil, fmt.Errorf(
+ "aborting cluster snapshot for %s: the cluster API surface has not been observed yet; "+
+ "refusing to snapshot a partial cluster view",
+ gitDest.String())
+ }
+
+ table := m.residentWatchedTypeTable(gitDest)
+
+ // A watched type the registry holds as `retained` is followable under the removal
+ // grace but is not actually served right now (a discovery wobble). Streaming it would
+ // fail, and sweeping the reduced view would delete a still-valid mirror, so fail
+ // closed until the wobble resolves (the type is served again, or the grace elapses and
+ // the registry drops it from the table). This re-expresses the old pending-removal
+ // fail-closed in terms of the registry's verdict.
+ if retained := m.retainedWatchedTypes(table); len(retained) > 0 {
+ return nil, fmt.Errorf(
+ "aborting cluster snapshot for %s: %s within the removal grace (currently unserved); "+
+ "refusing to sweep a reduced cluster view",
+ gitDest.String(), gvkListSummary(retained))
+ }
+
+ return snapshotGVRsFromTable(table), nil
+}
+
+// retainedWatchedTypes returns the GVKs of the target's watched types the registry
+// currently holds as `retained` (followable under the grace, but not served right now).
+func (m *Manager) retainedWatchedTypes(table WatchedTypeTable) []schema.GroupVersionKind {
+ var out []schema.GroupVersionKind
+ for _, wt := range table.Types {
+ if m.typeWobbling(wt.GVR) {
+ out = append(out, wt.GVK)
+ }
+ }
+ return out
+}
+
+// typeWobbling reports whether the registry currently holds gvr as `retained` — followable
+// under the removal grace, but not actually served right now (a discovery wobble). It is the
+// single "do not stream or sweep this type" predicate, shared by the whole-GitTarget snapshot
+// gate (resolveSnapshotGVRs) and the per-type gate (resolveSnapshotGVRForType), so both fail
+// closed on exactly the same registry verdict instead of re-deriving it. This is the M12
+// consolidation: one read off the registry's decision, not a re-classification.
+func (m *Manager) typeWobbling(gvr schema.GroupVersionResource) bool {
+ rec, ok := m.typeRegistryInstance().ByGVR(gvr)
+ return ok && rec.Followability.Verdict == typeset.VerdictRetained
+}
+
+// gvkListSummary renders held GVKs for the fail-closed error, naming each so a blocked
+// gather log says exactly which wobbling types caused it.
+func gvkListSummary(gvks []schema.GroupVersionKind) string {
+ parts := make([]string, 0, len(gvks))
+ for _, gvk := range gvks {
+ parts = append(parts, gvk.String())
+ }
+ sort.Strings(parts)
+ if len(parts) == 1 {
+ return "watched type " + parts[0]
+ }
+ return fmt.Sprintf("%d watched types [%s]", len(parts), strings.Join(parts, ", "))
+}
+
+// snapshotGVRsFromTable projects a watched-type table into the deterministic, sorted
+// (GVR, namespace-scope) stream set. A cluster-wide type yields no namespaces; the
+// per-type SnapshotNamespaces collapse preserves the historic gvrSnapshotEntry
+// behaviour (a cluster-wide selection overrides any named namespaces).
+func snapshotGVRsFromTable(table WatchedTypeTable) []snapshotGVR {
+ out := make([]snapshotGVR, 0, len(table.Types))
+ for _, wt := range table.Types {
+ out = append(out, snapshotGVR{gvr: wt.GVR, namespaces: wt.SnapshotNamespaces()})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].gvr.String() < out[j].gvr.String()
+ })
+ return out
+}
diff --git a/internal/watch/snapshot_stream_test.go b/internal/watch/snapshot_stream_test.go
new file mode 100644
index 00000000..55f5ba21
--- /dev/null
+++ b/internal/watch/snapshot_stream_test.go
@@ -0,0 +1,245 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/util/validation/field"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/client-go/dynamic"
+ dynamicfake "k8s.io/client-go/dynamic/fake"
+ clienttesting "k8s.io/client-go/testing"
+)
+
+var configMapGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}
+
+// streamedCM is an unstructured ConfigMap an initial-events stream would replay.
+func streamedCM(namespace, name, rv string) *unstructured.Unstructured {
+ u := &unstructured.Unstructured{Object: map[string]interface{}{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]interface{}{"name": name, "namespace": namespace},
+ "data": map[string]interface{}{"k": "v"},
+ }}
+ u.SetResourceVersion(rv)
+ return u
+}
+
+// initialEventsEndBookmark is the synthetic bookmark the API server sends to mark the
+// end of the initial event set, carrying the consistent resourceVersion.
+func initialEventsEndBookmark(rv string) *unstructured.Unstructured {
+ u := &unstructured.Unstructured{Object: map[string]interface{}{}}
+ u.SetAnnotations(map[string]string{metav1.InitialEventsAnnotationKey: "true"})
+ u.SetResourceVersion(rv)
+ return u
+}
+
+// fakeStreamingClient returns a dynamic client whose every Watch returns a fresh fake
+// watcher preloaded by load(action). Buffered, non-blocking channels let a test stage
+// the whole stream (adds, bookmark, optional close) up front and read it deterministically.
+func fakeStreamingClient(load func(action clienttesting.Action, fw *watch.FakeWatcher)) dynamic.Interface {
+ client := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme())
+ client.PrependWatchReactor("*", func(action clienttesting.Action) (bool, watch.Interface, error) {
+ fw := watch.NewFakeWithChanSize(32, false)
+ load(action, fw)
+ return true, fw, nil
+ })
+ return client
+}
+
+// A healthy stream folds every initial ADDED object into the desired set and returns at
+// the initial-events-end bookmark with the pinned resourceVersion.
+func TestStreamInitialEvents_FoldsAddsUntilBookmark(t *testing.T) {
+ dc := fakeStreamingClient(func(_ clienttesting.Action, fw *watch.FakeWatcher) {
+ fw.Add(streamedCM("default", "a", "10"))
+ fw.Add(streamedCM("default", "b", "11"))
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("12"))
+ })
+
+ desired, rv, err := (&Manager{}).streamInitialEvents(context.Background(), dc, configMapGVR, "default")
+ require.NoError(t, err)
+ assert.Equal(t, "12", rv, "the bookmark's resourceVersion pins the set")
+ require.Len(t, desired, 2)
+ names := []string{desired[0].Resource.Name, desired[1].Resource.Name}
+ assert.ElementsMatch(t, []string{"a", "b"}, names)
+ assert.NotNil(t, desired[0].Object, "each desired entry carries its sanitized object")
+}
+
+// A stream that closes before its bookmark is an incomplete initial sync, so it errors
+// rather than returning a truncated set the caller might mistake for the full cluster.
+func TestStreamInitialEvents_ClosedBeforeBookmarkErrors(t *testing.T) {
+ dc := fakeStreamingClient(func(_ clienttesting.Action, fw *watch.FakeWatcher) {
+ fw.Add(streamedCM("default", "a", "10"))
+ fw.Stop() // channel closes before any initial-events-end bookmark
+ })
+
+ _, _, err := (&Manager{}).streamInitialEvents(context.Background(), dc, configMapGVR, "default")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "closed before the initial-events-end bookmark")
+}
+
+// A watch.Error event during the initial sync aborts the stream.
+func TestStreamInitialEvents_WatchErrorAborts(t *testing.T) {
+ dc := fakeStreamingClient(func(_ clienttesting.Action, fw *watch.FakeWatcher) {
+ fw.Error(&metav1.Status{Status: "Failure", Message: "boom"})
+ })
+
+ _, _, err := (&Manager{}).streamInitialEvents(context.Background(), dc, configMapGVR, "default")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "error event")
+}
+
+// A server that rejects sendInitialEvents (an aggregated apiserver without WatchList)
+// falls back to one consistent LIST for that type, rather than aborting the snapshot.
+func TestStreamInitialEvents_FallsBackToListWhenStreamingUnsupported(t *testing.T) {
+ scheme := runtime.NewScheme()
+ dc := dynamicfake.NewSimpleDynamicClient(scheme,
+ streamedCM("default", "via-list-1", "10"),
+ streamedCM("default", "via-list-2", "11"),
+ )
+ dc.PrependWatchReactor("*", func(_ clienttesting.Action) (bool, watch.Interface, error) {
+ return true, nil, apierrors.NewInvalid(
+ schema.GroupKind{Group: "meta.k8s.io", Kind: "ListOptions"}, "",
+ field.ErrorList{field.Forbidden(
+ field.NewPath("sendInitialEvents"), "forbidden unless the WatchList feature gate is enabled")},
+ )
+ })
+
+ desired, _, err := (&Manager{Log: logr.Discard()}).
+ streamInitialEvents(context.Background(), dc, configMapGVR, "default")
+ require.NoError(t, err, "a non-streaming server must fall back to a consistent list, not abort")
+ names := make([]string, len(desired))
+ for i, d := range desired {
+ names[i] = d.Resource.Name
+ }
+ assert.ElementsMatch(t, []string{"via-list-1", "via-list-2"}, names)
+}
+
+// A transient (non-streaming-related) Watch error still aborts — the fallback is only
+// for servers that genuinely cannot stream, never a mask for a flaky API surface.
+func TestStreamInitialEvents_TransientWatchErrorAborts(t *testing.T) {
+ scheme := runtime.NewScheme()
+ dc := dynamicfake.NewSimpleDynamicClient(scheme)
+ dc.PrependWatchReactor("*", func(_ clienttesting.Action) (bool, watch.Interface, error) {
+ return true, nil, apierrors.NewServiceUnavailable("apiserver is down")
+ })
+
+ _, _, err := (&Manager{Log: logr.Discard()}).
+ streamInitialEvents(context.Background(), dc, configMapGVR, "default")
+ require.Error(t, err, "a transient watch error must abort, not fall back")
+}
+
+// A non-terminal bookmark (no initial-events-end annotation) is a progress notification
+// and is ignored; the stream keeps reading until the real terminal bookmark.
+func TestStreamInitialEvents_IgnoresNonTerminalBookmark(t *testing.T) {
+ dc := fakeStreamingClient(func(_ clienttesting.Action, fw *watch.FakeWatcher) {
+ progress := &unstructured.Unstructured{Object: map[string]interface{}{}}
+ progress.SetResourceVersion("5")
+ fw.Action(watch.Bookmark, progress) // no terminal annotation
+ fw.Add(streamedCM("default", "a", "10"))
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("12"))
+ })
+
+ desired, rv, err := (&Manager{}).streamInitialEvents(context.Background(), dc, configMapGVR, "default")
+ require.NoError(t, err)
+ assert.Equal(t, "12", rv)
+ require.Len(t, desired, 1)
+}
+
+// joinSnapshotStreams unions every stream's desired set and pins the revision to the max
+// bookmark across types.
+func TestJoinSnapshotStreams_UnionsAndPinsMaxRevision(t *testing.T) {
+ dc := fakeStreamingClient(func(action clienttesting.Action, fw *watch.FakeWatcher) {
+ switch action.GetNamespace() {
+ case "team-a":
+ fw.Add(streamedCM("team-a", "a", "10"))
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("15"))
+ case "team-b":
+ fw.Add(streamedCM("team-b", "b", "20"))
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("25"))
+ }
+ })
+
+ tasks := []snapshotStreamTask{
+ {gvr: configMapGVR, namespace: "team-a"},
+ {gvr: configMapGVR, namespace: "team-b"},
+ }
+ desired, rv, err := (&Manager{}).joinSnapshotStreams(context.Background(), dc, tasks)
+ require.NoError(t, err)
+ assert.Len(t, desired, 2)
+ assert.Equal(t, "25", rv, "the joined revision is the max bookmark across streams")
+}
+
+// If any stream fails before its bookmark, the whole join aborts and returns nothing —
+// a partial mark must never drive a sweep.
+func TestJoinSnapshotStreams_OneFailureAbortsAll(t *testing.T) {
+ dc := fakeStreamingClient(func(action clienttesting.Action, fw *watch.FakeWatcher) {
+ if action.GetNamespace() == "bad" {
+ fw.Stop() // never reaches its bookmark
+ return
+ }
+ fw.Add(streamedCM("good", "ok", "10"))
+ fw.Action(watch.Bookmark, initialEventsEndBookmark("15"))
+ })
+
+ tasks := []snapshotStreamTask{
+ {gvr: configMapGVR, namespace: "good"},
+ {gvr: configMapGVR, namespace: "bad"},
+ }
+ _, _, err := (&Manager{}).joinSnapshotStreams(context.Background(), dc, tasks)
+ require.Error(t, err, "a partial stream aborts the whole snapshot")
+}
+
+func TestSnapshotStreamTasks_ExpandsScopes(t *testing.T) {
+ gvrA := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
+ tasks := snapshotStreamTasks([]snapshotGVR{
+ {gvr: configMapGVR, namespaces: []string{"ns1", "ns2"}},
+ {gvr: gvrA}, // cluster-wide
+ })
+ require.Len(t, tasks, 3)
+ assert.Empty(t, tasks[2].namespace, "a cluster-wide type is one unscoped stream")
+}
+
+func TestMaxResourceVersion(t *testing.T) {
+ assert.Equal(t, "12", maxResourceVersion("12", "9"), "numeric max")
+ assert.Equal(t, "100", maxResourceVersion("9", "100"), "numeric max ignores string length")
+ assert.Equal(t, "7", maxResourceVersion("7", ""), "empty is ignored")
+ assert.Equal(t, "8", maxResourceVersion("", "8"), "empty is ignored")
+}
+
+func TestDesiredFromObject(t *testing.T) {
+ dr, ok := desiredFromObject(configMapGVR, streamedCM("default", "app", "3"))
+ require.True(t, ok)
+ assert.Equal(t, "configmaps", dr.Resource.Resource)
+ assert.Equal(t, "app", dr.Resource.Name)
+ assert.Equal(t, "default", dr.Resource.Namespace)
+
+ _, ok = desiredFromObject(configMapGVR, (*unstructured.Unstructured)(nil))
+ assert.False(t, ok, "a nil object is not a desired entry")
+}
diff --git a/internal/watch/snapshot_stream_type_test.go b/internal/watch/snapshot_stream_type_test.go
new file mode 100644
index 00000000..ba0ac074
--- /dev/null
+++ b/internal/watch/snapshot_stream_type_test.go
@@ -0,0 +1,94 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/ConfigButler/gitops-reverser/internal/rulestore"
+)
+
+// configmapsGVR is a second served namespaced type, used to prove the per-type stream
+// gathers only its own type.
+var configmapsGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"}
+
+// The M12 per-type stream gathers only the named type's objects, scoped to the namespaces the
+// resident table watches it under — never a sibling type's objects.
+func TestStreamSnapshotForType_StreamsOnlyTheType(t *testing.T) {
+ store := rulestore.NewStore()
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+ addWatchRule(store, "wr-configmaps", "ns-a", "configmaps")
+
+ m := streamingManager(t, gitTargetFixture(), store, map[schema.GroupVersionResource][]*unstructured.Unstructured{
+ secretsGVR: {uns("Secret", "ns-a", "secret-a"), uns("Secret", "ns-b", "secret-b")},
+ configmapsGVR: {uns("ConfigMap", "ns-a", "cm-a")},
+ })
+
+ snap, err := m.StreamSnapshotForType(context.Background(), myTargetRef(), secretsGVR)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"secret-a"}, desiredNames(snap.Desired), "only ns-a secrets, no configmaps, no ns-b leak")
+ for _, d := range snap.Desired {
+ assert.Equal(t, "secrets", d.Resource.Resource, "the per-type stream gathers only the named type")
+ }
+}
+
+// A type the GitTarget does not watch yields an empty snapshot and no error, so the caller
+// no-ops rather than gathering or sweeping.
+func TestStreamSnapshotForType_UnwatchedTypeIsEmpty(t *testing.T) {
+ store := rulestore.NewStore()
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+
+ m := streamingManager(t, gitTargetFixture(), store, nil)
+
+ snap, err := m.StreamSnapshotForType(context.Background(), myTargetRef(), configmapsGVR)
+ require.NoError(t, err)
+ assert.Empty(t, snap.Desired, "an unwatched type produces nothing to reconcile")
+}
+
+// resolveSnapshotGVRForType fails closed when the API surface has not been observed yet — the
+// per-type expression of the never-reconcile-a-partial-view invariant.
+func TestResolveSnapshotGVRForType_FailsClosedWhenRegistryNotReady(t *testing.T) {
+ store := rulestore.NewStore()
+ addWatchRule(store, "wr-secrets", "ns-a", "secrets")
+ empty := apiResourceDiscovery(staticCatalogDiscovery{})
+ m := &Manager{
+ Log: logr.Discard(),
+ RuleStore: store,
+ resourceCatalog: NewAPIResourceCatalog(),
+ discoveryClient: func() (apiResourceDiscovery, error) { return empty, nil },
+ }
+
+ _, _, err := m.resolveSnapshotGVRForType(context.Background(), myTargetRef(), secretsGVR)
+ require.Error(t, err, "an unobserved API surface must abort the per-type gather")
+ assert.Contains(t, err.Error(), "has not been observed yet")
+}
+
+// tableWatchesGVR reports membership of a type in a GitTarget's resident table.
+func TestTableWatchesGVR(t *testing.T) {
+ table := WatchedTypeTable{Types: []WatchedType{{GVR: secretsGVR}}}
+ assert.True(t, tableWatchesGVR(table, secretsGVR), "a watched type is reported present")
+ assert.False(t, tableWatchesGVR(table, configmapsGVR), "an unwatched type is reported absent")
+}
diff --git a/internal/watch/target_reconcile_metric_test.go b/internal/watch/target_reconcile_metric_test.go
index a67667be..881a4a2b 100644
--- a/internal/watch/target_reconcile_metric_test.go
+++ b/internal/watch/target_reconcile_metric_test.go
@@ -31,6 +31,26 @@ import (
const targetReconcileCompletedMetric = "gitopsreverser_target_reconcile_completed_total"
+const resyncBackgroundFailuresMetric = "gitopsreverser_resync_background_failures_total"
+
+// recordBackgroundResyncFailure must count a fire-and-forget resync that failed at the
+// worker, labelled per GitTarget, so a silently-recovered failure is observable.
+func TestRecordBackgroundResyncFailure_IncrementsPerGitTarget(t *testing.T) {
+ reader, err := telemetry.InitTestExporter()
+ require.NoError(t, err)
+
+ r := &EventRouter{Log: logr.Discard()}
+ gitDest := types.NewResourceReference("my-target", "my-ns")
+
+ r.recordBackgroundResyncFailure(gitDest)
+ r.recordBackgroundResyncFailure(gitDest)
+
+ value, ok := telemetry.CollectInt64Sum(reader, resyncBackgroundFailuresMetric,
+ map[string]string{"gittarget_namespace": "my-ns", "gittarget_name": "my-target"})
+ require.True(t, ok, "expected a resync_background_failures_total sample")
+ assert.Equal(t, int64(2), value)
+}
+
// recordTargetReconcileCompleted must increment the per-GitTarget counter and
// carry the gittarget_* and trigger labels.
func TestRecordTargetReconcileCompleted_IncrementsPerTrigger(t *testing.T) {
diff --git a/internal/watch/type_lifecycle.go b/internal/watch/type_lifecycle.go
new file mode 100644
index 00000000..2d6e0c69
--- /dev/null
+++ b/internal/watch/type_lifecycle.go
@@ -0,0 +1,188 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+
+ "github.com/go-logr/logr"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/telemetry"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// lifecycleEventBuffer bounds the channel between the registry's updater (which produces
+// lifecycle events synchronously under its single-updater discipline) and the drain goroutine
+// that turns them into per-type reconciles/sweeps. A full buffer drops the event rather than
+// stalling the updater; the periodic whole-GitTarget reconcile and the next transition recover
+// any missed edge, so the per-type path is an accelerator, never the sole source of truth.
+const lifecycleEventBuffer = 256
+
+// gitTargetSnapshotSyncedCondition mirrors the controller's GitTargetConditionSnapshotSynced.
+// It is duplicated as a string (rather than imported) because the controller package imports
+// watch, so watch must not import controller. The per-type path only acts on a GitTarget that
+// has completed its initial whole-GitTarget snapshot, so a transition during bootstrap does
+// not race or double-commit with the bootstrap resync.
+const gitTargetSnapshotSyncedCondition = "SnapshotSynced"
+
+// startTypeLifecycleConsumer subscribes to the registry's lifecycle transitions and launches
+// the drain goroutine that drives M12 per-type reconcile/sweep. It is a no-op without an
+// EventRouter (zero-value Managers in unit tests have none) and runs at most once per Manager.
+// Subscription happens before the first registry Update (the initial ReconcileForRuleChange),
+// so cold-start activations are observed.
+func (m *Manager) startTypeLifecycleConsumer(ctx context.Context, log logr.Logger) {
+ if m.EventRouter == nil {
+ return
+ }
+ m.lifecycleConsumerOnce.Do(func() {
+ m.lifecycleEvents = make(chan typeset.LifecycleEvent, lifecycleEventBuffer)
+ m.typeRegistryInstance().Subscribe(m.enqueueLifecycleEvent)
+ go m.drainTypeLifecycleEvents(ctx, log)
+ log.V(1).Info("type-lifecycle consumer started")
+ })
+}
+
+// enqueueLifecycleEvent is the registry Observer: a non-blocking hand-off so the registry's
+// updater is never stalled by per-type git work. It runs on whatever goroutine triggered the
+// registry Update.
+func (m *Manager) enqueueLifecycleEvent(ev typeset.LifecycleEvent) {
+ if m.lifecycleEvents == nil {
+ return
+ }
+ select {
+ case m.lifecycleEvents <- ev:
+ default:
+ m.Log.V(1).Info("type-lifecycle event buffer full; dropping",
+ "kind", ev.Kind, "gvr", ev.GVR.String())
+ }
+}
+
+// drainTypeLifecycleEvents processes lifecycle events off the buffer on a dedicated goroutine,
+// so a slow per-type gather/commit never blocks the registry updater or the reconcile loop.
+func (m *Manager) drainTypeLifecycleEvents(ctx context.Context, log logr.Logger) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case ev := <-m.lifecycleEvents:
+ m.handleTypeLifecycleEvent(ctx, log, ev)
+ }
+ }
+}
+
+// handleTypeLifecycleEvent turns one transition into per-type git work. Only the settled edges
+// act: TypeActivated reconciles the type into each synced GitTarget that watches it;
+// TypeRemoved (a settled absence-expired removal) sweeps only that type's documents. Wobbling,
+// Recovered, and Refused carry no git action here — they postpone/resume the reconcile, which
+// the absence of a TypeActivated already encodes.
+func (m *Manager) handleTypeLifecycleEvent(ctx context.Context, log logr.Logger, ev typeset.LifecycleEvent) {
+ switch ev.Kind {
+ case typeset.TypeActivated:
+ m.reconcileTypeForSyncedTargets(ctx, log, ev.GVR)
+ case typeset.TypeRemoved:
+ m.sweepTypeFromSyncedTargets(ctx, log, ev.GVR)
+ case typeset.TypeWobbling, typeset.TypeRecovered, typeset.TypeRefused:
+ log.V(1).Info("type-lifecycle transition (no git action)", "kind", ev.Kind, "gvr", ev.GVR.String())
+ }
+}
+
+// reconcileTypeForSyncedTargets fans a per-type reconcile to every snapshot-synced GitTarget
+// whose resident table watches the activated type. A type the GitTarget does not watch is
+// skipped; a target still mid-bootstrap is skipped (its whole-GitTarget resync covers the type).
+func (m *Manager) reconcileTypeForSyncedTargets(ctx context.Context, log logr.Logger, gvr schema.GroupVersionResource) {
+ for _, table := range m.allWatchedTypeTables() {
+ if !tableWatchesGVR(table, gvr) {
+ continue
+ }
+ if !m.gitTargetSnapshotSynced(ctx, table.GitDest) {
+ continue
+ }
+ if err := m.EventRouter.EmitTypeReconcileForGitDest(ctx, table.GitDest, gvr); err != nil {
+ log.Error(err, "per-type reconcile failed to enqueue",
+ "gitDest", table.GitDest.String(), "gvr", gvr.String())
+ continue
+ }
+ recordTypeLifecycleMetric(telemetry.TypeLifecycleReconcileTotal, table.GitDest)
+ }
+}
+
+// sweepTypeFromSyncedTargets fans a per-type sweep to every snapshot-synced GitTarget. The
+// removed type is no longer in any resident table, so the fan is over all targets; the sweep is
+// idempotent and self-limiting — a GitTarget holding no documents of the type commits nothing —
+// so this is safe even though it touches targets that never mirrored the type. Removals are
+// rare (a CRD deletion), so the broad fan is acceptable.
+func (m *Manager) sweepTypeFromSyncedTargets(ctx context.Context, log logr.Logger, gvr schema.GroupVersionResource) {
+ for _, table := range m.allWatchedTypeTables() {
+ if !m.gitTargetSnapshotSynced(ctx, table.GitDest) {
+ continue
+ }
+ if err := m.EventRouter.EmitTypeSweepForGitDest(ctx, table.GitDest, gvr); err != nil {
+ log.Error(err, "per-type sweep failed to enqueue", "gitDest", table.GitDest.String(), "gvr", gvr.String())
+ continue
+ }
+ recordTypeLifecycleMetric(telemetry.TypeLifecycleSweepTotal, table.GitDest)
+ }
+}
+
+// gitTargetSnapshotSynced reports whether a GitTarget has completed its initial snapshot, the
+// gate that keeps the per-type path additive to (never racing) the bootstrap resync. A
+// GitTarget that cannot be read is treated as not synced, so the per-type path waits.
+func (m *Manager) gitTargetSnapshotSynced(ctx context.Context, gitDest types.ResourceReference) bool {
+ if m.Client == nil {
+ return false
+ }
+ var gt configv1alpha1.GitTarget
+ if err := m.Client.Get(
+ ctx,
+ k8stypes.NamespacedName{Name: gitDest.Name, Namespace: gitDest.Namespace},
+ >,
+ ); err != nil {
+ return false
+ }
+ return apimeta.IsStatusConditionTrue(gt.Status.Conditions, gitTargetSnapshotSyncedCondition)
+}
+
+// tableWatchesGVR reports whether a GitTarget's resident table includes the given type.
+func tableWatchesGVR(table WatchedTypeTable, gvr schema.GroupVersionResource) bool {
+ for _, wt := range table.Types {
+ if wt.GVR == gvr {
+ return true
+ }
+ }
+ return false
+}
+
+// recordTypeLifecycleMetric increments a per-(GitTarget) lifecycle counter, a no-op until the
+// counter is registered.
+func recordTypeLifecycleMetric(counter metric.Int64Counter, gitDest types.ResourceReference) {
+ if counter == nil {
+ return
+ }
+ counter.Add(context.Background(), 1, metric.WithAttributes(
+ attribute.String("gittarget_namespace", gitDest.Namespace),
+ attribute.String("gittarget_name", gitDest.Name),
+ ))
+}
diff --git a/internal/watch/type_lifecycle_test.go b/internal/watch/type_lifecycle_test.go
new file mode 100644
index 00000000..bfb4beb2
--- /dev/null
+++ b/internal/watch/type_lifecycle_test.go
@@ -0,0 +1,76 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ itypes "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// gitTargetSnapshotSynced gates the per-type path on the GitTarget's bootstrap: only a target
+// whose SnapshotSynced condition is True is acted on, so a transition during bootstrap never
+// races the whole-GitTarget resync. A missing or not-yet-synced target reports false.
+func TestGitTargetSnapshotSynced(t *testing.T) {
+ scheme := makeScheme(t)
+ synced := &configv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{Name: "synced", Namespace: "gitops-reverser"},
+ Status: configv1alpha1.GitTargetStatus{Conditions: []metav1.Condition{{
+ Type: gitTargetSnapshotSyncedCondition, Status: metav1.ConditionTrue,
+ Reason: "OK", LastTransitionTime: metav1.Now(),
+ }}},
+ }
+ pending := &configv1alpha1.GitTarget{
+ ObjectMeta: metav1.ObjectMeta{Name: "pending", Namespace: "gitops-reverser"},
+ }
+ client := fakeclient.NewClientBuilder().WithScheme(scheme).WithObjects(synced, pending).Build()
+ m := &Manager{Client: client, Log: logr.Discard()}
+ ctx := context.Background()
+
+ assert.True(t, m.gitTargetSnapshotSynced(ctx, itypes.NewResourceReference("synced", "gitops-reverser")))
+ assert.False(t, m.gitTargetSnapshotSynced(ctx, itypes.NewResourceReference("pending", "gitops-reverser")),
+ "a target still mid-bootstrap is not yet eligible for per-type reconcile")
+ assert.False(t, m.gitTargetSnapshotSynced(ctx, itypes.NewResourceReference("absent", "gitops-reverser")),
+ "an unreadable target is treated as not synced, so the per-type path waits")
+}
+
+// The transitions that carry no git action (Wobbling/Recovered/Refused) are inert in the
+// handler: they neither reconcile nor sweep, so a Manager with no EventRouter handles them
+// without touching the (nil) router.
+func TestHandleTypeLifecycleEvent_NoActionTransitionsAreInert(t *testing.T) {
+ m := &Manager{Log: logr.Discard()}
+ ctx := context.Background()
+ gvr := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
+
+ for _, kind := range []typeset.EventKind{typeset.TypeWobbling, typeset.TypeRecovered, typeset.TypeRefused} {
+ // Must not panic or dereference the nil EventRouter for a no-git-action transition.
+ assert.NotPanics(t, func() {
+ m.handleTypeLifecycleEvent(ctx, logr.Discard(), typeset.LifecycleEvent{Kind: kind, GVR: gvr})
+ })
+ }
+}
diff --git a/internal/watch/watched_type_informer_test.go b/internal/watch/watched_type_informer_test.go
new file mode 100644
index 00000000..1568b229
--- /dev/null
+++ b/internal/watch/watched_type_informer_test.go
@@ -0,0 +1,217 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "sync"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+)
+
+// nsGVR builds a watch GVR for a core-group v1 namespaced resource the common test
+// catalog serves; the version is fixed because every served test resource is v1.
+func nsGVR(resource string) GVR {
+ return GVR{Group: "", Version: "v1", Resource: resource, Scope: configv1alpha1.ResourceScopeNamespaced}
+}
+
+// noopCancel returns a CancelFunc that records whether it was called.
+func noopCancel(called *bool) context.CancelFunc {
+ return func() { *called = true }
+}
+
+func activeInformers(cm GVR, namespaces ...string) map[GVR]map[string]context.CancelFunc {
+ ns := map[string]context.CancelFunc{}
+ for _, n := range namespaces {
+ ns[n] = func() {}
+ }
+ return map[GVR]map[string]context.CancelFunc{cm: ns}
+}
+
+func TestInformersObsolete_NamespaceShrinkTearsDownDroppedNamespace(t *testing.T) {
+ cm := nsGVR("configmaps")
+ obsolete := informersObsolete(activeInformers(cm, "ns-a", "ns-b"), map[GVR]map[string]struct{}{cm: {"ns-a": {}}})
+
+ require.Len(t, obsolete, 1)
+ assert.Equal(t, gvrNamespace{gvr: cm, ns: "ns-b"}, obsolete[0])
+}
+
+func TestInformersObsolete_NamespaceToClusterWideTearsDownNamespaced(t *testing.T) {
+ cm := nsGVR("configmaps")
+ // New scope is cluster-wide ("") for the same GVR.
+ obsolete := informersObsolete(activeInformers(cm, "ns-a"), map[GVR]map[string]struct{}{cm: {"": {}}})
+
+ require.Len(t, obsolete, 1)
+ assert.Equal(t, gvrNamespace{gvr: cm, ns: "ns-a"}, obsolete[0])
+}
+
+func TestInformersObsolete_WholeGVRGoneTearsDownEveryNamespace(t *testing.T) {
+ cm := nsGVR("configmaps")
+ obsolete := informersObsolete(activeInformers(cm, "ns-a", ""), map[GVR]map[string]struct{}{})
+
+ assert.Len(t, obsolete, 2)
+}
+
+func TestInformersToStart_StartsOnlyMissingNamespaces(t *testing.T) {
+ cm := nsGVR("configmaps")
+ toStart := informersToStart(activeInformers(cm, "ns-a"), map[GVR]map[string]struct{}{cm: {"ns-a": {}, "ns-b": {}}})
+
+ assert.Equal(
+ t,
+ []gvrNamespace{{gvr: cm, ns: "ns-b"}},
+ toStart,
+ "only the added namespace ns-b is started; ns-a stays",
+ )
+}
+
+func TestStopInformerNamespace_IsIdempotentAndDropsEmptyGVR(t *testing.T) {
+ cm := nsGVR("configmaps")
+ cancelledA := false
+ m := &Manager{
+ Log: logr.Discard(),
+ activeInformers: map[GVR]map[string]context.CancelFunc{
+ cm: {"ns-a": noopCancel(&cancelledA), "ns-b": func() {}},
+ },
+ }
+
+ m.stopInformerNamespace(cm, "ns-a")
+ assert.True(t, cancelledA, "the stopped namespace's informer must be cancelled")
+ assert.NotContains(t, m.activeInformers[cm], "ns-a")
+ assert.Contains(t, m.activeInformers[cm], "ns-b", "the surviving namespace stays")
+
+ // Idempotent: stopping an already-stopped namespace is a no-op.
+ m.stopInformerNamespace(cm, "ns-a")
+
+ // Stopping the last namespace drops the GVR entry entirely.
+ m.stopInformerNamespace(cm, "ns-b")
+ _, present := m.activeInformers[cm]
+ assert.False(t, present, "a GVR with no remaining namespaces is removed")
+
+ // Stopping a namespace of a GVR that is no longer active is a safe no-op.
+ assert.NotPanics(t, func() { m.stopInformerNamespace(cm, "ns-a") })
+}
+
+func TestDesiredInformerScope_ClusterWideWinsOverNamedNamespace(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ // configmaps watched in ns-a (WatchRule) AND cluster-wide (ClusterWatchRule).
+ store.AddOrUpdateWatchRule(
+ watchRuleForTarget("rule-a", "test-target", "ns-a"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-cw", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+
+ scope := manager.desiredInformerScope()
+ assert.Equal(t, map[string]struct{}{"": {}}, scope[nsGVR("configmaps")],
+ "a cluster-wide selection collapses the named namespace to a single cluster-wide stream")
+}
+
+func TestCompareInformerScope_NamespaceToClusterWideStartsAndRetires(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+ cm := nsGVR("configmaps")
+ // Pretend the old namespace-scoped informer is running.
+ manager.activeInformers = map[GVR]map[string]context.CancelFunc{cm: {"ns-a": func() {}}}
+
+ toStart, obsolete := manager.compareInformerScope(manager.desiredInformerScope())
+
+ assert.Contains(t, toStart, gvrNamespace{gvr: cm, ns: ""}, "the cluster-wide scope needs a new informer")
+ require.Len(t, obsolete, 1)
+ assert.Equal(t, gvrNamespace{gvr: cm, ns: "ns-a"}, obsolete[0],
+ "the obsolete namespace-scoped informer must be retired, not left running")
+}
+
+func TestCompareInformerScope_InitializesActiveInformers(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+ cm := nsGVR("configmaps")
+ // activeInformers is nil — compareInformerScope must lazily initialize it.
+ toStart, obsolete := manager.compareInformerScope(manager.desiredInformerScope())
+
+ assert.Contains(t, toStart, gvrNamespace{gvr: cm, ns: ""})
+ assert.Empty(t, obsolete)
+ assert.NotNil(t, manager.activeInformers)
+}
+
+func TestChangedInformerGVRs_DeduplicatesAcrossStartAndObsolete(t *testing.T) {
+ cm := nsGVR("configmaps")
+ secrets := nsGVR("secrets")
+
+ got := changedInformerGVRs(
+ []gvrNamespace{{gvr: cm, ns: "ns-b"}},
+ []gvrNamespace{{gvr: cm, ns: "ns-a"}, {gvr: secrets, ns: ""}},
+ )
+
+ assert.ElementsMatch(t, []GVR{cm, secrets}, got)
+}
+
+// TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge stresses the serialized
+// refresh (refreshMu) from many goroutines while rules change, asserting it never
+// deadlocks or races (run with -race) and converges to the final rule set.
+func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for range 50 {
+ manager.refreshWatchedTypeTables()
+ _ = manager.desiredInformerScope()
+ }
+ }()
+ }
+ // Concurrently add a second rule mid-flight.
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-2", "test-target", "secrets"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ wg.Wait()
+
+ // A final settled refresh must reflect both rules.
+ manager.refreshWatchedTypeTables()
+ table, ok := manager.watchedTypeTableForGitDest(gitDestRef("test-target"))
+ require.True(t, ok)
+ kinds := map[string]bool{}
+ for _, wt := range table.Types {
+ kinds[wt.GVK.Kind] = true
+ }
+ assert.True(t, kinds["ConfigMap"] && kinds["Secret"], "the settled table reflects both rules")
+}
diff --git a/internal/watch/watched_type_metrics_test.go b/internal/watch/watched_type_metrics_test.go
new file mode 100644
index 00000000..8eea1235
--- /dev/null
+++ b/internal/watch/watched_type_metrics_test.go
@@ -0,0 +1,48 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ConfigButler/gitops-reverser/internal/telemetry"
+)
+
+const watchedTypesMetric = "gitopsreverser_watched_types"
+
+func TestRefreshWatchedTypeTables_RecordsResolvedTypeGauge(t *testing.T) {
+ reader, err := telemetry.InitTestExporter()
+ require.NoError(t, err)
+
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+
+ manager.refreshWatchedTypeTables()
+
+ labels := map[string]string{"gittarget_namespace": "test-ns", "gittarget_name": "test-target"}
+ value, ok := telemetry.CollectInt64Sum(reader, watchedTypesMetric, labels)
+ require.True(t, ok, "expected a watched_types gauge sample")
+ assert.Equal(t, int64(1), value)
+}
diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go
new file mode 100644
index 00000000..121750e6
--- /dev/null
+++ b/internal/watch/watched_type_resolver.go
@@ -0,0 +1,498 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/cespare/xxhash/v2"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/metric"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/rulestore"
+ "github.com/ConfigButler/gitops-reverser/internal/telemetry"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// watchedTypeStore is the Manager's resident set of per-GitTarget watched-type tables.
+// It is the single source of "what each GitTarget watches", read by the snapshot,
+// informer, and plan-hash paths instead of each re-resolving inline. Re-projection is
+// gated on a deliberate trigger — a rule-set change or a type-registry generation bump —
+// so the common no-change reconcile is a cheap fingerprint compare rather than a rescan.
+//
+// Two locks: refreshMu serializes the whole resolve-and-publish so two concurrent
+// refreshes (ReconcileForRuleChange runs from both watch-rule controllers and the
+// manager loop) cannot have a slow older resolution overwrite a newer one; mu guards the
+// published fields for concurrent readers. The registry owns the live-set removal grace,
+// so nothing in this layer tracks absence.
+type watchedTypeStore struct {
+ refreshMu sync.Mutex
+ mu sync.Mutex
+ tables map[string]WatchedTypeTable
+ revision uint64
+ rulesFP uint64
+ resolved bool
+}
+
+// refreshWatchedTypeTables re-projects the resident watched-type tables from the type
+// registry's followable set when a deliberate trigger has fired since the last
+// resolution: a rule-set change (the rules fingerprint moved) or a registry generation
+// bump (discovery changed). A reconcile with neither change reuses the tables, which is
+// what keeps the scan→registry→projection work off the hot path.
+//
+// Production callers refresh the catalog (and thus the registry) first via
+// RefreshAPIResourceCatalog, so this never rebuilds the registry itself; it only does so
+// lazily the first time, for unit tests that drive the store directly. The whole
+// resolve-and-publish runs under refreshMu, so concurrent refreshes are serialized.
+func (m *Manager) refreshWatchedTypeTables() {
+ m.ensureWatchedTypeStore()
+ m.watchedTypes.refreshMu.Lock()
+ defer m.watchedTypes.refreshMu.Unlock()
+
+ // Lazily populate the registry the first time (unit tests drive this path without
+ // RefreshAPIResourceCatalog); in production the catalog refresh keeps it current, so
+ // the heavy scan→registry rebuild stays off this path.
+ if !m.typeRegistryInstance().Ready() {
+ m.refreshTypeRegistry()
+ }
+
+ reg := m.typeRegistryInstance()
+ revision := reg.Revision()
+ fingerprint := m.rulesFingerprint()
+
+ m.watchedTypes.mu.Lock()
+ upToDate := m.watchedTypes.resolved &&
+ m.watchedTypes.revision == revision &&
+ m.watchedTypes.rulesFP == fingerprint
+ m.watchedTypes.mu.Unlock()
+ if upToDate {
+ return
+ }
+
+ tables := m.resolveWatchedTypeTables(reg.Generation())
+
+ m.watchedTypes.mu.Lock()
+ previous := m.watchedTypes.tables
+ m.watchedTypes.tables = tables
+ m.watchedTypes.revision = revision
+ m.watchedTypes.rulesFP = fingerprint
+ m.watchedTypes.resolved = true
+ m.watchedTypes.mu.Unlock()
+
+ recordWatchedTypeMetrics(previous, tables)
+}
+
+// recordWatchedTypeMetrics publishes the per-GitTarget watched-type count gauge after a
+// re-resolution. A GitTarget present before but gone now is zeroed so its series does
+// not linger.
+func recordWatchedTypeMetrics(previous, current map[string]WatchedTypeTable) {
+ if telemetry.WatchedTypes == nil {
+ return
+ }
+ ctx := context.Background()
+ for _, table := range current {
+ telemetry.WatchedTypes.Record(ctx, int64(len(table.Types)), gitTargetAttrs(table.GitDest))
+ }
+ for key, table := range previous {
+ if _, ok := current[key]; ok {
+ continue
+ }
+ telemetry.WatchedTypes.Record(ctx, 0, gitTargetAttrs(table.GitDest))
+ }
+}
+
+func gitTargetAttrs(gitDest types.ResourceReference) metric.MeasurementOption {
+ return metric.WithAttributes(
+ attribute.String("gittarget_namespace", gitDest.Namespace),
+ attribute.String("gittarget_name", gitDest.Name),
+ )
+}
+
+// ensureWatchedTypeStore lazily initialises the resident store so a zero-value
+// Manager (used widely in tests) does not need explicit setup.
+func (m *Manager) ensureWatchedTypeStore() {
+ m.watchedTypeInit.Do(func() {
+ if m.watchedTypes == nil {
+ m.watchedTypes = &watchedTypeStore{}
+ }
+ })
+}
+
+// watchedTypeTableForGitDest returns the resident table for a GitTarget, refreshing the
+// tables first. The bool reports whether the GitTarget currently has a table (i.e. any
+// rules at all); a target whose rules resolve to nothing still returns an empty table.
+func (m *Manager) watchedTypeTableForGitDest(gitDest types.ResourceReference) (WatchedTypeTable, bool) {
+ m.refreshWatchedTypeTables()
+ m.watchedTypes.mu.Lock()
+ defer m.watchedTypes.mu.Unlock()
+ table, ok := m.watchedTypes.tables[gitDest.Key()]
+ return table, ok
+}
+
+// allWatchedTypeTables returns every resident table in a stable order, refreshing first.
+// It is the once-per-reconcile read the plan hash and the requested-GVR set derive from.
+func (m *Manager) allWatchedTypeTables() []WatchedTypeTable {
+ m.refreshWatchedTypeTables()
+ return m.residentWatchedTypeTables()
+}
+
+// residentWatchedTypeTables returns the currently published tables WITHOUT triggering a
+// refresh. Callers on the reconcile hot path read this after ReconcileForRuleChange (or
+// the snapshot gather) has already refreshed once, so per-read re-resolution stays off
+// the path that runs per watched type.
+func (m *Manager) residentWatchedTypeTables() []WatchedTypeTable {
+ m.ensureWatchedTypeStore()
+ m.watchedTypes.mu.Lock()
+ out := make([]WatchedTypeTable, 0, len(m.watchedTypes.tables))
+ for _, table := range m.watchedTypes.tables {
+ out = append(out, table)
+ }
+ m.watchedTypes.mu.Unlock()
+ sort.Slice(out, func(i, j int) bool { return out[i].GitDest.Key() < out[j].GitDest.Key() })
+ return out
+}
+
+// residentWatchedTypeTable returns one GitTarget's published table without refreshing,
+// for callers that have already refreshed in the same operation. A target with no resident
+// table (no rules) yields the zero table, which projects to an empty watch set.
+func (m *Manager) residentWatchedTypeTable(gitDest types.ResourceReference) WatchedTypeTable {
+ m.ensureWatchedTypeStore()
+ m.watchedTypes.mu.Lock()
+ defer m.watchedTypes.mu.Unlock()
+ return m.watchedTypes.tables[gitDest.Key()]
+}
+
+// targetSelections accumulates one GitTarget's selected followable records and write
+// destination while folding that target's rules.
+type targetSelections struct {
+ gitDest types.ResourceReference
+ dest string
+ selections []watchSelection
+}
+
+// resolveWatchedTypeTables projects every GitTarget's rules onto the type registry's
+// followable set: a WatchRule scopes its records to its own namespace, a ClusterWatchRule
+// streams them cluster-wide. A GitTarget whose rules select nothing followable is kept as
+// an empty table so a transient discovery gap does not look like rule removal.
+func (m *Manager) resolveWatchedTypeTables(generation uint64) map[string]WatchedTypeTable {
+ if m.RuleStore == nil {
+ return map[string]WatchedTypeTable{}
+ }
+ records := m.typeRegistryInstance().Followable()
+
+ byTarget := map[string]*targetSelections{}
+ get := func(ref types.ResourceReference, providerNS, provider, branch, path string) *targetSelections {
+ key := ref.Key()
+ ts := byTarget[key]
+ if ts == nil {
+ ts = &targetSelections{gitDest: ref}
+ byTarget[key] = ts
+ }
+ ts.dest = watchPlanDest(providerNS, provider, branch, path)
+ return ts
+ }
+
+ m.collectWatchRuleSelections(records, get)
+ m.collectClusterWatchRuleSelections(records, get)
+
+ tables := make(map[string]WatchedTypeTable, len(byTarget))
+ for key, ts := range byTarget {
+ table := buildWatchedTypeTable(ts.gitDest, generation, ts.selections)
+ table.Dest = ts.dest
+ tables[key] = table
+ }
+ return tables
+}
+
+// collectWatchRuleSelections folds every namespaced WatchRule into its GitTarget's
+// selected records, scoping each record to the rule's own namespace.
+func (m *Manager) collectWatchRuleSelections(
+ records []typeset.TypeRecord,
+ get func(types.ResourceReference, string, string, string, string) *targetSelections,
+) {
+ for _, rule := range m.RuleStore.SnapshotWatchRules() {
+ ts := get(
+ types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace),
+ rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path,
+ )
+ for _, rr := range rule.ResourceRules {
+ matched := matchFollowableRecords(
+ records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha1.ResourceScopeNamespaced)
+ for _, rec := range matched {
+ ts.selections = append(ts.selections, watchSelection{
+ record: rec, namespace: rule.Source.Namespace, ops: rr.Operations,
+ })
+ }
+ }
+ }
+}
+
+// collectClusterWatchRuleSelections folds every ClusterWatchRule into its GitTarget's
+// selected records as cluster-wide streams.
+func (m *Manager) collectClusterWatchRuleSelections(
+ records []typeset.TypeRecord,
+ get func(types.ResourceReference, string, string, string, string) *targetSelections,
+) {
+ for _, rule := range m.RuleStore.SnapshotClusterWatchRules() {
+ ts := get(
+ types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace),
+ rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path,
+ )
+ for _, rr := range rule.Rules {
+ matched := matchFollowableRecords(records, rr.APIGroups, rr.APIVersions, rr.Resources, rr.Scope)
+ for _, rec := range matched {
+ ts.selections = append(ts.selections, watchSelection{
+ record: rec, namespace: "", ops: rr.Operations,
+ })
+ }
+ }
+ }
+}
+
+// matchFollowableRecords returns the followable records a rule selector names, applying
+// group/version/resource/scope semantics over the registry's already-followable set — so
+// a refused type (gvk-not-unique, denied-by-policy, verb-poor) simply never matches. It is
+// the single rule-matching surface, shared by the per-GitTarget watched-type tables and by
+// WatchRule/ClusterWatchRule status. It resolves one resource entry at a time (deduping
+// records across entries) so it can preserve the resolver's ambiguity rule: when
+// apiGroups is omitted and a *named* resource is served in more than one group, it is
+// refused (watched in no group) rather than silently expanded across groups. A
+// version-less entry collapses to the preferred version per (group, resource) so the same
+// object is never watched under two versions; a "*" or explicit-version entry keeps every
+// matched version.
+func matchFollowableRecords(
+ records []typeset.TypeRecord,
+ groups, versions, resources []string,
+ scope configv1alpha1.ResourceScope,
+) []typeset.TypeRecord {
+ var out []typeset.TypeRecord
+ seen := map[schema.GroupVersionResource]struct{}{}
+ for _, resource := range resources {
+ resource = normalizeResource(resource)
+ matched := recordsForResourceEntry(records, groups, versions, resource, scope)
+ for _, rec := range matched {
+ if _, dup := seen[rec.Identity.GVR]; dup {
+ continue
+ }
+ seen[rec.Identity.GVR] = struct{}{}
+ out = append(out, rec)
+ }
+ }
+ return out
+}
+
+// recordsForResourceEntry resolves one (groups, versions, resource, scope) entry against
+// the followable records, returning the records to watch — or nothing when the entry is
+// ambiguous (omitted apiGroups, a named resource served in more than one group).
+func recordsForResourceEntry(
+ records []typeset.TypeRecord,
+ groups, versions []string,
+ resource string,
+ scope configv1alpha1.ResourceScope,
+) []typeset.TypeRecord {
+ var matched []typeset.TypeRecord
+ for _, rec := range records {
+ gvr := rec.Identity.GVR
+ if !matchesScope(rec.Identity.Scope == typeset.ScopeNamespaced, scope) {
+ continue
+ }
+ if resource != "*" && gvr.Resource != resource {
+ continue
+ }
+ if !matchLookupValue(groups, gvr.Group) {
+ continue
+ }
+ if !matchLookupValue(versions, gvr.Version) {
+ continue
+ }
+ matched = append(matched, rec)
+ }
+ if ambiguousAcrossGroups(groups, resource, matched) {
+ return nil // omitted apiGroups can't disambiguate a multi-group resource: watch nothing
+ }
+ return choosePreferredRecordVersions(matched, versions)
+}
+
+// matchesScope reports whether a discovery namespaced flag aligns with a declared
+// resource scope.
+func matchesScope(namespaced bool, scope configv1alpha1.ResourceScope) bool {
+ switch scope {
+ case configv1alpha1.ResourceScopeNamespaced:
+ return namespaced
+ case configv1alpha1.ResourceScopeCluster:
+ return !namespaced
+ default:
+ return false
+ }
+}
+
+// ambiguousAcrossGroups is the omitted-apiGroups ambiguity rule: a named resource (not
+// "*") selected without an apiGroups filter, matching records in more than one group, is
+// ambiguous — the operator must name the group, so it is watched in none.
+func ambiguousAcrossGroups(groups []string, resource string, matched []typeset.TypeRecord) bool {
+ if len(groups) != 0 || resource == "*" {
+ return false
+ }
+ distinct := map[string]struct{}{}
+ for _, rec := range matched {
+ distinct[rec.Identity.GVR.Group] = struct{}{}
+ }
+ return len(distinct) > 1
+}
+
+// choosePreferredRecordVersions collapses a version-less match to one record per
+// (group, resource) — the preferred served version, else the first by version — so the
+// same object is not watched under two served versions. When the selector names versions
+// (explicitly or "*"), every matched version is kept.
+func choosePreferredRecordVersions(records []typeset.TypeRecord, versions []string) []typeset.TypeRecord {
+ if len(versions) != 0 {
+ return records
+ }
+ byGroupResource := map[string][]typeset.TypeRecord{}
+ for _, rec := range records {
+ key := groupResourceKey(rec.Identity.GVR.Group, rec.Identity.GVR.Resource)
+ byGroupResource[key] = append(byGroupResource[key], rec)
+ }
+ out := make([]typeset.TypeRecord, 0, len(byGroupResource))
+ for _, candidates := range byGroupResource {
+ out = append(out, preferredRecord(candidates))
+ }
+ return out
+}
+
+// preferredRecord picks the preferred served version among records for one
+// (group, resource), falling back to the lowest version string for determinism.
+func preferredRecord(records []typeset.TypeRecord) typeset.TypeRecord {
+ sort.Slice(records, func(i, j int) bool {
+ return records[i].Identity.GVR.Version < records[j].Identity.GVR.Version
+ })
+ selected := records[0]
+ for _, rec := range records {
+ if rec.Preferred {
+ return rec
+ }
+ }
+ return selected
+}
+
+// watchPlanFromTable reconstructs a GitTarget's effective-watch-plan hash input from its
+// resident watched-type table. It re-emits the (GVR, scope, namespace, operations)
+// entries via addEntry, so the plan hash that drives snapshot selection is byte-identical
+// to the pre-table hash. Each (type, namespace) pair maps to one plan entry; the empty
+// namespace is a cluster-wide stream.
+func watchPlanFromTable(table WatchedTypeTable) *targetWatchPlan {
+ p := &targetWatchPlan{
+ gitDest: table.GitDest,
+ entries: make(map[string]map[string]struct{}),
+ dest: table.Dest,
+ }
+ for _, wt := range table.Types {
+ gvr := GVR{Group: wt.GVR.Group, Version: wt.GVR.Version, Resource: wt.GVR.Resource, Scope: wt.Scope}
+ for ns, opSet := range wt.NamespaceOps {
+ p.addEntry(gvr, ns, operationSetToTypes(opSet))
+ }
+ }
+ return p
+}
+
+// operationSetToTypes converts a normalised OperationSet back into the OperationType
+// slice addEntry expects, mapping the "*" sentinel to OperationAll. addEntry
+// re-normalises identically, so the round trip is lossless.
+func operationSetToTypes(s OperationSet) []configv1alpha1.OperationType {
+ out := make([]configv1alpha1.OperationType, 0, len(s))
+ for op := range s {
+ if op == "*" {
+ out = append(out, configv1alpha1.OperationAll)
+ continue
+ }
+ out = append(out, configv1alpha1.OperationType(op))
+ }
+ return out
+}
+
+// watchPlanDest renders a GitTarget's write destination fingerprint in the exact form
+// the effective-plan hash uses, so the hash is byte-identical whether built from the
+// table or inline.
+func watchPlanDest(providerNS, provider, branch, path string) string {
+ return fmt.Sprintf("provider=%s/%s|branch=%q|path=%q", providerNS, provider, branch, path)
+}
+
+// rulesFingerprint is a cheap, resolution-free hash of the raw rule inputs — the
+// rule-change half of the re-projection gate. It moves whenever any rule input that
+// could change a resolved table changes, and is deliberately over-sensitive rather than
+// ever under-sensitive (a spurious rebuild is harmless; a missed one would leave the
+// mirror stale).
+func (m *Manager) rulesFingerprint() uint64 {
+ if m.RuleStore == nil {
+ return 0
+ }
+ var parts []string
+ for _, rule := range m.RuleStore.SnapshotWatchRules() {
+ parts = append(parts, watchRuleFingerprint(rule))
+ }
+ for _, rule := range m.RuleStore.SnapshotClusterWatchRules() {
+ parts = append(parts, clusterWatchRuleFingerprint(rule))
+ }
+ sort.Strings(parts)
+ return xxhash.Sum64String(strings.Join(parts, "\x00"))
+}
+
+func watchRuleFingerprint(rule rulestore.CompiledRule) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "wr|gt=%s/%s|src=%s|dest=%s",
+ rule.GitTargetNamespace, rule.GitTargetRef, rule.Source.Namespace,
+ watchPlanDest(rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path))
+ for _, rr := range rule.ResourceRules {
+ fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s]",
+ strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","),
+ strings.Join(rr.Resources, ","), operationsString(rr.Operations))
+ }
+ return b.String()
+}
+
+func clusterWatchRuleFingerprint(rule rulestore.CompiledClusterRule) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "cwr|gt=%s/%s|dest=%s",
+ rule.GitTargetNamespace, rule.GitTargetRef,
+ watchPlanDest(rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path))
+ for _, rr := range rule.Rules {
+ fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s;scope=%s]",
+ strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","),
+ strings.Join(rr.Resources, ","), operationsString(rr.Operations), rr.Scope)
+ }
+ return b.String()
+}
+
+func operationsString(ops []configv1alpha1.OperationType) string {
+ if len(ops) == 0 {
+ return ""
+ }
+ out := make([]string, len(ops))
+ for i, op := range ops {
+ out[i] = string(op)
+ }
+ return strings.Join(out, ",")
+}
diff --git a/internal/watch/watched_type_resolver_test.go b/internal/watch/watched_type_resolver_test.go
new file mode 100644
index 00000000..bb48e4da
--- /dev/null
+++ b/internal/watch/watched_type_resolver_test.go
@@ -0,0 +1,220 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/rulestore"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+)
+
+// makeWatchedTypeManager builds a Manager with a real RuleStore and the common test
+// catalog, with no informers or workers — enough to exercise watched-type resolution
+// and the resident store in isolation.
+func makeWatchedTypeManager(t *testing.T) (*Manager, *rulestore.RuleStore) {
+ t.Helper()
+ store := rulestore.NewStore()
+ manager := &Manager{
+ Log: logr.Discard(),
+ RuleStore: store,
+ resourceCatalog: newCommonTestCatalog(t),
+ discoveryClient: commonTestDiscoveryClient(),
+ }
+ return manager, store
+}
+
+func gitDestRef(name string) types.ResourceReference {
+ return types.NewResourceReference(name, "test-ns")
+}
+
+func TestRefreshWatchedTypeTables_ClusterWatchRuleResolvesClusterWideType(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+
+ manager.refreshWatchedTypeTables()
+
+ table, ok := manager.watchedTypeTableForGitDest(gitDestRef("test-target"))
+ require.True(t, ok)
+ require.Len(t, table.Types, 1)
+ wt := table.Types[0]
+ assert.Equal(t, "ConfigMap", wt.GVK.Kind)
+ assert.True(t, wt.ClusterWide(), "a ClusterWatchRule with Namespaced scope streams cluster-wide")
+ assert.Empty(t, wt.SnapshotNamespaces())
+ assert.Equal(t, `provider=test-ns/test-provider|branch="main"|path="test-path"`, table.Dest)
+}
+
+func TestRefreshWatchedTypeTables_WatchRuleScopesTypeToItsNamespace(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateWatchRule(
+ watchRuleForTarget("rule-a", "wt-ns-target", "ns-a"),
+ "wt-ns-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ store.AddOrUpdateWatchRule(
+ watchRuleForTarget("rule-b", "wt-ns-target", "ns-b"),
+ "wt-ns-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+
+ manager.refreshWatchedTypeTables()
+
+ table, ok := manager.watchedTypeTableForGitDest(gitDestRef("wt-ns-target"))
+ require.True(t, ok)
+ require.Len(t, table.Types, 1)
+ assert.Equal(t, []string{"ns-a", "ns-b"}, table.Types[0].SnapshotNamespaces())
+ assert.False(t, table.Types[0].ClusterWide())
+}
+
+func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+ first, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target"))
+ require.Len(t, first.Types, 1)
+
+ // A second rule selecting a different resource is reflected on the next refresh.
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-2", "test-target", "secrets"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+
+ second, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target"))
+ kinds := []string{second.Types[0].GVK.Kind, second.Types[1].GVK.Kind}
+ assert.ElementsMatch(t, []string{"ConfigMap", "Secret"}, kinds)
+}
+
+func TestResolveWatchedTypeTables_NilRuleStoreIsEmpty(t *testing.T) {
+ m := &Manager{Log: logr.Discard()}
+ assert.Empty(t, m.resolveWatchedTypeTables(0))
+}
+
+func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+ manager.watchedTypes.mu.Lock()
+ firstRev := manager.watchedTypes.revision
+ firstFP := manager.watchedTypes.rulesFP
+ manager.watchedTypes.mu.Unlock()
+
+ // A second refresh with no rule or registry change is a no-op gate hit.
+ manager.refreshWatchedTypeTables()
+ manager.watchedTypes.mu.Lock()
+ assert.Equal(t, firstRev, manager.watchedTypes.revision)
+ assert.Equal(t, firstFP, manager.watchedTypes.rulesFP)
+ manager.watchedTypes.mu.Unlock()
+}
+
+func TestRulesFingerprint_StableUntilRuleChanges(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "configmaps"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ fp1 := manager.rulesFingerprint()
+ assert.Equal(t, fp1, manager.rulesFingerprint(), "fingerprint must be stable for unchanged rules")
+
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-2", "test-target", "secrets"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ assert.NotEqual(t, fp1, manager.rulesFingerprint(), "a new rule must move the fingerprint")
+}
+
+func TestRefreshWatchedTypeTables_KeepsTargetWithUnresolvableRulesAsEmptyTable(t *testing.T) {
+ manager, store := makeWatchedTypeManager(t)
+ // "ghosts" is not served by the common catalog: the rule resolves to nothing,
+ // but the GitTarget must still appear as an empty table, not vanish.
+ store.AddOrUpdateClusterWatchRule(
+ clusterRuleForResource("rule-1", "test-target", "ghosts"),
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+ manager.refreshWatchedTypeTables()
+
+ table, ok := manager.watchedTypeTableForGitDest(gitDestRef("test-target"))
+ require.True(t, ok, "a GitTarget with unresolvable rules must remain a (empty) table")
+ assert.Empty(t, table.Types)
+}
+
+// A GVK served by more than one resource is refused globally by the registry
+// (gvk-not-unique), so it never reaches a GitTarget's table even when a wildcard rule
+// selects both resources.
+func TestRefreshWatchedTypeTables_ExcludesAmbiguousGVK(t *testing.T) {
+ store := rulestore.NewStore()
+ manager := &Manager{Log: logr.Discard(), RuleStore: store, resourceCatalog: newWidgetConflictCatalog(t)}
+ store.AddOrUpdateClusterWatchRule(
+ configv1alpha1.ClusterWatchRule{
+ ObjectMeta: metav1.ObjectMeta{Name: "rule-widgets"},
+ Spec: configv1alpha1.ClusterWatchRuleSpec{
+ TargetRef: configv1alpha1.NamespacedTargetReference{Name: "test-target", Namespace: "test-ns"},
+ Rules: []configv1alpha1.ClusterResourceRule{{
+ APIGroups: []string{"example.com"},
+ APIVersions: []string{"v1"},
+ Resources: []string{"*"},
+ Scope: configv1alpha1.ResourceScopeNamespaced,
+ }},
+ },
+ },
+ "test-target", "test-ns", "test-provider", "test-ns", "main", "test-path",
+ )
+
+ manager.refreshWatchedTypeTables()
+
+ table, ok := manager.watchedTypeTableForGitDest(gitDestRef("test-target"))
+ require.True(t, ok)
+ assert.Empty(t, table.Types, "a kind served by >1 resource is refused, not watched")
+}
+
+// newWidgetConflictCatalog builds a pathological catalog where one group/version serves
+// the same kind from two distinct resources, violating the GVK<->GVR 1:1 assumption.
+func newWidgetConflictCatalog(t *testing.T) *APIResourceCatalog {
+ t.Helper()
+ listWatch := metav1.Verbs{"get", "list", "watch"}
+ disco := staticCatalogDiscovery{
+ groups: []*metav1.APIGroup{testAPIGroup("example.com", "v1")},
+ resources: []*metav1.APIResourceList{
+ {
+ GroupVersion: "example.com/v1",
+ APIResources: []metav1.APIResource{
+ {Name: "widgets", Kind: "Widget", Namespaced: true, Verbs: listWatch},
+ {Name: "widgetslegacy", Kind: "Widget", Namespaced: true, Verbs: listWatch},
+ },
+ },
+ },
+ }
+ catalog := NewAPIResourceCatalog()
+ _, err := catalog.Refresh(disco)
+ require.NoError(t, err)
+ return catalog
+}
diff --git a/internal/watch/watched_type_table.go b/internal/watch/watched_type_table.go
new file mode 100644
index 00000000..6e829dac
--- /dev/null
+++ b/internal/watch/watched_type_table.go
@@ -0,0 +1,205 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "sort"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+// OperationSet is the set of operation filters recorded for a watched type in one
+// namespace. The sentinel "*" means all operations and subsumes the rest, exactly
+// as the effective-plan hash encodes operations today.
+type OperationSet map[string]struct{}
+
+// add folds a rule's operation slice into the set, normalising an empty slice and
+// the explicit OperationAll to the "*" sentinel.
+func (s OperationSet) add(ops []configv1alpha1.OperationType) {
+ if len(ops) == 0 {
+ s["*"] = struct{}{}
+ return
+ }
+ for _, op := range ops {
+ if op == configv1alpha1.OperationAll {
+ s["*"] = struct{}{}
+ continue
+ }
+ s[string(op)] = struct{}{}
+ }
+}
+
+// Sorted returns the operations in a stable order, collapsing to ["*"] when the
+// all-operations sentinel is present.
+func (s OperationSet) Sorted() []string {
+ if _, all := s["*"]; all {
+ return []string{"*"}
+ }
+ out := make([]string, 0, len(s))
+ for op := range s {
+ out = append(out, op)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// WatchedType is one followable type a GitTarget watches: a (GVK, GVR, scope) triple
+// plus the namespace scope and served-version metadata, projected straight from the
+// type registry's followable set. The registry owns identity (GVK<->GVR is 1:1 there),
+// followability, and the removal grace, so a WatchedType is a copy of a registry fact,
+// never a re-decision.
+type WatchedType struct {
+ GVK schema.GroupVersionKind
+ GVR schema.GroupVersionResource
+ Namespaced bool
+ Scope configv1alpha1.ResourceScope
+ ServedVersion string
+ Preferred bool
+
+ // NamespaceOps maps each watched namespace to the union of operation filters
+ // for this type in that namespace. The empty-string key is a cluster-wide
+ // stream: a cluster-scoped resource, or a namespaced resource a ClusterWatchRule
+ // follows across every namespace.
+ NamespaceOps map[string]OperationSet
+}
+
+// ClusterWide reports whether this type is gathered with a single cluster-wide
+// stream, true for a cluster-scoped resource and for a namespaced resource a
+// ClusterWatchRule follows across all namespaces.
+func (t WatchedType) ClusterWide() bool {
+ _, ok := t.NamespaceOps[""]
+ return ok
+}
+
+// SnapshotNamespaces returns the namespaces this type is gathered under for the
+// streaming snapshot and informers: an empty slice means cluster-wide. A
+// cluster-wide selection overrides any named namespaces, matching the historic
+// gvrSnapshotEntry collapse.
+func (t WatchedType) SnapshotNamespaces() []string {
+ if t.ClusterWide() {
+ return nil
+ }
+ out := make([]string, 0, len(t.NamespaceOps))
+ for ns := range t.NamespaceOps {
+ out = append(out, ns)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// WatchedTypeTable is a GitTarget's resident, resolved-once set of watched types: the
+// subset of the type registry's followable set its WatchRules and ClusterWatchRules
+// select. It is re-resolved only on a deliberate trigger (a rule-set change or a
+// catalog/registry generation bump) and read by the snapshot, informer, and plan-hash
+// paths instead of each re-resolving inline.
+type WatchedTypeTable struct {
+ GitDest types.ResourceReference
+ // Dest is the GitTarget's write destination fingerprint (provider/branch/path),
+ // carried so the effective-plan hash can be derived from the table alone.
+ Dest string
+ Types []WatchedType
+ ResolvedAt uint64
+}
+
+// watchSelection is one followable registry record a rule selected for a GitTarget,
+// with the namespace it was selected under ("" = cluster-wide stream) and the rule's
+// operation filters.
+type watchSelection struct {
+ record typeset.TypeRecord
+ namespace string
+ ops []configv1alpha1.OperationType
+}
+
+// watchedTypeAccum accumulates one followable record's namespace/operation scope while
+// folding a GitTarget's selections.
+type watchedTypeAccum struct {
+ record typeset.TypeRecord
+ namespaceOps map[string]OperationSet
+}
+
+// buildWatchedTypeTable folds a GitTarget's selected followable records into its
+// watched-type table, unioning each record's per-namespace operation filters. Identity
+// and followability are already settled by the registry, so this is a pure fold with no
+// catalog lookup and no conflict decision.
+func buildWatchedTypeTable(
+ gitDest types.ResourceReference,
+ generation uint64,
+ selections []watchSelection,
+) WatchedTypeTable {
+ byGVR := map[schema.GroupVersionResource]*watchedTypeAccum{}
+ for _, sel := range selections {
+ gvr := sel.record.Identity.GVR
+ acc := byGVR[gvr]
+ if acc == nil {
+ acc = &watchedTypeAccum{record: sel.record, namespaceOps: map[string]OperationSet{}}
+ byGVR[gvr] = acc
+ }
+ opSet := acc.namespaceOps[sel.namespace]
+ if opSet == nil {
+ opSet = OperationSet{}
+ acc.namespaceOps[sel.namespace] = opSet
+ }
+ opSet.add(sel.ops)
+ }
+
+ table := WatchedTypeTable{GitDest: gitDest, ResolvedAt: generation}
+ for _, acc := range byGVR {
+ table.Types = append(table.Types, watchedTypeFromRecord(acc.record, acc.namespaceOps))
+ }
+ sortWatchedTypes(table.Types)
+ return table
+}
+
+// watchedTypeFromRecord copies a followable registry record's identity into a
+// WatchedType, attaching the per-namespace operation scope the rules folded.
+func watchedTypeFromRecord(rec typeset.TypeRecord, namespaceOps map[string]OperationSet) WatchedType {
+ return WatchedType{
+ GVK: rec.Identity.GVK,
+ GVR: rec.Identity.GVR,
+ Namespaced: rec.Identity.Scope == typeset.ScopeNamespaced,
+ Scope: resourceScopeFor(rec.Identity.Scope),
+ ServedVersion: rec.Identity.GVR.Version,
+ Preferred: rec.Preferred,
+ NamespaceOps: namespaceOps,
+ }
+}
+
+// resourceScopeFor maps a typeset scope onto the API's ResourceScope. A followable
+// record always carries a concrete scope, so Unknown never reaches the table.
+func resourceScopeFor(scope typeset.Scope) configv1alpha1.ResourceScope {
+ if scope == typeset.ScopeCluster {
+ return configv1alpha1.ResourceScopeCluster
+ }
+ return configv1alpha1.ResourceScopeNamespaced
+}
+
+func sortWatchedTypes(watched []WatchedType) {
+ sort.Slice(watched, func(i, j int) bool {
+ return gvkSortKey(watched[i].GVK) < gvkSortKey(watched[j].GVK)
+ })
+}
+
+// gvkSortKey builds a stable group|version|kind ordering key.
+func gvkSortKey(gvk schema.GroupVersionKind) string {
+ return gvk.Group + "|" + gvk.Version + "|" + gvk.Kind
+}
diff --git a/internal/watch/watched_type_table_test.go b/internal/watch/watched_type_table_test.go
new file mode 100644
index 00000000..79812049
--- /dev/null
+++ b/internal/watch/watched_type_table_test.go
@@ -0,0 +1,245 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package watch
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ configv1alpha1 "github.com/ConfigButler/gitops-reverser/api/v1alpha1"
+ "github.com/ConfigButler/gitops-reverser/internal/types"
+ "github.com/ConfigButler/gitops-reverser/internal/typeset"
+)
+
+func testGitDest() types.ResourceReference {
+ return types.NewResourceReference("git", "default")
+}
+
+// followableRecord builds a minimal followable typeset record for the given identity,
+// the shape resolveWatchedTypeTables folds into the table.
+func followableRecord(group, version, resource, kind string, scope typeset.Scope, preferred bool) typeset.TypeRecord {
+ return typeset.TypeRecord{
+ Identity: typeset.Identity{
+ GVK: schema.GroupVersionKind{Group: group, Version: version, Kind: kind},
+ GVR: schema.GroupVersionResource{Group: group, Version: version, Resource: resource},
+ Scope: scope,
+ },
+ Preferred: preferred,
+ }
+}
+
+func nsRecord(group, resource, kind string) typeset.TypeRecord {
+ return followableRecord(group, "v1", resource, kind, typeset.ScopeNamespaced, true)
+}
+
+// namespaceRecord is the followable cluster-scoped Namespace record the matcher tests
+// use to exercise scope filtering.
+func namespaceRecord() typeset.TypeRecord {
+ return followableRecord("", "v1", "namespaces", "Namespace", typeset.ScopeCluster, true)
+}
+
+func TestBuildWatchedTypeTable_NamespacedTypeCarriesRecordMetadata(t *testing.T) {
+ selections := []watchSelection{
+ {record: nsRecord("apps", "deployments", "Deployment"), namespace: "team-a"},
+ }
+
+ table := buildWatchedTypeTable(testGitDest(), 7, selections)
+
+ require.Len(t, table.Types, 1)
+ wt := table.Types[0]
+ assert.Equal(t, schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, wt.GVK)
+ assert.Equal(t, schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, wt.GVR)
+ assert.True(t, wt.Namespaced)
+ assert.Equal(t, configv1alpha1.ResourceScopeNamespaced, wt.Scope)
+ assert.Equal(t, "v1", wt.ServedVersion)
+ assert.True(t, wt.Preferred)
+ assert.Equal(t, []string{"team-a"}, wt.SnapshotNamespaces())
+ assert.False(t, wt.ClusterWide())
+ assert.Equal(t, uint64(7), table.ResolvedAt)
+}
+
+func TestBuildWatchedTypeTable_ClusterWideOverridesNamedNamespaces(t *testing.T) {
+ // The same record followed both in a specific namespace (WatchRule) and cluster-wide
+ // (ClusterWatchRule) collapses to one cluster-wide stream for the snapshot, but both
+ // namespace keys survive for the plan hash.
+ cm := nsRecord("", "configmaps", "ConfigMap")
+ selections := []watchSelection{
+ {record: cm, namespace: "team-a"},
+ {record: cm, namespace: ""},
+ }
+
+ table := buildWatchedTypeTable(testGitDest(), 1, selections)
+
+ require.Len(t, table.Types, 1)
+ wt := table.Types[0]
+ assert.True(t, wt.ClusterWide())
+ assert.Empty(t, wt.SnapshotNamespaces())
+ assert.Contains(t, wt.NamespaceOps, "")
+ assert.Contains(t, wt.NamespaceOps, "team-a")
+}
+
+func TestBuildWatchedTypeTable_OperationsUnionPerNamespace(t *testing.T) {
+ cm := nsRecord("", "configmaps", "ConfigMap")
+ selections := []watchSelection{
+ {record: cm, namespace: "team-a", ops: []configv1alpha1.OperationType{configv1alpha1.OperationCreate}},
+ {record: cm, namespace: "team-a", ops: []configv1alpha1.OperationType{configv1alpha1.OperationUpdate}},
+ {record: cm, namespace: "team-b", ops: []configv1alpha1.OperationType{configv1alpha1.OperationAll}},
+ }
+
+ table := buildWatchedTypeTable(testGitDest(), 1, selections)
+
+ require.Len(t, table.Types, 1)
+ wt := table.Types[0]
+ assert.Equal(t, []string{"CREATE", "UPDATE"}, wt.NamespaceOps["team-a"].Sorted())
+ assert.Equal(t, []string{"*"}, wt.NamespaceOps["team-b"].Sorted())
+}
+
+func TestBuildWatchedTypeTable_EmptyOperationsAreAllOperations(t *testing.T) {
+ selections := []watchSelection{
+ {record: nsRecord("", "configmaps", "ConfigMap"), namespace: "team-a"},
+ }
+
+ table := buildWatchedTypeTable(testGitDest(), 1, selections)
+
+ require.Len(t, table.Types, 1)
+ assert.Equal(t, []string{"*"}, table.Types[0].NamespaceOps["team-a"].Sorted())
+}
+
+func TestBuildWatchedTypeTable_ClusterScopedType(t *testing.T) {
+ selections := []watchSelection{
+ {record: namespaceRecord(), namespace: ""},
+ }
+
+ table := buildWatchedTypeTable(testGitDest(), 1, selections)
+
+ require.Len(t, table.Types, 1)
+ wt := table.Types[0]
+ assert.Equal(t, schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Namespace"}, wt.GVK)
+ assert.False(t, wt.Namespaced)
+ assert.Equal(t, configv1alpha1.ResourceScopeCluster, wt.Scope)
+ assert.True(t, wt.ClusterWide())
+ assert.Empty(t, wt.SnapshotNamespaces())
+}
+
+func TestBuildWatchedTypeTable_SortsTypesByGVK(t *testing.T) {
+ selections := []watchSelection{
+ {record: nsRecord("", "services", "Service"), namespace: "team-a"},
+ {record: nsRecord("apps", "deployments", "Deployment"), namespace: "team-a"},
+ {record: nsRecord("", "configmaps", "ConfigMap"), namespace: "team-a"},
+ }
+
+ table := buildWatchedTypeTable(testGitDest(), 1, selections)
+
+ require.Len(t, table.Types, 3)
+ got := []string{table.Types[0].GVK.Kind, table.Types[1].GVK.Kind, table.Types[2].GVK.Kind}
+ // Sorted by group|version|kind. The empty core group renders as a leading "|"
+ // (ASCII 124), which sorts after named groups like "apps" (ASCII 97).
+ assert.Equal(t, []string{"Deployment", "ConfigMap", "Service"}, got)
+}
+
+func TestMatchFollowableRecords_MatchesResourceGroupVersionScope(t *testing.T) {
+ records := []typeset.TypeRecord{
+ nsRecord("apps", "deployments", "Deployment"),
+ nsRecord("", "configmaps", "ConfigMap"),
+ namespaceRecord(),
+ }
+
+ matched := matchFollowableRecords(
+ records, []string{"apps"}, []string{"v1"}, []string{"deployments"},
+ configv1alpha1.ResourceScopeNamespaced)
+
+ require.Len(t, matched, 1)
+ assert.Equal(t, "Deployment", matched[0].Identity.GVK.Kind)
+}
+
+func TestMatchFollowableRecords_ScopeFiltersClusterFromNamespaced(t *testing.T) {
+ records := []typeset.TypeRecord{namespaceRecord()}
+
+ // A namespaced selector never matches a cluster-scoped record, and vice versa.
+ assert.Empty(t, matchFollowableRecords(
+ records, nil, nil, []string{"namespaces"}, configv1alpha1.ResourceScopeNamespaced))
+ assert.Len(t, matchFollowableRecords(
+ records, nil, nil, []string{"namespaces"}, configv1alpha1.ResourceScopeCluster), 1)
+}
+
+func TestMatchFollowableRecords_WildcardResourceExpandsWithinScope(t *testing.T) {
+ records := []typeset.TypeRecord{
+ nsRecord("", "configmaps", "ConfigMap"),
+ nsRecord("", "secrets", "Secret"),
+ namespaceRecord(),
+ }
+
+ matched := matchFollowableRecords(
+ records, []string{""}, []string{"v1"}, []string{"*"}, configv1alpha1.ResourceScopeNamespaced)
+
+ kinds := map[string]bool{}
+ for _, rec := range matched {
+ kinds[rec.Identity.GVK.Kind] = true
+ }
+ assert.True(t, kinds["ConfigMap"] && kinds["Secret"])
+ assert.False(t, kinds["Namespace"], "a cluster-scoped record must not match a namespaced selector")
+}
+
+func TestMatchFollowableRecords_VersionlessSelectorCollapsesToPreferred(t *testing.T) {
+ records := []typeset.TypeRecord{
+ followableRecord("example.com", "v1", "widgets", "Widget", typeset.ScopeNamespaced, true),
+ followableRecord("example.com", "v1beta1", "widgets", "Widget", typeset.ScopeNamespaced, false),
+ }
+
+ matched := matchFollowableRecords(
+ records, []string{"example.com"}, nil, []string{"widgets"}, configv1alpha1.ResourceScopeNamespaced)
+
+ require.Len(t, matched, 1, "a version-less selector must not watch the same object under two versions")
+ assert.Equal(t, "v1", matched[0].Identity.GVR.Version, "the preferred version wins")
+}
+
+func TestMatchFollowableRecords_OmittedGroupMultiGroupResourceIsAmbiguous(t *testing.T) {
+ // The same resource name served in two groups, selected without an apiGroups filter,
+ // is ambiguous: it must be watched in no group, not silently expanded across both.
+ records := []typeset.TypeRecord{
+ followableRecord("a.example.com", "v1", "widgets", "Widget", typeset.ScopeNamespaced, true),
+ followableRecord("b.example.com", "v1", "widgets", "Widget", typeset.ScopeNamespaced, true),
+ }
+
+ assert.Empty(t, matchFollowableRecords(
+ records, nil, nil, []string{"widgets"}, configv1alpha1.ResourceScopeNamespaced),
+ "an omitted apiGroups selector over a multi-group resource is ambiguous")
+
+ // Naming the group disambiguates it.
+ matched := matchFollowableRecords(
+ records, []string{"a.example.com"}, nil, []string{"widgets"}, configv1alpha1.ResourceScopeNamespaced)
+ require.Len(t, matched, 1)
+ assert.Equal(t, "a.example.com", matched[0].Identity.GVR.Group)
+}
+
+func TestMatchFollowableRecords_WildcardVersionKeepsEveryVersion(t *testing.T) {
+ records := []typeset.TypeRecord{
+ followableRecord("example.com", "v1", "widgets", "Widget", typeset.ScopeNamespaced, true),
+ followableRecord("example.com", "v1beta1", "widgets", "Widget", typeset.ScopeNamespaced, false),
+ }
+
+ matched := matchFollowableRecords(
+ records, []string{"example.com"}, []string{"*"}, []string{"widgets"},
+ configv1alpha1.ResourceScopeNamespaced)
+
+ assert.Len(t, matched, 2, "an explicit version wildcard keeps every served version")
+}
diff --git a/internal/webhook/audit_handler.go b/internal/webhook/audit_handler.go
index 81c985f3..76251cf8 100644
--- a/internal/webhook/audit_handler.go
+++ b/internal/webhook/audit_handler.go
@@ -646,10 +646,7 @@ func isFailedAuditRequest(event *auditv1.Event) bool {
// checkEvent validates an audit event before processing.
func (h *AuditHandler) checkEvent(event *audit.Event) (bool, error) {
- // Subresource audit verbs do not describe top-level object mutations that
- // the current Git routing path can mirror. Some streaming subresources such
- // as pods/exec are audited as verb=create with no resource body at all.
- process := event.ObjectRef == nil || event.ObjectRef.Subresource == ""
+ process := shouldForwardSubresource(event)
if string(event.AuditID) == "" {
return process, errors.New("invalid audit event: auditID cannot be empty")
}
@@ -657,6 +654,24 @@ func (h *AuditHandler) checkEvent(event *audit.Event) (bool, error) {
return process, nil
}
+// shouldForwardSubresource is the cheap subresource forwarding gate. Top-level
+// resource events always pass. A subresource event passes only when it is a mutating
+// /scale — the single subresource GitOps Reverser mirrors — so a deployments/scale
+// event reaches the consumer to be translated into a parent-manifest replicas patch,
+// while status, exec, proxy, log, and every other subresource is dropped before Redis.
+// The consumer remains the authority for whether a forwarded scale can actually be
+// resolved (it drops a scale whose parent replica path is unknown). See
+// docs/design/manifest/version2/subresource-scope-reduction.md.
+func shouldForwardSubresource(event *audit.Event) bool {
+ if event.ObjectRef == nil || event.ObjectRef.Subresource == "" {
+ return true
+ }
+ if _, ok := auditutil.VerbToOperation(event.Verb); !ok {
+ return false
+ }
+ return auditutil.IsScaleSubresource(event.ObjectRef.Subresource)
+}
+
func hasAuditV1ObjectBody(event *auditv1.Event) bool {
return event != nil && (hasRuntimeUnknownBody(event.RequestObject) || hasRuntimeUnknownBody(event.ResponseObject))
}
diff --git a/internal/webhook/audit_handler_test.go b/internal/webhook/audit_handler_test.go
index a8a98529..8e536d35 100644
--- a/internal/webhook/audit_handler_test.go
+++ b/internal/webhook/audit_handler_test.go
@@ -453,6 +453,58 @@ func TestAuditHandler_validateEvent(t *testing.T) {
expectedErr: "",
expectedProcessed: false,
},
+ {
+ name: "scale subresource event forwards",
+ event: audit.Event{
+ AuditID: "some-scale",
+ Verb: "patch",
+ ObjectRef: &audit.ObjectReference{
+ Resource: "deployments",
+ Subresource: "scale",
+ },
+ },
+ expectedErr: "",
+ expectedProcessed: true,
+ },
+ {
+ name: "read verb on a subresource is dropped",
+ event: audit.Event{
+ AuditID: "scale-read",
+ Verb: "get",
+ ObjectRef: &audit.ObjectReference{
+ Resource: "deployments",
+ Subresource: "scale",
+ },
+ },
+ expectedErr: "",
+ expectedProcessed: false,
+ },
+ {
+ name: "services proxy subresource is dropped as non-scale",
+ event: audit.Event{
+ AuditID: "svc-proxy",
+ Verb: "create",
+ ObjectRef: &audit.ObjectReference{
+ Resource: "services",
+ Subresource: "proxy",
+ },
+ },
+ expectedErr: "",
+ expectedProcessed: false,
+ },
+ {
+ name: "an arbitrary mutating subresource is dropped as non-scale",
+ event: audit.Event{
+ AuditID: "widget-throttle",
+ Verb: "update",
+ ObjectRef: &audit.ObjectReference{
+ Resource: "widgets",
+ Subresource: "throttle",
+ },
+ },
+ expectedErr: "",
+ expectedProcessed: false,
+ },
{
name: "empty auditID",
event: audit.Event{
@@ -505,6 +557,36 @@ func TestAuditHandler_validateEvent(t *testing.T) {
}
}
+// TestAuditHandler_ForwardsRealScaleSubresourceRecording drives the real captured
+// kube-apiserver recording of a `kubectl scale deployment` through the full webhook
+// path — decode, the subresource forwarding gate, ingress classification, and
+// enqueue — and asserts the deployments/scale event reaches the canonical stream
+// (verb/resource/subresource intact) instead of being dropped as it was before the
+// gate change. This is the e2e-shaped proof at unit speed that the recording the
+// design is built around is actually forwarded.
+func TestAuditHandler_ForwardsRealScaleSubresourceRecording(t *testing.T) {
+ recording, err := os.ReadFile("testdata/audit-events/deployment-scale-subresource.json")
+ require.NoError(t, err, "the captured scale recording must be readable")
+
+ queue := &recordingAuditEventQueue{}
+ handler, err := NewAuditHandler(AuditHandlerConfig{Queue: queue})
+ require.NoError(t, err)
+
+ body := `{"kind":"EventList","apiVersion":"audit.k8s.io/v1","items":[` + string(recording) + `]}`
+ req := httptest.NewRequest(http.MethodPost, "/audit-webhook", bytes.NewReader([]byte(body)))
+ w := httptest.NewRecorder()
+
+ handler.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Len(t, queue.events, 1, "the real deployments/scale recording must be forwarded to the canonical stream")
+ enqueued := queue.events[0]
+ require.NotNil(t, enqueued.ObjectRef)
+ assert.Equal(t, "deployments", enqueued.ObjectRef.Resource)
+ assert.Equal(t, "scale", enqueued.ObjectRef.Subresource)
+ assert.Equal(t, "patch", enqueued.Verb)
+}
+
func TestAuditHandler_ReadYAMLToJSON(t *testing.T) {
// Read the YAML file
yamlContent, err := os.ReadFile("testdata/audit-events/config-update.yaml")
diff --git a/internal/webhook/testdata/audit-events/deployment-scale-subresource.json b/internal/webhook/testdata/audit-events/deployment-scale-subresource.json
new file mode 100644
index 00000000..4ca32b6c
--- /dev/null
+++ b/internal/webhook/testdata/audit-events/deployment-scale-subresource.json
@@ -0,0 +1,48 @@
+{
+ "auditID": "539bcccb-1b11-4820-83fa-356b5846b95e",
+ "stage": "ResponseComplete",
+ "verb": "patch",
+ "requestURI": "/apis/apps/v1/namespaces/scale-audit-capture/deployments/scale-audit-target/scale",
+ "user": {
+ "username": "system:admin"
+ },
+ "objectRef": {
+ "resource": "deployments",
+ "namespace": "scale-audit-capture",
+ "name": "scale-audit-target",
+ "apiGroup": "apps",
+ "apiVersion": "v1",
+ "subresource": "scale"
+ },
+ "responseStatus": {
+ "metadata": {},
+ "code": 200
+ },
+ "requestObject": {
+ "spec": {
+ "replicas": 3
+ }
+ },
+ "responseObject": {
+ "kind": "Scale",
+ "apiVersion": "autoscaling/v1",
+ "metadata": {
+ "name": "scale-audit-target",
+ "namespace": "scale-audit-capture",
+ "uid": "251415e8-3e88-4fd3-93e0-a07f99d0a890",
+ "resourceVersion": "6977",
+ "creationTimestamp": "2026-06-08T04:38:48Z"
+ },
+ "spec": {
+ "replicas": 3
+ },
+ "status": {
+ "replicas": 0,
+ "selector": "app=scale-audit-target"
+ }
+ },
+ "annotations": {
+ "authorization.k8s.io/decision": "allow",
+ "authorization.k8s.io/reason": ""
+ }
+}
diff --git a/test/e2e/E2E_DEBUGGING.md b/test/e2e/E2E_DEBUGGING.md
index 9c37dbf8..6e9975c5 100644
--- a/test/e2e/E2E_DEBUGGING.md
+++ b/test/e2e/E2E_DEBUGGING.md
@@ -130,12 +130,12 @@ Kind Cluster
Every e2e target writes a Ginkgo JSON report to
`.stamps/cluster///ginkgo-report-.json` (e.g.
-`ginkgo-report-smoke.json` for `task test-e2e`). Render the per-spec
+`ginkgo-report-full.json` for `task test-e2e`). Render the per-spec
duration table from the latest run with:
```bash
go run ./test/e2e/tools/spec-timings \
- .stamps/cluster/k3d-gitops-reverser-test-e2e/gitops-reverser/ginkgo-report-smoke.json
+ .stamps/cluster/k3d-gitops-reverser-test-e2e/gitops-reverser/ginkgo-report-full.json
```
The companion `test/e2e/tools/ts` reads stdin and prefixes each line with
diff --git a/test/e2e/Taskfile.yml b/test/e2e/Taskfile.yml
index 720d5a5e..5ddc74e8 100644
--- a/test/e2e/Taskfile.yml
+++ b/test/e2e/Taskfile.yml
@@ -178,29 +178,16 @@ tasks:
- _install-config-dir
test-e2e:
- desc: Run the smoke e2e suite
+ desc: Run the standard e2e suite without CI-only workflow checks
+ vars:
+ # The whole suite at ~22s avg + ~6m cold prepare ≈ 25m, so it gets a 30m
+ # timeout by default; a shorter budget times out mid-run and gives a
+ # goroutine dump instead of a useful report.
+ E2E_FULL_TIMEOUT: '{{.E2E_FULL_TIMEOUT | default "30m"}}'
# Ginkgo parallelism (--procs) is orchestrated by the ginkgo CLI, not by
# `go test`. Run it via `go run` so the CLI version matches the pinned
# github.com/onsi/ginkgo/v2 module (a globally-installed ginkgo can differ
# and refuse to run on a version mismatch).
- cmds:
- - |
- export CTX="{{.CTX}}"
- export INSTALL_MODE="{{.INSTALL_MODE}}"
- export NAMESPACE="{{.NAMESPACE}}"
- export E2E_AGE_KEY_FILE="{{.CS}}/age-key.txt"
- go run github.com/onsi/ginkgo/v2/ginkgo \
- --procs={{.E2E_GINKGO_PROCS}} --timeout="{{.E2E_GO_TEST_TIMEOUT}}" -v \
- --label-filter=smoke \
- --output-dir="{{.CS}}/{{.NAMESPACE}}" --json-report=ginkgo-report-smoke.json \
- ./test/e2e/
-
- test-e2e-full:
- desc: Run the full e2e suite, including slow and specialized scenarios
- vars:
- # 46 specs at ~22s avg + ~6m cold prepare ≈ 25m; the smoke-tuned 15m default
- # times out mid-run and gives a goroutine dump instead of a useful report.
- E2E_FULL_TIMEOUT: '{{.E2E_FULL_TIMEOUT | default "30m"}}'
cmds:
- |
export CTX="{{.CTX}}"
@@ -209,6 +196,7 @@ tasks:
export E2E_AGE_KEY_FILE="{{.CS}}/age-key.txt"
go run github.com/onsi/ginkgo/v2/ginkgo \
--procs={{.E2E_GINKGO_PROCS}} --timeout="{{.E2E_FULL_TIMEOUT}}" -v \
+ --label-filter='!image-refresh' \
--output-dir="{{.CS}}/{{.NAMESPACE}}" --json-report=ginkgo-report-full.json \
./test/e2e/
@@ -289,31 +277,6 @@ tasks:
- task: allure-e2e-report
- python3 -m http.server "{{.ALLURE_PORT}}" --bind 0.0.0.0 --directory "{{.ALLURE_REPORT_DIR}}"
- test-e2e-manager:
- desc: Run manager-focused e2e scenarios
- cmds:
- - |
- export CTX="{{.CTX}}"
- export INSTALL_MODE="{{.INSTALL_MODE}}"
- export NAMESPACE="{{.NAMESPACE}}"
- export E2E_AGE_KEY_FILE="{{.CS}}/age-key.txt"
- go run github.com/onsi/ginkgo/v2/ginkgo \
- --procs={{.E2E_GINKGO_PROCS}} --timeout="{{.E2E_GO_TEST_TIMEOUT}}" -v \
- --label-filter=manager \
- --output-dir="{{.CS}}/{{.NAMESPACE}}" --json-report=ginkgo-report-manager.json \
- ./test/e2e/
-
- test-e2e-signing:
- desc: Run commit-signing e2e scenarios
- cmds:
- - |
- export CTX="{{.CTX}}"
- export INSTALL_MODE="{{.INSTALL_MODE}}"
- export NAMESPACE="{{.NAMESPACE}}"
- export E2E_AGE_KEY_FILE="{{.CS}}/age-key.txt"
- go test -timeout "{{.E2E_GO_TEST_TIMEOUT}}" ./test/e2e/ -v -ginkgo.v -ginkgo.label-filter=signing \
- -ginkgo.json-report="{{.CS}}/{{.NAMESPACE}}/ginkgo-report-signing.json"
-
test-image-refresh:
desc: Validate the image-refresh dependency chain
cmds:
@@ -326,7 +289,7 @@ tasks:
-ginkgo.json-report="{{.CS}}/{{.NAMESPACE}}/ginkgo-report-image-refresh.json"
test-e2e-quickstart-helm:
- desc: Run quickstart smoke test with Helm install
+ desc: Run quickstart install validation with Helm install
cmds:
- |
export CTX="{{.CTX}}"
@@ -339,7 +302,7 @@ tasks:
-ginkgo.json-report="{{.CS}}/{{.NAMESPACE}}/ginkgo-report-quickstart-helm.json"
test-e2e-quickstart-manifest:
- desc: Run quickstart smoke test with manifest install
+ desc: Run quickstart install validation with manifest install
cmds:
- |
export CTX="{{.CTX}}"
diff --git a/test/e2e/aggregated_apiserver_e2e_test.go b/test/e2e/aggregated_apiserver_e2e_test.go
index 38bae82f..a7868a51 100644
--- a/test/e2e/aggregated_apiserver_e2e_test.go
+++ b/test/e2e/aggregated_apiserver_e2e_test.go
@@ -108,7 +108,7 @@ var _ = Describe("Aggregated API server", Label("aggregated-api"), Ordered, func
SetDefaultEventuallyTimeout(30 * time.Second)
SetDefaultEventuallyPollingInterval(time.Second)
- It("should install and serve flunders through the aggregation layer", Label("smoke"), func() {
+ It("should install and serve flunders through the aggregation layer", func() {
By("waiting for the wardle APIService to report available")
Eventually(func(g Gomega) {
output, err := kubectlRun(
diff --git a/test/e2e/commit_author_attribution_e2e_test.go b/test/e2e/commit_author_attribution_e2e_test.go
index cac8bb35..bcd3768c 100644
--- a/test/e2e/commit_author_attribution_e2e_test.go
+++ b/test/e2e/commit_author_attribution_e2e_test.go
@@ -45,7 +45,7 @@ import (
// assertion reads the author scoped to its own unique file path, so concurrent
// audit traffic from other specs lands in separate commits and cannot change
// the author of this spec's commit.
-var _ = Describe("Commit Author Attribution", Label("manager", "smoke"), Ordered, func() {
+var _ = Describe("Commit Author Attribution", Label("manager"), Ordered, func() {
var (
testNs string
repo *RepoArtifacts
diff --git a/test/e2e/commit_request_e2e_test.go b/test/e2e/commit_request_e2e_test.go
index f667ad6a..c015c426 100644
--- a/test/e2e/commit_request_e2e_test.go
+++ b/test/e2e/commit_request_e2e_test.go
@@ -42,7 +42,7 @@ import (
// namespace. The HEAD/SHA assertions below therefore read back only this spec's
// own commit; concurrent audit traffic for other GitTargets lands in other
// repos and cannot move this repo's HEAD. See docs/design/e2e-serial-registry.md.
-var _ = Describe("Commit Request", Label("commit-request", "audit-consumer", "smoke"), Ordered, func() {
+var _ = Describe("Commit Request", Label("commit-request", "audit-consumer"), Ordered, func() {
var (
testNs string
repo *RepoArtifacts
diff --git a/test/e2e/commit_window_batching_e2e_test.go b/test/e2e/commit_window_batching_e2e_test.go
index 3aa466e2..40e845b7 100644
--- a/test/e2e/commit_window_batching_e2e_test.go
+++ b/test/e2e/commit_window_batching_e2e_test.go
@@ -44,7 +44,7 @@ import (
// routed to other GitTargets cannot land in this spec's grouped commit. See
// docs/design/e2e-serial-registry.md.
var _ = Describe("Commit Window Batching",
- Label("commit-window-batching", "audit-consumer", "smoke"), Ordered, func() {
+ Label("commit-window-batching", "audit-consumer"), Ordered, func() {
var (
testNs string
repo *RepoArtifacts
diff --git a/test/e2e/controller_basics_e2e_test.go b/test/e2e/controller_basics_e2e_test.go
index 70ac73a0..5d8c15e0 100644
--- a/test/e2e/controller_basics_e2e_test.go
+++ b/test/e2e/controller_basics_e2e_test.go
@@ -40,7 +40,7 @@ var _ = Describe("Manager Controller Basics", Label("manager"), Ordered, func()
SetDefaultEventuallyTimeout(30 * time.Second)
SetDefaultEventuallyPollingInterval(time.Second)
- It("should run successfully", Label("smoke"), func() {
+ It("should run successfully", func() {
By("validating that the gitops-reverser pods are running as expected")
verifyControllerUp := func(g Gomega) {
// Get the names of the gitops-reverser pods
@@ -78,7 +78,7 @@ var _ = Describe("Manager Controller Basics", Label("manager"), Ordered, func()
Eventually(verifyControllerUp).Should(Succeed())
})
- It("should expose the controller service", Label("smoke"), func() {
+ It("should expose the controller service", func() {
By("verifying controller service exists")
_, err := kubectlRunInNamespace(namespace, "get", "svc", controllerServiceName)
Expect(err).NotTo(HaveOccurred(), "Controller service should exist")
@@ -100,7 +100,7 @@ var _ = Describe("Manager Controller Basics", Label("manager"), Ordered, func()
}, 30*time.Second).Should(Succeed())
})
- It("should ensure the metrics endpoint is serving metrics", Label("smoke"), func() {
+ It("should ensure the metrics endpoint is serving metrics", func() {
By("validating that the controller service is available for metrics")
_, err := kubectlRunInNamespace(namespace, "get", "service", controllerServiceName)
Expect(err).NotTo(HaveOccurred(), "Controller service should exist")
@@ -148,7 +148,7 @@ var _ = Describe("Manager Controller Basics", Label("manager"), Ordered, func()
fmt.Printf("📊 Inspect metrics: %s\n", getPrometheusURL())
})
- It("should receive audit webhook events from kube-apiserver", Label("smoke"), func() {
+ It("should receive audit webhook events from kube-apiserver", func() {
By("recording baseline audit event count")
baselineAuditEvents, err := queryPrometheus("sum(gitopsreverser_audit_events_received_total) or vector(0)")
Expect(err).NotTo(HaveOccurred())
diff --git a/test/e2e/crd_lifecycle_e2e_test.go b/test/e2e/crd_lifecycle_e2e_test.go
index 0fbea376..debf1429 100644
--- a/test/e2e/crd_lifecycle_e2e_test.go
+++ b/test/e2e/crd_lifecycle_e2e_test.go
@@ -94,7 +94,7 @@ var _ = Describe("Manager CRD Lifecycle", Label("manager"), Ordered, func() {
SetDefaultEventuallyTimeout(30 * time.Second)
SetDefaultEventuallyPollingInterval(time.Second)
- It("should create Git commit when IceCreamOrder CRD is installed via ClusterWatchRule", Label("smoke"), func() {
+ It("should create Git commit when IceCreamOrder CRD is installed via ClusterWatchRule", func() {
gitProviderName := "gitprovider-normal"
clusterWatchRuleName := "clusterwatchrule-crd-install"
crdName := iceCreamCRDName(crdGroupCRDLifecycle)
@@ -159,7 +159,7 @@ var _ = Describe("Manager CRD Lifecycle", Label("manager"), Ordered, func() {
"File should contain CRD name")
}
Eventually(verifyGitCommit).
- WithTimeout(60 * time.Second).
+ WithTimeout(2 * time.Minute).
WithPolling(2 * time.Second).
Should(Succeed())
@@ -335,7 +335,7 @@ var _ = Describe("Manager CRD Lifecycle", Label("manager"), Ordered, func() {
"CRD instance file should NOT contain status field")
}
Eventually(verifyGitCommit).
- WithTimeout(60 * time.Second).
+ WithTimeout(2 * time.Minute).
WithPolling(2 * time.Second).
Should(Succeed())
diff --git a/test/e2e/deployment_scale_subresource_e2e_test.go b/test/e2e/deployment_scale_subresource_e2e_test.go
new file mode 100644
index 00000000..d2a84d6a
--- /dev/null
+++ b/test/e2e/deployment_scale_subresource_e2e_test.go
@@ -0,0 +1,161 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "sigs.k8s.io/yaml"
+)
+
+var _ = Describe("Deployment scale subresource", Label("manager", "subresource"), func() {
+ It("mirrors kubectl scale through the parent Deployment watch", func() {
+ testNs := testNamespaceFor("deployment-scale")
+ repoName := fmt.Sprintf("e2e-deployment-scale-%d", GinkgoRandomSeed())
+ providerName := "deployment-scale-provider"
+ targetName := "deployment-scale-target"
+ watchRuleName := "deployment-scale-watchrule"
+ targetPath := "e2e/deployment-scale"
+ deploymentName := "scale-target"
+
+ By("creating the deployment-scale test namespace")
+ _, _ = kubectlRun("create", "namespace", testNs)
+
+ By("setting up a dedicated Gitea repo and credentials")
+ repo := SetupRepo(resolveE2EContext(), testNs, repoName)
+ _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace")
+ applySOPSAgeKeyToNamespace(testNs)
+
+ defer cleanupNamespace(testNs)
+
+ By("creating GitProvider, GitTarget and a Deployment WatchRule")
+ createGitProviderWithURLInNamespace(providerName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP)
+ createGitTarget(targetName, testNs, providerName, targetPath, "main")
+ applyDeploymentWatchRule(testNs, watchRuleName, targetName)
+ verifyResourceStatus("gitprovider", providerName, testNs, "True", "Ready", "")
+ verifyResourceStatus("gittarget", targetName, testNs, "True", "Ready", "")
+ verifyResourceStatus("watchrule", watchRuleName, testNs, "True", "Ready", "")
+
+ By("creating a Deployment with replicas=1")
+ applyScaleTestDeployment(testNs, deploymentName, 1)
+ deploymentFile := filepath.Join(
+ repo.CheckoutDir,
+ targetPath,
+ fmt.Sprintf("apps/v1/deployments/%s/%s.yaml", testNs, deploymentName),
+ )
+ Eventually(func(g Gomega) {
+ g.Expect(committedDeploymentReplicas(g, repo.CheckoutDir, deploymentFile)).To(Equal(int64(1)))
+ }, 90*time.Second, 2*time.Second).Should(Succeed())
+
+ By("scaling the Deployment through the deployments/scale subresource")
+ _, err = kubectlRunInNamespace(testNs, "scale", "deployment", deploymentName, "--replicas=3")
+ Expect(err).NotTo(HaveOccurred(), "kubectl scale should succeed")
+
+ By("verifying the parent Deployment manifest is updated in git")
+ Eventually(func(g Gomega) {
+ g.Expect(committedDeploymentReplicas(g, repo.CheckoutDir, deploymentFile)).To(Equal(int64(3)))
+ }, 90*time.Second, 2*time.Second).Should(Succeed())
+
+ cleanupWatchRule(watchRuleName, testNs)
+ cleanupGitTarget(targetName, testNs)
+ })
+})
+
+func applyDeploymentWatchRule(namespace, name, targetName string) {
+ manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha1
+kind: WatchRule
+metadata:
+ name: %s
+ namespace: %s
+spec:
+ targetRef:
+ kind: GitTarget
+ name: %s
+ rules:
+ - apiGroups: ["apps"]
+ apiVersions: ["v1"]
+ resources: ["deployments"]
+`, name, namespace, targetName)
+ _, err := kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-")
+ Expect(err).NotTo(HaveOccurred(), "failed to apply Deployment WatchRule")
+}
+
+func applyScaleTestDeployment(namespace, name string, replicas int64) {
+ manifest := fmt.Sprintf(`apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: %s
+ namespace: %s
+spec:
+ replicas: %d
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: %s
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: %s
+ spec:
+ containers:
+ - name: pause
+ image: registry.k8s.io/pause:3.10
+`, name, namespace, replicas, name, name)
+ _, err := kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-")
+ Expect(err).NotTo(HaveOccurred(), "failed to apply scale test Deployment")
+}
+
+func committedDeploymentReplicas(g Gomega, checkoutDir, deploymentFile string) int64 {
+ GinkgoHelper()
+
+ pullLatestRepoState(g, checkoutDir)
+ content, err := os.ReadFile(deploymentFile)
+ g.Expect(err).NotTo(HaveOccurred(), "Deployment file should exist at %s", deploymentFile)
+
+ var obj unstructured.Unstructured
+ g.Expect(yaml.Unmarshal(content, &obj.Object)).To(Succeed(), "Deployment YAML should parse")
+ value, found, err := unstructured.NestedFieldNoCopy(obj.Object, "spec", "replicas")
+ g.Expect(err).NotTo(HaveOccurred(), "Deployment spec.replicas should be readable")
+ g.Expect(found).To(BeTrue(), "Deployment spec.replicas should be present")
+ replicas, err := replicasAsInt64(value)
+ g.Expect(err).NotTo(HaveOccurred(), "Deployment spec.replicas should be numeric")
+ return replicas
+}
+
+func replicasAsInt64(value interface{}) (int64, error) {
+ switch v := value.(type) {
+ case int64:
+ return v, nil
+ case int:
+ return int64(v), nil
+ case float64:
+ return int64(v), nil
+ case string:
+ return strconv.ParseInt(v, 10, 64)
+ default:
+ return 0, fmt.Errorf("unsupported replicas value %T", value)
+ }
+}
diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go
index c3c49e3f..34154a99 100644
--- a/test/e2e/e2e_suite_test.go
+++ b/test/e2e/e2e_suite_test.go
@@ -59,6 +59,16 @@ var _ = SynchronizedBeforeSuite(func() []byte {
}
}()
+ // Hold an exclusive lock on the cluster for the whole run before touching it,
+ // so two concurrent e2e invocations against the same k3d cluster cannot
+ // clobber each other. This replaces the per-task with-lock.sh wrapper: the
+ // lock now lives in one place (here), and because Go opens the fd O_CLOEXEC
+ // it is not inherited by the detached `kubectl port-forward` children that
+ // prepare spawns — so it releases cleanly when this process exits, instead of
+ // being pinned past the run. Destructive standalone tasks (clean-cluster)
+ // honor the same lock via a flock precondition; see test/e2e/Taskfile.yml.
+ acquireE2ERunLock()
+
if img := os.Getenv("PROJECT_IMAGE"); img == "" {
By("local run: preparing cluster via Task target")
} else {
@@ -72,10 +82,74 @@ var _ = SynchronizedBeforeSuite(func() []byte {
configureE2EProcess()
})
+// Release the cluster lock once every parallel process has finished. The second
+// function runs only on process #1, where the lock was taken.
+var _ = SynchronizedAfterSuite(func() {}, func() {
+ releaseE2ERunLock()
+})
+
var _ = AfterEach(func() {
dumpFailureDiagnostics()
})
+// e2eRunLock is the open file descriptor whose flock serializes e2e runs against
+// one cluster. It is held by process #1 for the whole suite and released in
+// SynchronizedAfterSuite (it would also release on process exit).
+var e2eRunLock *os.File
+
+// e2eRunLockPath is the lock co-located with the rest of the cluster stamps, so
+// it matches the {{.CS}}/e2e.lock the Taskfile's clean-cluster precondition
+// probes. utils.GetProjectDir() yields the repo root regardless of the test
+// binary's working directory.
+func e2eRunLockPath() string {
+ projectDir, err := utils.GetProjectDir()
+ Expect(err).NotTo(HaveOccurred(), "failed to resolve project dir for e2e lock")
+ return filepath.Join(projectDir, ".stamps", "cluster", resolveE2EContext(), "e2e.lock")
+}
+
+// acquireE2ERunLock takes an exclusive flock on the cluster lock file. By default
+// it fails fast if another run holds it; set E2E_LOCK_WAIT=true to queue instead.
+func acquireE2ERunLock() {
+ lockPath := e2eRunLockPath()
+ Expect(os.MkdirAll(filepath.Dir(lockPath), 0o755)).To(Succeed(), "failed to create e2e lock dir")
+
+ f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644)
+ Expect(err).NotTo(HaveOccurred(), "failed to open e2e lock file %s", lockPath)
+
+ how := syscall.LOCK_EX | syscall.LOCK_NB
+ if e2eLockWaitEnabled() {
+ how = syscall.LOCK_EX
+ _, _ = fmt.Fprintf(GinkgoWriter, "Waiting for e2e lock %s (CTX=%s)...\n", lockPath, resolveE2EContext())
+ }
+ if err := syscall.Flock(int(f.Fd()), how); err != nil {
+ _ = f.Close()
+ Fail(fmt.Sprintf(
+ "another e2e run is already active for context %s (lock %s); "+
+ "set E2E_LOCK_WAIT=true to wait for it instead of failing fast: %v",
+ resolveE2EContext(), lockPath, err))
+ }
+ e2eRunLock = f
+}
+
+// releaseE2ERunLock closes the lock fd, which releases the flock.
+func releaseE2ERunLock() {
+ if e2eRunLock == nil {
+ return
+ }
+ _ = e2eRunLock.Close()
+ e2eRunLock = nil
+}
+
+// e2eLockWaitEnabled reports whether E2E_LOCK_WAIT opts into blocking on the lock.
+func e2eLockWaitEnabled() bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv("E2E_LOCK_WAIT"))) {
+ case "true", "1", "yes":
+ return true
+ default:
+ return false
+ }
+}
+
// prepareE2EClusterOnce runs the expensive, cluster-mutating bootstrap exactly
// once (parallel process #1): the Task prepare flow plus the cluster-scoped CRD
// pre-cleanup. It is safe to run concurrently with nothing else.
diff --git a/test/e2e/fixtures/inplace-edit-folder/apply/bundle.yaml b/test/e2e/fixtures/inplace-edit-folder/apply/bundle.yaml
new file mode 100644
index 00000000..e86c7458
--- /dev/null
+++ b/test/e2e/fixtures/inplace-edit-folder/apply/bundle.yaml
@@ -0,0 +1,22 @@
+# A human-authored multi-document file.
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: folder-bundle
+ labels:
+ app.kubernetes.io/part-of: manifest-folder-edit
+data:
+ # e2e-folder-edit: preserve bundle data comment
+ color: blue
+ shape: round
+---
+# This sibling document should survive edits to folder-bundle untouched.
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: folder-sibling
+ labels:
+ app.kubernetes.io/part-of: manifest-folder-edit
+data:
+ role: untouched
+ note: still-here
diff --git a/test/e2e/fixtures/inplace-edit-folder/apply/nested/sidecar.yaml b/test/e2e/fixtures/inplace-edit-folder/apply/nested/sidecar.yaml
new file mode 100644
index 00000000..aa7ad56e
--- /dev/null
+++ b/test/e2e/fixtures/inplace-edit-folder/apply/nested/sidecar.yaml
@@ -0,0 +1,12 @@
+# A managed manifest below the root folder to prove location follows content,
+# not the canonical generated path.
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: folder-nested
+ labels:
+ app.kubernetes.io/part-of: manifest-folder-edit
+data:
+ # e2e-folder-edit: preserve nested data comment
+ mode: quiet
+ depth: subfolder
diff --git a/test/e2e/fixtures/inplace-edit-folder/kustomization.yaml b/test/e2e/fixtures/inplace-edit-folder/kustomization.yaml
new file mode 100644
index 00000000..7460fdd4
--- /dev/null
+++ b/test/e2e/fixtures/inplace-edit-folder/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: __E2E_NAMESPACE__
+resources:
+ - apply/bundle.yaml
+ - apply/nested/sidecar.yaml
diff --git a/test/e2e/gitprovider_validation_e2e_test.go b/test/e2e/gitprovider_validation_e2e_test.go
index e9bcdc11..102c1e32 100644
--- a/test/e2e/gitprovider_validation_e2e_test.go
+++ b/test/e2e/gitprovider_validation_e2e_test.go
@@ -57,7 +57,7 @@ var _ = Describe("Manager GitProvider Validation", Label("manager"), Ordered, fu
SetDefaultEventuallyTimeout(30 * time.Second)
SetDefaultEventuallyPollingInterval(time.Second)
- It("should validate GitProvider with real Gitea repository", Label("smoke"), func() {
+ It("should validate GitProvider with real Gitea repository", func() {
gitProviderName := "gitprovider-e2e-test"
By("showing initial controller logs")
diff --git a/test/e2e/gittarget_isolation_e2e_test.go b/test/e2e/gittarget_isolation_e2e_test.go
index 49a2959e..89f23bd8 100644
--- a/test/e2e/gittarget_isolation_e2e_test.go
+++ b/test/e2e/gittarget_isolation_e2e_test.go
@@ -35,9 +35,9 @@ import (
// every target into rule-change snapshot mode.
//
// It is deliberately NOT Serial — the whole point is that isolation must hold
-// under parallel execution. It is Label("smoke") so it runs in the same suite
-// where the flake originally appeared.
-var _ = Describe("Manager GitTarget Isolation", Label("manager"), Label("smoke"), Ordered, func() {
+// under parallel execution. It runs as part of the single e2e suite, the same
+// suite where the flake originally appeared.
+var _ = Describe("Manager GitTarget Isolation", Label("manager"), Ordered, func() {
const (
providerName = "gitprovider-iso"
targetA = "iso-target-a"
@@ -102,7 +102,7 @@ var _ = Describe("Manager GitTarget Isolation", Label("manager"), Label("smoke")
SetDefaultEventuallyTimeout(30 * time.Second)
SetDefaultEventuallyPollingInterval(time.Second)
- It("keeps target A's commits as events while target B's rules churn", Label("smoke"), func() {
+ It("keeps target A's commits as events while target B's rules churn", func() {
// Let any in-flight reconciles from setup drain before we begin, so the
// baseline snapshot commits are settled and only our event commits land
// next on target A's path.
diff --git a/test/e2e/gittarget_overlap_e2e_test.go b/test/e2e/gittarget_overlap_e2e_test.go
new file mode 100644
index 00000000..464b850b
--- /dev/null
+++ b/test/e2e/gittarget_overlap_e2e_test.go
@@ -0,0 +1,109 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+ "fmt"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// This spec is the e2e for the GitTarget non-overlap topology guard (design:
+// docs/design/manifest/current-manifest-support-review.md, milestone C1 in
+// docs/design/manifest/implementation-plan.md). Within one provider+branch, no
+// GitTarget path may be equal to, an ancestor of, or a descendant of another's.
+// Sibling folders are fine; nesting is rejected at the Validated gate so every
+// materialized folder has exactly one owner. The reject path surfaces as a
+// reconcile-time status condition (Ready=False / ValidationFailed), not an
+// admission webhook — this project has no admission webhooks.
+var _ = Describe("Manager GitTarget Overlap Guard", Label("manager"), Ordered, func() {
+ const providerName = "gitprovider-overlap"
+
+ var (
+ testNs string
+ overlapRepo *RepoArtifacts
+ )
+
+ BeforeAll(func() {
+ By("creating GitTarget overlap test namespace")
+ testNs = testNamespaceFor("manager-overlap")
+ _, _ = kubectlRun("create", "namespace", testNs) // idempotent; ignore AlreadyExists
+
+ By("setting up Gitea repo and credentials for overlap tests")
+ overlapRepo = SetupRepo(
+ resolveE2EContext(),
+ testNs,
+ fmt.Sprintf("e2e-manager-overlap-%d", GinkgoRandomSeed()),
+ )
+
+ By("applying git secrets to test namespace")
+ _, err := kubectlRunInNamespace(testNs, "apply", "-f", overlapRepo.SecretsYAML)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace")
+ applySOPSAgeKeyToNamespace(testNs)
+
+ By("creating shared GitProvider for overlap specs")
+ createGitProviderWithURLInNamespace(
+ providerName,
+ testNs,
+ overlapRepo.GitSecretHTTP,
+ overlapRepo.RepoURLHTTP,
+ )
+ verifyResourceStatus(
+ "gitprovider", providerName, testNs,
+ "True", "Ready", "Repository connectivity validated",
+ )
+ })
+
+ AfterAll(func() {
+ cleanupNamespace(testNs)
+ })
+
+ SetDefaultEventuallyTimeout(30 * time.Second)
+ SetDefaultEventuallyPollingInterval(time.Second)
+
+ It("accepts sibling paths in the same repo+branch", func() {
+ createGitTarget("overlap-sibling-a", testNs, providerName, "overlap/team-a", "main")
+ createGitTarget("overlap-sibling-b", testNs, providerName, "overlap/team-b", "main")
+
+ verifyResourceStatus("gittarget", "overlap-sibling-a", testNs, "True", "Ready", "")
+ verifyResourceStatus("gittarget", "overlap-sibling-b", testNs, "True", "Ready", "")
+ })
+
+ It("rejects a path nested inside an existing target's path", func() {
+ // The child name sorts after the parent name so the controller's
+ // deterministic tie-breaker (later timestamp, then identity) agrees with
+ // the creation order: the nested target always loses, even on the rare
+ // same-second tie. That keeps this spec from flaking.
+ By("creating the parent target first so it wins the overlap election")
+ createGitTarget("overlap-parent", testNs, providerName, "overlap/nested", "main")
+ verifyResourceStatus("gittarget", "overlap-parent", testNs, "True", "Ready", "")
+
+ By("creating a target nested under the parent (created later, must lose)")
+ createGitTarget("overlap-parent-child", testNs, providerName, "overlap/nested/child", "main")
+
+ // The nested target is refused at the Validated gate: Ready=False with the
+ // ValidationFailed reason and a TargetConflict message. It owns nothing.
+ verifyResourceStatus(
+ "gittarget", "overlap-parent-child", testNs,
+ "False", "ValidationFailed", "TargetConflict",
+ )
+ })
+})
diff --git a/test/e2e/inplace_edit_e2e_test.go b/test/e2e/inplace_edit_e2e_test.go
new file mode 100644
index 00000000..745f4010
--- /dev/null
+++ b/test/e2e/inplace_edit_e2e_test.go
@@ -0,0 +1,459 @@
+/*
+SPDX-License-Identifier: Apache-2.0
+
+Copyright 2025 ConfigButler
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package e2e
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// This spec proves the manifestedit in-place editing path end to end: when a
+// document in Git carries hand-authored formatting (here a YAML comment) that the
+// operator did not produce, a later cluster update is applied as a minimal
+// in-place edit that preserves the comment, rather than a wholesale rewrite. See
+// docs/design/manifest/manifestedit-integration-readonly-reconcile.md.
+var _ = Describe("Manager In-Place Manifest Editing", Label("manager", "inplace-edit"), Ordered, func() {
+ var (
+ testNs string
+ repo *RepoArtifacts
+ destName = "inplace-edit-dest"
+ ruleName = "inplace-edit-rule"
+ cmName = "inplace-demo"
+ gitPath = "e2e/inplace-edit"
+ )
+
+ const preservedComment = "# gitops-reverser-e2e: preserve-this-comment"
+
+ configMapRepoPath := func() string {
+ return filepath.Join(gitPath, fmt.Sprintf("v1/configmaps/%s/%s.yaml", testNs, cmName))
+ }
+
+ BeforeAll(func() {
+ By("creating the in-place-edit test namespace")
+ testNs = testNamespaceFor("manager-inplace-edit")
+ _, _ = kubectlRun("create", "namespace", testNs) // idempotent
+
+ By("setting up Gitea repo and credentials")
+ repo = SetupRepo(resolveE2EContext(), testNs, fmt.Sprintf("e2e-inplace-edit-%d", GinkgoRandomSeed()))
+
+ _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace")
+
+ // createGitTarget references the shared sops-age-key secret for its
+ // EncryptionConfigured gate; without it the GitTarget never reaches Ready.
+ applySOPSAgeKeyToNamespace(testNs)
+
+ By("creating the GitProvider")
+ createGitProviderWithURLInNamespace("inplace-edit-provider", testNs, repo.GitSecretHTTP, repo.RepoURLHTTP)
+ verifyResourceStatus("gitprovider", "inplace-edit-provider", testNs, "True", "Ready", "")
+ })
+
+ AfterAll(func() {
+ _, _ = kubectlRunInNamespace(testNs, "delete", "configmap", cmName, "--ignore-not-found=true")
+ cleanupWatchRule(ruleName, testNs)
+ cleanupGitTarget(destName, testNs)
+ cleanupNamespace(testNs)
+ })
+
+ It("preserves a hand-authored comment when a watched ConfigMap is updated", func() {
+ By("creating the GitTarget and a ConfigMap WatchRule")
+ createGitTarget(destName, testNs, "inplace-edit-provider", gitPath, "main")
+ err := applyFromTemplate("test/e2e/templates/manager/watchrule-configmap.tmpl", struct {
+ Name string
+ Namespace string
+ DestinationName string
+ }{Name: ruleName, Namespace: testNs, DestinationName: destName}, testNs)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply ConfigMap WatchRule")
+ verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "")
+ verifyResourceStatus("watchrule", ruleName, testNs, "True", "Ready", "")
+
+ // Let any in-flight reconciles from prior specs settle before our event.
+ time.Sleep(5 * time.Second)
+
+ By("creating the ConfigMap with color=blue")
+ _, _ = kubectlRunInNamespace(testNs, "delete", "configmap", cmName, "--ignore-not-found=true")
+ _, err = kubectlRunInNamespace(testNs, "create", "configmap", cmName, "--from-literal=color=blue")
+ Expect(err).NotTo(HaveOccurred(), "ConfigMap creation should succeed")
+
+ By("waiting for the operator to commit the canonical ConfigMap file")
+ fullPath := filepath.Join(repo.CheckoutDir, configMapRepoPath())
+ Eventually(func(g Gomega) {
+ pullLatestRepoState(g, repo.CheckoutDir)
+ content, readErr := os.ReadFile(fullPath)
+ g.Expect(readErr).NotTo(HaveOccurred(), "ConfigMap file must exist at %s", fullPath)
+ g.Expect(string(content)).To(ContainSubstring("color: blue"))
+ }, 90*time.Second, 2*time.Second).Should(Succeed())
+
+ By("seeding a hand-authored comment into the committed file (semantically identical)")
+ // Adding only a comment keeps the document semantically equal to the
+ // cluster object, so the operator leaves it untouched until a real change.
+ seedCommentIntoRepoFile(repo, testNs, configMapRepoPath(), preservedComment)
+
+ By("confirming the comment is on the remote and survives until a change")
+ Eventually(func(g Gomega) {
+ pullLatestRepoState(g, repo.CheckoutDir)
+ content, readErr := os.ReadFile(fullPath)
+ g.Expect(readErr).NotTo(HaveOccurred())
+ g.Expect(string(content)).To(ContainSubstring(preservedComment))
+ }, 60*time.Second, 2*time.Second).Should(Succeed())
+ Consistently(func(g Gomega) {
+ pullLatestRepoState(g, repo.CheckoutDir)
+ content, readErr := os.ReadFile(fullPath)
+ g.Expect(readErr).NotTo(HaveOccurred())
+ g.Expect(string(content)).To(ContainSubstring(preservedComment),
+ "a comment-only change is semantically equal, so the operator must not rewrite the file")
+ }, 15*time.Second, 3*time.Second).Should(Succeed())
+
+ By("updating the ConfigMap to color=green to trigger an in-place edit")
+ _, err = kubectlRunInNamespace(testNs, "patch", "configmap", cmName,
+ "--type=merge", "--patch", `{"data":{"color":"green"}}`)
+ Expect(err).NotTo(HaveOccurred(), "ConfigMap patch should succeed")
+
+ By("verifying the update was applied in place: comment preserved AND value updated")
+ Eventually(func(g Gomega) {
+ pullLatestRepoState(g, repo.CheckoutDir)
+ content, readErr := os.ReadFile(fullPath)
+ g.Expect(readErr).NotTo(HaveOccurred())
+ body := string(content)
+ g.Expect(body).To(ContainSubstring("color: green"), "the changed value must be written")
+ g.Expect(body).To(ContainSubstring(preservedComment),
+ "the hand-authored comment must survive the in-place edit")
+ g.Expect(body).NotTo(ContainSubstring("color: blue"))
+ }, 90*time.Second, 3*time.Second).Should(Succeed())
+
+ By("✅ in-place edit preserved hand-authored formatting through a live update")
+ })
+})
+
+var _ = Describe(
+ "Manager Manifest Folder Editing",
+ Label("manager", "inplace-edit", "manifest-folder"),
+ Ordered,
+ func() {
+ var (
+ testNs string
+ repo *RepoArtifacts
+ providerName = "manifest-folder-provider"
+ destName = "manifest-folder-dest"
+ ruleName = "manifest-folder-rule"
+ gitPath = "e2e/manifest-folder"
+ )
+
+ const (
+ fixtureRoot = "test/e2e/fixtures/inplace-edit-folder"
+ bundleComment = "# e2e-folder-edit: preserve bundle data comment"
+ nestedComment = "# e2e-folder-edit: preserve nested data comment"
+ bundleConfigMapName = "folder-bundle"
+ nestedConfigMapName = "folder-nested"
+ siblingConfigMapName = "folder-sibling"
+ bundleRepoPath = "apply/bundle.yaml"
+ nestedRepoPath = "apply/nested/sidecar.yaml"
+ kustomizationRepoPath = "kustomization.yaml"
+ manifestFolderRepoName = "e2e-manifest-folder"
+ )
+
+ BeforeAll(func() {
+ By("creating the manifest-folder test namespace")
+ testNs = testNamespaceFor("manager-manifest-folder")
+ _, _ = kubectlRun("create", "namespace", testNs)
+
+ By("setting up Gitea repo and credentials")
+ repo = SetupRepo(
+ resolveE2EContext(),
+ testNs,
+ fmt.Sprintf("%s-%d", manifestFolderRepoName, GinkgoRandomSeed()),
+ )
+
+ _, err := kubectlRunInNamespace(testNs, "apply", "-f", repo.SecretsYAML)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to test namespace")
+
+ applySOPSAgeKeyToNamespace(testNs)
+
+ By("creating the GitProvider")
+ createGitProviderWithURLInNamespace(providerName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP)
+ verifyResourceStatus("gitprovider", providerName, testNs, "True", "Ready", "")
+ })
+
+ AfterAll(func() {
+ for _, name := range []string{bundleConfigMapName, nestedConfigMapName, siblingConfigMapName} {
+ _, _ = kubectlRunInNamespace(testNs, "delete", "configmap", name, "--ignore-not-found=true")
+ }
+ cleanupWatchRule(ruleName, testNs)
+ cleanupGitTarget(destName, testNs)
+ _, _ = kubectlRunInNamespace(testNs, "delete", "gitprovider", providerName, "--ignore-not-found=true")
+ cleanupNamespace(testNs)
+ })
+
+ It("edits existing manifests in a real folder without breaking sibling files", func() {
+ renderedFixture := renderInPlaceFixtureFolder(fixtureRoot, testNs)
+ DeferCleanup(func() { _ = os.RemoveAll(renderedFixture) })
+
+ By("seeding the Git repository with the rendered manifest folder")
+ seedRenderedFolderIntoRepo(repo, testNs, renderedFixture, gitPath)
+
+ By("applying the rendered fixture folder with Kustomize")
+ _, err := kubectlRunInNamespace(testNs, "apply", "-k", renderedFixture)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply rendered fixture kustomization")
+
+ By("creating the GitTarget and ConfigMap WatchRule")
+ createGitTarget(destName, testNs, providerName, gitPath, "main")
+ err = applyFromTemplate("test/e2e/templates/manager/watchrule-configmap.tmpl", struct {
+ Name string
+ Namespace string
+ DestinationName string
+ }{Name: ruleName, Namespace: testNs, DestinationName: destName}, testNs)
+ Expect(err).NotTo(HaveOccurred(), "failed to apply ConfigMap WatchRule")
+ verifyResourceStatus("gittarget", destName, testNs, "True", "Ready", "")
+ verifyResourceStatus("watchrule", ruleName, testNs, "True", "Ready", "")
+
+ By("patching ConfigMaps that live in a multi-document file and a nested folder")
+ _, err = kubectlRunInNamespace(testNs, "patch", "configmap", bundleConfigMapName,
+ "--type=merge", "--patch", `{"data":{"color":"green"}}`)
+ Expect(err).NotTo(HaveOccurred(), "failed to patch bundle ConfigMap")
+
+ _, err = kubectlRunInNamespace(testNs, "patch", "configmap", nestedConfigMapName,
+ "--type=merge", "--patch", `{"data":{"mode":"loud"}}`)
+ Expect(err).NotTo(HaveOccurred(), "failed to patch nested ConfigMap")
+
+ By("verifying the existing files were edited in place and sibling content survived")
+ bundleFullPath := filepath.Join(repo.CheckoutDir, gitPath, bundleRepoPath)
+ nestedFullPath := filepath.Join(repo.CheckoutDir, gitPath, nestedRepoPath)
+ kustomizationFullPath := filepath.Join(repo.CheckoutDir, gitPath, kustomizationRepoPath)
+ renderedKustomization := filepath.Join(renderedFixture, kustomizationRepoPath)
+
+ Eventually(func(g Gomega) {
+ pullLatestRepoState(g, repo.CheckoutDir)
+
+ bundleBody := readRepoFile(g, bundleFullPath)
+ g.Expect(bundleBody).To(ContainSubstring(bundleComment))
+ g.Expect(bundleBody).To(ContainSubstring("name: " + bundleConfigMapName))
+ g.Expect(bundleBody).To(ContainSubstring("color: green"))
+ g.Expect(bundleBody).NotTo(ContainSubstring("color: blue"))
+ g.Expect(bundleBody).To(ContainSubstring("name: " + siblingConfigMapName))
+ g.Expect(bundleBody).To(ContainSubstring("role: untouched"))
+ g.Expect(bundleBody).To(ContainSubstring("note: still-here"))
+ g.Expect(bundleBody).NotTo(ContainSubstring("namespace:"))
+
+ nestedBody := readRepoFile(g, nestedFullPath)
+ g.Expect(nestedBody).To(ContainSubstring(nestedComment))
+ g.Expect(nestedBody).To(ContainSubstring("name: " + nestedConfigMapName))
+ g.Expect(nestedBody).To(ContainSubstring("mode: loud"))
+ g.Expect(nestedBody).NotTo(ContainSubstring("mode: quiet"))
+ g.Expect(nestedBody).NotTo(ContainSubstring("namespace:"))
+
+ kustomizationBody := readRepoFile(g, kustomizationFullPath)
+ g.Expect(kustomizationBody).To(Equal(readRepoFile(g, renderedKustomization)))
+
+ for _, name := range []string{bundleConfigMapName, nestedConfigMapName} {
+ canonicalPath := filepath.Join(repo.CheckoutDir, gitPath, "v1", "configmaps", testNs, name+".yaml")
+ _, statErr := os.Stat(canonicalPath)
+ g.Expect(os.IsNotExist(statErr)).
+ To(BeTrue(), "must not create canonical duplicate %s", canonicalPath)
+ }
+ }, 120*time.Second, 3*time.Second).Should(Succeed())
+
+ By("✅ fixture-backed manifest folder was edited in place")
+ })
+ },
+)
+
+// seedCommentIntoRepoFile inserts a YAML comment under the data block of the
+// committed manifest and pushes it to main, authenticating the local checkout's
+// origin from the GitTarget's Git Secret. It retries once over a remote race by
+// rebasing on origin/main, mirroring how a human would resolve a concurrent push.
+func seedCommentIntoRepoFile(repo *RepoArtifacts, namespace, relPath, comment string) {
+ GinkgoHelper()
+
+ configureRepoOriginWithCredentials(repo, namespace)
+
+ mustGit := func(args ...string) {
+ out, gitErr := gitRun(repo.CheckoutDir, args...)
+ Expect(gitErr).NotTo(HaveOccurred(), fmt.Sprintf("git %s: %s", strings.Join(args, " "), out))
+ }
+
+ if _, err := gitRun(repo.CheckoutDir, "fetch", "origin", "main"); err == nil {
+ mustGit("checkout", "-B", "main", "origin/main")
+ mustGit("reset", "--hard", "origin/main")
+ } else {
+ mustGit("checkout", "--orphan", "main")
+ _, _ = gitRun(repo.CheckoutDir, "rm", "-rf", ".")
+ }
+
+ full := filepath.Join(repo.CheckoutDir, relPath)
+ content, readErr := os.ReadFile(full)
+ Expect(readErr).NotTo(HaveOccurred(), "committed file must exist before seeding a comment")
+ // Attach the comment as the head comment of the data block's first key.
+ seeded := strings.Replace(string(content), "data:\n", "data:\n "+comment+"\n", 1)
+ Expect(seeded).NotTo(Equal(string(content)), "expected to insert the comment under data:")
+ Expect(os.WriteFile(full, []byte(seeded), 0o600)).To(Succeed())
+
+ mustGit("add", relPath)
+ mustGit("commit", "-m", "e2e: seed hand-authored comment")
+ if out, pushErr := gitRun(repo.CheckoutDir, "push", "origin", "HEAD:main"); pushErr != nil {
+ // Lost a race with the operator's own push: rebase on the new tip and retry.
+ _ = out
+ mustGit("fetch", "origin", "main")
+ mustGit("rebase", "origin/main")
+ mustGit("push", "origin", "HEAD:main")
+ }
+}
+
+func renderInPlaceFixtureFolder(fixtureRoot, namespace string) string {
+ GinkgoHelper()
+
+ rendered, err := os.MkdirTemp("", "gitops-reverser-e2e-manifest-folder-*")
+ Expect(err).NotTo(HaveOccurred(), "failed to create rendered fixture directory")
+
+ err = filepath.WalkDir(fixtureRoot, func(src string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ rel, relErr := filepath.Rel(fixtureRoot, src)
+ if relErr != nil {
+ return relErr
+ }
+ if rel == "." {
+ return nil
+ }
+
+ dst := filepath.Join(rendered, rel)
+ if d.IsDir() {
+ return os.MkdirAll(dst, 0o750)
+ }
+
+ content, readErr := os.ReadFile(src)
+ if readErr != nil {
+ return readErr
+ }
+ content = []byte(strings.ReplaceAll(string(content), "__E2E_NAMESPACE__", namespace))
+ if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
+ return err
+ }
+ return os.WriteFile(dst, content, 0o600)
+ })
+ Expect(err).NotTo(HaveOccurred(), "failed to render fixture folder")
+
+ return rendered
+}
+
+func seedRenderedFolderIntoRepo(repo *RepoArtifacts, namespace, renderedFolder, gitPath string) {
+ GinkgoHelper()
+
+ configureRepoOriginWithCredentials(repo, namespace)
+ mustGit := func(args ...string) {
+ out, gitErr := gitRun(repo.CheckoutDir, args...)
+ Expect(gitErr).NotTo(HaveOccurred(), fmt.Sprintf("git %s: %s", strings.Join(args, " "), out))
+ }
+
+ if _, err := gitRun(repo.CheckoutDir, "fetch", "origin", "main"); err == nil {
+ mustGit("checkout", "-B", "main", "origin/main")
+ mustGit("reset", "--hard", "origin/main")
+ } else {
+ mustGit("checkout", "--orphan", "main")
+ _, _ = gitRun(repo.CheckoutDir, "rm", "-rf", ".")
+ }
+
+ dest := filepath.Join(repo.CheckoutDir, gitPath)
+ Expect(os.RemoveAll(dest)).To(Succeed())
+ Expect(copyFixtureDir(renderedFolder, dest)).To(Succeed())
+
+ mustGit("add", gitPath)
+ mustGit("commit", "-m", "e2e: seed manifest folder fixture")
+ mustGit("push", "origin", "HEAD:main")
+}
+
+func copyFixtureDir(src, dst string) error {
+ return filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+ if rel == "." {
+ return os.MkdirAll(dst, 0o750)
+ }
+
+ target := filepath.Join(dst, rel)
+ if d.IsDir() {
+ return os.MkdirAll(target, 0o750)
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
+ return err
+ }
+ return os.WriteFile(target, content, 0o600)
+ })
+}
+
+func readRepoFile(g Gomega, path string) string {
+ GinkgoHelper()
+
+ content, err := os.ReadFile(path)
+ g.Expect(err).NotTo(HaveOccurred(), "expected repo file %s to exist", path)
+ return string(content)
+}
+
+func configureRepoOriginWithCredentials(repo *RepoArtifacts, namespace string) {
+ GinkgoHelper()
+
+ username, password := inplaceReadGitCredentials(namespace, repo.GitSecretHTTP)
+ originOut, err := gitRun(repo.CheckoutDir, "remote", "get-url", "origin")
+ Expect(err).NotTo(HaveOccurred(), "failed to read origin URL")
+ parsed, err := url.Parse(strings.TrimSpace(originOut))
+ Expect(err).NotTo(HaveOccurred(), "failed to parse origin URL")
+ parsed.User = url.UserPassword(username, password)
+
+ out, err := gitRun(repo.CheckoutDir, "remote", "set-url", "origin", parsed.String())
+ Expect(err).NotTo(HaveOccurred(), "failed to configure authenticated origin: %s", out)
+}
+
+// inplaceReadGitCredentials reads username/password from a GitTarget Git Secret.
+func inplaceReadGitCredentials(namespace, secretName string) (string, string) {
+ GinkgoHelper()
+ output, err := kubectlRunInNamespace(namespace, "get", "secret", secretName, "-o", "json")
+ Expect(err).NotTo(HaveOccurred(), "failed to fetch Git Secret")
+
+ var secret struct {
+ Data map[string]string `json:"data"`
+ }
+ Expect(json.Unmarshal([]byte(output), &secret)).To(Succeed())
+
+ username, err := base64.StdEncoding.DecodeString(secret.Data["username"])
+ Expect(err).NotTo(HaveOccurred(), "failed to decode Git username")
+ password, err := base64.StdEncoding.DecodeString(secret.Data["password"])
+ Expect(err).NotTo(HaveOccurred(), "failed to decode Git password")
+
+ return strings.TrimSpace(string(username)), strings.TrimSpace(string(password))
+}
diff --git a/test/e2e/restart_snapshot_e2e_test.go b/test/e2e/restart_snapshot_e2e_test.go
index b34129eb..3fbd677f 100644
--- a/test/e2e/restart_snapshot_e2e_test.go
+++ b/test/e2e/restart_snapshot_e2e_test.go
@@ -47,7 +47,7 @@ import (
// present — which is exactly why the existing e2e suite never caught this.
// Serial: rolls the controller deployment, which disrupts any spec running
// concurrently on another process. See docs/design/e2e-serial-registry.md.
-var _ = Describe("Restart Snapshot Safety", Label("restart-snapshot", "smoke"), Serial, Ordered, func() {
+var _ = Describe("Restart Snapshot Safety", Label("restart-snapshot"), Serial, Ordered, func() {
var (
testNs string
restartRepo *RepoArtifacts
diff --git a/test/e2e/signing_e2e_test.go b/test/e2e/signing_e2e_test.go
index 8827f71b..5d3b2a09 100644
--- a/test/e2e/signing_e2e_test.go
+++ b/test/e2e/signing_e2e_test.go
@@ -85,7 +85,7 @@ var _ = Describe("Commit Signing", Label("signing"), Ordered, func() {
// ── Test 1: generated signing key — local + Gitea verification ──────────
It("should produce per-event commits verifiable locally and by Gitea (generated key)",
- Label("smoke"), func() {
+ func() {
gitea := giteaTestInstance()
providerName := "signing-per-event"
signingSecretName := "signing-key-per-event"
diff --git a/test/e2e/watchrule_configmap_secret_e2e_test.go b/test/e2e/watchrule_configmap_secret_e2e_test.go
index 83d60459..c29f3073 100644
--- a/test/e2e/watchrule_configmap_secret_e2e_test.go
+++ b/test/e2e/watchrule_configmap_secret_e2e_test.go
@@ -78,7 +78,7 @@ var _ = Describe("Manager WatchRule ConfigMap and Secret", Label("manager"), Ord
SetDefaultEventuallyTimeout(30 * time.Second)
SetDefaultEventuallyPollingInterval(time.Second)
- It("should handle a normal and healthy GitProvider", Label("smoke"), func() {
+ It("should handle a normal and healthy GitProvider", func() {
// gitprovider-normal is created and verified in BeforeAll; this spec
// re-asserts it stays Ready without re-creating it.
verifyResourceStatus(
@@ -115,7 +115,7 @@ var _ = Describe("Manager WatchRule ConfigMap and Secret", Label("manager"), Ord
cleanupGitTarget(destName, testNs)
})
- It("should expand wildcard resources across core and custom namespaced APIs", Label("smoke"), func() {
+ It("should expand wildcard resources across core and custom namespaced APIs", func() {
gitProviderName := "gitprovider-normal"
watchRuleName := "watchrule-wildcard-expansion-test"
destName := watchRuleName + "-dest"
@@ -176,7 +176,10 @@ spec:
"jsonpath={.status.conditions[?(@.type=='ResourcesResolved')].message}",
)
g.Expect(getErr).NotTo(HaveOccurred())
- g.Expect(output).To(ContainSubstring("wildcard expanded to"))
+ // Status reports only what the rule watches: a wildcard rule resolves to the
+ // followable types in the cluster (a non-zero count).
+ g.Expect(output).To(ContainSubstring("watching "))
+ g.Expect(output).NotTo(ContainSubstring("watching 0 resource type(s)"))
}, 90*time.Second, 2*time.Second).Should(Succeed())
By("verifying the initial wildcard snapshot committed core and custom resources")
@@ -213,7 +216,7 @@ spec:
cleanupGitTarget(destName, testNs)
})
- It("should commit encrypted Secret manifests when WatchRule includes secrets", Label("smoke"), func() {
+ It("should commit encrypted Secret manifests when WatchRule includes secrets", func() {
gitProviderName := "gitprovider-normal"
watchRuleName := "watchrule-secret-encryption-test"
secretName := "test-secret-encryption"
@@ -457,7 +460,7 @@ spec:
cleanupGitTarget(destName, testNs)
})
- It("should create Git commit when ConfigMap is added via WatchRule", Label("smoke"), func() {
+ It("should create Git commit when ConfigMap is added via WatchRule", func() {
gitProviderName := "gitprovider-normal"
watchRuleName := "watchrule-configmap-test"
configMapName := "test-configmap"
@@ -616,7 +619,7 @@ spec:
// observable in at the user-visible layer so a future revamp of the
// snapshot trigger logic can't silently regress it.
//
- It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", Label("smoke"), func() {
+ It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", func() {
gitProviderName := "gitprovider-normal"
watchRuleName := "watchrule-backfill-test"
configMapName := "preexisting-configmap"
@@ -694,7 +697,7 @@ spec:
configMapName, uniqueRepoName)
})
- It("should delete Git file when ConfigMap is deleted via WatchRule", Label("smoke"), func() {
+ It("should delete Git file when ConfigMap is deleted via WatchRule", func() {
gitProviderName := "gitprovider-normal"
watchRuleName := "watchrule-delete-test"
configMapName := "test-configmap-to-delete"
diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml
index e899a437..aa40f853 100644
--- a/test/playground/config/gittarget.yaml
+++ b/test/playground/config/gittarget.yaml
@@ -8,6 +8,7 @@ spec:
kind: GitProvider
name: playground-provider
branch: main
+ # Keep playground writes under a folder. Use "." only when testing repo-root ownership.
path: live-cluster
encryption:
provider: sops