diff --git a/api/v1alpha3/clusterprovider_types.go b/api/v1alpha3/clusterprovider_types.go
index 1b846bf1..713d0467 100644
--- a/api/v1alpha3/clusterprovider_types.go
+++ b/api/v1alpha3/clusterprovider_types.go
@@ -68,20 +68,10 @@ type ClusterProviderSpec struct {
// +optional
KubeConfig *meta.KubeConfigReference `json:"kubeConfig,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // This is the one namespace policy that survived the source-scope deletion, and it survived
- // because the boundary it draws is available nowhere else. Source-cluster RBAC bounds what a
- // CREDENTIAL may read; it cannot express which control-plane tenant may WIELD that credential,
- // because the tenant is not a subject in the source cluster at all. Deleting it would make a
- // shared source credential usable from any namespace that can create a GitTarget.
- //
- // Its selector is affordable in a way the deleted source-side one was not: it reads
- // CONTROL-cluster Namespace labels, locally, with no cross-cluster call and no degradation
- // path. Both halves stay.
- //
- // The rename is what makes it readable now that the two allowed*Namespaces fields no longer sit
- // side by side to disambiguate each other. See docs/design/source-scope-simplification.md.
+ // The one namespace policy that survived the source-scope deletion: source-cluster RBAC bounds
+ // what a credential may READ, and cannot express which control-plane tenant may WIELD it.
+ // Without this, a shared source credential is usable from any namespace that can create a
+ // GitTarget. The selector reads control-cluster labels locally, so it has no degradation path.
// AccessFrom is the deny-by-default policy for which CONTROL-CLUSTER namespaces may reference
// this provider from a GitTarget. Empty (or omitted) means no namespace may reference it. Its
@@ -90,29 +80,11 @@ type ClusterProviderSpec struct {
// +optional
AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // Remote and in-cluster providers use the same mechanism but deserve very different sign-off.
- // For a REMOTE provider the config-plane namespace and the source namespace are on different
- // clusters, so their sharing a name never was a boundary and naming one widens nothing. For an
- // IN-CLUSTER provider (kubeConfig omitted) the same-name coupling WAS the boundary: setting this
- // deliberately bypasses live namespace RBAC, letting the owner of an admitted GitTarget in one
- // namespace mirror another namespace's objects — read through the operator's own cluster-wide
- // credential — into a Git destination they control. That is legitimate for a cluster-admin to
- // grant on purpose, and must never happen by default or as a side effect of another field,
- // which is why this exists and defaults to false. LOCALITY is not the switch: in-cluster-ness
- // follows from spec.kubeConfig, and neither that nor the provider's name decides this.
- //
- // The name keeps "Source" deliberately. This object carries two namespace planes, and an
- // allowAnyNamespace sitting directly beneath accessFrom would read as a modifier on it.
- // allowCrossNamespace was the other candidate, borrowing Flux's --no-cross-namespace-refs
- // vocabulary, and it was not taken: in Flux the phrase means references across namespaces in
- // ONE cluster, while here the far side is a namespace in a DIFFERENT cluster. "Crossing" is
- // literally true only for the in-cluster provider; "any" is literally true for both.
- //
- // It stays a boolean because there are two states and no third one is in view: impersonation
- // and source-side selectors are both out, so an enum would only leave room for something
- // nobody can name.
+ // The dangerous case is an IN-CLUSTER provider (kubeConfig omitted), where same-name coupling
+ // WAS the boundary: setting this bypasses live namespace RBAC, letting the owner of an admitted
+ // GitTarget mirror another namespace's objects through the operator's own cluster-wide
+ // credential into a destination they control. Legitimate to grant deliberately, never by
+ // default or as a side effect of another field, which is why it defaults to false.
// AllowAnySourceNamespace delegates SOURCE-namespace selection to the GitTargets this provider
// admits. While false (the default) a WatchRule mirroring through this provider may watch only
@@ -254,7 +226,7 @@ func (p *ClusterProvider) IsInCluster() bool {
// cluster already partitions its facts by name, so an unset field resolves exactly what it always
// resolved. Deliberately NOT conditional on locality: defaulting an in-cluster provider to the
// literal "default" would make that name reserved for the local cluster again, a rule this project
-// enforced with CEL and then reversed before shipping (docs/finished/multi-cluster-author-attribution.md).
+// enforced with CEL and then reversed before shipping.
func (p *ClusterProvider) AuditRoute() string {
if p.Spec.Attribution == nil || p.Spec.Attribution.AuditRoute == "" {
return p.Name
diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go
index 15fa821d..71d0435d 100644
--- a/api/v1alpha3/clusterwatchrule_types.go
+++ b/api/v1alpha3/clusterwatchrule_types.go
@@ -111,14 +111,9 @@ type ClusterResourceRule struct {
// +kubebuilder:validation:items:Pattern=`^[^/]*$`
Resources []string `json:"resources"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // The field is retained in the schema purely so that re-applying a manifest that still says
- // "Namespaced" FAILS. Deleting it outright would be worse and silent twice over: CRD pruning
- // happens on write, so the value would be dropped without an error and the rule would quietly
- // stop mirroring namespaced objects; and a stored pre-release object would keep its value in
- // etcd with no Go field left to read, leaving the controller nothing to refuse. The narrowed
- // enum rejects it at admission, and the compile path refuses a stored value.
+ // Retained in the schema purely so re-applying a manifest that still says "Namespaced" FAILS.
+ // Deleting it would be silent twice over: pruning drops the value without an error, and a
+ // stored pre-release object would keep its value with no Go field left to refuse it.
// Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so "Cluster" is the
// only accepted value and also the default, making the field omittable. To watch NAMESPACED
@@ -169,12 +164,9 @@ type ClusterWatchRuleStatus struct {
Streams *WatchRuleStreamsStatus `json:"streams,omitempty"`
}
-// Design rationale, kept out of the generated CRD description by the blank line below.
-//
-// Cluster-scoped objects have no namespace, so no namespace policy is a bound for them: a
-// ClusterWatchRule is intentionally cluster-global and is limited only by its source credential's
-// Kubernetes RBAC. Isolating cluster-scoped objects between tenants therefore takes separate
-// credentials/ClusterProviders.
+// Cluster-scoped objects have no namespace, so no namespace policy bounds them: this is
+// cluster-global, limited only by its source credential's RBAC. Isolating tenants takes separate
+// ClusterProviders.
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
diff --git a/api/v1alpha3/examples_test.go b/api/v1alpha3/examples_test.go
new file mode 100644
index 00000000..db71a451
--- /dev/null
+++ b/api/v1alpha3/examples_test.go
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package v1alpha3
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8syaml "k8s.io/apimachinery/pkg/util/yaml"
+ "sigs.k8s.io/yaml"
+)
+
+// exampleRoots are the folders whose manifests a reader is invited to copy: the samples, the
+// worked examples in the layout corpus, the playground, and the e2e setup fixtures that are checked
+// in as YAML rather than rendered from a template.
+var exampleRoots = []string{
+ "../../config/samples",
+ "../../test/fixtures/layout-corpus",
+ "../../test/playground",
+ "../../test/e2e/setup",
+}
+
+// TestExamplesDecodeStrictly folds every checked-in example of our own kinds through the REAL types
+// with strict decoding, which is the only thing that catches a field the API no longer has.
+//
+// It exists because the fields the breaking wave removed are PRUNED rather than refused: an example
+// still naming one applies cleanly, does nothing, and reports nothing, so a stale example is
+// invisible both to a reader and to a cluster. The layout corpus already decodes the GitTargets it
+// executes, but an example folder with no input/ is executed by nothing, and that is exactly where
+// a `spec.commit.author` survived a rename.
+//
+// Every document is parsed before anything decides whether to skip it. A document this test cannot
+// read, or one in our own group naming a version or kind we do not serve, is a failure rather than
+// something quietly passed over as somebody else's schema: those are the shapes a stale example
+// takes, so skipping them would leave the hole this test was written to close.
+func TestExamplesDecodeStrictly(t *testing.T) {
+ decoded := 0
+ for _, root := range exampleRoots {
+ require.NoError(t, filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return err
+ }
+ if ext := filepath.Ext(path); ext != ".yaml" && ext != ".yml" {
+ return nil
+ }
+ decoded += decodeExampleFile(t, path)
+ return nil
+ }))
+ }
+ require.NotZero(t, decoded, "the example roots moved: this test decoded nothing")
+}
+
+// decodeExampleFile strict-decodes every document of our own group in one file and returns how many
+// it decoded. It reads documents through a YAML reader rather than splitting on a "---" line, which
+// is not the separator YAML actually defines: a document can open with one, and the sequence can
+// appear inside a block scalar.
+func decodeExampleFile(t *testing.T, path string) int {
+ t.Helper()
+
+ raw, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ reader := k8syaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(raw)))
+ decoded := 0
+ for i := 0; ; i++ {
+ doc, err := reader.Read()
+ if errors.Is(err, io.EOF) {
+ return decoded
+ }
+ require.NoError(t, err, "%s: document %d could not be read", path, i)
+ if len(bytes.TrimSpace(doc)) == 0 {
+ continue
+ }
+
+ var parsed any
+ require.NoError(t, yaml.Unmarshal(doc, &parsed),
+ "%s: document %d is not parseable YAML", path, i)
+
+ // A document that is valid YAML but not a mapping (a bare list, a scalar) carries no
+ // apiVersion and cannot be one of ours.
+ fields, isMapping := parsed.(map[string]any)
+ if !isMapping {
+ continue
+ }
+ apiVersion, _ := fields["apiVersion"].(string)
+ gv, err := schema.ParseGroupVersion(apiVersion)
+ if err != nil || gv.Group != GroupVersion.Group {
+ // Somebody else's schema, including the neighbouring examples.configbutler.ai and
+ // manifestanalyzer.configbutler.ai groups, which are not this API.
+ continue
+ }
+
+ require.Equal(t, GroupVersion.Version, gv.Version,
+ "%s: document %d is %q; this group serves only %s, and an example on a version the "+
+ "operator no longer installs cannot be applied", path, i, apiVersion, GroupVersion)
+
+ kind, _ := fields["kind"].(string)
+ obj := newExampleObject(kind)
+ require.NotNil(t, obj, "%s: document %d declares kind %q, which %s does not serve",
+ path, i, kind, GroupVersion)
+
+ require.NoError(t, yaml.UnmarshalStrict(doc, obj),
+ "%s: document %d names a field this API does not have; applied to a cluster it would "+
+ "be pruned in silence", path, i)
+ decoded++
+ }
+}
+
+// newExampleObject returns an empty object of the named kind, or nil when this group does not serve
+// it. Every root kind this API registers belongs here: a kind missing from the switch would make a
+// valid example fail rather than be checked, which is why the list is asserted against the scheme
+// by TestExampleKindsCoverTheScheme.
+func newExampleObject(kind string) any {
+ switch kind {
+ case "GitProvider":
+ return &GitProvider{}
+ case "GitTarget":
+ return &GitTarget{}
+ case "ClusterProvider":
+ return &ClusterProvider{}
+ case "WatchRule":
+ return &WatchRule{}
+ case "ClusterWatchRule":
+ return &ClusterWatchRule{}
+ case "CommitRequest":
+ return &CommitRequest{}
+ }
+ return nil
+}
+
+// TestExampleKindsCoverTheScheme pins newExampleObject to the scheme, so a kind added to this API
+// group cannot quietly fall outside the example guard. Without it, adding a CRD and an example for
+// it in the same change would leave that example unchecked: newExampleObject returns nil, and the
+// document reads as somebody else's schema.
+//
+// List kinds are excluded. A manifest is a single object, never a List, so an example carrying one
+// is not a case the guard has to decode.
+func TestExampleKindsCoverTheScheme(t *testing.T) {
+ s := runtime.NewScheme()
+ require.NoError(t, AddToScheme(s))
+
+ ourPackage := reflect.TypeOf(GitTarget{}).PkgPath()
+ for gvk, goType := range s.AllKnownTypes() {
+ if gvk.GroupVersion() != GroupVersion || strings.HasSuffix(gvk.Kind, "List") {
+ continue
+ }
+ // Every scheme carries meta kinds (GetOptions, WatchEvent, ...) under each registered
+ // group version. They are apimachinery's types, not ours, and no example declares one, so
+ // identify them by the package they come from rather than by keeping a list of names.
+ if goType.PkgPath() != ourPackage {
+ continue
+ }
+ require.NotNil(t, newExampleObject(gvk.Kind),
+ "%s is registered in the scheme but newExampleObject does not build one, so every "+
+ "example of it is skipped by TestExamplesDecodeStrictly", gvk.Kind)
+ }
+}
diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go
index 6ea6aabf..8ae6cbbc 100644
--- a/api/v1alpha3/gittarget_types.go
+++ b/api/v1alpha3/gittarget_types.go
@@ -87,26 +87,10 @@ type GitTargetSpec struct {
// +optional
Placement *GitTargetPlacementSpec `json:"placement,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // It sits at the TOP LEVEL rather than inside placement, and the line between the two is
- // retroactivity. spec.placement decides where a NEW document goes and never moves one already
- // written; this governs the bytes of EVERY write, and it also decides how a managed document
- // is FOUND — a document whose namespace is inherited is located in the file bytes by a
- // namespace-less identity. A field with that blast radius nested inside a struct documented as
- // "new files only" would be a trap.
- //
- // It is a *bool because no plain default preserves today's behavior: false breaks a flat
- // folder, whose documents must carry their own namespace or they are ambiguous, and true
- // writes a redundant line into every kustomize folder that already supplies one. nil means
- // infer, which is what the operator has always done.
- //
- // The name deliberately avoids writeNamespace. "Write" is the most loaded word in this API —
- // the write boundary, the write jail, WriteBoundaryRefused — so writeNamespace: false invites
- // the reading "never write to this namespace", a permission, which is precisely what the
- // neighbouring sourceNamespace fields are.
- //
- // See docs/layout/model.md § "serializeNamespace".
+ // Top level rather than inside placement because it is retroactive: placement decides where a
+ // NEW document goes, this governs the bytes of every write and how a managed document is found.
+ // *bool because neither default is safe — false breaks a flat folder, true writes a redundant
+ // line into a kustomize folder that already supplies one.
// SerializeNamespace declares whether a committed document carries its own
// metadata.namespace. It governs every write this target makes, not just the first one, and it
@@ -133,12 +117,9 @@ type GitTargetSpec struct {
// +optional
SerializeNamespace *bool `json:"serializeNamespace,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // It defaults to a concrete {name: "default"} rather than an implicit nil so a target that omits
- // it persists with a ref a reader can jump to. The operator never creates that provider: a
- // GitTarget naming one that does not exist is held unready rather than silently defaulting to
- // in-cluster access.
+ // Defaults to a concrete {name: "default"}, not nil, so the persisted object names something a
+ // reader can jump to. The operator never creates that provider; a missing one holds the target
+ // unready rather than falling back to in-cluster access.
// ClusterProviderRef names the SOURCE cluster this GitTarget mirrors FROM, by referencing a
// cluster-scoped ClusterProvider by name. That ClusterProvider owns the cluster's connectivity
@@ -149,12 +130,8 @@ type GitTargetSpec struct {
// +optional
ClusterProviderRef *ClusterProviderReference `json:"clusterProviderRef,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // Deliberately MUTABLE, unlike the destination fields above. The whole point of the safe
- // default is that a target keeps its documents while a scope mistake is diagnosed; turning
- // convergence back on afterwards must not require deleting and recreating the GitTarget, which
- // would be the one operation guaranteed to lose the folder's history.
+ // Mutable, unlike the destination fields above: recovering from a scope mistake must not
+ // require recreating the GitTarget, which is the one operation that loses the folder's history.
// Prune controls which deletion paths may remove documents from this target's folder: an
// explicit source DELETE event, and the resync mark-and-sweep that infers a deletion from a
@@ -163,24 +140,8 @@ type GitTargetSpec struct {
// +optional
Prune *PrunePolicy `json:"prune,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // These fields lived on GitProvider until this release, as spec.push.commitWindow and
- // spec.commit.message. GitProvider is the CONNECTION — a URL, a credential, the branches it
- // will accept — and how a folder's writes are batched and phrased is a property of the folder,
- // not of the route to the repository. Two GitTargets sharing one GitProvider had no way to
- // disagree about either, which is the concrete cost of the old placement.
- //
- // They are grouped under spec.commit rather than landing as two top-level fields. The move is
- // breaking either way, so the grouping is free HERE and would cost a bump in any later
- // release; and spec.commit is the shape these fields already had on GitProvider, so nothing
- // about them has to be relearned. See docs/design/gittarget-api-wave.md § "Where the fields
- // live".
- //
- // What did NOT move: commit.committer and commit.signing stay on GitProvider. Both are
- // properties of the identity that talks to the remote — the signing key is a Secret in the
- // provider's namespace, and the committer is the bot the platform sees — so they belong to the
- // connection in a way the window and the message do not.
+ // Batching and phrasing describe the folder, so they live here; committer and signing describe
+ // the identity talking to the remote and stay on GitProvider. Migration: docs/UPGRADING.md.
// Commit configures how this target's writes are batched into commits, and how those commits
// are phrased. Omitted, writes coalesce over a 5s rolling silence window and use the built-in
@@ -188,28 +149,9 @@ type GitTargetSpec struct {
// +optional
Commit *GitTargetCommitSpec `json:"commit,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // Suspend is a PANIC KNOB: one field that stops this target writing, reachable without
- // deleting anything and without unpicking the watch configuration that would have to be
- // rebuilt afterwards. That is the whole justification, and it is enough on its own.
- //
- // It is deliberately NOT a preview mechanism. A target that writes nothing has nothing to
- // show, and the honest way to see what a target would do is to point one at a scratch branch
- // and read the commits — real bytes, real registrations, real deletes, diffable. The
- // manifest-analyzer CLI is the other half of that answer. Neither is something status should
- // grow a second, worse copy of; see docs/layout/model.md § "Previewing a target: point it at a
- // scratch branch".
- //
- // The scan is deliberately NOT suspended with the write, and the reason is incident response
- // rather than preview: a stopped valve that also stopped looking would freeze status.placement
- // at whatever the folder looked like the moment someone panicked, which is exactly when a
- // stale answer costs the most.
- //
- // Deliberately MUTABLE and deliberately not a fault: a suspended target reports Ready=True with
- // reason Suspended, because not writing is the configured outcome. The precedent is
- // status.retention — a condition asserts health, and suppressing a write on request is not ill
- // health.
+ // The scan keeps running while writes are suspended: freezing status.placement too would leave
+ // a stale answer at the moment it costs most. A suspended target is Ready=True reason
+ // Suspended, because not writing is the configured outcome rather than ill health.
// Suspend stops this target from writing to Git, without deleting it. It is the knob to turn
// when something is wrong and the writes have to stop now: watches keep running, events keep
@@ -233,21 +175,10 @@ type GitTargetSpec struct {
Suspend bool `json:"suspend,omitempty"`
}
-// Design rationale, kept out of the generated CRD description by the blank line below.
-//
-// Message reuses CommitMessageSpec verbatim rather than collapsing its three templates into one.
-// The three render three genuinely different things with three different variable sets — a single
-// event, a reconcile of one type, and a grouped commit-window batch — so one template could only
-// have been a fourth thing, and inventing it is a redesign this move deliberately is not.
-
// GitTargetCommitSpec configures how a GitTarget's writes become commits.
type GitTargetCommitSpec struct {
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // It stays a string rather than becoming metav1.Duration because that is what it was on
- // GitProvider, and re-typing a field in the same release that relocates it would make a
- // mechanical migration a rewrite. Parsing stays at the write path, where an unparseable value
- // falls back to the default loudly rather than blocking admission of the whole target.
+ // A string, not metav1.Duration: parsing happens at the write path, where an unparseable value
+ // falls back to the default loudly instead of blocking admission of the whole target.
// Window is the rolling silence window used to coalesce this target's events into a single
// commit per author. The timer resets on every event arrival, and the commit is made after
@@ -300,24 +231,10 @@ type GitTargetPlacementSpec struct {
// +optional
Default string `json:"default,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // It has exactly ONE job, and the name says less than the field does. Registering a new file
- // with the kustomization that already governs its directory is an INVARIANT rather than a
- // setting (#295, fixed by #319): a file no kustomization lists is a file nothing renders, so
- // that happens in both columns. What this flag decides is only what to do when there is no
- // root at all.
- //
- // So useKustomize: false does not mean "leave kustomize alone". If a folder's root must not be
- // touched, do not point a GitTarget at that folder: the ancestor walk is bounded by the write
- // jail, so a kustomization ABOVE spec.path is never edited, and rooting the target lower is
- // the existing, better-tested way to say it.
- //
- // It belongs inside placement, unlike spec.serializeNamespace, because it is retroactive in
- // the same way the rest of this struct is: it decides whether a NEW file's directory has a
- // root to join, and creates one if not. Nothing already written moves or changes.
- //
- // See docs/layout/model.md § "useKustomize".
+ // The trap: false does NOT mean "leave kustomize alone". Registering a new file with the
+ // kustomization already governing its directory is an invariant, not a setting, so it happens
+ // either way; this flag only decides what to do when there is no root at all. To leave a
+ // folder's root untouched, root the target lower — the ancestor walk stops at the write jail.
// UseKustomize declares that this folder is a kustomize folder whose root the operator
// maintains. It controls one thing: what happens when NO kustomization governs the path a new
@@ -370,13 +287,8 @@ type GitTargetStatus struct {
// +optional
Streams *GitTargetStreamsStatus `json:"streams,omitempty"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // An observation, not a condition. A sweep suppressed by spec.prune.mode is the configured
- // outcome and a healthy reconciliation, so no condition may go False for it — doing so would
- // train operators to ignore the conditions that mean the mirror is genuinely broken. The
- // distinction this field rests on: a condition asserts health, an observation reports a fact.
- // status.streams is the precedent for the second kind.
+ // An observation, not a condition: a sweep suppressed by spec.prune.mode is the configured
+ // outcome, and a condition going False for it would train operators to ignore the real ones.
// Retention reports documents a resync kept because this target's spec.prune.mode suppressed
// the mark-and-sweep. It covers the INFERRED deletion path only: under `never`, a suppressed
@@ -394,33 +306,10 @@ type GitTargetStatus struct {
Placement *GitTargetPlacementStatus `json:"placement,omitempty"`
}
-// Design rationale, kept out of the generated CRD description by the blank line below.
-//
-// This stanza answers ONE question: why did a write take the shape it did, or why was it refused.
-// It is deliberately not a preview of what the target would do, and three fields were removed for
-// trying to be one — examples (a fabricated object at a fabricated path), byTypeEntries (a count
-// of a spec map the same GET already returns), and serializeNamespace (a copy of a spec field).
-//
-// The rule that kept them out is worth stating, because it is what to hold new fields against: a
-// field earns its place only if a reader cannot get it from the spec, AND it varies with this
-// folder. The write behaviours that follow from Mode — registration into resources:, the
-// deregistration a delete performs, the $patch: delete an inherited object needs — are constants
-// of the mode rather than facts about the folder, so they are documented on Mode and not
-// enumerated here.
-//
-// To PREVIEW what a target would do, point one at a scratch branch and read the commits it makes.
-// That is complete, reviewable, and real, where any status stanza is a summary; see
-// docs/layout/model.md § "Previewing a target: point it at a scratch branch".
-//
-// The resolution REASON is a condition reason (LayoutResolved), not a field, because every
-// consumer in this ecosystem already reads reasons from conditions.
-//
-// There are NO counters. placedResources, overriddenTypes and refusedResources are metrics, and
-// placements_total carries them with better labels; a counter in status is a status write per
-// event, which re-creates the self-triggering reconcile edge the status work already fixed once.
-//
-// Nothing here may depend on a placement having HAPPENED. Every field is a fact about the folder
-// from the last scan, so the whole stanza is available before the target has ever written a byte.
+// Two rules for anything added here. A field earns its place only if a reader cannot get it from
+// the spec AND it varies with this folder. And nothing may depend on a placement having HAPPENED:
+// every field is a fact from the last scan, available before the target has written a byte.
+// No counters — those are metrics, and a counter in status is a status write per event.
// GitTargetPlacementStatus is what the last scan resolved about a GitTarget folder's layout.
type GitTargetPlacementStatus struct {
@@ -517,17 +406,9 @@ type GitTargetStreamsStatus struct {
Blocked int32 `json:"blocked"`
}
-// Design rationale, kept out of the generated CRD description by the blank line below.
-//
-// Counts, never a per-document list, for the same reason GitTargetStreamsStatus is counts: the
-// field must stay bounded however many documents are retained. An operator who needs to know WHICH
-// documents reads the retention log line or the folder; status answers "how many, under what
-// policy, as of when".
-//
-// The projection is pull-based — the GitTarget controller reads it from the watch manager on each
-// reconcile — so it is only as fresh as the last reconcile, exactly like GitPathAccepted. That is
-// acceptable for an observation and would not be for a gate, which is a further reason this must
-// not become a condition.
+// Counts, never a per-document list, so the field stays bounded however many documents are
+// retained. Pull-based: only as fresh as the last reconcile, which is fine for an observation and
+// is another reason this must not become a condition.
// GitTargetRetentionStatus is a bounded roll-up of what this GitTarget's prune policy kept.
type GitTargetRetentionStatus struct {
@@ -550,8 +431,7 @@ type GitTargetRetentionStatus struct {
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
-// Seven default-priority columns wrapped `kubectl get gittargets` on any normal terminal.
-// Flux ships three or four; the identity fields stay one `-o wide` away.
+// Three default columns; seven wrapped `kubectl get gittargets` on a normal terminal.
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// +kubebuilder:printcolumn:name="Streams",type=string,JSONPath=`.status.streams.summary`
diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go
index 77984024..da4322ce 100644
--- a/api/v1alpha3/namespace_matcher.go
+++ b/api/v1alpha3/namespace_matcher.go
@@ -23,12 +23,9 @@ import (
// Matches therefore still takes the labels rather than fetching them, which is now merely
// convenient rather than load-bearing.
type NamespaceMatcher struct {
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // `*` is rejected for a reason that is not cosmetic: Kubernetes treats it as a LITERAL namespace
- // name — a list or watch against `namespaces/*` matches nothing — so `names: ["*"]` would name a
- // namespace that cannot exist, and the policy would admit nothing while reading as if it
- // admitted everything. `selector: {}` is the "every namespace" form because it resolves live.
+ // `*` is rejected because Kubernetes treats it as a LITERAL name: `names: ["*"]` would admit
+ // nothing while reading as if it admitted everything. `selector: {}` is the "every namespace"
+ // form, because it resolves live.
// Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123
// labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`.
diff --git a/api/v1alpha3/prune_policy.go b/api/v1alpha3/prune_policy.go
index a8b4588d..eee26903 100644
--- a/api/v1alpha3/prune_policy.go
+++ b/api/v1alpha3/prune_policy.go
@@ -23,22 +23,14 @@ const (
PruneAlways PruneMode = "Always"
)
-// Design rationale, kept out of the generated CRD description by the blank line below.
-//
-// An object rather than a bare enum field on GitTargetSpec, so a later volume guard (for example
-// maxDeletesPerCommit) can be added as a sibling field. Shipping the enum as a scalar would force
-// a scalar-to-object change later, which is breaking; shipping the object costs one nesting level
-// now and nothing afterwards.
+// An object rather than a bare enum so a later volume guard (maxDeletesPerCommit) can be a sibling
+// field; scalar-to-object would be breaking later, the nesting level costs nothing now.
// PrunePolicy declares which deletion paths may remove documents from a GitTarget's folder.
type PrunePolicy struct {
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // The kubebuilder default writes OnEvent into a NEWLY created object, which is useful but is
- // deliberately NOT the compatibility mechanism: a GitTarget stored before this field existed
- // carries no value at all, and Kubernetes does not retro-default stored objects. Every reader
- // must therefore go through EffectivePruneMode, which maps both an absent policy and an empty
- // mode to OnEvent — so an old GitTarget becomes safe without first being edited.
+ // The kubebuilder default only applies to NEWLY created objects; Kubernetes does not
+ // retro-default stored ones. Every reader must go through EffectivePruneMode, which maps an
+ // absent policy and an empty mode to OnEvent.
// Mode selects which deletion paths are enabled. `Never` removes nothing; `OnEvent` mirrors an
// observed source DELETE but never infers a deletion from a resync snapshot; `Always` enables
diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go
index d7ee7aeb..0bad55da 100644
--- a/api/v1alpha3/watchrule_types.go
+++ b/api/v1alpha3/watchrule_types.go
@@ -120,27 +120,14 @@ type ResourceRule struct {
// +kubebuilder:validation:items:Pattern=`^[^/]*$`
Resources []string `json:"resources"`
- // Design rationale, kept out of the generated CRD description by the blank line below.
- //
- // Every item's outcome is aggregated into the ONE SourceNamespaceAuthorized condition, so
- // automation has a single condition to inspect. A denied explicit name refuses the whole
- // WatchRule rather than silently trimming that item: mirroring two of the three namespaces a
- // rule asked for is worse than a loud failure.
- //
- // "*" used to mean "every namespace the GitTarget's allowedSourceNamespaces admits", resolved
- // live into a concrete set and planned as one stream PER NAMESPACE. It was therefore defined in
- // terms of a field that no longer exists, and RBAC cannot supply the missing definition: it
- // answers "may I watch X in namespace Y", never "which namespaces may I watch". Any set-valued
- // reading needs a Namespace LIST in the source cluster, which is exactly the read the deletion
- // removed. So "*" is now one cluster-wide list and one cluster-wide watch, all or nothing,
- // which is what a Kubernetes reader expects it to mean and whose failure is a clean 403 rather
- // than a silent empty set.
+ // A denied explicit name refuses the WHOLE WatchRule rather than trimming that item: mirroring
+ // two of the three namespaces a rule asked for is worse than a loud failure.
//
// A cluster-wide cell is a PEER of a named-namespace cell on the same type, never a
// replacement: each rule carries its own operations filter, and collapsing the two once widened
- // a named rule's stream to every namespace its credential could read while discarding that
- // filter (see CellKey in internal/types/cell.go). A target carrying both "*" and a named rule
- // for one type therefore runs two streams over overlapping objects, and that is correct.
+ // a named rule's stream while discarding that filter (see CellKey in internal/types/cell.go).
+ // A target carrying both therefore runs two streams over overlapping objects, and that is
+ // correct.
// SourceNamespace is the namespace this item watches IN THE SOURCE CLUSTER its GitTarget
// mirrors from: omitted for this WatchRule's own namespace, an exact name for one other, or
@@ -250,13 +237,8 @@ type WatchRuleStreamsStatus struct {
PendingSample []string `json:"pendingSample,omitempty"`
}
-// Design rationale, kept out of the generated CRD description by the blank line below.
-//
-// The source-namespace gate is deny-by-default and re-evaluated on EVERY reconcile, which is what
-// makes a policy tightened after a rule was accepted revoke that rule rather than grandfather it.
-// Where the source is the operator's OWN cluster, an authorized override deliberately bypasses live
-// namespace RBAC — the operator reads through its own cluster-wide credential — which is why it
-// takes an explicit platform-admin delegation on the ClusterProvider to enable at all.
+// Deny-by-default and re-evaluated on EVERY reconcile, so a policy tightened after a rule was
+// accepted revokes it rather than grandfathering it.
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 4eebdc53..7930a22e 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -106,18 +106,20 @@ says what we support and refuse** — and then its
kustomize field taxonomy, the write boundary, the orchestrator/expansion line, and
how secrets are handled.
-Eighteen other open items:
+Each page opens with a **label** saying where it stands: **design** (still being
+decided), **design, decided** (decision made, not built), **partly built**, **built**, or
+**deferred** (parked, kept as a decision record). The label is the first thing in the page,
+so you never have to read a proposal to find out it already shipped.
+
+### Open — 15 pages
| Doc | Open question |
|---|---|
| [`open-asks-priority.md`](design/open-asks-priority.md) | **the work queue.** Merges three overlapping backlogs — the gitops-api consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal (B1–B6) — into one ordered queue under four stated tests, and says where we deliberately do **not** do what was asked. The standing caveat narrowed once the layout model reversed: a Tier 2 entry belongs to postponed [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) only if it breaks a `GitTarget` field, and everything else is independently schedulable. Makes one design call against what was asked: **delete Option C sibling inference** rather than ship an off-switch for it, because it let a human's edit to the repository change operator behavior with nothing in status recording the move. That deletion has shipped, and "what the deletion taught" records what building it found. **F9 is Tier 1**: the only item whose answer is unknown rather than whose work is unscheduled, and it gates planning the enum work |
| [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design.** The three questions the inference deletion left, **decided and then not built**: PR #291 shipped the deletion and none of the eight items queued behind it. The residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296). Its Question 2 is superseded outright by [`layout/model.md`](layout/model.md). What still stands: keep `canonical` as the name for the built-in path and split `declared` into `byType`/`default`; **no CRD default for `placement.default`**, on the structural argument that a defaulted default is never empty and so shadows the kustomize-root rung; `status.layout` instead, over the `MarkTargetRetention` seam that already enqueues on change; and `{kindLower}`, not a `toLower` function |
| [`created-root-namespace.md`](design/created-root-namespace.md) | **design, decided.** One question with five answers: what namespace a `kustomization.yaml` the operator CREATES should carry. Decided **B, never write one** — `spec.serializeNamespace: false` means the artifact does not encode its deployment namespace, and adding a root must not quietly change that contract; the namespace comes from the documents when the field is unset or `true`, and from the installer (Flux `targetNamespace`, Argo `destination.namespace`) when it is `false`. Records the three facts an earlier draft got wrong (a namespace-less root is ordinary, both installers supply one, and what refused it was our own fidelity gate rather than kustomize), and carries the scoped fidelity rule that follows: the namespace is ignored in the render comparison ONLY when the governing root sets none, so a root that declares `namespace: shop` still rejects a live `billing` object. Also the sibling call: under `useKustomize` a placement no `resources:` list would name is refused rather than committed unrendered |
-| [`build-order.md`](design/build-order.md) | **design**, and the one page that is only about sequencing. Five in-flight changes resolve to **three tracks that do not block each other** — additive placement, the breaking source-scope wave, and patch authoring — with the two real couplings named (`*` is defined in terms of the field the wave deletes; `useKustomize`'s created root depends on the one-source-namespace rule) and three couplings people keep assuming that do not exist. Holds no design: every item is specified elsewhere and the specification wins |
| [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **design**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). What is left of one breaking wave on `GitTarget` after the layout model reversed and left it: B4's `commitWindow`/`commit.message` move off the connection, the source-scope deletion (the only member that makes the API smaller), and the riders. Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that becomes a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers. Records that F9's envtest stays OUTSIDE the wave and gates it, and that staying `v1alpha3` on loud rejections is a **one-consumer countdown**, not a constant |
-| [`target-watch-plan.md`](design/target-watch-plan.md) | **built.** The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. |
-| [`watch-manager-ownership.md`](design/watch-manager-ownership.md) | **built.** A rule edit used to be applied inline by the controller worker that observed it, and it re-planned EVERY GitTarget rather than the one the rule names: 1256 plan reconciles across 28 targets in one e2e run, peaking at 78 in a second, behind two network calls, on a shared worker pool. The watch manager had no owner, so eleven mutexes stood in for one. Now controllers post a trigger naming a GitTarget and return, one loop owns the plan and paces itself, and repeated triggers for one target collapse into a single pass. The debounce is framed around how the config is actually edited: a GitTarget and its rules are one piece of configuration applied together, so a per-target ROLLING SILENCE window of 2s (max wait ~10s) turns a five-object `kubectl apply` into one pass, the same mechanism `DefaultCommitWindow` already uses one layer down on the write path. That reverses an earlier revision's "never debounce the first declaration": declaring a GitTarget the instant it lands means declaring it with no rules yet, which manufactures a transient EMPTY plan on every cold start, and an empty plan is what vacuously cleared a write divergence in the fidelity gate. States the contract as "one settled configuration adjustment, not one function invocation": the window is a heuristic and never a correctness boundary (Kubernetes has no apply-complete event), a per-target DIRTY SEQUENCE means a change arriving mid-pass is never lost, and the pass reads a coherent rule-store snapshot rather than the rule that triggered it. Carries the deletion inventory, because the point is that the system got smaller: four trigger mechanisms collapsed to one (`signalCatalogRefresh` and `catalogRefreshCh` are gone), `refreshRunningTargetWatches` and its running-set filter are gone (that filter is why a target whose first declare never completed was never picked up again), and six mutexes went for stated reasons, with `RenderFidelityGate.mu` and the two event-channel locks kept and justified. The four steps shipped: the local-cluster discovery call is bounded (a real defect — the legacy non-context `ServerGroupsAndResources()` ran with no deadline at all), the owner loop carries the debounce, dirty sequence, per-target deadline and 2s/5s/10s/30s/1m backoff, the plan now carries NO lock while the projection is a published snapshot, and catalog invalidation is scoped by diffing each target's rendered plan across the re-projection. "What shipped" records where the implementation departed from the page, including the one bug only e2e caught: streams were parented to the PASS context, which a deadline cancels the moment the pass returns, so every stream died the instant its plan was applied — and it reads like health, because the plan logs `start:1`, every later pass reports `keep:1` and never restarts it, and nothing logs an error while readiness sits at `Replaying` and every WatchRule sits `Ready=False`. A stream's parent is the manager's lifetime; the pass deadline bounds the pass. A second e2e catch is a BEHAVIORAL consequence worth knowing: toggling a rule off and on inside the settle window is no longer a replay — it used to tear the stream down and re-establish it because each apply replanned synchronously, and it is now one pass over a plan that never changed. Correct (a net-zero change is no change; widening `prune.mode` remains the supported force) but a real difference in what an operator gesture does. Both specs that broke were also gating on the wrong thing: asking a GitTarget "are all your streams running" about a change to ONE rule, which a target that is already mirroring answers True to before that rule has been planned, and which a different controller publishes than the one that compiled the rule — so the rule's OWN StreamsRunning is the gate, and `waitForWatchRuleStreamsRunning` existed unused for exactly this. Departures: reports became a published snapshot rather than a second channel; ISOLATION came from taking the I/O off the loop rather than from the deadline (a pass never dials, the shared refresh runs on its own goroutine, and the deadline is the backstop it should have been — a first cut that kept two network calls on the loop had one unreachable cluster holding every healthy target, which is the same availability failure relocated); DELETION names an incarnation resolved when it is queued, because both production callers react to a NotFound and carry no UID, so a UID-less delete matched everything and could tear down the successor of a same-name recreate; and persistent failure surfaces as `WatchPlanFailing`, where pending means "no pass has ever landed", not "dirty right now". Step 3's type-to-target index did not earn its staleness. Still open: whether the settle window ever needs to be configurable |
-| [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope |
+| [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. |
| [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite |
| [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** |
| [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | the canonical metrics plan, reconciled to the code after the fact-stream switchover and now carrying the attribution surface that shipped (documented in [`spec/attribution.md`](spec/attribution.md)). Reads the product as one pipeline — watch events arrive, and are processed into commits — and maps a metric to each stage. The attribution join is built and correctly labelled; **watch ingestion, shard queue delay, and the relevance filter are still dark**. **Phase 1 — the attribution relabel plus the loss-path counters — has shipped**; Phase 2 is the watch stage, Phase 3 the filter and push health, Phase 4 the dashboard and alerts. Open: Phases 2-4, and the dashboard JSON is deliberately not written until the watch families exist |
@@ -128,8 +130,19 @@ Eighteen other open items:
| [`e2e-finish-plan.md`](design/e2e-finish-plan.md) | remaining e2e harness work |
| [`sensitive-resource-diagnostics-follow-up.md`](design/sensitive-resource-diagnostics-follow-up.md) | deferred diagnostics |
| [`e2e-git-server-choice.md`](design/e2e-git-server-choice.md) | stay on Gitea or move to Forgejo — the `_csrf` pin is fixable in place on both, so the migration is now a preference call, not a fix; also why we adopt no SDK either way |
-| [`azure-devops-multi-ack.md`](design/azure-devops-multi-ack.md) | **decided and built: go-git v6** — why Azure DevOps rejects our fetches, and what to do instead of PR [#292](https://github.com/ConfigButler/gitops-reverser/pull/292)'s bundled `git` binary. The capability filter fails in two independent halves: advertising `multi_ack` is a four-line change, but v5 then cannot parse the multi-ACK **response**, which only a fetch with `have` lines provokes. That is why **Flux ships ADO support on v5 with no git binary — it never fetches**, only `CloneContext`, so it never enters the path v5 cannot serve; our persistent-clone-plus-incremental-fetch design is the opposite, which makes the trim alone insufficient for us. **go-git v6 already implements `multi_ack`** (PR #1204, in every v6 tag; upstream then deleted their ADO example saying it "works out of the box"), and its churn in the packages we import runs 96 → 39 → **1** → **9** removals per alpha, so it is one settled breaking wave rather than a moving target; the migration is four known API removals over two rewritten files, `transport.AuthMethod` being the invasive one. Prices PR #292 as measured rather than argued: the image goes **217 MB → 940 MB**, of which 723 MB is a `cp -rL` that dereferences 165 hardlinks to one binary (a one-character fix), arm64 is unaffected and native, but **Trivy reports zero findings on both images** while the new one carries git 2.54.0, OpenSSH 10.3p1 and OpenSSL 3.5.7 as loose files no package database describes — so the CRITICAL gate is blind to a third of the runtime. Also catches an unflagged non-ADO regression (`Depth: 1` dropped, so every provider full-fetches) and 10% patch coverage on an untestable path. The unlock is that **canonical `git upload-pack` advertises `multi_ack`** (verified), so the Gitea already in the e2e lab plus a 400-injecting proxy is a faithful ADO simulator — no tenant needed, and the only way any option becomes CI-testable. Four options priced, and Option A (v6) is the one shipped. Carries a measured **capability matrix** over our three network calls with two diagrams, which narrows the blast radius to **one call, `repo.Fetch`**: `receive-pack` never advertises `multi_ack` (measured), so **the atomic push is out of scope for every option** — its safety rests on the same-session advertisement plus the server-side `Old`/`New` compare-and-swap in `packp.Command`, neither of which touches `upload-pack`, and we already push from a shallow store today. v6 keeps that pattern 1:1 (`Handshake` → `GetRemoteRefs`/`Push`, same `[]*packp.Command`), which is an argument *for* migrating. Records what the migration actually cost, including the four v6 behaviour changes it surfaced — two of them settings v6 reads from the environment and fails closed on, invisible to unit tests |
-| [`source-scope-simplification.md`](design/source-scope-simplification.md) | **SHIPPED**, except the additive `SelfSubjectAccessReview` pass it explicitly leaves for later. Declines Flux-style impersonation, deletes `GitTarget.spec.allowedSourceNamespaces` and its selector machinery (**4,569 lines**, and the only cross-cluster read in the authorization path), renames two `ClusterProvider` fields, and redefines `sourceNamespace: "*"` as one cluster-wide list and watch. The argument is an API reading, not a security one: the chain from a Git folder back to the object that fills it never leaves one namespace, so ordinary RBAC on `watchrules` already answers it. **Keeps `allowedNamespaces`** (renamed `accessFrom`), reversing an earlier draft — source RBAC bounds what a credential may READ, never which tenant may WIELD it. Prices what is lost: source-side label selectors, which have no replacement. Archaeology in [`facts/kubernetes-impersonation-and-flux-identity.md`](facts/kubernetes-impersonation-and-flux-identity.md) |
+
+### Built, and kept here anyway — 4 pages
+
+These have shipped. They stay in `design/` under the exception above, because Go source
+cites them by path as the rationale for what the code does, and `finished/` declares
+itself non-binding. Read them as history that the code still points at.
+
+| Doc | Open question |
+|---|---|
+| [`watch-manager-ownership.md`](design/watch-manager-ownership.md) | A rule edit used to be applied inline by the controller worker that observed it, and it re-planned EVERY GitTarget rather than the one the rule names: 1256 plan reconciles across 28 targets in one e2e run, peaking at 78 in a second, behind two network calls, on a shared worker pool. The watch manager had no owner, so eleven mutexes stood in for one. Now controllers post a trigger naming a GitTarget and return, one loop owns the plan and paces itself, and repeated triggers for one target collapse into a single pass. The debounce is framed around how the config is actually edited: a GitTarget and its rules are one piece of configuration applied together, so a per-target ROLLING SILENCE window of 2s (max wait ~10s) turns a five-object `kubectl apply` into one pass, the same mechanism `DefaultCommitWindow` already uses one layer down on the write path. That reverses an earlier revision's "never debounce the first declaration": declaring a GitTarget the instant it lands means declaring it with no rules yet, which manufactures a transient EMPTY plan on every cold start, and an empty plan is what vacuously cleared a write divergence in the fidelity gate. States the contract as "one settled configuration adjustment, not one function invocation": the window is a heuristic and never a correctness boundary (Kubernetes has no apply-complete event), a per-target DIRTY SEQUENCE means a change arriving mid-pass is never lost, and the pass reads a coherent rule-store snapshot rather than the rule that triggered it. Carries the deletion inventory, because the point is that the system got smaller: four trigger mechanisms collapsed to one (`signalCatalogRefresh` and `catalogRefreshCh` are gone), `refreshRunningTargetWatches` and its running-set filter are gone (that filter is why a target whose first declare never completed was never picked up again), and six mutexes went for stated reasons, with `RenderFidelityGate.mu` and the two event-channel locks kept and justified. The four steps shipped: the local-cluster discovery call is bounded (a real defect — the legacy non-context `ServerGroupsAndResources()` ran with no deadline at all), the owner loop carries the debounce, dirty sequence, per-target deadline and 2s/5s/10s/30s/1m backoff, the plan now carries NO lock while the projection is a published snapshot, and catalog invalidation is scoped by diffing each target's rendered plan across the re-projection. "What shipped" records where the implementation departed from the page, including the one bug only e2e caught: streams were parented to the PASS context, which a deadline cancels the moment the pass returns, so every stream died the instant its plan was applied — and it reads like health, because the plan logs `start:1`, every later pass reports `keep:1` and never restarts it, and nothing logs an error while readiness sits at `Replaying` and every WatchRule sits `Ready=False`. A stream's parent is the manager's lifetime; the pass deadline bounds the pass. A second e2e catch is a BEHAVIORAL consequence worth knowing: toggling a rule off and on inside the settle window is no longer a replay — it used to tear the stream down and re-establish it because each apply replanned synchronously, and it is now one pass over a plan that never changed. Correct (a net-zero change is no change; widening `prune.mode` remains the supported force) but a real difference in what an operator gesture does. Both specs that broke were also gating on the wrong thing: asking a GitTarget "are all your streams running" about a change to ONE rule, which a target that is already mirroring answers True to before that rule has been planned, and which a different controller publishes than the one that compiled the rule — so the rule's OWN StreamsRunning is the gate, and `waitForWatchRuleStreamsRunning` existed unused for exactly this. Departures: reports became a published snapshot rather than a second channel; ISOLATION came from taking the I/O off the loop rather than from the deadline (a pass never dials, the shared refresh runs on its own goroutine, and the deadline is the backstop it should have been — a first cut that kept two network calls on the loop had one unreachable cluster holding every healthy target, which is the same availability failure relocated); DELETION names an incarnation resolved when it is queued, because both production callers react to a NotFound and carry no UID, so a UID-less delete matched everything and could tear down the successor of a same-name recreate; and persistent failure surfaces as `WatchPlanFailing`, where pending means "no pass has ever landed", not "dirty right now". Step 3's type-to-target index did not earn its staleness. Still open: whether the settle window ever needs to be configurable |
+| [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope |
+| [`azure-devops-multi-ack.md`](design/azure-devops-multi-ack.md) | go-git v6 was the answer: why Azure DevOps rejects our fetches, and what to do instead of PR [#292](https://github.com/ConfigButler/gitops-reverser/pull/292)'s bundled `git` binary. The capability filter fails in two independent halves: advertising `multi_ack` is a four-line change, but v5 then cannot parse the multi-ACK **response**, which only a fetch with `have` lines provokes. That is why **Flux ships ADO support on v5 with no git binary — it never fetches**, only `CloneContext`, so it never enters the path v5 cannot serve; our persistent-clone-plus-incremental-fetch design is the opposite, which makes the trim alone insufficient for us. **go-git v6 already implements `multi_ack`** (PR #1204, in every v6 tag; upstream then deleted their ADO example saying it "works out of the box"), and its churn in the packages we import runs 96 → 39 → **1** → **9** removals per alpha, so it is one settled breaking wave rather than a moving target; the migration is four known API removals over two rewritten files, `transport.AuthMethod` being the invasive one. Prices PR #292 as measured rather than argued: the image goes **217 MB → 940 MB**, of which 723 MB is a `cp -rL` that dereferences 165 hardlinks to one binary (a one-character fix), arm64 is unaffected and native, but **Trivy reports zero findings on both images** while the new one carries git 2.54.0, OpenSSH 10.3p1 and OpenSSL 3.5.7 as loose files no package database describes — so the CRITICAL gate is blind to a third of the runtime. Also catches an unflagged non-ADO regression (`Depth: 1` dropped, so every provider full-fetches) and 10% patch coverage on an untestable path. The unlock is that **canonical `git upload-pack` advertises `multi_ack`** (verified), so the Gitea already in the e2e lab plus a 400-injecting proxy is a faithful ADO simulator — no tenant needed, and the only way any option becomes CI-testable. Four options priced, and Option A (v6) is the one shipped. Carries a measured **capability matrix** over our three network calls with two diagrams, which narrows the blast radius to **one call, `repo.Fetch`**: `receive-pack` never advertises `multi_ack` (measured), so **the atomic push is out of scope for every option** — its safety rests on the same-session advertisement plus the server-side `Old`/`New` compare-and-swap in `packp.Command`, neither of which touches `upload-pack`, and we already push from a shallow store today. v6 keeps that pattern 1:1 (`Handshake` → `GetRemoteRefs`/`Push`, same `[]*packp.Command`), which is an argument *for* migrating. Records what the migration actually cost, including the four v6 behaviour changes it surfaced — two of them settings v6 reads from the environment and fails closed on, invisible to unit tests |
+| [`source-scope-simplification.md`](design/source-scope-simplification.md) | except the additive `SelfSubjectAccessReview` pass it explicitly leaves for later. Declines Flux-style impersonation, deletes `GitTarget.spec.allowedSourceNamespaces` and its selector machinery (**4,569 lines**, and the only cross-cluster read in the authorization path), renames two `ClusterProvider` fields, and redefines `sourceNamespace: "*"` as one cluster-wide list and watch. The argument is an API reading, not a security one: the chain from a Git folder back to the object that fills it never leaves one namespace, so ordinary RBAC on `watchrules` already answers it. **Keeps `allowedNamespaces`** (renamed `accessFrom`), reversing an earlier draft — source RBAC bounds what a credential may READ, never which tenant may WIELD it. Prices what is lost: source-side label selectors, which have no replacement. Archaeology in [`facts/kubernetes-impersonation-and-flux-identity.md`](facts/kubernetes-impersonation-and-flux-identity.md) |
## The layout topic — [`layout/`](layout/README.md)
@@ -144,12 +157,13 @@ by path from Go source.
| [`new-file-placement-rules.md`](layout/new-file-placement-rules.md) | **spec** | where a new resource's file goes: declared, the folder's one kustomize root, canonical. Sibling inference is removed, and kept as history |
| [`model.md`](layout/model.md) | **design** | **reversed, and much smaller than it was.** The earlier thesis wanted `spec.placement` replaced by a `spec.layout` discriminated union; three of its five arguments were retired by [#319](https://github.com/ConfigButler/gitops-reverser/pull/319), which made registration an invariant. So the template **stays** and gains two optional booleans: **`spec.placement.useKustomize`** (create and maintain the folder's root; registering into a root that already exists is an invariant, not a setting) and **`spec.serializeNamespace`** (a `*bool`, because unset must keep meaning "infer" — no plain default preserves today's behavior), which sits one level up because it governs the bytes of every write and the identity a managed document is found by, not just new files. Carries four kustomize facts **measured** against v5.8.1, three of which contradict the earlier model; the `status.placement` stanza and the post-scan pass; and the build order. The headline is what it deletes — `spec.layout`, `kind`, `scope`, `kustomize.create`, the `LayoutProfile` question, the migration, **the post-scan supplier guard** (the supplier of a namespace-free folder lives in another cluster and may not even be single, so the check fires on the correct configuration), and **the dry-run framing of `spec.suspend`** (a scratch branch is a better preview and needs nothing built, so `suspend` is a panic knob and `status.placement` explains writes rather than previewing them) — so the largest breaking change in the queue stops being breaking at all |
-[`shapes/`](layout/shapes/README.md) is the specification by example: the cross-product of folder
-shapes, one live object written into all of them, so the only difference between two folders is the
-configuration that produced it. [`specific-examples/`](layout/specific-examples/README.md) is the
-remainder — an Argo CD app-of-apps, a Flux two-layer repository, and the shared prerequisites. Both
-are design material, not install manifests, and [`model.md`](layout/model.md) turns them into an
-executable corpus in its first PR; [`build-order.md`](design/build-order.md) says when.
+The worked examples that used to sit in this folder are now the **layout corpus** at
+[`test/fixtures/layout-corpus/`](../test/fixtures/layout-corpus/README.md), because a test executes
+every one of them: `shapes/` is the specification by example (the cross-product of folder shapes,
+with one live object written into all of them, so the only difference between two folders is the
+configuration that produced it), and `specific-examples/` is the remainder (an Argo CD app-of-apps,
+a Flux two-layer repository, and the shared prerequisites). They are still design material rather
+than install manifests. What changed is that they are now checked.
## Deferred, but still wanted — [`future/`](future/)
diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md
index 9f2367e5..f01a73ca 100644
--- a/docs/UPGRADING.md
+++ b/docs/UPGRADING.md
@@ -311,7 +311,7 @@ spec:
One target is one environment is one write partition, which is what makes authorization, audit and
review line up with the environment boundary. The reasoning is in
-[`layout/shapes/README.md`](layout/shapes/README.md#why-only-a-leaf-can-be-a-kustomize-target).
+[`layout/shapes/README.md`](../test/fixtures/layout-corpus/shapes/README.md#why-only-a-leaf-can-be-a-kustomize-target).
`status.placement.mode` and `status.placement.renderRoot` report what the scan resolved, and both
are published before a target has written anything — so a target you have just declared already
diff --git a/docs/design/azure-devops-multi-ack.md b/docs/design/azure-devops-multi-ack.md
index 93423905..88b255e0 100644
--- a/docs/design/azure-devops-multi-ack.md
+++ b/docs/design/azure-devops-multi-ack.md
@@ -1,6 +1,6 @@
# Azure DevOps and `multi_ack`: why it fails, and what to do about it
-> **design** — **decided and built: Option A, go-git v6.** Index: [`../INDEX.md`](../INDEX.md)
+> **built**: decided and built as Option A, go-git v6. Index: [`../INDEX.md`](../INDEX.md)
>
> Written against PR [#292](https://github.com/ConfigButler/gitops-reverser/pull/292)
> (issue [#288](https://github.com/ConfigButler/gitops-reverser/issues/288)), which proposes shelling
diff --git a/docs/design/build-order.md b/docs/design/build-order.md
deleted file mode 100644
index 4d65bfeb..00000000
--- a/docs/design/build-order.md
+++ /dev/null
@@ -1,224 +0,0 @@
-# Build order: three PRs, and what actually blocks what
-
-> **design**: a sequencing summary, not a plan of record. Nothing here binds until scheduled.
-> Date: 2026-08-31. Index: [`../INDEX.md`](../INDEX.md)
->
-> **This page holds only the order.** Every item below is specified somewhere else, and the
-> specification is the authority on *what* is built — this page is the authority on *when*, and on
-> which releases the items have to share. If a detail appears both here and in a linked document,
-> the linked document wins and the copy here is the bug.
-
-Five changes are in flight at once and they are spread across five documents, none of which can see
-the other four. This page is the missing top: what ships together, what can ship alone, and the two
-couplings that are real.
-
-## What is in flight
-
-```mermaid
-flowchart TB
- subgraph A["Track A — additive placement (no consumer bump)"]
- direction LR
- A1["PR 1
corpus + suspend
+ status.placement
+ the Ambiguous rule"] --> A2["PR 2
useKustomize + serializeNamespace
+ one-source-namespace refusal"]
- end
- subgraph B["Track B — the breaking wave (one coordinated bump)"]
- direction LR
- B1["delete allowedSourceNamespaces"] --- B2["redefine sourceNamespace: *"]
- B2 --- B3["commit.window / commit.message move
+ riders"]
- end
- subgraph C["Track C — patch authoring (no API surface, NOT one of the three)"]
- direction LR
- C1["field-path ownership
attribution"] --> C2["update + retract
lifecycle"]
- end
-```
-
-**The whole API surface in flight is three pull requests.** Track A is two of them and track B is
-the third; track C is real work that is deliberately not one of the three, for the reason below. The
-tracks are the *independence* argument — why the order is free — and the PR cut is the plan.
-
-## The plan, as three PRs
-
-| PR | Contains | Breaking | Done when |
-|---|---|---|---|
-| **1 — explain what it did** | the corpus wired up, `spec.suspend` + the reconcile-request annotation, `status.placement` + `LayoutResolved`, and the post-scan pass (one rule: **`Ambiguous`**) | no | a refused or surprising write is explainable from status, and every corpus scenario either passes or is skipped naming PR 2 |
-| **2 — the two booleans** | `spec.serializeNamespace`, `placement.useKustomize`, the one-source-namespace refusal, creating a `kustomization.yaml` | no | every corpus skip naming PR 2 is gone |
-| **3 — the breaking wave** — **SHIPPED** | delete `allowedSourceNamespaces`, redefine `sourceNamespace: "*"`, the `commit.window` / `commit.message` moves. The riders were trimmed, as this row allowed: nothing depends on them | **yes**, one bump | the wave's own migration note is satisfied |
-
-PRs 1 and 2 are specified in
-[`../layout/model.md` § How it gets built](../layout/model.md#how-it-gets-built); PR 3 in
-[`source-scope-simplification.md` § Migration](source-scope-simplification.md#migration), sequenced
-with its co-members in [`gittarget-api-wave.md`](gittarget-api-wave.md); track C in
-[`support-boundary/patch-authoring.md` § Delivery sequence](support-boundary/patch-authoring.md#delivery-sequence).
-Those pages are the authority on *what*; this one only on *when*.
-
-**PR 1 is one feature with four parts, not four features.** The parts are each small, and the corpus
-is what proves any of them behaves as written. Their common property is what makes them one review:
-**PR 1 changes what the operator writes in exactly one case, and that case is the `Ambiguous`
-rule.** Everything else in it is a report.
-
-They are independent of each other, and that is worth stating because the grouping invites the
-opposite reading. `suspend` is a panic knob — a way to stop the writes that is not deleting the
-object — and needs no status to be useful. `status.placement` answers "why did that write take that
-shape" and needs no `suspend` to be useful. Neither is a preview: previewing a target means pointing
-one at a scratch branch and reading the commits
-([`../layout/model.md`](../layout/model.md#previewing-a-target-point-it-at-a-scratch-branch)). They
-ship together because they are small and adjacent.
-
-**The one case, stated plainly**, because a rule that gates is a write-behavior change however
-additive the rest of the PR is: a GitTarget covering more than one kustomize render root — an app
-root rather than a leaf overlay — stops placing new documents. Before
-PR 1 it placed them at the canonical path inside whichever folder it covered. The refusal is raised
-at the placement site and surfaces as `GitPathAccepted=False`, reason `AmbiguousLayout`, with
-`LayoutResolved=False` naming the roots the folder covers. An existing document is unaffected: it is
-edited where it already lives, whatever the folder covers.
-
-**It gates at the write rather than on `Validated`, and the difference is recoverability.**
-`Validated` is evaluated before the data plane exists, so a target failing it never registers a
-worker, never scans, and could therefore never observe that the folder had been fixed — and a target
-that had never scanned could never trip the rule in the first place. Refusing at the placement site
-keeps the target declared and scanning, so narrowing it to a leaf clears the refusal the way fixing
-any other unsupported content does.
-
-**The post-scan pass lands whole in PR 1**, and it is one rule: *a folder covering two render roots
-is `Ambiguous`*, which reads only the scan. It has no second rule — `serializeNamespace: false` is
-not checked against the folder, because the namespace supplier lives outside the repository
-([`../layout/model.md`](../layout/model.md#why-false-needs-no-guard)).
-
-**Order between them is free, and the numbering is a recommendation.** PR 3 does not block PR 1 or 2
-and neither blocks it — see [the couplings that do not
-exist](#three-couplings-people-expect-and-that-do-not-exist). It is last because it is the only one
-that costs consumers a coordinated bump, and the additive value is worth having before that is spent.
-PR 1 before PR 2 is not free: PR 2's review is the one the corpus exists to make possible.
-
-**What the merge costs, given that every PR here is squashed.** A squash merge collapses a PR into
-one commit on `main`, so the internal commits exist for the *review* and nowhere afterwards. Ordering
-work inside a PR still shapes what a reviewer reads commit by commit — keep **creating a
-`kustomization.yaml` as the last commit of PR 2, on its own**, since it is the one thing that writes
-a file nobody asked for by name, and keep **the write-plan precondition ahead of the admission
-check**, because the precondition is the correctness layer and admission is only feedback. But be
-honest about what that does not buy:
-
-- **Bisect and revert granularity is the PR.** A regression in `suspend`, in `status.placement`, or
- in the corpus is one commit on `main`, and reverting any of them reverts all three. The mitigation
- is that PR 1's only write-behavior change is the `Ambiguous` rule and nothing depends on the rest
- of it yet, so a revert is cheap — not that the granularity survives.
-- **The changelog entry is the PR title.** release-please reads the squashed commit, so PR 1's title
- has to cover four things honestly rather than name the most interesting one.
-- **One property is untouched by the merge:** scenarios for unbuilt behavior are written in PR 1 and
- skipped, each naming the track that unskips it, so PR 2 is still finished when every skip naming
- PR 2 is gone. Not all of them do: shape 8's `images:` authoring names track C and outlives PR 2,
- which is why the rule is "PR 2's own skips" rather than "the last skip". Either way it is enforced
- by the test suite rather than by history, which is why squashing cannot erode it.
-
-**Track C is not one of the three, on purpose.** It is one and a half to two weeks of engineering
-that blocks nothing; folding it into any of the three would make that PR unreviewable and would tie a
-field rename to a fortnight of patch machinery. Schedule it whenever
-there is appetite. If it truly must be inside a count of three, the only honest way is to merge PRs 2
-and 3 — both change the `GitTarget`/`WatchRule` API — and that is worth refusing: it makes the
-additive placement work breaking by association, which is exactly what the layout reversal was
-engineered to avoid.
-
-## The two couplings that are real
-
-Everything else is independent. These two are not, and both live inside a single track:
-
-- **Track B is one wave, not two items.** `sourceNamespace: "*"` is *defined* in terms of
- `allowedSourceNamespaces`, so deleting the field without deciding `*` leaves a value with no
- meaning. They ship in the same release or neither does. The
- [definition of record](source-scope-simplification.md#sourcenamespace--needs-its-own-decision)
- carries both readings.
-- **Inside track A, `useKustomize` depends on the one-source-namespace rule.** A created
- `kustomization.yaml` carries `namespace:` only when the folder is single-namespace, and the rule
- is what guarantees that for an explicit `serializeNamespace: false`. Both are in PR 2; build the
- rule first.
-
-## Three couplings people expect and that do not exist
-
-Worth stating, because each one has been assumed at least once:
-
-- **The one-source-namespace rule does not depend on `allowedSourceNamespaces`.** It computes
- `{the target's own namespace} ∪ {the explicit rules[].sourceNamespace names of the WatchRules
- pointing at it}` by reading `WatchRule` objects, not the policy field track B deletes. And a `*`
- item is refused under *both* readings of `*`, since neither is provably one namespace from the
- spec alone. So PR 2 needs no rewrite after the wave, and does not have to wait for it.
-- **Track A is not part of the breaking wave.** The layout model reversed: the path template stayed
- and gained two optional fields, so nothing in track A changes an existing field's meaning. A Tier
- 2 entry belongs to the wave only if it changes a `GitTarget` field in a breaking way.
-- **Track C touches no API.** No CRD field, no migration, no persisted state; backing it out returns
- to today's refusal, and patch files already committed stay valid kustomize. Its only real cost is
- a durable one and it is not technical — it moves the boundary from *we invert what kustomize
- declares* to *we author patches*.
-
-## What is left in each track
-
-**Track A.** All of it is unbuilt. PR 2's two halves are smaller than they look: registration into an
-existing root shipped in [#319](https://github.com/ConfigButler/gitops-reverser/pull/319), and
-inference is what `namespaceIsInheritedFromContext` already does. What is genuinely new is writing a
-`kustomization.yaml` that does not exist — build that last and on its own, since it is the only
-thing that writes a file nobody asked for by name.
-
-**Track B.** **Shipped**, and it was mostly a deletion, as expected. The one thing left to *build* is
-the `SelfSubjectAccessReview` pass, which is additive — so it was explicitly **not** in PR 3, and
-follows whenever, rather than widening the one PR that costs a bump. The riders were trimmed from
-PR 3 under this page's own rule, and are unbuilt.
-
-**Track C.** Steps 2 and 3 of its delivery sequence already shipped for another reason — the
-`$patch: delete` work built the patch-file author, and the render oracle built the verification. What
-remains is step 1 (ownership of a field path) plus the update/retract lifecycle the original sequence
-omitted. Ballpark: a spike over env vars is a couple of days; a slice worth shipping is one and a
-half to two weeks.
-
-## The corpus is the test, and it is not wired up yet
-
-The corpus is the one item every other item benefits from, and it is why PR 1 leads rather than
-merely happening to be first. The eighteen fixture folders under
-[`../layout/shapes/`](../layout/shapes/README.md) and
-[`../layout/specific-examples/`](../layout/specific-examples/README.md)
-are read today by **nothing but a human**: no Go file references either directory. Wiring them up
-converts every later review from *"does this prose hold together"* into *"does the diff match the
-patch"*.
-
-**The seam already exists, at both levels, and neither needs inventing:**
-
-| What | Where | Does |
-|---|---|---|
-| Golden-directory runner | [`contextual_namespace_corpus_test.go`](../../internal/manifestanalyzer/contextual_namespace_corpus_test.go) | walks `testdata/` folders, asserts a per-document outcome — the exact shape the corpus needs |
-| Write-path driver | `newWorktreeForTest` + `flushEventsToWorktree` ([`inplace_edit_test.go`](../../internal/git/inplace_edit_test.go)) | seeds a worktree, folds events through the real plan-then-flush path |
-| Precedent for a refusal fixture | [`namespace_context_refusal_test.go`](../../internal/git/namespace_context_refusal_test.go) | pins the two folder shapes where the store's view and kustomize's disagree |
-
-So PR 1 is assembly, not construction: seed a worktree from `repository/`, build the event from
-`input/`, derive the policy from `config/gittarget.yaml`, flush, and compare a normalized diff with
-`expected-*.patch`. A `-update` flag that rewrites the patches keeps the corpus cheap to extend.
-
-**Three rules for the corpus, each of which has already been learned the hard way here:**
-
-- **Scenarios for unbuilt behavior are written now and skipped**, with the track that unskips them
- named in the skip message. PR 2 is finished when every skip naming PR 2 is gone — shape 8's
- `images:` authoring names track C and outlives it.
-- **`config/gittarget.yaml` parses into a harness-local struct** until PR 2 deletes that mapping —
- which is itself a check that the API the examples describe is the API that got built.
-- **Refusals are fixtures too.** A set in which every scenario succeeds is advertising rather than
- specification. Three: a second source namespace against an explicit `serializeNamespace: false`, a
- folder covering two render roots, and a base-owned field edit — each asserting an
- `expected-status.yaml` rather than a patch. Only the two-roots one asserts a rule PR 1 ships; the
- second-namespace one is written in PR 1 and skipped until PR 2.
-
-### The behavior reference this leaves missing
-
-A passing corpus tells a maintainer what happens. It does not tell a **user** what happens, and
-there is no page that does: the behavior is currently spread across
-[`support-contract.md`](support-boundary/support-contract.md) (what we will and will not touch),
-[`status-conditions-guide.md`](../spec/status-conditions-guide.md) (condition shapes), and
-[`configuration.md`](../configuration.md) (fields). None of them answers "I changed X in the
-cluster — what lands in Git, and what do I see if it refuses?"
-
-That page should be **generated from the corpus rather than written beside it**, so it cannot drift:
-one row per scenario, naming the situation, the Git outcome, and the condition a user would read.
-Not scheduled, and deliberately not started before PR 1 — it has no source to generate from until
-the fixtures execute.
-
-## What this page deliberately does not do
-
-It does not rank the tracks. [`open-asks-priority.md`](open-asks-priority.md) is the priority
-argument and still holds its Tier 0–3 ordering for everything *inside* a track; what it could not
-carry is the cross-track picture, because it predates the layout reversal. Read that page for what
-matters most, and this one for what can move without waiting.
diff --git a/docs/design/created-root-namespace.md b/docs/design/created-root-namespace.md
index a9bdc5c2..fb26dea5 100644
--- a/docs/design/created-root-namespace.md
+++ b/docs/design/created-root-namespace.md
@@ -1,6 +1,6 @@
# What namespace does a created `kustomization.yaml` carry?
-> **design**: decided, and being built. Index: [`../INDEX.md`](../INDEX.md)
+> **design, decided**: being built. Index: [`../INDEX.md`](../INDEX.md)
> Date: 2026-09-01.
>
> One question, five answers, and the reason the obvious one is wrong. It came out of review of
@@ -24,7 +24,7 @@ Three facts, because the first draft of this argument got them wrong:
- **A `kustomization.yaml` with no `namespace:` is ordinary.** It is what a kustomize *base* is.
Three already ship in our own corpus, and
- [shape 6's](../layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/kustomization.yaml)
+ [shape 6's](../../test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/kustomization.yaml)
carries the comment "No namespace: the base is written to be deployable into any of them".
- **Both installers supply one downstream.** Flux's `Kustomization.spec.targetNamespace` and Argo
CD's `Application.spec.destination.namespace` each apply over the built output, so a
@@ -73,7 +73,7 @@ it really is being relocated: relaxing that would hide the exact failure the gat
| `false` | sets none, or there is no root | **ignored**, and every other field still compared |
The third row is not a weakening; it is the removal of an inconsistency. A namespace-free flat
-folder ([shape 2](../layout/shapes/2-flat-namespace-free/README.md)) is never namespace-checked
+folder ([shape 2](../../test/fixtures/layout-corpus/shapes/2-flat-namespace-free/README.md)) is never namespace-checked
today, because the gate only arms when a flush touched a kustomization. The same declaration got a
different answer in a kustomize folder purely because a root file existed. Now both answer the same
way.
@@ -105,5 +105,5 @@ behavior, and this is not a new refusal for folders that never asked for one.
[`../layout/model.md`](../layout/model.md) is the layout model and stays the specification; this
page is only the argument behind one of its sentences. The behavior a user reads is in
[`../configuration.md`](../configuration.md), and
-[shape 5](../layout/shapes/5-kustomize-single-folder/README.md) is the worked example the corpus
+[shape 5](../../test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/README.md) is the worked example the corpus
executes.
diff --git a/docs/design/data-plane-triggering.md b/docs/design/data-plane-triggering.md
index bb24704e..4d30f4de 100644
--- a/docs/design/data-plane-triggering.md
+++ b/docs/design/data-plane-triggering.md
@@ -1,17 +1,12 @@
----
-status: design
-date: 2026-08-25
-related:
- - target-watch-plan.md
- - reconcile-triggering.md
- - watch-and-catalog-architecture.md
- - ../spec/reconcile-via-watchlist-mark-and-sweep.md
- - ../spec/type-lifecycle-events-and-wobble-settling.md
----
-
# Data-plane triggering: why one config change replays everything
> **design**: background, open. Index: [`../INDEX.md`](../INDEX.md)
+> Date: 2026-08-25.
+> Related: [`target-watch-plan.md`](target-watch-plan.md),
+> [`reconcile-triggering.md`](reconcile-triggering.md),
+> [`watch-and-catalog-architecture.md`](watch-and-catalog-architecture.md),
+> [`../spec/reconcile-via-watchlist-mark-and-sweep.md`](../spec/reconcile-via-watchlist-mark-and-sweep.md),
+> [`../spec/type-lifecycle-events-and-wobble-settling.md`](../spec/type-lifecycle-events-and-wobble-settling.md)
**The short version.** Adding a single WatchRule tears down and replays every
watch stream a GitTarget has. Under load that floods a queue which is shared with
diff --git a/docs/design/docs-linting.md b/docs/design/docs-linting.md
index a3c0b073..070b4a35 100644
--- a/docs/design/docs-linting.md
+++ b/docs/design/docs-linting.md
@@ -1,5 +1,11 @@
# Docs linting with markdownlint-cli2 and Vale
+> **built**: both linters are wired into `task lint`. This page stays in `design/` because it is
+> the reasoning behind the gate, and the measured backlog it records is still the plan for
+> widening it. Index: [`../INDEX.md`](../INDEX.md)
+> Related: [`../style-guide.md`](../style-guide.md),
+> [`../../CONTRIBUTING.md`](../../CONTRIBUTING.md)
+
**Proposal: add two linters that between them mechanize most of
[`style-guide.md`](../style-guide.md), and gate them differently because their backlogs differ by
an order of magnitude.**
diff --git a/docs/design/e2e-coverage-gaps-and-improvements-plan.md b/docs/design/e2e-coverage-gaps-and-improvements-plan.md
index 1c7d3cca..9dade6bd 100644
--- a/docs/design/e2e-coverage-gaps-and-improvements-plan.md
+++ b/docs/design/e2e-coverage-gaps-and-improvements-plan.md
@@ -1,6 +1,6 @@
# E2E Coverage Gaps & Improvements — plan
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
>
> Status: PROPOSAL — 2026-06-26. **Architecture-led**: [architecture.md](../architecture.md) is the
> spine. This plan reads the current e2e suite against the architecture's load-bearing guarantees,
diff --git a/docs/design/e2e-finish-plan.md b/docs/design/e2e-finish-plan.md
index 8c830822..bb372c0d 100644
--- a/docs/design/e2e-finish-plan.md
+++ b/docs/design/e2e-finish-plan.md
@@ -1,6 +1,6 @@
# E2E Finish Plan
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
This is the one active plan for the remaining e2e harness work.
diff --git a/docs/design/e2e-git-server-choice.md b/docs/design/e2e-git-server-choice.md
index 4180a031..de86d9ef 100644
--- a/docs/design/e2e-git-server-choice.md
+++ b/docs/design/e2e-git-server-choice.md
@@ -1,6 +1,6 @@
# The e2e Git server: stay on Gitea, or move to Forgejo?
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
>
> Scope is test infrastructure only: no shipped code path talks to the Git server. This document
> records what was measured in both upstreams, the decision **not** to adopt a Go SDK either way, and
diff --git a/docs/design/gittarget-api-wave.md b/docs/design/gittarget-api-wave.md
index 126e6d9f..a0749f0d 100644
--- a/docs/design/gittarget-api-wave.md
+++ b/docs/design/gittarget-api-wave.md
@@ -1,6 +1,6 @@
# The wave after placement left it
-> **design**: a sequencing proposal. Steps 6 and 7 (B4 and the source-scope deletion) **shipped**
+> **partly built**: a sequencing proposal. Steps 6 and 7 (B4 and the source-scope deletion) **shipped**
> on 2026-09-01; step 8, the riders, was trimmed under this page's own rule and is unbuilt.
> Index: [`../INDEX.md`](../INDEX.md)
> Date: 2026-08-28 (originally 2026-07-30).
diff --git a/docs/design/gittarget-configuration-freshness.md b/docs/design/gittarget-configuration-freshness.md
index c65fcfe7..1fa594b6 100644
--- a/docs/design/gittarget-configuration-freshness.md
+++ b/docs/design/gittarget-configuration-freshness.md
@@ -1,18 +1,15 @@
----
-status: deferred
-date: 2026-08-27
-related:
- - watch-manager-ownership.md
- - target-watch-plan.md
- - ../spec/gittarget-isolation-on-rule-change.md
----
-
# GitTarget configuration freshness: desired in, applied out
-> **Deferred design, kept as a decision record.** Not an active implementation proposal, and
-> nothing here is being built. `StreamsRunning` reports the health of the applied watch plan
-> without identifying which configuration produced that plan; this page works out what an opaque
-> target-level freshness marker would have to be, and why it is not worth building yet. Read
+> **deferred**: kept as a decision record.
+> Date: 2026-08-27. Index: [`../INDEX.md`](../INDEX.md)
+> Related: [`watch-manager-ownership.md`](watch-manager-ownership.md),
+> [`target-watch-plan.md`](target-watch-plan.md),
+> [`../spec/gittarget-isolation-on-rule-change.md`](../spec/gittarget-isolation-on-rule-change.md)
+>
+> **Not an active implementation proposal**, and nothing here is being built. `StreamsRunning`
+> reports the health of the applied watch plan without identifying which configuration produced
+> it; this page works out what an opaque target-level freshness marker would have to be, and why it
+> is not worth building yet. Read
> ["Why this is deferred"](#why-this-is-deferred) first: it carries the trigger conditions for
> picking it up. The one part that stood on its own has shipped: see
> ["What already shipped"](#what-already-shipped).
diff --git a/docs/design/metrics-observability-plan.md b/docs/design/metrics-observability-plan.md
index 84ef64af..5c1f8407 100644
--- a/docs/design/metrics-observability-plan.md
+++ b/docs/design/metrics-observability-plan.md
@@ -1,6 +1,6 @@
# Metrics & Audit Observability — improvement plan
-> **design** — partly built. Index: [`../INDEX.md`](../INDEX.md)
+> **partly built**. Index: [`../INDEX.md`](../INDEX.md)
>
> Status: PLAN — revised 2026-07-29, reconciled to the code after the attribution fact-stream
> switchover. **Architecture-led**: [architecture.md](../architecture.md) is the spine; every metric
diff --git a/docs/design/multi-source-audit-ingress-hardening.md b/docs/design/multi-source-audit-ingress-hardening.md
index 1ab36958..ece83af4 100644
--- a/docs/design/multi-source-audit-ingress-hardening.md
+++ b/docs/design/multi-source-audit-ingress-hardening.md
@@ -1,6 +1,6 @@
# Multi-source audit-ingress hardening
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
>
> This is deliberately narrow: `ClusterProvider` source connectivity, provider-name fact partitioning,
> and reconcile-time namespace authorization are shipped. This document decides how several source
diff --git a/docs/design/placement-visibility-and-declared-defaults.md b/docs/design/placement-visibility-and-declared-defaults.md
index ef2b19ba..bb413444 100644
--- a/docs/design/placement-visibility-and-declared-defaults.md
+++ b/docs/design/placement-visibility-and-declared-defaults.md
@@ -1,6 +1,6 @@
# Placement, made visible: naming, a declared default, and `status.layout`
-> **design**: decided, mostly **not built**. Index: [`../INDEX.md`](../INDEX.md)
+> **design, decided**: mostly not built. Index: [`../INDEX.md`](../INDEX.md)
> Date: 2026-07-30, reconciled 2026-07-30 against what PR #291 actually contains.
>
> **The decisions below stand. The build list does not.** An earlier revision of this page said
diff --git a/docs/design/reconcile-triggering.md b/docs/design/reconcile-triggering.md
index 19e3a847..2c174486 100644
--- a/docs/design/reconcile-triggering.md
+++ b/docs/design/reconcile-triggering.md
@@ -1,14 +1,7 @@
----
-status: investigation + design
-date: 2026-06-29
-related:
- - manifest/gitpathaccepted-projection-race-and-external-drift.md
- - crd-relationships.md
----
-
# Reconcile triggering — how our controllers wake up
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> Date: 2026-06-29.
A controller is only as good as the events that wake it. Periodic requeue is a
**safety net**, not a mechanism: if state changes and nothing enqueues the owner,
diff --git a/docs/design/release-image-reuse-plan.md b/docs/design/release-image-reuse-plan.md
index e92f1880..de93608b 100644
--- a/docs/design/release-image-reuse-plan.md
+++ b/docs/design/release-image-reuse-plan.md
@@ -1,6 +1,6 @@
# Plan: build release images once on main, retag at release
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **partly built**: PR 1 (the core) merged; PRs 2-5 unstarted. Index: [`../INDEX.md`](../INDEX.md)
Status: **PR 1 (§8 core) MERGED to `main`** — squash-merged 2026-07-03 as
[#190], commit `2b3a949`. PRs 2–5 remain unstarted follow-ups. No real
diff --git a/docs/design/sensitive-resource-diagnostics-follow-up.md b/docs/design/sensitive-resource-diagnostics-follow-up.md
index ab8e1f14..f68a4b52 100644
--- a/docs/design/sensitive-resource-diagnostics-follow-up.md
+++ b/docs/design/sensitive-resource-diagnostics-follow-up.md
@@ -1,6 +1,6 @@
# Follow-up: Sensitive Resource Diagnostics
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
This follow-up builds on
[sensitive-resource-classification-plan.md](../finished/sensitive-resource-classification-plan.md).
diff --git a/docs/design/source-scope-simplification.md b/docs/design/source-scope-simplification.md
index 62e89586..0b573de9 100644
--- a/docs/design/source-scope-simplification.md
+++ b/docs/design/source-scope-simplification.md
@@ -1,6 +1,6 @@
# Source scope: what to delete, and what to keep
-> Status: **SHIPPED**, except the `SelfSubjectAccessReview` pass under "The one thing to build",
+> **built**: except the `SelfSubjectAccessReview` pass under "The one thing to build",
> which is additive and was deliberately kept out of the release that cost a bump.
> Date: 2026-08-28, shipped 2026-09-01. Index: [`../INDEX.md`](../INDEX.md).
>
diff --git a/docs/design/support-boundary/patch-authoring.md b/docs/design/support-boundary/patch-authoring.md
index 8599d90a..a5897a8a 100644
--- a/docs/design/support-boundary/patch-authoring.md
+++ b/docs/design/support-boundary/patch-authoring.md
@@ -13,7 +13,7 @@
> Related: [support contract](support-contract.md),
> [render-root scoping](render-root-scoping.md),
> [render attribution](render-attribution.md), and
-> [layout shape 8](../../layout/shapes/8-base-owned-field-edit/README.md), which is this
+> [layout shape 8](../../../test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/README.md), which is this
> document's refusal as an executable scenario.
An external-base overlay must never write an inherited field into the shared base. For
diff --git a/docs/design/support-boundary/render-root-scoping.md b/docs/design/support-boundary/render-root-scoping.md
index 76c95923..1d7598ec 100644
--- a/docs/design/support-boundary/render-root-scoping.md
+++ b/docs/design/support-boundary/render-root-scoping.md
@@ -134,7 +134,7 @@ controller is the tell, not the licence.
This is a rule for the person choosing `spec.path`, not something the operator can enforce: nothing
in the repository marks a folder as bootstrap-owned. It is stated here so the answer exists in the
support boundary rather than only in an example, and
-[`homelab-flux`](../../layout/specific-examples/homelab-flux/README.md) is the worked case.
+[`homelab-flux`](../../../test/fixtures/layout-corpus/specific-examples/homelab-flux/README.md) is the worked case.
## 6. Remaining work
diff --git a/docs/design/target-watch-plan.md b/docs/design/target-watch-plan.md
index 15839a57..8a13acb7 100644
--- a/docs/design/target-watch-plan.md
+++ b/docs/design/target-watch-plan.md
@@ -1,17 +1,12 @@
----
-status: design
-date: 2026-08-26
-related:
- - watch-and-catalog-architecture.md
- - data-plane-triggering.md
- - ../spec/type-lifecycle-events-and-wobble-settling.md
----
-
# Target watch plan: reconcile the changed cells
-> **design**: open. The diff is built and applied. Removal semantics are not,
+> **partly built**: the diff is built and applied. Removal semantics are not,
> and the deletion the diff unlocks has not been made.
> Index: [`../INDEX.md`](../INDEX.md)
+> Date: 2026-08-26.
+> Related: [`watch-and-catalog-architecture.md`](watch-and-catalog-architecture.md),
+> [`data-plane-triggering.md`](data-plane-triggering.md),
+> [`../spec/type-lifecycle-events-and-wobble-settling.md`](../spec/type-lifecycle-events-and-wobble-settling.md)
**The short version.** A GitTarget's watch set is replaced wholesale today, so
changing one rule replays all of them. This plan diffs the set instead and acts
diff --git a/docs/design/watch-and-catalog-architecture.md b/docs/design/watch-and-catalog-architecture.md
index 6ff1edb9..97c00d4f 100644
--- a/docs/design/watch-and-catalog-architecture.md
+++ b/docs/design/watch-and-catalog-architecture.md
@@ -1,6 +1,6 @@
# Watch & Catalog Architecture — Requirements, Current State, Target Design
-> **design** — open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
+> **design**: open, not yet built. Index: [`../INDEX.md`](../INDEX.md)
Status: **vision / proposed** — supersedes the framing in
[watchrule-wildcard-support-plan.md](../spec/type-followability.md),
diff --git a/docs/design/watch-manager-ownership.md b/docs/design/watch-manager-ownership.md
index 522a20ca..5a48c046 100644
--- a/docs/design/watch-manager-ownership.md
+++ b/docs/design/watch-manager-ownership.md
@@ -1,17 +1,12 @@
----
-status: implemented
-date: 2026-08-27
-related:
- - target-watch-plan.md
- - data-plane-triggering.md
- - watch-and-catalog-architecture.md
----
-
# One owner for the watch plane: triggers in, work coalesced
-> **built.** All four steps have shipped; "Implementation order" records what each one deleted, and
+> **built**: all four steps have shipped; "Implementation order" records what each one deleted, and
> "What shipped" at the end records where the implementation departed from this page and why.
> Index: [`../INDEX.md`](../INDEX.md)
+> Date: 2026-08-27.
+> Related: [`target-watch-plan.md`](target-watch-plan.md),
+> [`data-plane-triggering.md`](data-plane-triggering.md),
+> [`watch-and-catalog-architecture.md`](watch-and-catalog-architecture.md)
**The short version.** A rule edit is applied by the controller worker that observed
it, synchronously, and it re-plans every GitTarget in the process rather than the one
diff --git a/docs/design/watch-plane-status-convergence-failures.md b/docs/design/watch-plane-status-convergence-failures.md
index e06c7197..800afcec 100644
--- a/docs/design/watch-plane-status-convergence-failures.md
+++ b/docs/design/watch-plane-status-convergence-failures.md
@@ -1,5 +1,11 @@
# Status convergence failures on the watch-plane rework
+> **design**: Failure A solved, Failure B open. An investigation log rather than a proposal,
+> written so a fresh context can continue without re-deriving anything.
+> Index: [`../INDEX.md`](../INDEX.md)
+> Related: [`target-watch-plan.md`](target-watch-plan.md),
+> [`watch-manager-ownership.md`](watch-manager-ownership.md)
+
**Failure A solved; Failure B open.** Two reproducible failures on
`feat/target-watch-cell-identity` (PR #315), both in status the branch's own rework produces, plus
an inventory of what is ambient and must not be confused with them. Written so a fresh context can
diff --git a/docs/layout/README.md b/docs/layout/README.md
index 734dc335..174139a5 100644
--- a/docs/layout/README.md
+++ b/docs/layout/README.md
@@ -15,17 +15,25 @@ had in the old layout. Read the label before you read the page.
| [`contextual-namespace.md`](contextual-namespace.md) | **spec** | kustomize graph-aware namespace inference, and the supported subset. This is the inference `serializeNamespace` overrides |
| [`model.md`](model.md) | **design** | the proposal, reversed and much smaller: the path template **stays**, and gains two optional booleans — `spec.placement.useKustomize`, and `spec.serializeNamespace` one level up because it governs every write rather than only new files. Carries the status stanza, the post-scan pass, and the order the work is built in |
-[`shapes/`](shapes/README.md) is where a layout question is answered. It is the **cross-product**:
-the folder shapes a repository can have — flat and tree, each with and without `metadata.namespace`
-in the document, plus one kustomize folder, base-and-overlays, and layered — with the same live
-object written into all of them, so the only difference between two folders is the configuration
-that produced it. It carries the decision flow as a diagram, what each shape does when pointed at an
-**empty** folder, and the measured behavior of the deployers that consume them.
-
-[`specific-examples/`](specific-examples/README.md) is the remainder: the two ecosystem scenarios
-that are not a folder shape at all — an Argo CD app-of-apps and a Flux two-layer repository — plus
-the shared `GitProvider` prerequisites. Both folders use one fixture convention, and `model.md`
-turns both into an executable corpus in its first PR.
+## The worked examples are a test, and they live in the test tree
+
+The shapes and the ecosystem examples used to sit here as two subfolders. They now live at
+[`test/fixtures/layout-corpus/`](../../test/fixtures/layout-corpus/README.md), because they stopped
+being illustrations: every one of them is seeded, written through the real plan-then-flush path and
+diffed against a committed patch by `TestLayoutCorpus`. A folder that a test executes belongs beside
+the other fixtures a test executes, and the move also puts it next to
+[`test/fixtures/gitops-layouts/`](../../test/fixtures/gitops-layouts/README.md), the corpus it is
+most often confused with. The README there says which is which.
+
+Their prose travelled with them, so the corpus is still where a layout question is answered:
+
+| Folder | Answers |
+|---|---|
+| [`shapes/`](../../test/fixtures/layout-corpus/shapes/README.md) | the cross-product. Flat and tree, each with and without `metadata.namespace` in the document, plus one kustomize folder, base-and-overlays, and layered, with the same live object written into all of them. Carries the decision flow as a diagram, an empty-folder column for every shape, and the measured behavior of the deployers that consume them |
+| [`specific-examples/`](../../test/fixtures/layout-corpus/specific-examples/README.md) | the remainder: an Argo CD app-of-apps and a Flux two-layer repository, which are ecosystem scenarios rather than folder shapes, plus the shared `GitProvider` prerequisites |
+
+What stays in this folder is the argument: the two contracts the code cites by path, and the model
+that produced them. What moved is the evidence.
## What is deliberately not here
diff --git a/docs/layout/model.md b/docs/layout/model.md
index 94ab0224..c8202ed5 100644
--- a/docs/layout/model.md
+++ b/docs/layout/model.md
@@ -7,10 +7,12 @@
> no longer holds; why it stopped holding is the first section, because a reversal is worth more than
> a quiet edit.
>
-> Concrete repository folders and matching configurations live in
-> [`specific-examples/README.md`](specific-examples/README.md), for the Argo CD and Flux cases, and in
-> [`shapes/README.md`](shapes/README.md), which is the cross-product of folder shapes with the
-> decision flow drawn out and an empty-folder column for every one of them.
+> Concrete repository folders and matching configurations live in the executable layout corpus at
+> [`test/fixtures/layout-corpus/`](../../test/fixtures/layout-corpus/README.md): its
+> [`specific-examples/`](../../test/fixtures/layout-corpus/specific-examples/README.md) hold the Argo
+> CD and Flux cases, and its
+> [`shapes/`](../../test/fixtures/layout-corpus/shapes/README.md) are the cross-product of folder
+> shapes, with the decision flow drawn out and an empty-folder column for every one of them.
Placement today is a ladder of four rungs, three of which are path templates and one of which is not:
@@ -198,8 +200,8 @@ is for. That is also why unset cannot be spelled `false`.
`serializeNamespace: false` is not checked against the folder, and it cannot be: **the supplier of
the namespace lives outside the repository, and there may not be a single one.**
-For a raw namespace-free folder — [shape 2](shapes/2-flat-namespace-free/README.md) and
-[shape 4](shapes/4-tree-namespace-free/README.md) — the supplier is a Flux
+For a raw namespace-free folder — [shape 2](../../test/fixtures/layout-corpus/shapes/2-flat-namespace-free/README.md) and
+[shape 4](../../test/fixtures/layout-corpus/shapes/4-tree-namespace-free/README.md) — the supplier is a Flux
`Kustomization.spec.targetNamespace` or an Argo `Application.spec.destination.namespace`, in a
different cluster from the repository. Being unbound that way **is the point of the shape**: anyone
may point a deployer at that folder and land it wherever they choose, and two deployers may
@@ -441,11 +443,12 @@ exactly the truth.
registration, the render fidelity gate, refusal accounting, the metrics — already exists and stays
where it is. The two flags sit beside the ladder.
-These two PRs are **track A** of [`../design/build-order.md`](../design/build-order.md), which is
-the only page that carries the cross-track order and the authority on the cut. Two other tracks are
-in flight and neither is covered here: the breaking source-scope wave (PR 3 there), and patch
-authoring. Nothing below waits for either — see
-[the couplings that do not exist](../design/build-order.md#three-couplings-people-expect-and-that-do-not-exist).
+**Both PRs below have shipped**, and the table is kept as the record of what each one carried.
+The breaking source-scope wave shipped separately and is written up in
+[`../design/source-scope-simplification.md`](../design/source-scope-simplification.md); patch
+authoring remains unbuilt and lives in
+[`../design/support-boundary/patch-authoring.md`](../design/support-boundary/patch-authoring.md).
+Neither of those was ever a dependency of the two here.
| PR | Content | Breaking |
|---|---|---|
@@ -459,8 +462,7 @@ rule, which **gates**: a folder covering several render roots stops placing new
before it placed them at the canonical path inside whichever folder it covered. Existing documents
are untouched, and the refusal is raised at the
write rather than on `Validated` so the target keeps scanning and can observe the folder being
-fixed. [`../design/build-order.md`](../design/build-order.md#the-plan-as-three-prs) carries the
-before-and-after.
+fixed.
The post-scan pass lands whole in PR 1: it is the `Ambiguous` rule and nothing else, and that rule
reads only the scan.
@@ -470,7 +472,7 @@ Neither PR is breaking, so neither waits for a coordinated consumer bump. What i
[`gittarget-api-wave.md`](../design/gittarget-api-wave.md).
**PR 1 is the corpus, and it is the reason the rest is reviewable.**
-[`shapes/README.md`](shapes/README.md) already has the shape of a golden-file suite —
+[`shapes/README.md`](../../test/fixtures/layout-corpus/shapes/README.md) already has the shape of a golden-file suite —
`repository/`, `config/`, `input/`, `expected-*.patch` — and is read by nobody but a human. Wiring it
up converts the PR 2 review from "does this prose hold together" into "does the diff match the
patch". The seam exists: `newWorktreeForTest` and `flushEventsToWorktree` in
diff --git a/docs/layout/shapes/7-kustomize-layered/expected-shared-layer-status.yaml b/docs/layout/shapes/7-kustomize-layered/expected-shared-layer-status.yaml
deleted file mode 100644
index 865acd31..00000000
--- a/docs/layout/shapes/7-kustomize-layered/expected-shared-layer-status.yaml
+++ /dev/null
@@ -1,14 +0,0 @@
-# The second half of this scenario, and the one worth reviewing: an edit to the
-# scrape annotation the shared layer patches in. The live object changed, the only
-# expression of that field is in layers/observability, and that folder is outside
-# this target's write scope. No file is written, and the refusal names the boundary
-# rather than searching for another document with the same identity.
-status:
- conditions:
- - type: Ready
- status: "False"
- reason: WriteBoundaryRefused
- message: >-
- shop-prod/checkout: the changed field is expressed in
- apps/checkout/layers/observability, outside this target's path
- apps/checkout/envs/prod
diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md
index 2331c95f..2a1158b8 100644
--- a/docs/spec/status-conditions-guide.md
+++ b/docs/spec/status-conditions-guide.md
@@ -14,6 +14,54 @@ persist changes to it, and RBAC for it is granted separately from the main resou
verbs: ["get", "patch", "update"]
```
+## What belongs in status, and what is a metric
+
+**Status writes are bounded by configuration changes and health transitions, never by data-plane
+throughput.** That is the rule a proposed field has to pass, and it is a rule about the field's
+*rate*, not its subject.
+
+Two questions decide it:
+
+- Does the value move when a **user** changes something, or when health flips? That is status.
+- Does it move when the **workload** moves, on an event, a commit, or a mirrored object? That is a
+ metric, whatever it is called.
+
+A field that fails this is not merely expensive. It is wrong three ways, and only the first is cost:
+
+1. Every status write is an etcd write and a `resourceVersion` bump, which invalidates the cached
+ copy held by every watcher of that type, not just ours.
+2. It is a **feedback loop**. A status write fires an Update event that the controller's own `For()`
+ turns back into a queued request, which is the self-triggering edge the section below exists to
+ suppress. A field that moves on every pass defeats that suppression by construction.
+3. It destroys the field's own readability. A value that changes constantly cannot be read as a
+ statement about convergence, because there is no steady state to compare against.
+
+The rule is already visible in the fields this project ships, and they are the worked examples:
+
+| Field | Moves when | Verdict |
+|---|---|---|
+| `status.placement.mode`, `.renderRoot`, `.readOnlyBases` | the folder's shape changes | status |
+| `status.placement.resolvedAtRevision` | the **resolution** changes, deliberately not on every scan | status |
+| `status.streams` | counts that move when a stream's readiness changes | status |
+| `status.retention` | counts and a roll-up time that move when a **resync** reports, not per event | status |
+| placements, placement refusals | every placed or refused document | metric (`gitopsreverser_placements_total`, `_placement_refusals_total`) |
+| a "last reconcile attempt" timestamp | every pass | removed, see below |
+
+`resolvedAtRevision` is the one worth reading twice, because it looks like a timestamp field and is
+not one. Re-stamping it on every scan would write status once per commit to the branch, whichever
+target caused that commit, so it dates the resolution and not the last scan. A revision older than
+the branch head means the layout has been stable, not that nothing has looked.
+
+**The bound is on rate, not on human involvement.** A field written once per deliberate user action
+passes: a reconcile-request handshake such as Flux's `lastHandledReconcileAt` is bounded by how often
+someone pokes the annotation, which is not a throughput. Rejecting that kind of field is stricter
+than this rule requires.
+
+**Where this bites in review:** the durable per-type record. A map in status keyed by watched type
+grows with the number of types *and* is rewritten by each scan, so it fails on both halves at once.
+Publish the per-type facts as metric labels and keep status to the one resolved answer for the
+object.
+
## Conditions
A list treated as a map keyed by `type`. Don't append duplicates — update the existing entry.
diff --git a/internal/controller/constants.go b/internal/controller/constants.go
index 97b90aaa..64d5aec2 100644
--- a/internal/controller/constants.go
+++ b/internal/controller/constants.go
@@ -25,7 +25,6 @@ type WatchManagerInterface interface {
// It replaces a ReconcileForRuleChange that did the work inline, on the controller worker
// that observed the rule: a discovery call, a namespace list, a full re-projection, and then a
// replan of every running GitTarget. A rule edit now replans the ONE target the rule names.
- // See docs/design/watch-manager-ownership.md.
TriggerRuleChange(gitDest types.ResourceReference)
// TriggerAllRuleChange marks every declared GitTarget. It is the rule-DELETION path only: the
diff --git a/internal/controller/gitprovider_controller_test.go b/internal/controller/gitprovider_controller_test.go
index 6557fc15..131d4d18 100644
--- a/internal/controller/gitprovider_controller_test.go
+++ b/internal/controller/gitprovider_controller_test.go
@@ -28,8 +28,7 @@ import (
// two status patches can collide on the optimistic lock. reconcileStatus.requeueAfter then shortens
// the cadence deliberately: an object whose published status is the WINNER's older answer must be
// revisited sooner than its converged interval, because the winning write is status-only and every
-// For() filters those out, so nothing else re-enqueues it
-// (docs/design/watch-plane-status-convergence-failures.md, §2).
+// For() filters those out, so nothing else re-enqueues it.
//
// Pinning the exact interval made these specs depend on winning that race — they passed locally and
// failed under CI contention. The flag itself is covered deterministically by
diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go
index 53070bae..3552c3a5 100644
--- a/internal/controller/gittarget_controller.go
+++ b/internal/controller/gittarget_controller.go
@@ -272,14 +272,14 @@ func (r *GitTargetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
}
// requeueAfter shortens the cadence when the status write lost a race: the object then holds
// the WINNER's older answer and nothing re-enqueues it, because the For() predicate filters
- // status-only updates by design (docs/design/watch-plane-status-convergence-failures.md, §2.12).
+ // status-only updates by design.
requeue := st.requeueAfter(gitTargetRequeue(rd))
// TEMPORARY at Info, while Failure A is open. The data plane can converge and this status not
// follow it: a run has shown the render gate reaching True and both this GitTarget and every
// WatchRule on it still publishing "Rechecking" two minutes later, with no dropped reconcile
// request to explain it. This is the only place that publishes the axis, so it is the only
// place that can say what it published and how long it intends to wait before saying anything
- // again (docs/design/watch-plane-status-convergence-failures.md, §2.10).
+ // again.
log.Info("GitTarget status published",
"writeLost", st.writeLost(),
"gitTarget", target.Namespace+"/"+target.Name,
@@ -574,7 +574,7 @@ func (r *GitTargetReconciler) observeDataPlane(
// target has been quiet for its settle window, so this reconcile has no result to wait for —
// it reads back the LAST pass's outcome, which is the honest thing to project: the reconcile
// has never observed its own effect, it previously observed an intermediate state that merely
- // looked more immediate. See docs/design/watch-manager-ownership.md.
+ // looked more immediate.
manager.DeclareForGitTarget(
gitDest,
target.SourceCluster(),
@@ -1141,9 +1141,6 @@ func gitTargetRetentionStatus(summary watch.RetentionSummary) *configbutleraiv1a
return nil
}
observed := metav1.NewTime(summary.ObservedTime)
- if summary.ObservedTime.IsZero() {
- observed = metav1.Now()
- }
return &configbutleraiv1alpha3.GitTargetRetentionStatus{
Mode: summary.Mode,
RetainedDocuments: clampIntToInt32(summary.RetainedDocuments),
diff --git a/internal/controller/gittarget_layout_test.go b/internal/controller/gittarget_layout_test.go
index 94157ab7..063465ef 100644
--- a/internal/controller/gittarget_layout_test.go
+++ b/internal/controller/gittarget_layout_test.go
@@ -151,7 +151,7 @@ func TestPublishLayout_AmbiguousNamesTheRoots(t *testing.T) {
"with several roots there is no single way the folder is written")
}
-// The controller's half of docs/layout/shapes/6-kustomize-base-and-overlays. The corpus asserts
+// The controller's half of the corpus's shapes/6-kustomize-base-and-overlays. The corpus asserts
// that fixture's GitPathAccepted condition, which is the writer's own; LayoutResolved is projected
// here, from data no write-path test can produce, so without this the fixture could claim anything
// about it and stay green.
@@ -185,6 +185,7 @@ func TestGitTargetReadiness_StalledFollowsGitPathAccepted(t *testing.T) {
for _, fixture := range []struct{ dir, file string }{
{"2-flat-namespace-free", "expected-second-namespace-status.yaml"},
{"6-kustomize-base-and-overlays", "expected-app-root-status.yaml"},
+ {"7-kustomize-layered", "expected-shared-layer-status.yaml"},
{"8-base-owned-field-edit", "expected-env-change-status.yaml"},
} {
t.Run(fixture.dir, func(t *testing.T) {
diff --git a/internal/controller/gittarget_status_test.go b/internal/controller/gittarget_status_test.go
index ff607372..60341675 100644
--- a/internal/controller/gittarget_status_test.go
+++ b/internal/controller/gittarget_status_test.go
@@ -215,12 +215,11 @@ func TestGitTargetRetentionStatus_AbsentAndZeroMeanDifferentThings(t *testing.T)
converged := gitTargetRetentionStatus(watch.RetentionSummary{
Reported: true, Mode: configbutleraiv1alpha3.PruneAlways,
+ ObservedTime: time.Date(2026, 7, 21, 13, 20, 0, 0, time.UTC),
})
require.NotNil(t, converged, "a reported zero is a report")
assert.Zero(t, converged.RetainedDocuments)
assert.Equal(t, configbutleraiv1alpha3.PruneAlways, converged.Mode)
- require.NotNil(t, converged.ObservedTime, "a reading with no timestamp cannot be judged stale")
- assert.False(t, converged.ObservedTime.IsZero())
}
// TestGitTargetRetentionStatus_ReportsTheEffectiveMode covers the legacy GitTarget: it stores no
@@ -248,7 +247,7 @@ func TestGitTargetRetentionStatus_ReportsTheEffectiveMode(t *testing.T) {
//
// A 61-scope GitTarget produced ~66 reconciles in four seconds; the one that computed
// RenderMatchesLive=True lost the race, vanished, and left every WatchRule on that target reading
-// "Rechecking" for five minutes (docs/design/watch-plane-status-convergence-failures.md, §2.12).
+// "Rechecking" for five minutes.
func TestStatusCommit_LostRaceIsRecordedSoTheCallerComesBack(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme))
diff --git a/internal/controller/status.go b/internal/controller/status.go
index d832f955..f9897ce7 100644
--- a/internal/controller/status.go
+++ b/internal/controller/status.go
@@ -86,8 +86,7 @@ type reconcileStatus struct {
// writeLost reports whether the last commit() had its status write beaten by a concurrent one.
// A reconcile whose status never landed must come back soon whatever it computed, or the object
-// keeps the loser's answer until its periodic requeue
-// (docs/design/watch-plane-status-convergence-failures.md, §2.12).
+// keeps the loser's answer until its periodic requeue.
func (s *reconcileStatus) writeLost() bool { return s.writeLostToRace }
// requeueAfter is the cadence a reconcile should come back on, shortened to the settle interval
@@ -178,7 +177,7 @@ func (s *reconcileStatus) commit(ctx context.Context) error {
// That is Failure A: a 61-scope target produced ~66 reconciles in four seconds, one per scope
// report; the last of them computed RenderMatchesLive=True, lost the race, was silently
// dropped, and left every WatchRule on that target reading "Rechecking" until the five-minute
- // requeue (docs/design/watch-plane-status-convergence-failures.md, §2.12).
+ // requeue.
//
// So the loss is RECORDED and the caller asks writeLost() before choosing its requeue: a
// reconcile whose status never landed must come back soon, whatever it computed.
diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go
index ad011557..145fcb70 100644
--- a/internal/git/branch_worker.go
+++ b/internal/git/branch_worker.go
@@ -178,14 +178,10 @@ type BranchWorker struct {
// rate, so a storm of resyncs for one target can no longer fill the queue
// and starve every other GitTarget on this branch.
//
- // Coalescing reuses the marker's FIFO POSITION, so it is only sound while
- // nothing for that scope was queued behind the marker. Once a write inside
- // the scope has been enqueued, running the newer snapshot at the older
- // position would apply it before writes it already contains, and those
- // older writes would then overwrite it (target-watch-plan.md, "Queue ordering
- // and coalescing"). The
- // entry's tail flag records that boundary, and an arriving resync past it
- // takes its own position at the tail instead of coalescing.
+ // Coalescing reuses the marker's FIFO POSITION, so it is sound only while nothing for that
+ // scope was queued behind it: running a newer snapshot at the older position would apply it
+ // before writes it already contains, which would then overwrite it. The entry's tail flag
+ // records that boundary.
pendingResyncsMu sync.Mutex
pendingResyncs map[resyncKey]*pendingResync
@@ -379,10 +375,9 @@ func (w *BranchWorker) EnqueueAttach(req *AttachCommitRequest) {
// live events that follow it. If the queue is full the request is dropped and its
// caller is notified immediately via the result channel.
//
-// It reports whether the request actually entered the FIFO. A dropped request never reached
-// the queue, so a caller that gates downstream state on the resync's ordering (the per-type
-// coverage watermark, signing-snapshot-tail-replay-failure-investigation.md §7.4) must not treat
-// a drop as success — it would mark the target reconciled-through-Hc with no reconcile ever queued.
+// It reports whether the request actually entered the FIFO. A caller gating downstream state on
+// the resync's ordering must not treat a drop as success: it would mark the target
+// reconciled-through-Hc with no reconcile ever queued.
func (w *BranchWorker) EnqueueResync(request *ResyncRequest) bool {
if request == nil {
return false
@@ -455,16 +450,13 @@ func (w *BranchWorker) EnqueueResync(request *ResyncRequest) bool {
// behind its marker. A pending resync with a nil scope covers the whole GitTarget, so
// any write for that target marks it. The caller must hold pendingResyncsMu.
//
-// The target is read from the EVENT, not from the request. The live path
-// (BranchWorker.Enqueue, one event per request) leaves the request-level fields empty
-// and carries the target on the event, which GitTargetEventStream.OnWatchEvent sets;
-// only the atomic/reconcile paths populate the request. Reading the request alone would
-// silently never match the live path, which is the only path this fence exists for.
+// The target is read from the EVENT, not the request: the live path leaves the request-level
+// fields empty, so reading the request alone would silently never match the only path this fence
+// exists for.
//
-// The scope match is by object identity rather than by producing stream: a cluster-wide
-// and a namespaced stream both deliver one object, so the cell that produced an event
-// cannot be recovered from it today. Over-matching is deliberate — it can only forgo a
-// coalesce, never wrongly permit one.
+// The scope match is by object identity, not producing stream, because the cell that produced an
+// event cannot be recovered from it. Over-matching is deliberate: it can forgo a coalesce, never
+// wrongly permit one.
func (w *BranchWorker) markResyncTailForWriteLocked(request *WriteRequest) {
for key, pending := range w.pendingResyncs {
if pending.tailPassed {
@@ -563,7 +555,7 @@ func (w *BranchWorker) enqueueRequest(request *WriteRequest) bool {
w.inflightItems.Add(-1)
// Name the producing cell on a drop. A saturated queue is diagnosed from what was
// dropped and by whom: the 595-in-16-seconds storm was one GitTarget, and the next
- // one may be one CELL of one GitTarget (docs/design/data-plane-triggering.md §1).
+ // one may be one CELL of one GitTarget.
w.Log.Error(nil, "Event queue full, request dropped",
"events", len(request.Events),
"mode", request.CommitMode,
@@ -573,16 +565,10 @@ func (w *BranchWorker) enqueueRequest(request *WriteRequest) bool {
}
}
-// recordQueueDepth publishes the current pending-work depth for this worker:
-// the number of accepted-but-not-yet-handled items (queued or actively being
-// processed) plus one when the event loop is holding retained unpushed work (a
-// live open window or pending writes). It reads 0 only when the worker has
-// fully drained — every accepted item handled and nothing retained — so a
-// drain gate cannot be satisfied while a commit/push is still in flight. Called
-// only from the loop goroutine (via syncQueueDepthMetric) so the OTel gauge —
-// last-writer-wins — can never latch a stale depth from an enqueue goroutine
-// that raced the loop's drain. No-op until the gauge is registered (e.g. in
-// unit tests that never init the exporter).
+// recordQueueDepth publishes this worker's pending-work depth: accepted-but-unhandled items plus
+// one when the loop holds retained unpushed work. It reads 0 only on a full drain, so a drain gate
+// cannot be satisfied mid-push. Called only from the loop goroutine, so the last-writer-wins gauge
+// can never latch a stale depth from an enqueue goroutine racing the drain.
func (w *BranchWorker) recordQueueDepth() {
if telemetry.BranchWorkerQueueDepth == nil {
return
@@ -1021,15 +1007,10 @@ func (l *branchWorkerEventLoop) handleShutdown() {
l.drainUnhandledQueueItems()
}
-// drainUnhandledQueueItems clears items still buffered on eventQueue that the
-// exiting loop will never handle. Each was counted into inflightItems at enqueue
-// and is decremented only by the loop after handling, so without this drain the
-// final syncQueueDepthMetric would publish a non-zero depth for the exiting
-// worker that never clears — the loop has stopped, so nothing republishes a
-// corrected value. A buffered CommitRequest attach is simply dropped (it is
-// fire-and-forget; the controller re-sends on its next poll). The depth gauge
-// then settles to 0 once the open window is finalized and pending writes are
-// pushed (any genuinely-unpushed work keeps it non-zero, which is correct).
+// drainUnhandledQueueItems clears items the exiting loop will never handle. Each was counted into
+// inflightItems at enqueue and decremented only after handling, so without this the final depth
+// would stay non-zero forever: the loop has stopped, so nothing republishes a corrected value. A
+// buffered CommitRequest attach is dropped; the controller re-sends on its next poll.
func (l *branchWorkerEventLoop) drainUnhandledQueueItems() {
for {
select {
@@ -1066,16 +1047,13 @@ func (l *branchWorkerEventLoop) finalizeOpenWindowWithReason(reason windowFinali
return l.finalizeOpenWindowWithMessage(reason, "")
}
-// finalizeOpenWindowWithMessage closes the live event window into one retained
-// commit-shaped pending write and creates the corresponding local commit. The
-// commit message precedence is: an explicit override, else the window's attached
-// CommitRequest message (pendingMessage, §6.4.2), else the generated grouped
-// message. On success the events move from openWindow to pendingWrites (retained
-// until a push succeeds) and the method returns true; any CommitRequest claiming
-// the window is resolved Committed. On failure the window is dropped — either the
-// repo is unreachable or the events are otherwise unrecoverable, and we don't want
-// to keep retrying with the same broken state on every commit cycle — and a
-// claiming CommitRequest is resolved Failed.
+// finalizeOpenWindowWithMessage closes the live event window into one retained pending write and
+// creates the local commit. Message precedence: explicit override, then the attached
+// CommitRequest's, then the generated grouped message.
+//
+// On failure the window is DROPPED rather than retried: the repo is unreachable or the events are
+// unrecoverable, and retrying the same broken state every cycle helps nobody. A claiming
+// CommitRequest is then resolved Failed.
func (l *branchWorkerEventLoop) finalizeOpenWindowWithMessage(reason windowFinalizeReason, message string) bool {
if l.openWindow == nil {
return false
@@ -1455,22 +1433,16 @@ func (w *BranchWorker) rebuildPendingWrites(
return baseBranch, baseHash, nil
}
-// tightenPendingPruneModes lowers every retained write's captured prune mode to the more
-// restrictive of (captured, current) before the write is replayed. Tightening only: see
-// PruneMode.MoreRestrictiveOf for why the loosening direction must NOT propagate here.
-//
-// It mutates the Targets map in place, which is the point — the map is shared with the retained
-// PendingWrite, so one pass covers both deletion paths (the resync sweep reads it through
-// PendingWrite.Target, the steady-state DELETE writer through pruneModeForBase) and the tightening
-// survives every subsequent push attempt.
+// tightenPendingPruneModes lowers every retained write's captured prune mode before replay.
+// Tightening only: see PruneMode.MoreRestrictiveOf for why loosening must NOT propagate.
//
-// The two failure modes are answered differently on purpose:
+// It mutates the Targets map in place, which is the point: the map is shared with the retained
+// PendingWrite, so one pass covers both deletion paths and survives every push attempt.
//
-// - the GitTarget is GONE — a definite answer, and no policy exists to authorize anything, so
-// the write replays under the most restrictive mode. Retrying could not produce a better one.
-// - the read FAILED — no answer. Returning the error leaves the pending writes retained and the
-// push cycle retries them, so neither a legitimate delete is dropped nor an unauthorized one
-// applied. Guessing either way here would do one or the other.
+// The two failure modes differ on purpose. GONE is a definite answer, so the write replays under
+// the most restrictive mode. A FAILED read is no answer, so the error leaves the writes retained
+// for the next cycle — guessing either way would drop a legitimate delete or apply an
+// unauthorized one.
func (w *BranchWorker) tightenPendingPruneModes(ctx context.Context, pendingWrites []PendingWrite) error {
current := map[pendingTargetKey]configv1alpha3.PruneMode{}
// Ranged by value on purpose: Targets is a map, so writing through this copy still updates the
@@ -1631,19 +1603,14 @@ func (w *BranchWorker) getGitProvider(ctx context.Context) (*configv1alpha3.GitP
// commitWindowFor returns the commit-window duration for ONE GitTarget.
//
-// The window is a GitTarget field (spec.commit.window), not a GitProvider one, and this worker
-// serves every target sharing a (provider, branch). Resolving per target rather than once per
-// worker is what makes that field mean what it says: two targets on one branch can disagree about
-// their cadence. It is affordable because an open window is bound to exactly one target already —
-// windows finalize on a target change — so this is read once per window, not once per event, on
-// the same goroutine that already reads the target's encryption and prune policy at finalize.
+// The window is a GitTarget field but this worker serves every target sharing a (provider,
+// branch), so resolving per target is what makes the field mean what it says. Affordable because a
+// window is bound to one target already, so this is read once per window, not per event.
//
-// The string is parsed here rather than at admission so an unparseable stored value degrades
-// loudly to the fallback instead of blocking the whole target — the GitTarget reconciler is where
-// a NEW mistake is reported (Validated=False). Per design: parse errors → fallback (loud signal);
-// negative → 0 (the caller asked for near-zero coalescing and we honor that). An unreadable
-// GitTarget also takes the fallback: a missing target is not a reason to change how the events
-// already in hand are batched.
+// Parsed here rather than at admission so an unparseable stored value degrades loudly to the
+// fallback instead of blocking the target. Negative parses to 0: the caller asked for near-zero
+// coalescing. An unreadable GitTarget also takes the fallback, since a missing target is no reason
+// to change how the events in hand are batched.
func (w *BranchWorker) commitWindowFor(
ctx context.Context,
targetName, targetNamespace string,
diff --git a/internal/git/inplace_overrides_test.go b/internal/git/inplace_overrides_test.go
index 241b09c1..f0398557 100644
--- a/internal/git/inplace_overrides_test.go
+++ b/internal/git/inplace_overrides_test.go
@@ -19,7 +19,7 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/typeset"
)
-// The edit-through scenarios (docs/design/support-boundary/finished/images-and-replicas-edit-through.md):
+// The edit-through scenarios:
// a live change produced by a kustomization's images:/replicas: entry lands on
// the entry, and the source manifest keeps its bytes.
diff --git a/internal/git/kustomization_bootstrap_test.go b/internal/git/kustomization_bootstrap_test.go
index 0ccd4a01..d44150f1 100644
--- a/internal/git/kustomization_bootstrap_test.go
+++ b/internal/git/kustomization_bootstrap_test.go
@@ -17,8 +17,8 @@ import (
// spec.placement.useKustomize is the only thing in this operator that writes a file nobody asked
// for by name, so what it does NOT do matters as much as what it does. The corpus
-// (docs/layout/shapes/5-kustomize-single-folder) pins the bytes of the one commit it produces;
-// these pin its boundaries.
+// (test/fixtures/layout-corpus/shapes/5-kustomize-single-folder) pins the bytes of the one commit
+// it produces; these pin its boundaries.
func useKustomizePolicy() *manifestanalyzer.PlacementPolicy {
return &manifestanalyzer.PlacementPolicy{UseKustomize: true}
diff --git a/internal/git/layout_corpus_test.go b/internal/git/layout_corpus_test.go
index bb7c5207..ef3647ee 100644
--- a/internal/git/layout_corpus_test.go
+++ b/internal/git/layout_corpus_test.go
@@ -25,8 +25,8 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/typeset"
)
-// The layout corpus executes the worked examples under docs/layout/. Until this file
-// existed those folders were read by nothing but a human, so every claim in them was
+// The layout corpus executes the worked examples under test/fixtures/layout-corpus/. Until this
+// file existed those folders were read by nothing but a human, so every claim in them was
// prose: the READMEs said where a document lands and what the commit looks like, and
// nothing failed when the writer disagreed. Each scenario now seeds a worktree from
// `repository/`, folds `input/` through the real plan-then-flush path with the flush
@@ -34,7 +34,7 @@ import (
// `expected-*.patch`.
//
// Three conventions here are load-bearing and are stated in
-// docs/layout/shapes/README.md as well:
+// test/fixtures/layout-corpus/shapes/README.md as well:
//
// - A scenario describing behavior that is not built yet is written NOW and skipped, naming the
// track that unskips it. The corpus is the definition of done for that track: PR 2 is
@@ -52,11 +52,11 @@ import (
// Run with -update to rewrite the expected patches from the observed diff.
var updateLayoutCorpus = flag.Bool("update", false,
- "rewrite docs/layout expected-*.patch fixtures from the observed diff")
+ "rewrite test/fixtures/layout-corpus expected-*.patch fixtures from the observed diff")
-// layoutCorpusRoot is docs/layout/ as reached from this package's directory. The fixtures are read
-// in place rather than copied into testdata/: a copy would drift from the documents it
-// illustrates, and the drift would be invisible in review.
+// layoutCorpusRoot is test/fixtures/layout-corpus/ as reached from this package's directory. The
+// fixtures are read in place rather than copied into testdata/: a copy would drift from the
+// documents it illustrates, and the drift would be invisible in review.
const layoutCorpusRoot = layoutfixture.Root
// corpusNamespaces projects a scenario onto the namespace policy the write path takes: the
@@ -70,7 +70,7 @@ func corpusNamespaces(target v1alpha3.GitTarget, sources []string, wildcard bool
// it drives the write, which input object arrives, and what Git is expected to look like
// afterwards.
type corpusScenario struct {
- // dir is the fixture folder, relative to docs/layout.
+ // dir is the fixture folder, relative to test/fixtures/layout-corpus.
dir string
// config names the GitTarget under config/; folders with one target may omit it.
config string
@@ -124,8 +124,8 @@ func (s corpusScenario) name() string {
}
// layoutCorpus is the whole corpus. The eight folder shapes are the cross-product of
-// docs/layout/shapes/README.md; the two specific examples are the Argo CD and Flux
-// repositories of docs/layout/specific-examples/README.md.
+// test/fixtures/layout-corpus/shapes/README.md; the two specific examples are the Argo CD and Flux
+// repositories of test/fixtures/layout-corpus/specific-examples/README.md.
func layoutCorpus() []corpusScenario {
return []corpusScenario{
{
@@ -193,13 +193,25 @@ func layoutCorpus() []corpusScenario {
input: "checkout-config.yaml",
patch: "expected-checkout-config.patch",
},
+ {
+ // The refusal half of shape 7. The changed field (a pod-template annotation) is
+ // expressed only in layers/observability, which this target reads and never writes.
+ // The refusal it produces names base/deployment.yaml rather than the layer, because
+ // the layer's patch and the base's Deployment share an identity and the store keeps
+ // the base -- so a shared layer above a base does not change the answer. The fixture
+ // says so; it claimed the opposite for as long as nothing executed it.
+ dir: "shapes/7-kustomize-layered",
+ config: "gittarget-prod.yaml",
+ input: "deployment-scrape-changed.yaml",
+ status: "expected-shared-layer-status.yaml",
+ },
{
dir: "shapes/8-base-owned-field-edit",
config: "gittarget-prod.yaml",
input: "deployment-image-bumped.yaml",
patch: "expected-image-bump.patch",
- skip: "patch authoring (track C of docs/design/build-order.md): writing an " +
- "images: declaration into the overlay is not built, and is not PR 2 either",
+ skip: "patch authoring (docs/design/support-boundary/patch-authoring.md, step 1): " +
+ "writing an images: declaration into the overlay is not built",
},
{
// The refusal half of shape 8, and the reason the shape is in the set at all: the
@@ -224,7 +236,7 @@ func layoutCorpus() []corpusScenario {
}
}
-// TestLayoutCorpus runs every scenario in docs/layout/ against the real write path.
+// TestLayoutCorpus runs every scenario in test/fixtures/layout-corpus/ against the real write path.
func TestLayoutCorpus(t *testing.T) {
for _, sc := range layoutCorpus() {
t.Run(sc.name(), func(t *testing.T) {
@@ -236,6 +248,44 @@ func TestLayoutCorpus(t *testing.T) {
}
}
+// TestLayoutCorpus_EveryExpectationIsAsserted closes the corpus over its expectations, which the
+// folder-level guard above cannot do. A folder can be executed by one scenario and still carry an
+// expected-*.patch or expected-*-status.yaml that no row names, and such a file is worse than an
+// absent one: it reads in review as a behavior that is pinned, and pins nothing.
+//
+// That is not hypothetical. Shape 7's expected-shared-layer-status.yaml sat here unasserted for
+// long enough to describe a refusal the writer does not produce, in a condition shape the harness
+// cannot even read, while its README called it "the whole result".
+func TestLayoutCorpus_EveryExpectationIsAsserted(t *testing.T) {
+ asserted := map[string]bool{}
+ for _, sc := range layoutCorpus() {
+ expectation := sc.patch
+ if expectation == "" {
+ expectation = sc.status
+ }
+ asserted[sc.dir+"/"+expectation] = true
+ }
+ for _, parent := range []string{"shapes", "specific-examples"} {
+ entries, err := os.ReadDir(filepath.Join(layoutCorpusRoot, parent))
+ require.NoError(t, err)
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ dir := parent + "/" + entry.Name()
+ files, err := os.ReadDir(filepath.Join(layoutCorpusRoot, dir))
+ require.NoError(t, err)
+ for _, f := range files {
+ if !strings.HasPrefix(f.Name(), "expected-") {
+ continue
+ }
+ require.True(t, asserted[dir+"/"+f.Name()],
+ "%s/%s is an expectation no scenario in layoutCorpus() asserts", dir, f.Name())
+ }
+ }
+ }
+}
+
func runCorpusScenario(t *testing.T, sc corpusScenario) {
t.Helper()
folder := filepath.Join(layoutCorpusRoot, sc.dir)
@@ -350,7 +400,9 @@ func readCorpusSourceNamespaces(
}
require.NoError(t, err)
var rule v1alpha3.WatchRule
- require.NoError(t, yaml.Unmarshal(raw, &rule), "parsing %s", path)
+ // Strict, for the same reason the GitTarget above is: a fixture naming a field the API
+ // does not have must fail to parse rather than be quietly ignored.
+ require.NoError(t, yaml.UnmarshalStrict(raw, &rule), "parsing %s", path)
require.Equal(t, target.Name, rule.Spec.TargetRef.Name,
"%s points at a different GitTarget than the scenario's config", path)
for _, item := range rule.Spec.Rules {
diff --git a/internal/git/manifestedit/kustomization.go b/internal/git/manifestedit/kustomization.go
index 86877ed1..38c6657c 100644
--- a/internal/git/manifestedit/kustomization.go
+++ b/internal/git/manifestedit/kustomization.go
@@ -9,11 +9,9 @@ import (
"gopkg.in/yaml.v3"
)
-// Kustomization override sections the editor accepts. The editor is the
-// mechanism half of the images/replicas edit-through
-// (docs/design/support-boundary/finished/images-and-replicas-edit-through.md): it updates the
-// scalar value of a field that ALREADY EXISTS on an entry that ALREADY EXISTS,
-// and nothing else — it never adds or removes entries, keys, or files.
+// Kustomization override sections the editor accepts. The editor is the mechanism half of the
+// images/replicas edit-through: it updates the scalar value of a field that ALREADY EXISTS on an
+// entry that ALREADY EXISTS, and nothing else — it never adds or removes entries, keys, or files.
const (
KustomizationSectionImages = "images"
KustomizationSectionReplicas = "replicas"
diff --git a/internal/git/placement_metrics_test.go b/internal/git/placement_metrics_test.go
index e13ba2c1..42453990 100644
--- a/internal/git/placement_metrics_test.go
+++ b/internal/git/placement_metrics_test.go
@@ -89,8 +89,7 @@ func flushWithPolicy(
// The signal the Option C deletion owes its users: a repository whose layout this operator
// cannot derive gets the canonical path, and `source="canonical"` on placements_total names
// the GitTarget and the type that needs one `placement.byType` line. Without the labels the
-// counter would only say a fall-back happened somewhere, which is not a fix anybody can act
-// on — see docs/design/open-asks-priority.md.
+// counter would only say a fall-back happened somewhere, which is not a fix anybody can act on.
func TestPlacementMetrics_CanonicalFallbackNamesTargetAndType(t *testing.T) {
reader, err := telemetry.InitTestExporter()
require.NoError(t, err)
diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go
index a00c62ec..d581a6b7 100644
--- a/internal/git/plan_flush.go
+++ b/internal/git/plan_flush.go
@@ -27,20 +27,11 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/typeset"
)
-// flushEventsToWorktree is the plan-then-flush write path (M7), described in
-// docs/spec/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.
+// flushEventsToWorktree is the plan-then-flush write path: model the subtree once, resolve each
+// event to a single-identity action, apply to commit-scoped buffers, flush only what changed.
//
-// This is the steady-state half of the design's "Two Paths, One Plan Type"
-// (docs/spec/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.
+// Every event is a single-identity intent and the writer NEVER mark-and-sweeps a batch.
+// Whole-folder mark-and-sweep is the resync path, not steady state.
// mapperForCluster returns the GVK->GVR lookup for a source cluster: the per-cluster registry
// when a cluster is named and a cluster resolver is wired, else the default (local) mapper.
// The CLI and tests leave clusterMapper nil, so they always resolve against `mapper`.
@@ -148,15 +139,9 @@ type writeBatch struct {
// is "declare nothing", i.e. infer per document, which is what every caller with no GitTarget
// to read (the CLI, most tests) gets.
namespaces namespacePolicy
- // pruneMode is the GitTarget's effective spec.prune.mode, gating the EXPLICIT delete
- // path only (applyDelete). The inferred mark-and-sweep is gated a layer up, in the
- // planner, so a suppressed drop never becomes an action in the first place.
- //
- // Set only on the live-event batch, because that is the only batch that folds DELETE
- // events; the resync batch drops documents through the plan instead and leaves this
- // zero. It is therefore always read through OrDefault: the zero value is unset, not
- // `never`, and reading it literally would make a batch that simply never set it stop
- // mirroring deletes.
+ // pruneMode gates the EXPLICIT delete path only; inferred mark-and-sweep is gated in the
+ // planner. Always read through OrDefault: the zero value is unset, not `never`, and the resync
+ // batch never sets it — reading it literally would stop that batch mirroring deletes.
pruneMode v1alpha3.PruneMode
// writeSubdir is spec.path expressed relative to the render anchor (renderBase) — the
// write jail. It is "" for a self-contained subtree (renderBase == spec.path), where
@@ -175,17 +160,11 @@ type writeBatch struct {
// status.placement, and createNew reads it: a folder covering several render roots has no
// single one to place a new document into, so placing one is refused rather than guessed.
layout manifestanalyzer.LayoutResolution
- // coldBundles tracks, per path, the new resources this batch has placed at a
- // path that held no document before the batch started (keyed the same as
- // buffers). It exists so several new resources that render to the same
- // brand-new path — a collision LocateNew resolves against the pre-batch store
- // and therefore cannot see coming — form one deterministic, resource-identity-
- // sorted multi-document file instead of each writeWholeFile call silently
- // discarding the one before it. See
- // docs/layout/new-file-placement-rules.md,
- // "Collision and append behavior": "if several new plaintext resources in one
- // plan render to the same path, write a multi-document file in deterministic
- // resource-identity order."
+ // coldBundles tracks new resources placed at a path that held nothing before the batch.
+ // LocateNew resolves against the PRE-BATCH store, so it cannot see two new resources landing
+ // on one path; without this each write would silently discard the last. Members are re-sorted
+ // by resource identity so the result does not depend on event order.
+ // See docs/layout/new-file-placement-rules.md, "Collision and append behavior".
coldBundles map[string][]coldBundleMember
}
@@ -250,29 +229,20 @@ func newWriteBatch(
return batch
}
-// refusal runs the structure-only acceptance gate over the batch's store and returns a
-// *manifestanalyzer.AcceptanceRefusedError when the GitTarget subtree holds content the
-// operator cannot safely manage: a duplicate manifest identity, an impure managed file, a
-// standalone non-KRM / invalid YAML file, a managed resource hiding in a build directive,
-// or an unsupported kustomization. A refusal aborts the commit before any file is touched,
-// so the folder is left exactly as the human left it until they clean it.
+// refusal runs the acceptance gate over the batch's store; a refusal aborts the commit before any
+// file is touched.
//
-// It is structure-only on purpose: the writer must never refuse on a discovery-derived
-// followability fact (unwatched / out-of-scope), which can blink on a discovery wobble and
-// would otherwise turn a transient into a stuck, unwritable GitTarget.
+// Structure-only on purpose: refusing on a discovery-derived fact (unwatched / out-of-scope) would
+// turn a discovery wobble into a stuck, unwritable GitTarget.
func (wb *writeBatch) refusal() error {
return manifestanalyzer.RefusalError(manifestanalyzer.AcceptStructureOnly(wb.store))
}
-// sourceNamespaceRefusal is the write-plan precondition for the one-source-namespace rule: a target
-// that declared its folder namespace-free admits exactly one source namespace, and the second is
-// refused before a byte moves.
+// sourceNamespaceRefusal is the write-plan precondition for the one-source-namespace rule.
//
-// It is the CORRECTNESS layer, and it holds whatever admission did. The WatchRule admission check
-// is atomic feedback at the moment the mistake is made, but it is one-shot: it cannot see a
-// serializeNamespace flipped to false after the rules were created, and it is a fail-open webhook
-// that a cluster need not be running at all. Everything that must be true of the bytes is decided
-// here. See docs/spec/where-validation-lives.md.
+// This is the CORRECTNESS layer and it holds whatever admission did: the WatchRule webhook is
+// one-shot, cannot see a serializeNamespace flipped afterwards, and fails open.
+// See docs/spec/where-validation-lives.md.
func (wb *writeBatch) sourceNamespaceRefusal() error {
issues := manifestanalyzer.MultipleSourceNamespacesRefusal(
wb.namespaces.declaresNamespaceFree(),
@@ -396,15 +366,10 @@ func wroteBytes(o upsertOutcome) bool {
return o == upsertCreated || o == upsertUpdated
}
-// createNew resolves the placement of a resource with no existing document —
-// declared policy (Option B), the folder's one kustomize root, or the canonical
-// fallback — per docs/layout/new-file-placement-rules.md,
-// adds the kustomize resources: entry the placement may require, and writes the new
-// document: a brand-new file, or an additional document appended to an existing
-// accepted plaintext bundle. A placement LocateNew cannot honour safely (today, only
-// a sensitive resource whose resolved path collides with an existing file) is logged
-// and left unwritten rather than risking a mis-write; the next event or resync
-// retries it once the conflict is resolved (e.g. the placement policy is fixed).
+// createNew places a resource with no existing document, per
+// docs/layout/new-file-placement-rules.md, and adds any kustomize resources: entry it requires.
+// A placement that cannot be honoured safely (a sensitive resource colliding with an existing
+// file) is logged and left unwritten rather than mis-written; the next event or resync retries.
func (wb *writeBatch) createNew(ctx context.Context, event Event) (upsertOutcome, error) {
kind := ""
if event.Object != nil {
@@ -482,16 +447,10 @@ func (wb *writeBatch) createNew(ctx context.Context, event Event) (upsertOutcome
wb.appendKustomizationResource(ctx, event, placement)
}
- // A new document that joins a kustomization's resources: list is INSIDE a render root, so
- // the folder's images:/replicas: entries govern it from the moment it lands — and we do not
- // route a new document's values onto an entry (it has no override chain yet; it did not
- // exist when the store was built). So the live value goes into the file, and if an entry
- // overrides it, the folder renders something else and the resource never converges.
- //
- // Declaring it governed puts it in front of the oracle, which turns that from a silent
- // non-converging commit into a reported refusal naming the file and the object. It does not
- // make the write work — that needs attribution for a document that does not exist yet — but
- // "we cannot express this here" is an answer, and quietly writing a lie is not.
+ // A new document inside a render root is governed by the folder's images:/replicas: entries
+ // immediately, but has no override chain to route onto, so an entry that overrides it makes
+ // the resource never converge. Declaring it governed puts it in front of the oracle, turning a
+ // silent non-converging commit into a refusal naming the file and object.
wb.putToKustomize = wb.putToKustomize || placement.Kustomization != nil || wb.createdRoot != nil
wb.intend(markUnchecked(intentFor(live, placement.Path, false), sensitive))
return outcome, nil
@@ -556,22 +515,12 @@ func (wb *writeBatch) placeNewDocument(
buf := wb.buffer(placement.Path)
if buf.original == nil {
- // Nothing occupied this path before the batch started, so every write
- // here is a new resource: this event, or an earlier one in the same
- // batch that rendered to the same path (a collision LocateNew cannot
- // see coming — it only ever consults the pre-batch store). Route
- // through the cold-bundle path so a collision forms a deterministic
- // multi-document file instead of a second writeWholeFile silently
- // discarding whichever new resource arrived first.
+ // Nothing occupied this path pre-batch, so route through the cold-bundle path: a collision
+ // LocateNew could not see must form a deterministic multi-document file rather than one
+ // write discarding another.
//
- // A sensitive resource must never share a file (with anything), and a
- // plaintext resource must never join a bundle that already holds a
- // sensitive member — either way the file would co-mingle encrypted and
- // plaintext documents. Skip rather than mix; the next event or resync
- // retries once the placement policy stops routing them together. This is
- // the same-batch half of Option B2's write-safety guard (the cross-batch
- // half — appending into an already-encrypted file — is refused in
- // LocateNew/finishPlacement).
+ // A sensitive resource never shares a file, and plaintext never joins a bundle holding a
+ // sensitive member. Skip rather than co-mingle; the next event or resync retries.
if buf.current != nil && (sensitive || wb.coldBundleHasSensitive(placement.Path)) {
log.FromContext(ctx).Info(
"Skipping new resource: sensitive and plaintext resources must not share a new file",
@@ -588,18 +537,9 @@ func (wb *writeBatch) placeNewDocument(
return outcome, "", err
}
-// writeColdBundleMember writes a resource with no existing document to rel, a
-// path nothing occupied before this batch started. Because LocateNew resolves
-// every event against the pre-batch store snapshot (P2 of the design doc),
-// several new resources rendering to the same brand-new path each look like the
-// sole occupant to LocateNew, so a plain single-document write would let each
-// one overwrite the last. Instead every member seen so far at rel (including
-// this one) is re-sorted by resource identity and the file is rebuilt from
-// scratch, so the result is independent of which new resource's event the
-// writer processed first — see the design doc's "Collision and append
-// behavior": "if several new plaintext resources in one plan render to the same
-// path, write a multi-document file in deterministic resource-identity order."
-// For the common single-member case this produces byte-identical output to a
+// writeColdBundleMember writes a resource to a path nothing occupied before this batch. Every
+// member seen at rel is re-sorted by resource identity and the file rebuilt, so the result does
+// not depend on which event was processed first. The single-member case is byte-identical to a
// plain write.
func (wb *writeBatch) writeColdBundleMember(
ctx context.Context,
@@ -727,20 +667,15 @@ func (wb *writeBatch) appendKustomizationResource(
}
}
-// 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.
+// applyFieldPatch sets only the patch's declared field paths on the existing parent document.
//
-// 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).
+// Two refusals make a partial intent safe:
+// - NO creation path: a patch whose parent is absent is dropped rather than fabricated.
+// - A document that cannot be patched field-by-field is SKIPPED, never whole-replaced, which
+// would delete every field the subresource did not mention.
//
-// 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.
+// The document index is re-derived from CURRENT bytes so an earlier event in the 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 {
@@ -856,19 +791,13 @@ func (wb *writeBatch) resolveFieldPatchTarget(event Event) (string, manifestedit
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.
+// patchExisting edits the managed document in place, preserving sibling bytes and hand-authored
+// formatting. The no-op / patch / whole-replace / skip choice is a plan decision, not a per-event
+// heuristic, and the position is re-derived from CURRENT bytes so an earlier event in the batch
+// does not misdirect it.
//
-// When the document is governed by a kustomize images/replicas override chain, the
-// desired projection is first split: values the chain produces are restored to their
-// source form (so the file keeps its bytes) and the divergence is routed to the
-// override entries instead — see docs/design/support-boundary/finished/images-and-replicas-edit-through.md.
+// Under a kustomize images/replicas override chain the desired projection is split first: values
+// the chain produces are restored to source form and the divergence routed to the entries.
func (wb *writeBatch) patchExisting(
ctx context.Context,
event Event,
@@ -922,25 +851,17 @@ func (wb *writeBatch) patchExisting(
outcome = upsertUpdated
}
- // Declare what this document must render to. Attribution above decided WHERE the edit
- // goes and is allowed to be wrong; the render precondition adjudicates it once the whole
- // plan is known (see renderPrecondition).
+ // Declare what this document must render to. Attribution decided WHERE the edit goes and may
+ // be wrong; renderPrecondition adjudicates once the whole plan is known.
//
- // A GOVERNED document declares its intent even when its own bytes did not change, and
- // that is not belt-and-braces — it is the difference between the oracle working and the
- // oracle refusing perfectly good writes. An images: entry is shared: when two Deployments
- // run the same image and are bumped together, the FIRST event's entry edit already moves
- // what the second one renders to, so by the time the second is processed there is nothing
- // left to write. Its render still moves, and it moves onto its own live state — that is
- // the resource converging, not collateral damage, and only its declared intent says so.
+ // A GOVERNED document declares intent even when its own bytes did not change. images: entries
+ // are shared: bumping two Deployments on one image means the first event's edit already moved
+ // what the second renders to, leaving nothing to write. Its render still moves, onto its own
+ // live state, and only the declared intent says that is convergence rather than damage.
//
- // The oracle is armed for ANY document a render root produces, not only one an override
- // chain governs, and the difference is a hole rather than a refinement. The source form
- // leaves a field the build supplies to the source file — but where the live object and the
- // render DISAGREE the user has changed something, and that change is written through. If a
- // transformer or a patch owns that field it will be overridden right back, and the write
- // never converges. Only the re-render can see that, and until now it did not run at all
- // unless an images:/replicas: entry happened to exist somewhere in the chain.
+ // The oracle is armed for ANY document a render root produces, not only one an override chain
+ // governs: where live and render disagree the user changed something, and if a transformer
+ // owns that field the write never converges. Only the re-render can see that.
if dm.Rendered != nil {
wb.putToKustomize = true
}
@@ -1050,20 +971,13 @@ func renderFidelityRefusal(
return &manifestanalyzer.AcceptanceRefusedError{Issues: issues}
}
-// renderPrecondition is the oracle, and it is a write-plan precondition like the three
-// above it: it runs at the one moment the whole plan is known and before a single byte is
-// touched, so a refusal aborts the flush and commits nothing.
-//
-// It only runs when the flush actually routed something through a kustomization. A repo
-// with no override chain pays nothing, and a flush that changed no governed document has
-// nothing for kustomize to adjudicate.
+// renderPrecondition is the oracle: it runs once the whole plan is known and before a byte is
+// touched, so a refusal aborts the flush and commits nothing. It only runs when the flush routed
+// something through a kustomization, so a repo with no override chain pays nothing.
//
-// A refusal is an AcceptanceRefusedError, which is the seam that carries it to the user as
-// GitPathAccepted=False / Stalled=True with the file and object named. That is deliberate:
-// render-attribution.md §7 is explicit that a proposal the renderer cannot vouch for
-// "becomes a refused flush — that is the correct outcome and it must be reported, not
-// absorbed." A resource we silently stop mirroring is the failure this path exists to
-// prevent, so it must not be the failure this path introduces.
+// A refusal is an AcceptanceRefusedError, surfacing as GitPathAccepted=False / Stalled=True with
+// the file and object named. Silently not mirroring a resource is the failure this path exists to
+// prevent, so it must not be the failure it introduces.
func (wb *writeBatch) renderPrecondition() error {
if !wb.putToKustomize {
return nil
@@ -1108,16 +1022,13 @@ func (wb *writeBatch) intend(in manifestanalyzer.WriteIntent) {
wb.intents = append(wb.intents, in)
}
-// intentFor builds the intent for an ordinary object-bearing write: the document must
-// render to exactly the live object.
+// intentFor builds the intent for an object-bearing write: the document must render to exactly
+// the live object.
//
-// It takes the LIVE object, not the event, and that distinction is load-bearing. createNew
-// strips metadata.namespace out of the bytes it writes when the destination inherits its
-// namespace from a kustomization's namespace: transformer — correct, because the transformer
-// puts it back. But the render therefore HAS the namespace, so an intent built from the
-// stripped object would demand that the render not have one, and the oracle would refuse a
-// flush it had just planned perfectly. The bytes and the intent are different objects, and
-// the caller is the only one that still holds both.
+// It takes the LIVE object, not the event. createNew strips metadata.namespace when the
+// destination inherits it from a transformer, but the RENDER then has it, so an intent built from
+// the stripped object would demand a render without one and the oracle would refuse a perfectly
+// planned flush. The bytes and the intent are different objects.
func intentFor(live *unstructured.Unstructured, filePath string, governed bool) manifestanalyzer.WriteIntent {
desired := manifestreport.Project(live)
return manifestanalyzer.WriteIntent{
@@ -1316,17 +1227,12 @@ func (wb *writeBatch) writeWholeFile(ctx context.Context, event Event, rel strin
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.
+// applyDelete removes the document a DELETE event targets, located by content so a manifest moved
+// off its canonical path is still found. Removing the last document marks the file for deletion.
//
-// The target's spec.prune.mode gates this whole path: under `never` the managed document is
-// left exactly as Git holds it. The check is FIRST, before the document is even located, so a
-// suppressed delete touches no buffer, records no write intent, and cannot turn the kustomize
-// oracle on — a retention must be indistinguishable from the event never having arrived.
+// spec.prune.mode gates the whole path, and the check is FIRST, before the document is located: a
+// suppressed delete must be indistinguishable from the event never having arrived, so it touches
+// no buffer and cannot turn the kustomize oracle on.
func (wb *writeBatch) applyDelete(ctx context.Context, event Event) {
if !wb.pruneMode.OrDefault().AppliesEventDeletes() {
log.FromContext(ctx).V(1).Info("source DELETE not mirrored (spec.prune.mode)",
@@ -1545,15 +1451,12 @@ func (wb *writeBatch) resolveDelete(event Event) (deleteTarget, bool) {
return deleteTarget{}, false
}
-// rawManifestIDForCurrentBytes maps an effective manifest identity back to the raw
-// identity as written in the file: when the namespace came from anywhere but the file — a
-// kustomization's namespace: transformer, or the GitTarget's declaration that this folder's
-// documents carry none — the bytes hold no metadata.namespace, so the document is located by a
+// rawManifestIDForCurrentBytes maps an effective manifest identity back to the raw identity as
+// written: where the namespace came from anywhere but the file, the document is located by a
// namespace-less identity.
//
-// It reads the DOCUMENT, never spec.serializeNamespace. The setting says what the next write will
-// contain; this asks what the file already contains, and a folder written before the setting
-// changed still has to be found.
+// It reads the DOCUMENT, never spec.serializeNamespace: the setting says what the NEXT write will
+// contain, and a folder written before it changed still has to be found.
func rawManifestIDForCurrentBytes(
id manifestedit.Identity,
dm *manifestanalyzer.DocumentModel,
@@ -1576,16 +1479,12 @@ func currentDocIndex(filePath string, content []byte, id manifestedit.Identity)
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.
+// flush writes every dirty buffer and removes every deleted one, staging each change.
//
-// Before touching a single byte it enforces the write-plan precondition (§4.3 of
-// docs/spec/gitpath-foreign-content-stringency.md): no path the operator is about to
-// write, edit, or delete may be shadowed by the active .gittargetignore. The check is a
-// precondition, not a post-hoc detector, so the unrecoverable state (an ignored file the
-// operator can no longer see) is never reached — the flush is refused and the GitTarget
-// fails before the file exists.
+// Before touching a byte it enforces the write-plan precondition: no path about to be written may
+// be shadowed by the active .gittargetignore. A precondition rather than a detector, so the
+// unrecoverable state (an ignored file the operator can no longer see) is never reached.
+// See docs/spec/gitpath-foreign-content-stringency.md §4.3.
func (wb *writeBatch) flush(ctx context.Context, worktree *gogit.Worktree, root, base string) (bool, error) {
// Write-plan preconditions run before any byte is touched, so a violation aborts the
// whole flush and commits nothing (each reuses the existing "refusal aborts before a file
@@ -1727,16 +1626,10 @@ func (wb *writeBatch) writePathEscapesScope(rel string) bool {
return wb.writeSubdir != "" && !pathWithin(clean, wb.writeSubdir)
}
-// fanInPrecondition enforces the L2 write-boundary invariant: never write a live change
-// through into a source file that more than one kustomize render root reaches (write-fan-in
-// > 1). It refuses the whole flush — one IssueWriteFanIn per offending path — when a
-// dirty/deleted buffer targets a file the store flags either as override-ambiguous
-// (reasonAmbiguousOverrides) or, since render-root scoping, as reachable from more than one
-// render root at all (ReachedByMultipleRenderRoots). The generalised check no longer leans on
-// the emergent side effect that a namespace-ambiguous base with no override entries never
-// becomes dirty: any file two roots read is refused for in-place editing, whether or not an
-// images/replicas entry is at stake. It fires only on an actual planned write, so a base
-// reached by a single overlay (write-fan-in = 1) is edited through normally.
+// fanInPrecondition enforces the L2 write-boundary invariant: never write through into a source
+// file that more than one kustomize render root reaches. Any file two roots read is refused for
+// in-place editing, whether or not an images/replicas entry is at stake. It fires only on an
+// actual planned write, so a base reached by a single overlay is edited through normally.
func (wb *writeBatch) fanInPrecondition() error {
var issues []manifestanalyzer.AcceptanceIssue
for _, rel := range sortedBufferKeys(wb.buffers) {
@@ -1783,17 +1676,12 @@ func writeAndStageFile(worktree *gogit.Worktree, worktreePath, fullPath string,
return nil
}
-// scanWorktreeSubtree walks the GitTarget subtree at absBase into a
-// manifestanalyzer.FolderScan: the YAML manifests to model and hydrate, the foreign
-// entries the acceptance gate refuses, and the active root .gittargetignore matcher the
-// write-plan precondition consults. It applies the SAME shared ClassifyEntry policy the
-// analyzer's fs.FS scan uses, so the live writer and a dry-run scan agree on what is
-// foreign, what is ignored, and what is an operator artifact.
+// scanWorktreeSubtree walks the GitTarget subtree into a FolderScan, applying the SAME
+// ClassifyEntry policy the analyzer uses so the live writer and a dry-run scan agree.
//
-// A missing base directory (a never-written GitTarget path) yields an empty scan, not an
-// error. Unlike the analyzer scan, a mid-walk read error is fatal: the live writer must
-// never plan against a partial view of the subtree (an unreadable managed file it skipped
-// would be re-created, churning the mirror). Symlinks are never followed.
+// A missing base directory yields an empty scan. Unlike the analyzer scan, a mid-walk read error
+// is FATAL: planning against a partial view would re-create an unreadable managed file it skipped
+// and churn the mirror. Symlinks are never followed.
func scanWorktreeSubtree(absBase string) (manifestanalyzer.FolderScan, error) {
ignore, ignoreIssues := loadWorktreeGitTargetIgnore(absBase)
scan := manifestanalyzer.FolderScan{Ignore: ignore, IgnoreIssues: ignoreIssues}
diff --git a/internal/git/render_fidelity_gate.go b/internal/git/render_fidelity_gate.go
index 77f69726..0e5c82f1 100644
--- a/internal/git/render_fidelity_gate.go
+++ b/internal/git/render_fidelity_gate.go
@@ -53,8 +53,7 @@ type renderFidelityScopeResult struct {
// stale tail is correct and must stay; being unable to see that it happened is not. A scope
// stuck pending looks identical whether no stream has reported yet or a stream is reporting
// steadily under a revision the plan has moved past, and those two have opposite repairs —
- // the first waits, the second can wait for ever
- // (docs/design/watch-plane-status-convergence-failures.md, §2.5).
+ // the first waits, the second can wait for ever.
refusedRevision uint64
}
@@ -80,8 +79,7 @@ type renderFidelityTargetState struct {
// a cell whose stream is left running across a plan change keeps its result and its revision,
// and only the cells that were started or restarted go back to pending. A target-wide epoch
// would have marked every cell pending on every plan edit — closing writes on a target whose
-// streams never moved — and would have cleared a divergence that nothing re-measured
-// (docs/design/target-watch-plan.md, "Readiness").
+// streams never moved — and would have cleared a divergence that nothing re-measured.
type RenderFidelityGate struct {
mu sync.RWMutex
targets map[string]renderFidelityTargetState
@@ -297,7 +295,7 @@ func reduceRenderFidelity(state renderFidelityTargetState) RenderFidelityStatus
// the one surface an operator, a WatchRule and an e2e assertion can all read — said that
// something was pending but never what, how many, or under which revision. A roll-up that
// cannot name what it is waiting for is not observable, and an unobservable roll-up that latches
-// is indistinguishable from a hang (docs/design/watch-plane-status-convergence-failures.md).
+// is indistinguishable from a hang.
//
// The revision is part of the answer, not decoration. The failure mode this diagnoses is a scope
// holding a revision that no running stream will ever report under, so "which revision" is
diff --git a/internal/git/render_fidelity_gate_test.go b/internal/git/render_fidelity_gate_test.go
index 6b392fd4..09e7e30f 100644
--- a/internal/git/render_fidelity_gate_test.go
+++ b/internal/git/render_fidelity_gate_test.go
@@ -275,8 +275,7 @@ func TestRenderFidelityGate_AnEmptyPlanDoesNotClearAWriteDivergence(t *testing.T
// TestRenderFidelityGate_PendingMessageNamesTheScope is the regression guard for the diagnostic
// gap that made Failure A cost three rounds of controller-log archaeology: the gate knew which
-// scope it was waiting on and published a constant string that named nothing
-// (docs/design/watch-plane-status-convergence-failures.md, §2.3).
+// scope it was waiting on and published a constant string that named nothing.
//
// The GitTarget condition carries this message verbatim, and a WatchRule's Ready inherits it, so
// this string is the whole diagnosis surface for a target that will not converge. It must name
@@ -327,8 +326,7 @@ func TestRenderFidelityGate_PendingMessageIsBounded(t *testing.T) {
// roll-up. Refusing a report that carries a superseded revision is correct and must stay — a stale
// tail must never reopen writes — but a scope that is refusing reports and a scope that has simply
// not been replayed yet look identical from outside, and they have opposite repairs: one converges
-// by waiting, the other never does
-// (docs/design/watch-plane-status-convergence-failures.md, §2.4).
+// by waiting, the other never does.
func TestRenderFidelityGate_PendingMessageNamesARefusedReport(t *testing.T) {
gate := NewRenderFidelityGate()
target := types.NewResourceReference("apps", "default")
diff --git a/internal/git/resync_push_test.go b/internal/git/resync_push_test.go
index b2788960..e8d57ed1 100644
--- a/internal/git/resync_push_test.go
+++ b/internal/git/resync_push_test.go
@@ -265,7 +265,7 @@ func TestHandleResyncRequest_ClosedWindowIsPushedEvenWhenNoOpResync(t *testing.T
}
// TestEnqueueResync_DoesNotCoalescePastQueuedWrites pins the ordering fence on
-// coalescing (docs/design/target-watch-plan.md, "Queue ordering and coalescing").
+// coalescing.
// Coalescing reuses the queued
// marker's FIFO POSITION, and that position is only correct while nothing for the
// scope sits behind it. Once a write inside the scope is queued, running a newer
diff --git a/internal/git/resync_scope_test.go b/internal/git/resync_scope_test.go
index ffe9f8d9..a01ae7c0 100644
--- a/internal/git/resync_scope_test.go
+++ b/internal/git/resync_scope_test.go
@@ -225,7 +225,7 @@ func TestResync_ClusterWideScopeStillSweepsEveryNamespace(t *testing.T) {
// a full GVR while Matches compared group, resource and namespace only, so two served versions
// of one resource were two coalescing keys, two deferred-heal keys and two render-fidelity
// scopes — over one sweep boundary. The version is now data on the scope, and the cell is the
-// identity (docs/design/target-watch-plan.md, "Diff the plan").
+// identity.
func TestResyncScope_ServedVersionIsDataNotIdentity(t *testing.T) {
v1 := ResyncScopeFor(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "team-a")
v1beta1 := ResyncScopeFor(
diff --git a/internal/git/serialize_namespace_test.go b/internal/git/serialize_namespace_test.go
index e44e23f7..a5c5a2ab 100644
--- a/internal/git/serialize_namespace_test.go
+++ b/internal/git/serialize_namespace_test.go
@@ -18,10 +18,11 @@ import (
)
// spec.serializeNamespace overrides the inference at every site that decides whether
-// metadata.namespace is in the bytes. The corpus (docs/layout/shapes/2-flat-namespace-free and
-// 4-tree-namespace-free) pins the FIRST write of a namespace-free folder; what it cannot pin is
-// everything after it — an update, a second write of the same object, and the folder that already
-// supplies the namespace being overridden the other way. Those are here.
+// metadata.namespace is in the bytes. The corpus (shapes/2-flat-namespace-free and
+// 4-tree-namespace-free under test/fixtures/layout-corpus) pins the FIRST write of a
+// namespace-free folder; what it cannot pin is everything after it: an update, a second write
+// of the same object, and the folder that already supplies the namespace being overridden the
+// other way. Those are here.
func serializeNamespacePolicy(serialize bool, sources ...string) namespacePolicy {
return namespacePolicy{Serialize: &serialize, SourceNamespaces: sources}
@@ -144,7 +145,7 @@ func TestSerializeNamespace_FalseAttributesNothingWhenTwoNamespacesReachTheTarge
// billing/checkout-config both resolve to bytes carrying no namespace, so one document ends up
// flipping between two live objects and Git holds no record that it happened.
//
-// docs/layout/shapes/2-flat-namespace-free asserts the same refusal end to end, against the
+// test/fixtures/layout-corpus/shapes/2-flat-namespace-free asserts the same refusal end to end, against the
// condition a user reads. This asserts the halves that fixture cannot: that the refusal is a
// precondition (nothing is written, whatever the events were), and that a wildcard is refused
// without enumerating anything.
diff --git a/internal/git/types.go b/internal/git/types.go
index 32788a06..246805c5 100644
--- a/internal/git/types.go
+++ b/internal/git/types.go
@@ -29,38 +29,27 @@ const (
// AttributionNotAttempted is configured-author mode: attribution is switched off, so the
// committer legitimately IS the author and no actor was ever sought.
//
- // It is deliberately the EMPTY string, so that it is also the ZERO VALUE of the type. Most
- // paths that build an Event never assign Attribution at all — reconcile, resync, bootstrap,
- // and configured-author mode's early return in the watch pipeline — and every one of them
- // means exactly "no actor was sought". Any other string would make the zero value a silent
- // fourth state equal to none of the three named outcomes, which is precisely the bug that
- // stopped every CommitRequest attaching in the default deployment. Nothing serializes this
- // value (it reaches no CRD field and no metric label; authorKind branches on the typed
- // value), so the empty string costs nothing. TestAttributionZeroValueIsNotAttempted pins it.
+ // Deliberately the EMPTY string so it is also the ZERO VALUE: the many paths that never assign
+ // Attribution (reconcile, resync, bootstrap) all mean exactly this. Any other value makes the
+ // zero value a silent fourth state, which is the bug that stopped every CommitRequest
+ // attaching. TestAttributionZeroValueIsNotAttempted pins it.
AttributionNotAttempted AttributionOutcome = ""
// AttributionResolved means an audit fact named the actor.
AttributionResolved AttributionOutcome = "resolved"
// AttributionUnresolved means attribution ran and did not arrive at an actor.
//
- // Deliberately "unresolved", not "failed": the lookup collapses several genuinely
- // different situations into one miss — no fact was ever produced (correct; not every
- // change has an audited human actor), a cancelled wait, a Redis read error, and a
- // malformed value all return the same not-found. Calling that a failure would assert a
- // fault the operator cannot prove.
+ // "Unresolved", not "failed": no fact produced (correct; not every change has a human actor),
+ // a cancelled wait, a read error and a malformed value all return the same not-found, so
+ // calling it a failure would assert a fault the operator cannot prove.
AttributionUnresolved AttributionOutcome = "unresolved"
)
// NamesActor reports whether the outcome carries an actor to compare against.
//
-// This is the ONLY distinction that survives across subsystem boundaries. Whether an outcome
-// is "not attempted" or "unresolved" is a property of how the subsystem that produced it is
-// configured — and the mirrored-resource attribution path (--author-attribution) and the
-// command-authorship path (--admission-webhook) are configured independently of each other
-// (cmd/main.go:311-316). Two independently configured producers can therefore disagree about
-// the enum while agreeing perfectly about the thing that matters: whether there is an actor.
-// Compare the enums across that boundary and you couple the two flags; compare NamesActor and
-// you do not. Within a single subsystem the enum itself is meaningful and IS compared directly
-// (openWindow.canAppend), because both sides come from the same producer.
+// The ONLY distinction that survives across subsystem boundaries: --author-attribution and
+// --admission-webhook are configured independently, so two producers can disagree about the enum
+// while agreeing about whether there is an actor. Comparing enums across that boundary couples the
+// two flags; comparing NamesActor does not. Within one subsystem the enum IS compared directly.
func (o AttributionOutcome) NamesActor() bool {
return o == AttributionResolved
}
@@ -69,21 +58,13 @@ func (o AttributionOutcome) NamesActor() bool {
// did not resolve an actor. It exists so an unresolved attribution is visible in `git log`
// instead of being indistinguishable from a configured-author commit.
//
-// Scope: the git author header, and nothing else. It is DERIVED at the write path
-// (commitOptionsFor) from the carried AttributionOutcome — it is never stamped onto an Event.
-// The outcome is the fact; this identity is one rendering of it. So the sentinel deliberately
-// does NOT reach window grouping, the grouped commit-message body, or user-authored
-// {{.Username}} templates: those keep the empty author they have always had for an unnamed
-// actor, on both this path and in configured-author mode. Pushing a magic token into message
-// bodies would change the commit text of every existing deployment that has attribution misses,
-// and force user templates to special-case a value they never had to handle.
+// Scope: the git author header and nothing else. DERIVED at the write path from the carried
+// outcome, never stamped onto an Event, so it does NOT reach window grouping, message bodies, or
+// {{.Username}} templates — pushing a magic token there would change commit text in every existing
+// deployment and force user templates to handle a value they never had.
//
-// Three fields, three different strings, because the header needs all three:
-// - Username is the stable machine token, so tooling that parses the header has something
-// greppable that will not drift with the human-facing wording.
-// - DisplayName is what a human reads in `git log`, so it leads with what they care about.
-// - Email uses the RFC 2606 reserved .invalid TLD, so it can never collide with a real
-// address and never routes mail.
+// Three strings because the header needs all three: Username is the greppable machine token,
+// DisplayName is what a human reads, Email uses the RFC 2606 .invalid TLD so it never routes mail.
func UnresolvedAuthor() UserInfo {
return UserInfo{
Username: UnresolvedAuthorUsername,
@@ -108,16 +89,11 @@ const (
DefaultCommitterEmail = "noreply@configbutler.ai"
// DefaultEventCommitMessageTemplate reproduces the current per-event commit message shape.
DefaultEventCommitMessageTemplate = "[{{.Operation}}] {{.APIVersion}}/{{.Resource}}/{{.Name}}"
- // DefaultReconcileCommitMessageTemplate is the default reconcile commit message shape.
- // It names the synced type for a per-type reconcile (e.g. "reconciled 6 secrets (last
- // resourceVersion: 1331)"), so the otherwise-indistinguishable per-type reconciles a single
- // GitTarget produces become self-describing — and the pinned resourceVersion shows exactly
- // how fresh the reconcile is, which is useful for demos and first-user trust. The plural
- // resource alone (no group/version) is chosen for readability; a custom template can add
- // {{.APIVersion}} when cross-group plural collisions matter. The {{if .Resource}} and
- // {{if .Revision}} guards fall back to "reconciled N resources" for a whole-target reconcile
- // (nil Scope) or the events-based atomic path, where the type/revision fields are empty —
- // so the subject never degrades to a trailing-space, identity-less "reconciled N ".
+ // DefaultReconcileCommitMessageTemplate names the synced type, so the otherwise
+ // indistinguishable per-type reconciles one GitTarget produces are self-describing. Plural
+ // resource alone for readability; add {{.APIVersion}} when plural collisions matter. The
+ // {{if}} guards fall back to "reconciled N resources" for a whole-target reconcile, so the
+ // subject never degrades to an identity-less "reconciled N ".
DefaultReconcileCommitMessageTemplate = "reconciled {{.Count}} " +
"{{if .Resource}}{{.Resource}}{{else}}resources{{end}}" +
"{{if .Revision}} (last resourceVersion: {{.Revision}}){{end}}"
@@ -261,14 +237,10 @@ type ResolvedTargetMetadata struct {
// It gates both deletion paths: the resync mark-and-sweep (through the planner's
// SweepMode) and the steady-state DELETE-event writer.
//
- // Retained on the pending write with the rest of the target's metadata, so a write replayed
- // after a rebase is not re-planned under a LOOSER policy than the one it was planned against:
- // its retention decisions were taken over a desired snapshot that is now stale, and a later
- // `always` applies to the next resync, which gathers a fresh one.
- //
- // It is not frozen, though. tightenPendingPruneModes lowers it before a replay when the
- // GitTarget's current policy is stricter, because the whole point of tightening a deletion
- // policy is to stop deletions that have not landed yet.
+ // Retained on the pending write so a replay after a rebase is not re-planned under a LOOSER
+ // policy than it was planned against. Not frozen, though: tightenPendingPruneModes lowers it
+ // when the current policy is stricter, because tightening exists to stop deletions that have
+ // not landed yet.
PruneMode v1alpha3.PruneMode
// SourceCluster is the NAME of the source cluster the GitTarget mirrors from —
// (api/v1alpha3).GitTarget.SourceCluster(), the referenced ClusterProvider's name
@@ -277,16 +249,10 @@ type ResolvedTargetMetadata struct {
// against the right cluster's mapping.
SourceCluster string
- // Suspend is the GitTarget's spec.suspend, captured with the rest of its metadata so a write
- // replayed after a rebase honours the policy it was planned under. It suppresses the write
- // only: the scan that precedes it still runs, and the layout that scan resolves is still
- // published, which is what keeps a suspended target's status fresh while it is stopped.
- //
- // Being CAPTURED is what defines suspend's cutover: it is the value as of planning, so a
- // suspension that arrives after this write was planned does not retract it, and a write
- // already committed locally is still pushed. Reading the live GitTarget at push time instead
- // would strand that commit in the worker's checkout, to surface later and out of order on
- // resume. See the field's doc on GitTargetSpec.
+ // Suspend suppresses the WRITE only; the scan still runs, which keeps a suspended target's
+ // status fresh. Being CAPTURED defines the cutover: a suspension arriving after this write was
+ // planned does not retract it, and a commit already made locally is still pushed. Reading the
+ // live GitTarget at push time would strand that commit, to resurface out of order on resume.
Suspend bool
}
@@ -361,13 +327,10 @@ type WorkItem struct {
// snapshot was actually gathered over: one cell, and the served version that cell was
// gathered at.
//
-// The invariant this type exists to hold: THE SWEEP SCOPE MUST BE EXACTLY THE SCOPE THE
-// DESIRED SET WAS GATHERED OVER. A desired set narrower than its sweep scope deletes
-// managed documents that were never in scope; a desired set wider than its sweep scope
-// silently leaves documents unmanaged. The namespace lives inside the cell, next to the
-// type, precisely so a per-namespace replay cannot reach the sweep carrying only its type. That
-// was a real defect: a replay of one namespace swept every other namespace's documents of the
-// same type, because the sweep knew the type and had lost the namespace.
+// The invariant: THE SWEEP SCOPE MUST BE EXACTLY THE SCOPE THE DESIRED SET WAS GATHERED OVER.
+// Narrower deletes documents that were never in scope; wider silently leaves documents unmanaged.
+// The namespace lives inside the cell so a per-namespace replay cannot reach the sweep carrying
+// only its type — a replay of one namespace once swept every other namespace's documents.
type ResyncScope struct {
// Cell is the sweep boundary and the scope's identity: group, resource, namespace.
Cell types.CellKey
@@ -430,13 +393,10 @@ type ResyncRequest struct {
// removed type). Nil is a whole-GitTarget resync. See ResyncScope for the invariant
// binding this to Desired.
Scope *ResyncScope
- // Heal marks a non-urgent drift-correcting resync (a watch re-establishment re-anchor or a
- // removed-type sweep) that the worker DEFERS while a commit window is open, instead of
- // force-finalizing it. Because one worker serves N GitTargets and the commit window is a
- // worker singleton, a force-finalizing heal can steal a DIFFERENT GitTarget's held
- // CommitRequest window — the 8f2ad84 regression. A heal therefore waits for the worker to be
- // idle (no open window), a boundary that recurs on every silence timeout and identity switch,
- // so it never starves and, when it runs, has no window to steal. A first-sync backfill is NOT
+ // Heal marks a non-urgent drift-correcting resync the worker DEFERS while a commit window is
+ // open. One worker serves N GitTargets and the window is a worker singleton, so a
+ // force-finalizing heal can steal a DIFFERENT GitTarget's held CommitRequest window. Waiting
+ // for idle recurs on every silence timeout, so it never starves. A first-sync backfill is NOT
// a heal: it must establish initial state promptly.
Heal bool
// SourceCell names the target-watch cell that gathered this snapshot. Zero for a
@@ -489,15 +449,10 @@ type ResyncResult struct {
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). PlacementSkipped is new resources the writer refused to
-// place fail-safe — placement could not be resolved safely, or the write would
-// co-mingle sensitive and plaintext documents (placement Option B2). It is counted (not
-// silently swallowed) and logged per-resource so a not-mirrored resource is visible
-// in the resync summary; it is not (yet) surfaced as a dedicated GitTarget status
-// condition.
+// ResyncStats summarises what a resync changed. Skipped is documents present but not safely
+// editable; PlacementSkipped is new resources the writer refused to place fail-safe. Both are
+// counted and logged per-resource rather than swallowed, so a not-mirrored resource is visible in
+// the summary. Neither has a dedicated status condition yet.
type ResyncStats struct {
Created int
Updated int
@@ -552,14 +507,10 @@ type Event struct {
// UserInfo contains user information for commit messages.
UserInfo UserInfo
- // Attribution records whether naming the actor was attempted and whether it succeeded.
- // It is the authority for author rendering, the author_kind metric, and CommitRequest
- // window matching — none of which may infer the outcome from UserInfo, because an empty
- // or sentinel username cannot distinguish "attribution is off" from "attribution ran and
- // found nothing". The zero value is AttributionNotAttempted — the constant is defined as the
- // empty string precisely so that it is — which is correct for every non-live path
- // (reconcile, resync, bootstrap) where no actor is ever sought, and for configured-author
- // mode. attachAuthor is the only assignment to this field outside tests.
+ // Attribution is the authority for author rendering, the author_kind metric, and
+ // CommitRequest window matching. None may infer the outcome from UserInfo: an empty username
+ // cannot distinguish "attribution is off" from "it ran and found nothing". attachAuthor is
+ // the only assignment outside tests.
Attribution AttributionOutcome
// Path is the POSIX-like relative path prefix for this event's files.
@@ -609,12 +560,9 @@ type FieldPatch struct {
// 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.
+ // The parent Kind is NOT carried: the audit objectRef gives only the GVR, and the subresource
+ // body's own Kind ("Scale") is not the parent's. The writer resolves the parent through the
+ // same resource-identity inventory the GVR-only delete uses.
Source string
}
@@ -652,13 +600,10 @@ type CommitMessageData struct {
// ReconcileCommitMessageData is the template context for reconcile commit messages.
//
-// Group, Version, Resource, and APIVersion name the synced type, mirroring the per-event
-// CommitMessageData fields so a reconcile template can identify its type exactly as a per-event
-// template does. They are populated for a per-type reconcile (whose ResyncRequest carries a
-// non-nil Scope) and left empty for a whole-target reconcile or the events-based atomic
-// path. Revision is the cluster resourceVersion the desired set was pinned to
-// (empty for a pure sweep or the events-based path). Any template that references these fields
-// must render cleanly when they are absent — the default guards both with {{if}}.
+// Group, Version, Resource and APIVersion mirror the per-event CommitMessageData fields, and are
+// populated only for a per-type reconcile. Revision is the resourceVersion the desired set was
+// pinned to. Any template referencing these must render cleanly when absent; the default guards
+// both with {{if}}.
type ReconcileCommitMessageData struct {
Count int
GitTarget string
diff --git a/internal/layoutfixture/layoutfixture.go b/internal/layoutfixture/layoutfixture.go
index 3f768cbf..6cc429de 100644
--- a/internal/layoutfixture/layoutfixture.go
+++ b/internal/layoutfixture/layoutfixture.go
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
-// Package layoutfixture reads the expected-*-status.yaml fixtures under docs/layout.
+// Package layoutfixture reads the expected-*-status.yaml fixtures under test/fixtures/layout-corpus.
//
// It exists because those fixtures are asserted from two packages that cannot share a test
// helper: internal/git pins the half a refusal produces (GitPathAccepted), and
@@ -17,11 +17,12 @@ import (
"sigs.k8s.io/yaml"
)
-// Root is docs/layout as reached from a package directory two levels below the repository root
+// Root is test/fixtures/layout-corpus as reached from a package directory two levels below the
+// repository root
// (internal/git, internal/controller). The fixtures are read in place rather than copied into a
// testdata directory: a copy would drift from the documents it illustrates, and the drift would
// be invisible in review.
-const Root = "../../docs/layout"
+const Root = "../../test/fixtures/layout-corpus"
// Condition is one expected condition in a fixture.
type Condition struct {
diff --git a/internal/manifestanalyzer/acceptance.go b/internal/manifestanalyzer/acceptance.go
index a08b5835..deb3399c 100644
--- a/internal/manifestanalyzer/acceptance.go
+++ b/internal/manifestanalyzer/acceptance.go
@@ -11,36 +11,23 @@ import (
"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/spec/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.
+// Acceptance is the adoption gate between building the store and using it as the planning model.
+// A folder is adopted only when it passes; any blocking refusal reconciles nothing until a human
+// cleans the folder. It refuses:
//
-// 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 duplicate manifest identity (we will not guess which copy the author meant);
+// - a managed file that is not entirely valid KRM, never an empty/non-KRM passenger. This is
+// what lets the store drop the per-document index: an accepted file's documents are contiguous;
+// - a standalone non-KRM or invalid YAML file;
+// - unwatched API-backed KRM: refused, never pruned;
+// - 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).
+// - a managed file mixing managed resources with an allowlisted non-API KRM document.
//
-// 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.
+// Non-YAML files and standalone empty documents are ignored and never refuse.
//
-// 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".
+// The mapping-aware refusals need an API source, so a structure-only store skips them and runs
+// only the structural checks. See docs/spec/current-manifest-support-review.md.
type Acceptance struct {
// Accepted is true only when no blocking refusal was found.
Accepted bool
@@ -85,16 +72,12 @@ const (
// IssueOutOfScope marks a watched kind whose resource falls outside this
// GitTarget's scope (right kind, wrong namespace).
IssueOutOfScope IssueKind = "out-of-scope"
- // IssueUnsupportedKustomize marks a retained kustomization.yaml that uses a feature
- // the contextual-namespace writer cannot map back to editable source documents
- // (generators / components / helm / replacements / transformers / name(pre|suf)fix /
- // remote bases). The folder is refused rather than written, because the operator cannot
- // take responsibility for content produced this way.
+ // IssueUnsupportedKustomize marks a kustomization using a feature the writer cannot map back
+ // to editable source. The folder is refused rather than written, because the operator cannot
+ // take responsibility for content produced that way.
//
- // `patches:` is NOT on that list any more: a strategic-merge patch named by path is
- // tolerated as read-only build context (the render is mirrored, the patch file is never
- // managed, and nothing is routed into it). The shapes we cannot read still refuse by name —
- // an inline patch, a JSON6902 op list, a path outside the tree.
+ // `patches:` is NOT on that list: a strategic-merge patch named by path is tolerated as
+ // read-only build context. The shapes we cannot read still refuse by name.
IssueUnsupportedKustomize IssueKind = "unsupported-kustomize"
// IssueForeignFile marks a non-YAML regular file under spec.path that matches no
// recognized role — the operator-exclusive subtree refuses content it cannot manage
@@ -131,13 +114,11 @@ const (
// flush was re-rendered with the write applied, and either the edited document did not
// come out as the live object, or the write moved an object it never set out to touch.
//
- // It is the write-plan half of "attribution may be heuristic, verification may not"
- // (docs/design/support-boundary/render-attribution.md §5). The projection is ALLOWED to
- // guess which file an edit belongs in, precisely because this refuses the guess when the
- // renderer disagrees. And it must refuse LOUDLY: a write that does not survive the
- // re-render is one that would not converge — the entry overrides it straight back on the
- // next render — so absorbing it would leave a resource silently un-mirrored forever,
- // which is the exact failure this whole path exists to prevent.
+ // The write-plan half of "attribution may be heuristic, verification may not": the projection
+ // is ALLOWED to guess which file an edit belongs in precisely because this refuses the guess
+ // when the renderer disagrees. It must refuse LOUDLY, because a write that does not survive
+ // the re-render would never converge and absorbing it leaves a resource silently un-mirrored.
+ // See docs/design/support-boundary/render-attribution.md §5.
IssueRenderRefused IssueKind = "kustomize-render-refused"
// IssueRenderDoesNotMatchLive marks a rendered ${...} value whose corresponding live field is
// absent or different. It is a runtime fidelity refusal, distinct from structural Git-path
@@ -147,13 +128,11 @@ const (
// document: the BUILD and the USER both rewrote one list whose elements carry no unique
// name to pair the source's with the render's by (see SourceFormRefusedError).
//
- // The alternative to refusing is aligning the two lists by position, and that is not a
- // conservative guess — it is measurably wrong: kustomize's strategic merge PREPENDS a
- // container a patch adds, so the source's first element is not the render's first element in
- // exactly the case where it matters. Writing one element's fields into another is the kind of
- // corruption no re-render can catch, because the patch re-imposes its own values and the
- // render comes out identical either way. So this refusal is not the oracle being cautious; it
- // is the one place the oracle cannot see, and it must fail loudly instead.
+ // Aligning the two lists by position is not a conservative guess but measurably wrong:
+ // kustomize's strategic merge PREPENDS a container a patch adds, so the source's first element
+ // is not the render's first in exactly the case where it matters. Writing one element's fields
+ // into another is corruption no re-render can catch, since the patch re-imposes its values.
+ // This is the one place the oracle cannot see, so it must fail loudly.
IssueUnplaceableEdit IssueKind = "unplaceable-edit"
// A refusal made up purely of the write-boundary kinds above surfaces as the GitTarget
@@ -231,16 +210,12 @@ func Accept(store *ManifestStore, policy AcceptancePolicy) Acceptance {
return acceptWith(store, policy, hasAPISource(store))
}
-// AcceptStructureOnly runs only the refusals that are pure structural facts about the
-// folder — duplicate identity, impure managed file, standalone non-KRM/invalid YAML,
-// a managed resource hiding in an allowlisted build-directive, and an unsupported
-// kustomization. It NEVER runs the mapping-aware refusals (unwatched / out-of-scope),
-// which depend on live followability discovery and can blink on a discovery wobble.
+// AcceptStructureOnly runs only the refusals that are pure structural facts about the folder. It
+// NEVER runs the mapping-aware ones, which depend on live discovery and can blink on a wobble.
//
-// This is the live writer's entry point. The writer's store is built with a ready
-// followability registry, so hasAPISource would be true and plain Accept would also run
-// the mapping refusals — but the writer must refuse only on the cases we already know are
-// a problem from structure alone, never on a transient discovery fact.
+// This is the live writer's entry point. Its store has a ready followability registry, so plain
+// Accept would run the mapping refusals too — but the writer must refuse only on what is a problem
+// from structure alone, never on a transient discovery fact.
func AcceptStructureOnly(store *ManifestStore) Acceptance {
return acceptWith(store, AcceptancePolicy{}, false)
}
diff --git a/internal/manifestanalyzer/kustomization_parse.go b/internal/manifestanalyzer/kustomization_parse.go
index 81b1eb2c..6cf744bc 100644
--- a/internal/manifestanalyzer/kustomization_parse.go
+++ b/internal/manifestanalyzer/kustomization_parse.go
@@ -13,40 +13,23 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
)
-// This file decodes kustomization.yaml with kustomize's own type
-// (sigs.k8s.io/kustomize/api/types.Kustomization) instead of a hand-written walk
-// over map[string]interface{}, and derives the unsupported-feature set by
-// reflecting over that type's fields.
-//
-// The point is not brevity. It is that the check becomes exhaustive by
-// construction: a kustomization field the operator has never heard of lands
-// outside supportedKustomizationFields and refuses the folder, instead of being
-// silently tolerated. The hand-written deny-list could only refuse what someone
-// had remembered to add to it, and it had holes — see supportedKustomizationFields.
-//
+// Decodes kustomization.yaml with kustomize's own type and derives the unsupported-feature set by
+// reflecting over that type's fields, which makes the check exhaustive by construction: a field
+// the operator has never heard of lands outside supportedKustomizationFields and refuses the
+// folder rather than being silently tolerated.
// See docs/design/support-boundary/kustomize-support-boundary.md §7.
// supportedKustomizationFields names the struct fields of kustypes.Kustomization
// that the operator models. Every other field, present and non-zero, refuses the
// folder by its own name.
//
-// Inverting the old hand-written deny-list into an allowlist over kustomize's own
-// type closed two holes it had, each of which let a kustomization render
-// differently from what we believed:
-//
-// - vars — a source document containing $(SOME_VAR) renders to the substituted
-// value. We mirrored that value straight back over the variable in the source
-// file. Silent corruption, in a folder we accepted.
-// - validators — plugin code. Arbitrary code is an unknowable render.
+// The allowlist closed two holes the old deny-list had: `vars` (a source document containing
+// $(SOME_VAR) renders to the substituted value, which we mirrored back over the variable — silent
+// corruption in a folder we accepted) and `validators` (plugin code, an unknowable render).
+// Neither could be missed here, because anything not named below is refused.
//
-// Neither was in the deny-list, and neither could have been missed here: they are
-// fields on kustomize's type, and anything not named below is refused. When
-// kustomize grows a field, it lands outside this set and refuses rather than being
-// silently tolerated.
-//
-// Deprecated spellings (bases, imageTags) are absent on purpose: FixKustomization
-// folds them into resources/images before this map is consulted, exactly as the
-// builder does.
+// Deprecated spellings are absent on purpose: FixKustomization folds them into resources/images
+// before this map is consulted, exactly as the builder does.
func supportedKustomizationFields() map[string]struct{} {
return map[string]struct{}{
"TypeMeta": {}, // apiVersion / kind
@@ -59,16 +42,11 @@ func supportedKustomizationFields() map[string]struct{} {
"Images": {},
"Replicas": {},
- // TOLERATED, NOT AUTHORED. A patch is read-only context: kustomize applies it, we
- // mirror what it renders, and nothing is ever routed INTO it. That is a weaker claim
- // than the four above, and it is the whole of what "tolerate" means — see
- // patchRefusals for the shapes that are still refused by name.
- //
- // It is only safe because the projection leaves every field the BUILD supplies to the
- // build (sourceForm): a patched base is no longer something the writer can absorb one
- // environment's values into. Tolerating patches without that is silent corruption, and
- // no re-render can catch it — the patch re-imposes its value, so the render comes out
- // identical either way.
+ // TOLERATED, NOT AUTHORED: kustomize applies it, we mirror what it renders, nothing is
+ // routed INTO it. Only safe because the projection leaves every field the BUILD supplies
+ // to the build, so a patched base cannot absorb one environment's values. Tolerating
+ // patches without that is corruption no re-render can catch, since the patch re-imposes
+ // its value and the render comes out identical either way.
"Patches": {},
// These inject metadata into every rendered object. They used to leak into mirrored
@@ -114,20 +92,13 @@ const featureRenderFailed = "render-failed"
// `path:` to a sparse KRM document inside the scanned tree — and everything else says so rather
// than falling through into a folder we would then mishandle.
//
-// The three of them are not arbitrary. Each is a different kind of thing wearing the same key:
-//
-// - an INLINE patch is bytes in the kustomization, so there is no document to retain as build
-// context and no file an authoring step could ever edit;
-// - a JSON6902 patch is not a sparse KRM document at all — it is a list of `op`/`path`/`value`
-// operations, and a file full of them would otherwise be indexed as a broken manifest;
-// - a path leaving the scanned tree is a file we never read, so we cannot know what it does.
+// Each is a different kind of thing wearing the same key: an INLINE patch has no document to
+// retain and no file to edit; a JSON6902 patch is an op list, not a sparse KRM document, and would
+// otherwise index as a broken manifest; a path leaving the tree is a file we never read.
//
-// The deprecated spellings need no entry here, and that was MEASURED rather than assumed:
-// FixKustomization folds `bases` into `resources` and `imageTags` into `images`, but it does NOT
-// fold `patchesStrategicMerge` or `patchesJson6902` into `Patches`. They stay in their own fields,
-// land outside supportedKustomizationFields, and refuse the folder under their own names — which
-// is what we want, and which a kustomize bump could change. TestParse_DeprecatedPatchSpellings
-// pins it.
+// The deprecated spellings need no entry, MEASURED rather than assumed: FixKustomization does NOT
+// fold patchesStrategicMerge or patchesJson6902 into Patches, so they refuse under their own
+// names. A kustomize bump could change that; TestParse_DeprecatedPatchSpellings pins it.
const (
featurePatchInline = "patches-inline"
featurePatchJSON6902 = "patches-json6902"
@@ -145,15 +116,10 @@ const (
func parseKustomization(content []byte, path string, tree map[string][]byte) (*kustomizationDoc, []string) {
doc := &kustomizationDoc{path: path}
- // Unmarshal then FixKustomization is exactly what kustomize's own loader does
- // (internal/target/kusttarget.go: load), so we model the kustomization the
- // builder will actually see: bases folded into resources, imageTags into
- // images, the deprecated generator spellings normalised.
- //
- // The loader's CheckEmpty/EnforceFields validations are deliberately not run
- // here: they decide whether a build would succeed, which is a different
- // question from whether we can model the render, and adding them would refuse
- // more than this change intends to.
+ // Unmarshal then FixKustomization is exactly what kustomize's own loader does, so we model
+ // the kustomization the builder will actually see. Its CheckEmpty/EnforceFields validations
+ // are deliberately NOT run: they decide whether a build would succeed, a different question
+ // from whether we can model the render.
var k kustypes.Kustomization
if err := k.Unmarshal(content); err != nil {
doc.unsupported = true
@@ -195,16 +161,13 @@ func parseKustomization(content []byte, path string, tree map[string][]byte) (*k
return doc, out
}
-// KustomizationBuildRefs returns the local files a kustomization loads from disk: its
-// resources+bases graph entries and its patch `path:` files, each raw and relative to the
-// kustomization's own directory (deprecated bases: folded into resources exactly as the
-// builder does). ok is false when the bytes are not a parseable kustomization. Remote and
-// inline entries are omitted — they name no local file to scan.
+// KustomizationBuildRefs returns the local files a kustomization loads: its resources+bases graph
+// entries and its patch `path:` files, relative to its own directory. Remote and inline entries
+// name no local file, so they are omitted.
//
// The live writer follows these to resolve the exact files an overlay reads from OUTSIDE its
-// spec.path, so render-root scoping pulls in only what kustomize would load — a referenced
-// file or base kustomization — never a whole sibling directory. See
-// internal/git/render_scope.go and docs/design/support-boundary/render-root-scoping.md §4.
+// spec.path, so render-root scoping pulls in only what kustomize would load, never a whole sibling
+// directory. See docs/design/support-boundary/render-root-scoping.md §4.
func KustomizationBuildRefs(content []byte) ([]string, []string, bool) {
var k kustypes.Kustomization
if err := k.Unmarshal(content); err != nil {
@@ -422,11 +385,9 @@ func trimmedEntries(lists ...[]string) []string {
// 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.
//
-// It is the ONE place a kustomization is judged, and it is file-aware because it has to be: a
-// `patches:` entry names a file, and what that file holds — a sparse KRM document, or a JSON6902
-// op list, or nothing at all — is what decides whether the folder can be tolerated. Every consumer
-// (the acceptance gate, the repo scan, the namespace walk) reads the doc this produces, so no two
-// of them can drift on what "unsupported" means.
+// The ONE place a kustomization is judged, and file-aware because it must be: a `patches:` entry
+// names a file, and what that file holds decides whether the folder can be tolerated. Every
+// consumer reads the doc this produces, so none of them can drift on what "unsupported" means.
func parseKustomizations(files []manifestedit.FileContent) map[string]*kustomizationDoc {
tree := contentByPath(files)
out := map[string]*kustomizationDoc{}
diff --git a/internal/manifestanalyzer/layout.go b/internal/manifestanalyzer/layout.go
index 0179fc3e..02b6e0e1 100644
--- a/internal/manifestanalyzer/layout.go
+++ b/internal/manifestanalyzer/layout.go
@@ -31,7 +31,7 @@ const (
LayoutSingleKustomization LayoutReason = "SingleKustomization"
// LayoutAmbiguous is a folder covering more than one render root. Placement declines to
// pick one rather than guessing, so the folder is not a write partition: point the target
- // at a leaf instead. See docs/layout/shapes/README.md § "Why only a leaf can be a
+ // at a leaf instead. See test/fixtures/layout-corpus/shapes/README.md § "Why only a leaf can be a
// kustomize target".
LayoutAmbiguous LayoutReason = "Ambiguous"
// LayoutNone is a folder with no supported kustomization at all. New documents land at a
@@ -234,7 +234,7 @@ func AmbiguousLayoutRefusal(resolution LayoutResolution, specPath string) []Acce
// scope and path — and misfiling it would send the one actionable instruction we have
// ("point the GitTarget at one of them") to someone who does not own the object it
// names. It is solvable and it is not a support boundary. See
- // docs/layout/shapes/README.md, "Why only a leaf can be a kustomize target".
+ // test/fixtures/layout-corpus/shapes/README.md, "Why only a leaf can be a kustomize target".
Solvable: true,
Actor: ActorPlatformOperator,
}}
diff --git a/internal/manifestanalyzer/overrides_projection.go b/internal/manifestanalyzer/overrides_projection.go
index 272ad80d..d8f0e1a9 100644
--- a/internal/manifestanalyzer/overrides_projection.go
+++ b/internal/manifestanalyzer/overrides_projection.go
@@ -43,39 +43,27 @@ type OverrideEdit struct {
// FORM of the live state, so the file keeps every byte the build supplied — plus the entry edits
// for the values an override entry supplies.
//
-// It is two rules, and neither models a transformer:
+// Two rules, and neither models a transformer:
//
-// 1. WHERE THE LIVE OBJECT AND THE RENDER AGREE, THE SOURCE KEEPS ITS BYTES (sourceForm). The
-// build already produces what the cluster runs, so the source is by construction what
-// produced it. This is what stops the writer mirroring the build's own output back into the
-// build's input — an injected label, a patched CPU request — and it needs to know nothing
-// about labels or patches to do it.
-// 2. WHERE THEY DISAGREE, THE USER CHANGED SOMETHING. If an images:/replicas: entry supplies
-// that field — which the dye says, read off a counterfactual render — the change is routed
-// to the ENTRY and the source keeps its bytes there too. Otherwise it is written through to
-// the source document.
+// 1. WHERE LIVE AND THE RENDER AGREE, THE SOURCE KEEPS ITS BYTES. The build produces what the
+// cluster runs, so the source is by construction what produced it. This stops the writer
+// mirroring the build's own output back into its input, knowing nothing about labels or
+// patches to do it.
+// 2. WHERE THEY DISAGREE, THE USER CHANGED SOMETHING. If an entry supplies that field (the dye
+// says which) the change is routed to the ENTRY; otherwise it is written to the source.
//
-// Anything it cannot route safely — a component removal an entry supplies, a component a
-// sibling entry clears, or two containers demanding different values for one entry field —
-// routes NOTHING and leaves the live value in place. That is not a guess and not a fallback to
-// another heuristic: the proposal then has to survive the verification re-render, which for a
-// field an entry governs it will not, so it becomes a reported refusal rather than a commit
-// that quietly never converges.
+// Anything it cannot route safely routes NOTHING and leaves the live value in place. Not a guess:
+// the proposal must survive the verification re-render, which for a field an entry governs it will
+// not, so it becomes a reported refusal rather than a commit that never converges.
//
-// The one thing it refuses outright is a list the build and the user BOTH changed whose elements
-// cannot be paired by name (*SourceFormRefusedError): there is no honest way to say which of the
-// source's bytes the user meant to keep, and aligning by position is measurably wrong.
+// It refuses outright only a list the build and the user BOTH changed whose elements cannot be
+// paired by name: there is no honest way to say which bytes the user meant to keep, and aligning
+// by position is measurably wrong.
//
-// gitRaw is the source document parsed as JSON-typed maps (sigs.k8s.io/yaml); desired is the
-// sanitized projection the writer would otherwise compare. The returned object is always a
-// copy; desired is never mutated.
-// authorInto is the kustomization the writer may AUTHOR a new images:/replicas: entry into when
-// a value the SOURCE document supplies diverges in live and the source is out of the write jail
-// (a base an overlay reads read-only). It is "" for a self-contained subtree and for an in-jail
-// document, where a source-supplied change is written into the file directly. When set, a
-// diverging source-supplied image component or replica count becomes a proposed new entry rather
-// than a refused base write — the "edit a specific environment, get the override authored"
-// capability of docs/design/support-boundary/render-root-scoping.md §4.
+// authorInto is the kustomization the writer may AUTHOR a new entry into when a SOURCE-supplied
+// value diverges and the source is out of the write jail. "" for a self-contained subtree or an
+// in-jail document, where the file is written directly.
+// See docs/design/support-boundary/render-root-scoping.md §4.
func SplitDesiredForOverrides(
gitRaw map[string]interface{},
desired *unstructured.Unstructured,
@@ -150,17 +138,13 @@ func isContainerListKey(k string) bool {
// collectImageSlots walks the object for every field that can hold an image.
//
-// Which fields those are was MEASURED against kustomize, not derived from its fieldspecs,
-// and the two surprises are both in here:
+// Which fields those are was MEASURED against kustomize, not derived from its fieldspecs, and both
+// surprises are here:
//
-// - volumes[].image.reference — an OCI volume source. kustomize REWRITES it (measured), and
-// the old collector did not look at it, so the rendered value was written back into the
-// source document as if the user had typed it.
-// - ephemeralContainers — kustomize does NOT rewrite them (measured), so no dye ever lands
-// here and no entry is ever credited with the value. They are still collected, because the
-// SOURCE document owns them and an edit to one belongs in the file. That is the dye doing
-// the fieldspec's job: we no longer have to know which fields kustomize touches, only to
-// look at where its dyes came out.
+// - volumes[].image.reference: kustomize REWRITES it, and the old collector did not look, so
+// the rendered value was written back into the source as if the user had typed it.
+// - ephemeralContainers: kustomize does NOT rewrite them, so no dye lands and no entry is
+// credited. Still collected, because the SOURCE owns them and an edit belongs in the file.
//
// Slots are sorted by key so edit output is deterministic.
func collectImageSlots(obj map[string]interface{}) []imageSlot {
@@ -253,15 +237,10 @@ type slotPlan struct {
// document share. It rewrites out's images to their SOURCE-FILE form and returns the entry
// edits — or routes nothing when the inversion is unsafe.
//
-// Nothing here re-derives what kustomize does any more. The rendered value comes from the
-// renderer and the supplier comes from the dye, so the two questions that used to be answered
-// by a hand-written transformer — "what does this folder render to" and "who supplied it" —
-// are now both answered by kustomize.
-//
-// And nothing here is trusted. The proposal is put to kustomize before it can become a commit
-// (VerifyBatchRenders), so this only has to be a candidate that is usually right. Routing
-// nothing is always a legal answer: the proposal then falls back to whatever the source
-// document alone can carry, and the re-render adjudicates it.
+// Nothing here re-derives what kustomize does: the rendered value comes from the renderer and the
+// supplier from the dye. Nothing here is trusted either — the proposal is put to kustomize before
+// it can become a commit, so this only has to be a candidate that is usually right, and routing
+// nothing is always a legal answer.
func projectImages(
gitRaw map[string]interface{},
live, out *unstructured.Unstructured,
@@ -394,29 +373,16 @@ func authorFor(enabled bool, author func(field, value string), field string) fun
// routeComponent decides where one changed image component (tag or digest) goes: onto the
// entry that supplies it, into the source file when no entry does, or nowhere at all.
//
-// TAG AND DIGEST ARE MUTUALLY EXCLUSIVE IN KUSTOMIZE, and that is what `sibling` is for. From
-// its own image transformer (filters/imagetag/updater.go, SetImageValue):
-//
-// case NewTag != "" && Digest != "": tag = NewTag; digest = Digest
-// case NewTag != "": tag = NewTag; digest = "" // a tag entry CLEARS the digest
-// case Digest != "": tag = ""; digest = Digest // a digest entry CLEARS the tag
-//
-// So an entry can GOVERN a component it does not declare. When a digest entry has cleared the
-// tag, no dye lands in the tag — nothing supplies it — but writing a tag into the source file
-// would be wiped by the very next render. The dye cannot see that on its own; the sibling
-// component's supplier is what reveals it, and it is the bug (#231) that corrupted real source
-// files by rewriting a tag out of them.
-//
-// The two unroutable cases:
+// TAG AND DIGEST ARE MUTUALLY EXCLUSIVE IN KUSTOMIZE, which is what `sibling` is for: a tag entry
+// clears the digest and a digest entry clears the tag (filters/imagetag/updater.go, SetImageValue).
+// So an entry can GOVERN a component it does not declare — when a digest entry cleared the tag no
+// dye lands there, yet writing a tag into the source would be wiped by the next render. The
+// sibling's supplier is what reveals that, and missing it corrupted real source files.
//
-// - a REMOVAL of a component an entry supplies — there is no way to say "no tag" on an
-// entry that sets one;
-// - a change to a component the SIBLING entry clears — nowhere to land, and the file would
-// be overridden straight back.
+// Two unroutable cases: a REMOVAL of a component an entry supplies (no way to say "no tag" on an
+// entry that sets one), and a change to a component the SIBLING clears (nowhere to land).
//
-// author is the overlay hook: when the source document supplies the component and an overlay is
-// available to author into, the change becomes a new images: entry instead of a source write.
-// It is nil for a self-contained subtree and for an in-jail source, where the file is writable.
+// author is the overlay hook, nil for a self-contained subtree and for an in-jail source.
func routeComponent(
supplier *ImageOverride,
sibling *ImageOverride,
@@ -485,13 +451,10 @@ func collectConsistentEdits(plans []slotPlan) ([]OverrideEdit, bool) {
// transformer creates the field) and a count edit is emitted only when live diverges from the
// pinned count.
//
-// There is no list of kinds here any more, and that is a bug fix rather than a tidy-up. We
-// used to gate this on isReplicaKind — Deployment, ReplicaSet, StatefulSet — while kustomize's
-// fieldspec is Deployment, ReplicaSet, StatefulSet AND ReplicationController. A scale on an RC
-// governed by a replicas: entry was written into the source document, where the transformer
-// overrode it right back: non-converging drift, silently, forever. The dye ends the argument:
-// if a dyed count came out of this object, an entry governs the field, whatever the kind is.
-// kustomize's fieldspec is the authority, and we no longer keep a second opinion about it.
+// There is no list of kinds here, and that is a bug fix. Gating on Deployment/ReplicaSet/
+// StatefulSet missed ReplicationController, which kustomize's fieldspec includes, so a scale on an
+// RC was written into the source and overridden right back: silent non-converging drift. The dye
+// ends the argument — if a dyed count came out, an entry governs the field, whatever the kind.
func projectReplicas(
gitRaw map[string]interface{},
live, out *unstructured.Unstructured,
diff --git a/internal/manifestanalyzer/placement.go b/internal/manifestanalyzer/placement.go
index 9a193540..f3772c29 100644
--- a/internal/manifestanalyzer/placement.go
+++ b/internal/manifestanalyzer/placement.go
@@ -12,22 +12,13 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/types"
)
-// PlacementPolicy is a resolved GitTarget placement declaration (Option B2 of
-// docs/layout/new-file-placement-rules.md): a single
-// exact-type map plus a fallback default template, consulted for every resource
-// regardless of sensitivity. It mirrors api/v1alpha3.GitTargetPlacementSpec
-// field-for-field but is defined locally so this analyzer package stays free of any
-// Kubernetes API type dependency; the git package converts the CRD spec into this
-// shape.
+// PlacementPolicy is a resolved GitTarget placement declaration: an exact-type map plus a fallback
+// default template. It mirrors api/v1alpha3.GitTargetPlacementSpec but is defined locally so this
+// package stays free of Kubernetes API types.
//
-// There is no sensitive/normal split here: sensitivity is a write-safety property
-// (encrypt the content, keep the path identity-complete, never append or
-// co-mingle) enforced after resolution — in finishPlacement (sensitive never
-// appends) and in the writer (encrypt by classification) — not a second map to
-// configure.
-//
-// A nil *PlacementPolicy, or one with no matching ByType entry and no Default,
-// falls through to the kustomize-root fallback and then the canonical path.
+// No sensitive/normal split: sensitivity is a write-safety property enforced after resolution, not
+// a second map to configure. A nil policy falls through to the kustomize-root fallback, then
+// canonical. See docs/layout/new-file-placement-rules.md.
type PlacementPolicy struct {
ByType map[string]string
Default string
@@ -54,17 +45,12 @@ type PlacementRequest struct {
WriteScope string
}
-// PlacementSource names which mechanism produced a PlacementResult's Path. It is
-// the "why here" answer for one new document, and it is reported three ways: the
-// write path's log line, the placements_total metric's `source` label, and the
-// scan/dry-run trace. The values are a public observability contract — they are
-// metric label values — so they are lower_snake_case and are not renamed lightly.
+// PlacementSource names which mechanism produced a Path: the "why here" answer for one document.
+// The values are a public observability contract (metric label values), so lower_snake_case and
+// not renamed lightly.
//
-// There are exactly three, and the list is closed by construction: a declaration,
-// one structural fact about the folder, and the built-in path. Nothing here reads
-// the repository's *layout* to guess an intent — that was Option C's sibling-cohort
-// ladder, and it is gone (see the deletion argument in
-// docs/design/open-asks-priority.md).
+// Exactly three, closed by construction: a declaration, one structural fact about the folder, and
+// the built-in path. Nothing reads the repository's layout to guess intent.
type PlacementSource string
const (
@@ -111,15 +97,12 @@ type PlacementResult struct {
NamespaceInherited bool
}
-// PlacementRefusalReason names WHY a placement was refused, from a closed set. It is a
-// metric label value (placement_refusals_total{reason}) as well as a log field, so the
-// strings are lower_snake_case and are part of the observability contract.
+// PlacementRefusalReason names WHY a placement was refused, from a closed set. A metric label
+// value as well as a log field, so lower_snake_case and part of the observability contract.
//
-// A refusal is a resource the operator did NOT mirror. It has to be countable per
-// (GitTarget, type): before this it left a log line and, on the resync path, a single
-// integer in a summary — neither of which a dashboard or an alert can reach, so a
-// misconfigured template that silently skipped one Secret on every reconcile was
-// invisible unless somebody read the logs.
+// A refusal is a resource the operator did NOT mirror, so it must be countable per
+// (GitTarget, type): a misconfigured template silently skipping one Secret every reconcile is
+// otherwise invisible unless somebody reads the logs.
type PlacementRefusalReason string
const (
@@ -164,30 +147,16 @@ func (e *PlacementRefusedError) Error() string { return e.detail }
// errors.Is/As still reach it.
func (e *PlacementRefusedError) Unwrap() error { return e.cause }
-// LocateNew resolves the placement of a resource with no existing document, per
-// docs/layout/new-file-placement-rules.md: a declared template (Option B)
-// wins when present; otherwise the folder's one supported kustomize root, if it has
-// exactly one; otherwise the canonical path.
-//
-// There is no step that reads the layout of the *other* documents of this type.
-// Sibling-cohort inference (Option C) was removed: it let a human's edit to the
-// repository change where the operator writes, with no Kubernetes object changing
-// and nothing in status recording the move, and its central namespace-agnosticism
-// guard had already failed once by cascading. The argument, and what replaced it
-// (a declared byType line, plus the placements_total metric that says which
-// (GitTarget, type) needs one), is in docs/design/open-asks-priority.md.
+// LocateNew resolves the placement of a resource with no existing document: a declared template
+// wins; otherwise the folder's one supported kustomize root; otherwise the canonical path.
+// Nothing reads the layout of the OTHER documents of this type.
//
-// store is still the pre-plan snapshot for the whole batch and must never be mutated
-// mid-batch: the remaining store reads — does the resolved path already hold an
-// append-safe file, does its directory carry a kustomization — must answer the same
-// way for every resource in one batch, so a batch of several new creates resolves
-// order-independently regardless of event order (P2 of the design doc).
+// store is the pre-plan snapshot and must never be mutated mid-batch: its reads must answer the
+// same way for every resource in one batch, so several new creates resolve order-independently.
//
-// An error is returned only when the resolved placement cannot be honoured safely
-// — currently, a sensitive resource whose resolved path already exists (sensitive
-// documents are never appended; see "Sensitive placement and uniqueness" in the
-// design doc). The caller must skip creating that resource and surface the error as
-// a diagnostic rather than writing into a shared or multi-document sensitive file.
+// An error means the placement cannot be honoured safely (a sensitive resource whose path already
+// exists; sensitive documents are never appended). The caller must skip that resource rather than
+// write into a shared file. See docs/layout/new-file-placement-rules.md.
func LocateNew(store *ManifestStore, policy *PlacementPolicy, req PlacementRequest) (PlacementResult, error) {
vars := placementVars(req)
@@ -206,24 +175,14 @@ func LocateNew(store *ManifestStore, policy *PlacementPolicy, req PlacementReque
return finishPlacement(store, req, canonicalPath(req), PlacementSourceCanonical)
}
-// resolveKustomizeRoot is the one non-declared, non-canonical placement, and it is a
-// structural fact rather than a reading of the repository's conventions. The canonical
-// path is a {namespaceOrCluster}/{group}/{resource}/{name}.yaml tree a kustomization's
-// resources: graph can never reach, so a new document in an otherwise kustomize-managed
-// folder would land outside every render — not merely oddly placed, but never applied.
-// When the whole writable subtree is governed by exactly one supported kustomization
-// (today's single-context baseline), the new resource belongs beside that
-// kustomization's other files, and finishPlacement adds the resources: entry.
+// resolveKustomizeRoot is a structural fact, not a reading of conventions. The canonical path is a
+// tree no resources: graph can reach, so a new document in a kustomize-managed folder would land
+// outside every render and never be applied. With exactly one supported kustomization the resource
+// belongs beside its other files.
//
-// The destination follows from there being ONE root, not from picking a cohort: more
-// than one supported kustomization under the scanned root is ambiguous and declines
-// rather than guessing. That is why this survived the Option C deletion — deleting it
-// would reintroduce the unreachable-file bug it was added to fix.
-//
-// The "exactly one writable supported kustomization" predicate lives in writableRenderRoots
-// (layout.go), because status.placement reports the rung this function will take and the two
-// must not be able to drift: a LayoutResolved that says SingleKustomization while placement
-// declines here would be worse than no report at all.
+// The destination follows from there being ONE root, never from picking a cohort: more than one
+// declines rather than guessing. The predicate lives in writableRenderRoots (layout.go) because
+// status.placement reports the rung this function takes and the two must not drift.
func resolveKustomizeRoot(store *ManifestStore, req PlacementRequest) (string, bool) {
roots := writableRenderRoots(store, req.WriteScope)
if len(roots) != 1 {
@@ -237,22 +196,13 @@ func resolveKustomizeRoot(store *ManifestStore, req PlacementRequest) (string, b
return cleanJoin(slashDir(only.Path), name), true
}
-// resolveDeclaredKustomizeFolder is the rung above for a folder that has no root YET. A target
-// declaring spec.placement.useKustomize keeps this folder as a kustomize folder, and the writer
-// creates the missing root at the jail's own directory in the same commit, so a new document
-// belongs beside it exactly as it would beside a root that was already there.
-//
-// Without this the first document of a bootstrapped folder would land at the canonical
-// {namespaceOrCluster}/{group}/{resource}/{name}.yaml path, which is a tree no resources: graph
-// reaches: the operator would create a root and then place the document outside it.
+// resolveDeclaredKustomizeFolder stands in for the rung above when the folder has no root YET:
+// useKustomize makes the writer create one in the same commit, so a new document belongs beside it.
+// Without this the first document would land at the canonical path, outside the root just created.
//
-// It reports the same PlacementSource as the rung it stands in for, because it IS that rung: the
-// source label names the mechanism a reader can act on, and "beside the folder's one kustomize
-// root" is what happened. The label set is a public observability contract and this adds no member
-// to it.
-//
-// It runs only when the folder has NO writable root: exactly one is the rung above, and several is
-// refused before placement is asked (an ambiguous folder has no single root to create beside).
+// It reports the same PlacementSource because it IS that rung, and the label set is a public
+// contract. It runs only when there is NO writable root: one is the rung above, several is refused
+// before placement is asked.
func resolveDeclaredKustomizeFolder(
store *ManifestStore,
policy *PlacementPolicy,
@@ -314,15 +264,9 @@ func finishPlacement(
}
}
res := PlacementResult{Path: resolvedPath, Source: source}
- // A resolved path that already holds a file is only a safe append target when
- // every document already in it is cleanly editable. A file that tolerates a
- // non-editable construct (an anchor, alias, or other disallowed pattern) may
- // hold a document that looks like a match but does not actually claim its
- // identity — appending after it is not the data-loss risk that overwriting it
- // would be, but treating it as an ordinary bundle is still wrong: the writer
- // cannot vouch for what is already in that file. Append stays false, so the
- // caller falls back to writeWholeFile, whose own multi-document guard is the
- // established, tested safety net for exactly this collision.
+ // Only safe to append when every document already there is cleanly editable. A file tolerating
+ // a non-editable construct may hold a document that looks like a match but does not claim its
+ // identity, and the writer cannot vouch for it. Fall back to writeWholeFile and its guard.
fm, exists := store.FilesByPath[resolvedPath]
if exists && fileIsAppendSafe(fm) {
res.Append = true
@@ -367,27 +311,17 @@ func finishPlacement(
return res, nil
}
-// namespaceIsInheritedFromContext reports whether a new document at a path this
-// kustomization governs must OMIT metadata.namespace, because the build context already
-// supplies it. Two conditions, and the second one is the safety half:
-//
-// - the kustomization sets a namespace: transformer at all, and
-// - it sets it to THIS resource's own namespace.
+// namespaceIsInheritedFromContext reports whether a new document must OMIT metadata.namespace
+// because the build context supplies it. Two conditions: the kustomization sets a namespace:
+// transformer, AND it sets it to THIS resource's own namespace.
//
-// The second condition is what keeps the write honest. Omitting metadata.namespace hands
-// the namespace to kustomize, so if the transformer named a DIFFERENT namespace the
-// document would render as another object entirely — the mirror would claim to hold a
-// resource it does not. Writing the namespace explicitly in that case is not a
-// convention break; it is the only truthful bytes available, and the render oracle then
-// reports the folder as unable to express this object rather than silently mis-rendering
-// it. (kustomize's namespace transformer overrides an explicit metadata.namespace, so the
-// explicit line is redundant when the two agree and load-bearing when they do not.)
+// The second is the safety half. Omitting hands the namespace to kustomize, so a transformer
+// naming a DIFFERENT namespace would render the document as another object entirely. Writing it
+// explicitly there is the only truthful bytes available, and the oracle then reports the folder as
+// unable to express the object rather than mis-rendering it.
//
-// A cluster-scoped resource has no namespace, so it never inherits one.
-//
-// This applies to every resolved path, not just the kustomize-root fallback: a DECLARED
-// template pointing into a governed directory has exactly the same obligation, and before
-// this it silently wrote a namespace: line the folder's own documents omit.
+// Cluster-scoped resources never inherit one. This applies to DECLARED paths too, not just the
+// kustomize-root fallback.
func namespaceIsInheritedFromContext(k *KustomizationInfo, req PlacementRequest) bool {
if k.Namespace == "" || req.Identifier.Namespace == "" {
return false
@@ -395,23 +329,13 @@ func namespaceIsInheritedFromContext(k *KustomizationInfo, req PlacementRequest)
return k.Namespace == req.Identifier.Namespace
}
-// governingKustomization returns the kustomization whose resources: list a new file at
-// resolvedPath must join to render: the NEAREST one at or above the file's own directory,
-// bounded by the write jail. Without this an overlay's new object would be committed to a
-// file no kustomization includes, so it would never render and the oracle (armed only for
-// governed writes) would not catch it — a silent divergence.
-//
-// The walk is what makes a DECLARED path into a subdirectory behave like every other path
-// (#295). Before it, the lookup was the file's own directory plus a special case for the
-// write scope's root, so `byType: {v1/configmaps: "configmaps/{name}.yaml"}` in a kustomize
-// folder committed a document nothing renders — and the two cases differed by render-root
-// scoping, which the user cannot see. It also silently skipped
-// namespaceIsInheritedFromContext, writing a namespace: line the folder's own documents omit.
+// governingKustomization returns the kustomization a new file must join to render: the NEAREST one
+// at or above its directory, bounded by the write jail. Without the walk, a declared path into a
+// subdirectory commits a document nothing renders, and the oracle (armed only for governed writes)
+// does not catch it.
//
-// The jail is the bound, and it is load-bearing: writeScope is where the target may write, so
-// a kustomization ABOVE it is a read-only ancestor (a base pulled into the scan by render-root
-// scoping) whose resources: list is not ours to edit. With no scope the whole scanned subtree
-// is the target's own, so the walk may reach its root.
+// The jail bound is load-bearing: a kustomization ABOVE the write scope is a read-only ancestor
+// whose resources: list is not ours to edit. With no scope the walk may reach the subtree root.
func governingKustomization(store *ManifestStore, writeScope, resolvedPath string) *KustomizationInfo {
jail := path.Clean(writeScope)
if writeScope == "" {
@@ -450,17 +374,10 @@ func kustomizationListsResource(k *KustomizationInfo, resolvedPath string) bool
return false
}
-// ValidateResolvedPlacementPath enforces the design doc's "Path validation"
-// contract against a fully-resolved (variable-substituted) placement path,
-// regardless of which mechanism produced it: non-empty, a clean relative path
-// staying under the GitTarget's spec.path (no "..", not absolute, no redundant
-// segments), no Windows-style backslash separators, a non-empty final file name,
-// and a recognized YAML suffix (".sops.yaml"/".sops.yml" satisfy this too, since
-// they end in ".yaml"/".yml"). finishPlacement runs this on every path before a
-// single byte is written, so a bad declared template (Option B) can never
-// escape the folder the writer owns — sanitizePlacementSegment already defends
-// each individual variable's value, but the template's own literal text is
-// author-supplied and unconstrained without this gate.
+// ValidateResolvedPlacementPath checks a fully-substituted placement path: non-empty, clean and
+// relative, under spec.path, no backslashes, a real file name, a YAML suffix. Run on every path
+// before a byte is written, because sanitizePlacementSegment defends each variable's VALUE but the
+// template's own literal text is author-supplied and otherwise unconstrained.
func ValidateResolvedPlacementPath(p string) error {
if p == "" {
return errors.New("path is empty")
@@ -661,17 +578,10 @@ func ValidPlacementTemplateSyntax(tmpl string) error {
return err
}
-// ValidPlacementTemplatePath statically rejects a declared template whose own
-// literal text (never mind any variable substitution, which sanitizePlacementSegment
-// already defends per-value) could render outside the GitTarget's spec.path or
-// with the wrong kind of file name: an explicit ".." path segment, a leading "/"
-// (absolute), a "\" separator, or a suffix that isn't ".yaml"/".yml" (a template
-// ending in the literal "{sensitiveSuffix}" placeholder is accepted without
-// rendering it, since that variable only ever expands to ".yaml" or ".sops.yaml").
-// This runs at the GitTarget's Validated gate — before any repository scan, and
-// before any resource can ever trigger a write — so a bad template fails fast and
-// visibly instead of silently skipping (or, without ValidateResolvedPlacementPath's
-// runtime backstop, escaping) resource by resource.
+// ValidPlacementTemplatePath statically rejects a template whose own literal text could render
+// outside spec.path or with the wrong file name: "..", a leading "/", a "\" separator, or a
+// non-YAML suffix. Runs at the Validated gate, before any scan, so a bad template fails fast and
+// visibly instead of skipping resource by resource.
func ValidPlacementTemplatePath(tmpl string) error {
trimmed := strings.TrimSpace(tmpl)
if trimmed == "" {
@@ -695,19 +605,13 @@ func ValidPlacementTemplatePath(tmpl string) error {
return nil
}
-// IdentityCompletePlacementTemplate reports whether tmpl is guaranteed to render a
-// distinct path for every distinct resource identity — the structural guarantee
-// "Sensitive placement and uniqueness" in the design doc requires of every accepted
-// sensitive template. narrowedToOneType is true for a ByType entry (the map key
-// itself already names one exact type); a Default template must additionally carry
-// the type variables since it applies across every type the class does not name
-// explicitly.
+// IdentityCompletePlacementTemplate reports whether tmpl renders a distinct path for every
+// distinct resource identity, which every accepted sensitive template must. narrowedToOneType is
+// true for a ByType entry; a Default template must carry the type variables itself.
//
-// The type variables are {groupPath} and {resource}, and deliberately NOT {version}
-// (#295): the built-in canonical path is versionless because two served versions of one
-// group/resource are the SAME object, so a version segment separates no identities — it
-// splits one. Requiring it also judged the canonical shape we would default to as not
-// identity-complete, which is what made a spec-level default fail our own validation gate.
+// Those are {groupPath} and {resource}, deliberately NOT {version}: two served versions of one
+// group/resource are the SAME object, so a version segment splits one identity rather than
+// separating two.
func IdentityCompletePlacementTemplate(tmpl string, narrowedToOneType bool) bool {
hasName := strings.Contains(tmpl, "{name}")
hasScope := strings.Contains(tmpl, "{namespace}") || strings.Contains(tmpl, "{namespaceOrCluster}")
@@ -722,14 +626,10 @@ func IdentityCompletePlacementTemplate(tmpl string, narrowedToOneType bool) bool
// --- Write-safety helpers for an already-occupied destination ------------------
-// fileIsAppendSafe reports whether every document already in fm is cleanly
-// editable or an ordinary encrypted document — never a document tolerated despite
-// an unsupported construct (CauseNonEditable: an anchor, alias, or other disallowed
-// pattern), which does not claim its identity and so cannot be vouched for. Such a
-// file is excluded from the append decision (finishPlacement): a genuinely new
-// resource must never be joined to a file the writer cannot fully account for. The
-// caller falls back to writeWholeFile, whose own multi-document guard refuses rather
-// than overwrites.
+// fileIsAppendSafe reports whether every document in fm is cleanly editable or ordinarily
+// encrypted, never one tolerated despite an unsupported construct: such a document does not claim
+// its identity and cannot be vouched for. A new resource must never join a file the writer cannot
+// fully account for.
func fileIsAppendSafe(fm *FileModel) bool {
if fm == nil {
return false
diff --git a/internal/manifestanalyzer/plan.go b/internal/manifestanalyzer/plan.go
index 63af4b2d..98105d5e 100644
--- a/internal/manifestanalyzer/plan.go
+++ b/internal/manifestanalyzer/plan.go
@@ -12,15 +12,10 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/types"
)
-// Plan is the first-class, cross-layer contract described in
-// docs/spec/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.
+// Plan is a pure function of (ManifestStore, desired set, policy): the same value the live writer
+// applies, scan mode renders, the CLI prints and status summarizes. It carries enough detail to
+// render text/JSON/status without recomputing any decision.
+// See docs/spec/current-manifest-support-review.md.
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
@@ -123,24 +118,14 @@ func (p Plan) Counts() map[PlanActionKind]int {
// 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/spec/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).
+// A FULL-SNAPSHOT input, never a partial batch: BuildPlan mark-and-sweeps every watched document
+// absent from this set as a managed drop.
//
-// 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.
+// Object must be non-nil. A nil entry is malformed, deliberately NOT a delete tombstone: in a
+// sweeping planner a lone tombstone is indistinguishable from "every other document is an orphan".
+// It cannot be skipped either, since skipping would leave the matching document unmatched and let
+// the sweep DROP it. BuildPlan instead protects that document and emits a diagnostic. A genuine
+// per-event delete is PlanDelete, which targets one identity and never sweeps.
type DesiredResource struct {
Resource types.ResourceIdentifier
Object *unstructured.Unstructured
@@ -151,10 +136,9 @@ type DesiredResource struct {
// spec.prune.mode, kept as its own type (like PlacementPolicy) so manifestanalyzer stays
// free of any Kubernetes API type dependency.
//
-// Only the INFERRED deletion path is modelled here. An explicit source DELETE event
-// never reaches this planner — it is resolved by PlanDelete and gated at the writer — so
-// PruneNever and PruneOnEvent both map to SweepRetainOrphans. The two differ only on the
-// path this type knows nothing about.
+// Only the INFERRED deletion path is modelled here: an explicit DELETE never reaches this planner,
+// so PruneNever and PruneOnEvent both map to SweepRetainOrphans. They differ only on the path this
+// type knows nothing about.
type SweepMode string
const (
@@ -195,23 +179,15 @@ type Policy struct {
Sweep SweepMode
}
-// 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.
+// BuildPlan computes the Plan from the store, the bytes backing it, the COMPLETE desired snapshot
+// and the policy.
//
-// 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 full-snapshot planner: it mark-and-sweeps, so desired MUST be the whole desired state, never
+// a partial batch. The per-event removal path is PlanDelete.
//
-// 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. policy.Sweep is the second, independent gate on the same
-// deletions — the caller's declared prune policy — and its zero value retains.
+// Under a structure-only store (no resolved mappings) no managed drop is emitted, preserving the
+// no-cluster promise even if a desired set is passed by mistake. policy.Sweep is a second,
+// independent gate on the same deletions, and its zero value retains.
func BuildPlan(
store *ManifestStore,
files []manifestedit.FileContent,
@@ -226,18 +202,14 @@ func BuildPlan(
// 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.
+// BuildScopedPlan is BuildPlan restricted to the documents inScope reports: the sweep only touches
+// a managed document whose RESOLVED identity is in scope, so no sibling type is collaterally
+// deleted. The per-type primitive: a reconcile passes that type's objects with a matching
+// predicate, a sweep passes an EMPTY desired set with the same one. The caller MUST keep desired
+// in scope, since it 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/spec/type-lifecycle-events-and-wobble-settling.md (Proposal 3 / M12).
+// With allInScope this is exactly BuildPlan, so the two share one implementation and one set of
+// safety guarantees. See docs/spec/type-lifecycle-events-and-wobble-settling.md.
func BuildScopedPlan(
store *ManifestStore,
files []manifestedit.FileContent,
@@ -476,15 +448,10 @@ func actionFromDecision(a manifestedit.DecisionAction) (PlanActionKind, bool) {
}
}
-// 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.
+// documentLocations indexes every managed document to its (file path, document index).
+// DocumentModel stores neither: the path is the map key, and the position is reconstructed from
+// record-less diagnostic gaps. Exact for every file, contiguous or not, so a plan's reference
+// targets the right document even in an impure managed file the gate is refusing.
func documentLocations(store *ManifestStore) map[*DocumentModel]RecordRef {
diagsByPath := diagnosticsByPath(store.Diagnostics)
out := map[*DocumentModel]RecordRef{}
diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go
index f93fec13..4f001872 100644
--- a/internal/manifestanalyzer/scan_repo.go
+++ b/internal/manifestanalyzer/scan_repo.go
@@ -15,21 +15,13 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/git/manifestedit"
)
-// This file implements the first cut of repo discovery (the onboarding scan),
-// designed in docs/design/support-boundary/repo-discovery-and-onboarding-scan.md. It
-// walks a WHOLE repository once (today's Scan/ScanDir is subtree-only), enumerates
-// candidate GitTarget subtrees, classifies each one's layout, runs the same
-// acceptance gate the operator runs, and emits a machine-readable report.
+// Repo discovery (the onboarding scan): walks a WHOLE repository once, enumerates candidate
+// GitTarget subtrees, classifies each layout, runs the same acceptance gate the operator runs, and
+// emits a machine-readable report. Deliberately reuse-heavy — only the whole-repo pass, candidate
+// enumeration, layout classification and the report contract are new here.
//
-// It is deliberately reuse-heavy: the repo walk is collectFiles, the kustomization
-// graph and render roots are parseKustomizations/renderRoots, the adoption decision
-// is Scan/Accept, and overlap detection mirrors the controller's
-// gittarget_path_overlap. What is new here is the whole-repo pass, candidate
-// enumeration, layout classification, and the report contract.
-//
-// Scope of this cut: it REPORTS, it does not PROPOSE. There is no GitTarget/WatchRule
-// generation yet, no rename of the existing --mode discovery, and no repo-level
-// --policy refuse exit semantics — see the design doc's "explicitly defer" list.
+// It REPORTS, it does not PROPOSE: no GitTarget/WatchRule generation, no repo-level refuse exit.
+// See docs/design/support-boundary/repo-discovery-and-onboarding-scan.md.
// Layout is the structural shape of a candidate subtree. Layout and acceptedByOperator are
// two distinct truths: a kustomize-overlay has a well-understood layout and is now adopted
@@ -82,18 +74,13 @@ type RefusalReason struct {
Actor Actor `json:"actor,omitempty"`
}
-// RenderedTypes is what a folder renders, expressed so that the pairing between a type and
-// the namespace it lands in survives. A set of types beside a set of namespaces loses it:
-// a folder rendering a Deployment into frontend and a Service into backend would read as
-// four combinations, and a tool generating one watch rule per pair would authorize two
-// that match nothing in the repository.
-//
-// Every type is a canonical GVK string — "group/version/kind", or "version/kind" for the
-// core group, which is [GVK.String] and the same spelling Summary.ByGVK already uses.
+// RenderedTypes is what a folder renders, keeping the PAIRING between a type and the namespace it
+// lands in. A set of types beside a set of namespaces loses it: a Deployment into frontend and a
+// Service into backend would read as four combinations, and a tool generating one rule per pair
+// would authorize two that match nothing.
//
-// For a render root the sets come off a real kustomize build, so a base outside the subtree
-// is included and a `namespace:` transformer is already applied. For a plain folder the
-// documents are the render. A root that failed to build reports nothing at all: what it
+// For a render root the sets come off a real kustomize build, so an outside base is included and a
+// `namespace:` transformer is applied. A root that failed to build reports nothing: what it
// renders is not knowable.
type RenderedTypes struct {
// ByNamespace lists the types that land in each namespace, sorted, keyed by namespace.
@@ -101,13 +88,9 @@ type RenderedTypes struct {
// NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted.
//
- // It is NOT a list of cluster-scoped types. It holds two facts this scan cannot tell
- // apart: a genuinely cluster-scoped type, and a namespaced type relying on whatever
- // namespace the applier defaults to. Separating them needs API discovery, which a
- // structure-only scan does not have.
- //
- // A type can appear here AND under ByNamespace. Two ConfigMaps, one carrying a
- // namespace and one not, is an ordinary folder, not a contradiction.
+ // NOT a list of cluster-scoped types: it cannot tell a genuinely cluster-scoped type from a
+ // namespaced one relying on the applier's default, which needs API discovery. A type can
+ // appear here AND under ByNamespace, which is an ordinary folder, not a contradiction.
NamespaceUndeclared []string `json:"namespaceUndeclared,omitempty"`
}
@@ -200,16 +183,12 @@ type RepoSummary struct {
Refused int `json:"refused"`
// OverlapConflicts lists every nesting conflict between candidates.
OverlapConflicts []OverlapConflict `json:"overlapConflicts,omitempty"`
- // ReadEdges is the repo's folder dependency graph: one edge per (candidate, directory
- // it renders from outside its own subtree), sorted. It is the same relation each
- // candidate's readScope/readBy report, collected in one place so a consumer can draw
- // the graph without walking the candidates.
+ // ReadEdges is the repo's folder dependency graph, collected in one place so a consumer can
+ // draw it without walking the candidates.
//
- // Most edges end at a directory that is NOT a candidate — a folder a kustomization
- // references is never a render root, so it is offered to nobody. Those nodes are the
- // edge targets absent from Candidates, and nothing else identifies them: they are not
- // all kustomize bases, since a referenced resource file or an out-of-subtree patch
- // makes its folder one too.
+ // Most edges end at a directory that is NOT a candidate, and nothing else identifies those
+ // nodes: they are not all kustomize bases, since a referenced resource file or an
+ // out-of-subtree patch makes its folder one too.
ReadEdges []ReadEdge `json:"readEdges,omitempty"`
// UnsupportedConstructs is the sorted, de-duplicated set of unsupported kustomize
// features seen across refused-structural candidates, so a product can say "this repo
@@ -423,18 +402,13 @@ func plainCandidates(
return out
}
-// overlayCandidateAcceptance runs the operator's own adoption gate over an external-base
-// overlay's RENDER SCOPE — the overlay subtree PLUS the exact base files its resources/patches
-// graph reaches — so the discovery report matches what the live writer's render-root scoping
-// (internal/git/render_scope.go) decides. Only the files the graph actually reaches enter the
-// scope, never a whole base directory, so parked YAML a base does not reference can never
-// refuse the overlay (mirroring the runtime's "read scope is the exact reachable file set").
+// overlayCandidateAcceptance runs the adoption gate over an overlay's RENDER SCOPE: the subtree
+// plus the exact base files its graph reaches, matching the live writer's render-root scoping.
+// Only reached files enter the scope, never a whole base directory, so parked YAML a base does not
+// reference can never refuse the overlay.
//
-// The scoped store keeps repo-relative paths, so a `../../base` reference resolves within it
-// exactly as kustomize resolves it. Acceptance here is folder adoption (GitPathAccepted); the
-// write half — editable overlay-local documents and declared images/replicas, but never a
-// base-owned field or a new overlay object — is out of scope for a read-only report, and the
-// candidate's editable count already reflects how much of the render the overlay owns.
+// The scoped store keeps repo-relative paths, so `../../base` resolves exactly as kustomize
+// resolves it. This is folder adoption only; the write half is out of scope for a read-only report.
func overlayCandidateAcceptance(
ctx context.Context,
rootDir string,
@@ -512,15 +486,10 @@ func candidateAcceptance(ctx context.Context, fsys fs.FS, dir string) Acceptance
return Scan(ctx, sub, nil, nil, policy).Acceptance
}
-// issuesToReasons is the projection for the solvability pair as well as the code: it is
-// already the single choke point through which every acceptance issue becomes a refusal
-// reason, so a check that classifies itself reaches a consumer without any second table.
-//
-// issuesToReasons projects acceptance-gate issues into refusal reasons so a refused plain
-// or self-contained kustomize candidate reports WHY — duplicate identity, non-KRM YAML, a
-// foreign file, a mixed build-directive file, an unsupported nested kustomization — not
-// just acceptedByOperator: false. The issue Kind is the machine code; the path-qualified
-// message is the detail.
+// issuesToReasons projects acceptance-gate issues into refusal reasons, so a refused candidate
+// reports WHY rather than just acceptedByOperator: false. It is the single choke point for that
+// mapping, so a check that classifies itself reaches a consumer without a second table. The issue
+// Kind is the machine code; the path-qualified message is the detail.
func issuesToReasons(issues []AcceptanceIssue) []RefusalReason {
out := make([]RefusalReason, 0, len(issues))
for _, iss := range issues {
@@ -542,19 +511,13 @@ func issuesToReasons(issues []AcceptanceIssue) []RefusalReason {
// lie outside its own subtree — every folder whose content the root renders yet does not
// own.
//
-// It is the directory projection of [renderScopePaths], deliberately and not incidentally:
-// that function already answers "which files does this build load", it is what the scoped
-// acceptance gate renders from, and any second enumeration here would drift from it. A
-// base directory, a `resources: ../shared/deployment.yaml`, and a
-// `patches: [{path: ../../shared/patch.yaml}]` are all the same fact — content this folder
-// renders and does not own — and only one of the three is a kustomize base.
-//
-// Minimal in the same sense as [outOfSubtreeBases]: a directory nested under another in the
-// set is dropped, since reading the parent already reaches it.
+// The directory projection of [renderScopePaths], deliberately: that function already answers
+// which files a build loads, and a second enumeration here would drift from it. A base directory,
+// a `resources: ../shared/x.yaml` and a `patches: [{path: ../../shared/p.yaml}]` are the same
+// fact, and only one of the three is a kustomize base.
//
-// This is the only relation the read graph is built from — [RepoCandidate.ReadScope],
-// [RepoCandidate.ReadBy] and [RepoSummary.ReadEdges] are three projections of it, so they
-// cannot disagree about which folder reads which.
+// Minimal: a directory nested under another in the set is dropped. This is the ONLY relation the
+// read graph is built from, so its three projections cannot disagree.
func readDirsOutside(rootDir string, kusts map[string]*kustomizationDoc) []string {
dirs := map[string]struct{}{}
for file := range renderScopePaths(rootDir, kusts) {
@@ -714,15 +677,11 @@ func reachedResourceFiles(kusts map[string]*kustomizationDoc) map[string]struct{
// refusedStructuralReason builds the render-root refusal, classified by the constructs
// that caused it rather than by the code.
//
-// The code itself MEANS "the writer cannot map this render root back to editable source",
-// so "no" is the answer whenever the constructs are unknown. But the same folder is judged
-// construct by construct when the gate refuses a NESTED kustomization
-// (IssueUnsupportedKustomize), and the two surfaces must not hand a consumer two different
-// answers about one directory: a root refused only because its kustomization does not
-// parse is one commit from being adoptable, and saying "no" there would send its author
-// away for nothing. Classifying both from the same feature set is what keeps them
-// agreeing, and it follows the rule the whole table follows — describe the folder, not the
-// rule.
+// The code MEANS "the writer cannot map this root back to editable source", so "no" is the answer
+// when the constructs are unknown. But the gate judges the same folder construct by construct when
+// refusing a NESTED kustomization, and the two surfaces must not give a consumer two answers about
+// one directory: a root refused only because its kustomization does not parse is one commit from
+// adoptable. Classifying both from the same feature set keeps them agreeing.
func refusedStructuralReason(doc *kustomizationDoc, content []byte) RefusalReason {
var features []string
if doc != nil {
diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go
index de460cc8..ac3b38ce 100644
--- a/internal/manifestanalyzer/store.go
+++ b/internal/manifestanalyzer/store.go
@@ -17,15 +17,11 @@ import (
"github.com/ConfigButler/gitops-reverser/internal/typeset"
)
-// ManifestStore is the byte-free, in-memory structure model of a GitTarget folder
-// described in docs/spec/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.
+// ManifestStore is the byte-free structure model of a GitTarget folder, consumed by the live
+// writer, scan mode, the CLI and status alike. See docs/spec/current-manifest-support-review.md.
//
-// 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.
+// Only MANAGED files live in FilesByPath: YAML carrying at least one KRM document. Everything else
+// is known to the analyzer but never becomes a FileModel, so it 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.
@@ -39,14 +35,9 @@ type ManifestStore struct {
// 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 is single-valued, first-occurrence-wins over documents that CLAIM their
+ // identity, so a later duplicate is detectable as such. Claiming mirrors manifestedit's
+ // duplicate rule exactly, so the collapse and its diagnostic agree.
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.
@@ -93,14 +84,11 @@ type ManifestStore struct {
// same surface as any other refusal.
IgnoreIssues []AcceptanceIssue
- // ValueFileRefs is the set of scanned non-KRM files (slash paths) a release document — an Argo
- // CD Application's helm.valueFiles or a Flux HelmRelease's spec.chart.spec.valuesFiles — names
- // by a path that matches locally: NAMED read-only context, not junk, and not proof the deployer
- // consumes this file. The acceptance gate consults it so a values file the repository points at
- // no longer refuses its folder as non-krm-yaml, and the discovery scan does not count it as
- // noise. It is never materialised, so the operator retains the file yet never writes it. See
- // docs/design/support-boundary/values-file-projection.md §2 (Move 1) and
- // values-content-architecture.md.
+ // ValueFileRefs is the set of scanned non-KRM files a release document names by a locally
+ // matching path: NAMED read-only context, not junk, and not proof the deployer consumes it.
+ // The acceptance gate consults it so such a file no longer refuses its folder as non-krm-yaml.
+ // Never materialised: retained, never written.
+ // See docs/design/support-boundary/values-file-projection.md §2 (Move 1).
ValueFileRefs map[string]struct{}
// RenderedInventory records what each render root RENDERS TO, keyed by the root's
@@ -131,19 +119,11 @@ type RetainedDocument struct {
Location manifestedit.Location
Identity manifestedit.Identity
GVK schema.GroupVersionKind
- // Unsupported is true for a whole-file kustomization retention that the operator
- // cannot map back to editable source documents, for either of two reasons:
- //
- // - it uses a feature outside the supported contextual-namespace subset
- // (generators / patches / components / helm / replacements / transformers /
- // name(pre|suf)fix / remote bases), or declares malformed images/replicas; or
- // - it is a render root KUSTOMIZE CANNOT BUILD (reasonRenderFailed). If the build
- // fails, Flux cannot deploy the folder either, and we cannot know what it renders
- // to — and a silent pass would be worse than useless, because a root that yields
- // no chain also yields no ambiguity, which disarms the write-fan-in guard.
- //
- // The acceptance gate refuses either (IssueUnsupportedKustomize) rather than writing
- // into content it cannot safely manage. Only ever set on a whole-file retention.
+ // Unsupported marks a kustomization retention the operator cannot map back to editable source:
+ // it uses a feature outside the supported subset, or it is a root KUSTOMIZE CANNOT BUILD. The
+ // second matters because a root yielding no chain also yields no ambiguity, which would disarm
+ // the write-fan-in guard. The acceptance gate refuses either. Only set on a whole-file
+ // retention.
Unsupported bool
// UnsupportedFeatures names the constructs that made this retention unsupported, as
// the user wrote them ("configMapGenerator", "remote-base", "render-failed"). It
@@ -180,20 +160,11 @@ func (f *FileModel) Dirty() bool { return f.Current != nil && !bytes.Equal(f.Cur
// 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/spec/current-manifest-support-review.md ("Concrete
-// Data Structures") and the M4 acceptance gate (acceptance.go).
+// DocumentModel is one managed KRM document, byte-free: the node tree is built only when a plan
+// action touches it. It stores neither file path nor position. The path is the containing
+// FileModel's; the index is reconstructed from record-less diagnostic gaps, since every
+// non-managed document leaves a diagnostic at its position. That keeps the report, the planner and
+// the acceptance gate agreeing without a fragile mutable field.
type DocumentModel struct {
// ManifestIdentity is the EFFECTIVE content identity (apiVersion + kind +
// namespace + name). For a namespace-less namespaced resource it may carry a
@@ -215,17 +186,12 @@ type DocumentModel struct {
// docs/design/support-boundary/finished/images-and-replicas-edit-through.md.
Overrides *KustomizeOverrides
- // Rendered is what kustomize ACTUALLY renders this document to, plus which override
- // entry supplied each override-produced value — the values read off the real render, the
- // suppliers read off a dyed counterfactual one. It is what the write-side projection
- // inverts against, and it replaced ~400 lines that re-implemented kustomize's
- // transformers in order to guess the same thing.
+ // Rendered is what kustomize ACTUALLY renders this document to, plus which override entry
+ // supplied each value: values off the real render, suppliers off a dyed counterfactual one.
//
- // Nil when no render root supplies a chain, when distinct roots disagree, or when the
- // dyed build could not be trusted (see attributeRoot). Nil means NO ATTRIBUTION: the
- // writer routes nothing to an entry, and the verification re-render adjudicates whatever
- // the source document alone can carry. See
- // docs/design/support-boundary/render-attribution.md.
+ // Nil when no root supplies a chain, roots disagree, or the dyed build could not be trusted.
+ // Nil means NO ATTRIBUTION: the writer routes nothing to an entry.
+ // See docs/design/support-boundary/render-attribution.md.
Rendered *RenderedOverrides
// ResourceIdentity is the API-side identity (GVR + namespace + name). It is set
@@ -287,11 +253,9 @@ const (
// namespace is the document's. Like Kustomize, the namespace must stay out of the file and
// the document is located in the bytes by a namespace-less identity.
//
- // It is what keeps a namespace-free folder mirrorable at all. Without it the operator writes
- // shop/config as a namespace-less document, reads it back as belonging to no namespace, and
- // the NEXT write of the same object matches nothing and appends a second copy of it. The
- // one-source-namespace refusal is what makes the attribution safe: with two namespaces
- // reaching the folder there is no single answer, and the write is refused rather than guessed.
+ // Without it the operator writes a namespace-less document, reads it back as belonging to no
+ // namespace, and the NEXT write matches nothing and appends a second copy. The
+ // one-source-namespace refusal is what makes it safe: two namespaces have no single answer.
NamespaceDeclared NamespaceSourceKind = "Declared"
)
@@ -407,26 +371,16 @@ type RecordRef struct {
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.
+// buildStore indexes the YAML files into the structure model and builds the manifest-identity,
+// resource-identity and GVK indexes.
//
-// 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.
+// A nil mapper is structure-only, so the analyzer's no-cluster promise holds.
//
-// 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.
+// allowlist names build-directive files retained rather than materialised. It is FILENAME-based
+// because a real kustomization.yaml has no metadata.name and is not a KRM record at all, so a
+// GVK match would never see it. An allowlisted file never becomes a FileModel and its per-document
+// diagnostics are suppressed; a named KRM record inside one is retained WITH its identity so the
+// acceptance gate can refuse the mixed file rather than un-manage a resource.
func buildStore(
ctx context.Context,
scan FolderScan,
@@ -520,16 +474,10 @@ func buildStore(
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.
+// BuildStoreFromFiles builds the store from already-collected bytes rather than walking an fs.FS.
+// It is the live writer's entry point: the writer reads the subtree once at a commit boundary and
+// hands the same slice here, so the store and the bytes the plan is applied to are one snapshot.
+// A nil lookup keeps it structure-only, exactly as BuildStore.
func BuildStoreFromFiles(
ctx context.Context,
files []manifestedit.FileContent,
@@ -609,18 +557,13 @@ type materializeInputs struct {
declaredNamespace string
}
-// materializeRecords sorts every KRM document into one of three fates — retained as a build
-// directive, retained as a patch, or materialised as a managed manifest — and returns the files
-// that were retained rather than managed.
-//
-// records arrive 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.
+// materializeRecords sorts every KRM document into one of three fates: retained as a build
+// directive, retained as a patch, or materialised as a managed manifest. Records arrive in stable
+// scan order, so first-occurrence-wins is deterministic.
//
-// A PATCH FILE IS A BUILD INPUT, NOT A MANIFEST, and nothing else in the store would know that:
-// a strategic-merge patch IS a KRM document. Materialised, it would be indexed as a manifest,
-// matched to a live object, mirrored over (a whole Deployment written where a sparse patch used to
-// be), or swept as an orphan when nothing in the cluster answers to it. It is retained exactly as
-// kustomization.yaml is: known, never managed.
+// A PATCH FILE IS A BUILD INPUT, NOT A MANIFEST, and nothing else in the store would know it: a
+// strategic-merge patch IS a KRM document. Materialised, it would be mirrored over (a whole
+// Deployment written where a sparse patch was) or swept as an orphan.
func (s *ManifestStore) materializeRecords(
ctx context.Context,
records []manifestedit.DocumentRecord,
@@ -741,15 +684,11 @@ func sortRetained(retained []RetainedDocument) {
})
}
-// 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. declaredNamespace is
-// the one exception, and it comes from the GitTarget rather than the folder: see
-// WithDeclaredNamespace.
+// resolveNamespaceContext determines a document's effective namespace and where it came from. A
+// namespace in the file is authoritative; otherwise the kustomization resources graph decides, and
+// zero or conflicting assignments leave it namespace-less. It never guesses by filesystem
+// proximity, so only a kustomization that actually references a file can give it a namespace.
+// declaredNamespace is the one exception and comes from the GitTarget: see WithDeclaredNamespace.
func resolveNamespaceContext(
ctx context.Context,
id manifestedit.Identity,
@@ -975,16 +914,13 @@ func collapseAssignments(nsByFile map[string]map[string]string) map[string]names
return out
}
-// hasRemoteResource reports whether any resources/bases entry points outside this
-// repository. It is the one piece of kustomize semantics the operator must keep
-// owning rather than delegate to the library.
+// hasRemoteResource reports whether any resources/bases entry points outside this repository: the
+// one piece of kustomize semantics the operator must own rather than delegate.
//
-// kustomize resolves a remote base by shelling out to `git fetch`, and it does so
-// under LoadRestrictionsRootOnly and under an in-memory filesystem alike (both
-// measured). No build option turns that off. Detecting a remote entry ourselves,
-// and refusing before any build is invoked, is therefore what keeps "the operator
-// never fetches a remote base" true — see
-// docs/design/support-boundary/kustomize-support-boundary.md §7.
+// kustomize resolves a remote base by shelling out to `git fetch`, under LoadRestrictionsRootOnly
+// and an in-memory filesystem alike (both measured), and no build option turns it off. Refusing
+// before any build is invoked is what keeps "the operator never fetches a remote base" true.
+// See docs/design/support-boundary/kustomize-support-boundary.md §7.
func hasRemoteResource(entries []string) bool {
for _, e := range entries {
if isRemoteResource(e) {
diff --git a/internal/queue/author_fact.go b/internal/queue/author_fact.go
index 488a5cc1..811fead2 100644
--- a/internal/queue/author_fact.go
+++ b/internal/queue/author_fact.go
@@ -68,18 +68,13 @@ type AttributionResult string
// `latest` and `name` can hold a delete fact too, so they are NOT named for one: either can equally
// hold a write, and a value that could mean either must not claim a verb.
const (
- // AttributionDeleteSticky is the sticky removal pointer: a fact whose own verb is a delete, filed by
- // uid into a slot no later WRITE fact may overwrite. It is the strongest evidence a removal can
- // have about itself, so it is consulted before the exact tier — and only by a removal, because an
- // exact-capable event asks who produced a version rather than who deleted an object.
+ // AttributionDeleteSticky is the sticky removal pointer: a delete-verb fact filed by uid into a
+ // slot no later WRITE may overwrite. The stickiness is the whole reason it can answer at all —
+ // every other structure would have been overwritten by the finalizer patch following the
+ // delete. Consulted before the exact tier, and only by a removal.
//
- // "sticky" is the half of the name that is not the verb, and it is there because the stickiness is
- // the whole reason this tier can answer at all: every other structure would have been overwritten
- // by the finalizer patch that followed the delete.
- //
- // It is also the only tier the TTL does not bound. A uid is unique across space and time, so the
- // statement can never be superseded; its horizon is the index's caps instead. See
- // docs/spec/attribution.md.
+ // The only tier the TTL does not bound: a uid is unique across space and time, so the statement
+ // can never be superseded. See docs/spec/attribution.md.
AttributionDeleteSticky AttributionResult = "delete_sticky"
// AttributionExact is an exact UID+resourceVersion match: this actor produced this exact version.
AttributionExact AttributionResult = "exact"
@@ -125,24 +120,15 @@ const (
// back by the watch-event resolver. It names an author candidate and carries the evidence the join
// needs to decide confidence; it is never object state.
//
-// Every field here is either read by the join or printed when a fact is investigated. That is a
-// deliberate bar, because a fact is not stored once: it is broadcast to every process following its
-// type, held for the whole TTL, and replayed into memory on every restart, so a field nothing reads
-// is paid for on all three. Three fields were removed for failing it:
-//
-// - the group/resource, which is the STREAM'S OWN NAME — the index takes the scope from the
-// entry's key, never from the fact, so carrying it duplicated the routing on every entry;
-// - the subresource, which no tier joins on and nothing logs.
+// Every field is read by the join or printed when a fact is investigated. A deliberate bar: a fact
+// is broadcast to every process following its type, held for the whole TTL, and replayed on every
+// restart, so a field nothing reads is paid for three times.
//
-// A stored is-service-account bool went the same way, for a different reason: it is not evidence,
-// it is a prefix check on Author that the reader can do for itself (see ActorKind).
-//
-// Name was removed with the subresource, on the same observation — no tier read it — and is back,
-// because that observation was true of the code and false of the domain. An aggregated-API write is
-// audited with no uid and no resourceVersion, and the name from the URL path is the ONLY identity it
-// carries, so a fact without it could not be joined at all for that whole population. "No code reads
-// it" and "nothing could ever read it" are different claims, and only the second justifies dropping a
-// field.
+// Name was once removed on the observation that no tier read it, and is back, because that was true
+// of the code and false of the domain: an aggregated-API write is audited with no uid and no
+// resourceVersion, so the name from the URL path is the ONLY identity it carries. "No code reads
+// it" and "nothing could ever read it" are different claims, and only the second justifies dropping
+// a field.
type AuthorFact struct {
Namespace string `json:"namespace,omitempty"`
UID string `json:"uid,omitempty"`
@@ -188,18 +174,13 @@ var errFactWithoutAuthor = errors.New("attribution fact carries no author")
// `author` must be present, a string, and non-empty. Missing, `null`, and `""` are the same
// violation and are all refused.
//
-// Go cannot express "a string of at least one character" as a type — every type has a zero value
-// that is constructible without going through any constructor, and `encoding/json` writes exported
-// fields straight past one anyway — so the constraint lives at the only boundary that can hold it:
-// the point where a fact written by somebody else enters this process.
+// Go cannot express "a non-empty string" as a type, so the constraint lives at the only boundary
+// that can hold it: where a fact written by somebody else enters this process.
//
-// The refusal is deliberately at ENTRY granularity, not per fact. This operator's publish gate
-// cannot produce an authorless fact (AuthorFactFromEvent refuses an event whose user is
-// unresolvable, and counts it as no_attribution_fact), so an entry carrying one was written by
-// something else: a different version, a different producer, or a hand-written entry. That is a
-// protocol violation rather than a low-quality fact, and it is better counted and logged loudly —
-// it lands on attribution_fact_stream_decode_errors_total with the stream and entry id — than
-// half-absorbed by silently dropping one fact out of a batch.
+// The refusal is at ENTRY granularity, not per fact. Our own publish gate cannot produce an
+// authorless fact, so an entry carrying one came from a different version or producer. That is a
+// protocol violation rather than a low-quality fact, and counting it loudly beats half-absorbing it
+// by dropping one fact out of a batch.
func (f *AuthorFact) UnmarshalJSON(raw []byte) error {
// wire has AuthorFact's fields and tags but none of its methods, so decoding it does not recurse.
type wire AuthorFact
@@ -261,13 +242,9 @@ func (r AuthorResolution) ActorKind() ActorKind {
// may be published: an event with no objectRef or no user produces nothing, or waiters are woken by
// facts that can name nobody.
//
-// The one rule that changes from the per-key write path is the name check. A deletecollection is
-// name-less by nature and is now exactly the case that produces a fact — one fact describing the
-// COLLECTION, which every removal in its scope joins — so "no resolvable name" becomes "no name and
-// not a collection verb".
-//
-// The caller has already applied the intrinsic accept gate: reads, failures, dry runs, and
-// non-ResponseComplete stages never reach here.
+// The one rule that changes from the per-key write path is the name check: a deletecollection is
+// name-less by nature and is exactly the case that produces a fact, so "no resolvable name" becomes
+// "no name and not a collection verb". The caller has already applied the intrinsic accept gate.
func AuthorFactFromEvent(
ctx context.Context,
event auditv1.Event,
diff --git a/internal/queue/fact_index.go b/internal/queue/fact_index.go
index 387f613b..ae87c2ac 100644
--- a/internal/queue/fact_index.go
+++ b/internal/queue/fact_index.go
@@ -170,14 +170,10 @@ func NewFactIndex(cfg FactIndexConfig) *FactIndex {
// Apply stores one delivered entry's facts and wakes whoever was waiting for them. Facts are
// applied in the order they were delivered, which is what makes the latest tier last-writer-wins
// mean the last fact APPENDED rather than whichever goroutine reached the map first.
-// A fact ages from when it was APPENDED, not from when this process happened to read it. The two
-// differ by more than a hair in the case that matters most: the follower replays the whole retention
-// window on start, so stamping those entries with the read time would hand every one of them a
-// second full TTL and let a restart resurrect facts the horizon had already retired. A follower that
-// falls behind, or a transport that hands back an entry its own retention should have dropped, lands
-// in the same place. Reading the append time off the entry's position makes the TTL mean the same
-// thing on both transports and on every delivery path, which is what SweepInterval bounding memory
-// rather than correctness depends on.
+// A fact ages from when it was APPENDED, not when this process read it. The follower replays the
+// whole retention window on start, so stamping those with the read time would give each a second
+// full TTL and let a restart resurrect facts the horizon had retired. Reading the append time off
+// the entry's position is what makes SweepInterval bound memory rather than correctness.
func (i *FactIndex) Apply(ctx context.Context, entry FactEntry) {
scope := factScope{route: entry.Key.AuditRoute, groupResource: entry.Key.groupResource()}
at := entryAppendTime(entry.ID, time.Now())
@@ -210,19 +206,14 @@ func entryAppendTime(id string, now time.Time) time.Time {
// returns an AttributionAbsent resolution when nothing matched in time; it never blocks longer than
// the grace and never returns an error path.
//
-// The order of the first two statements is the design, not a detail. The waiter is registered
-// BEFORE the index is read, so a fact applied in the gap between the two signals a waiter that is
-// already listening. Checking first and registering after loses exactly that fact — the race the
-// poll loop used to paper over by looking again.
+// The order of the first two statements is the design: the waiter is registered BEFORE the index
+// is read, so a fact applied in the gap signals a waiter that is already listening.
//
-// A match does not always end the wait. For a REMOVAL, the strongest fact present early is often
-// the object's last WRITE, which says who edited it and nothing about who deleted it — and the
-// watch event reliably beats the audit batch that carries the delete, which is the entire reason
-// the grace window exists. Returning on that first match answered "who deleted this" with "who last
-// edited it", every time an object was touched by someone else before being removed. Such a match
-// is held as a FALLBACK instead: the wait continues for evidence about the deletion itself, and the
-// fallback is returned only when the grace expires without any arriving. Attribution is never lost
-// by waiting — the worst case returns exactly what returning early would have.
+// A match does not always end the wait. For a REMOVAL the strongest early fact is often the
+// object's last WRITE, which says who edited it and nothing about who deleted it. Returning on
+// that answered "who deleted this" with "who last edited it" whenever someone else touched the
+// object first. Such a match is held as a FALLBACK and returned only if the grace expires, so
+// waiting never loses attribution: the worst case is what returning early would have given.
func (i *FactIndex) Await(ctx context.Context, query FactQuery, grace time.Duration) AuthorResolution {
waiter := i.waiters.register(query.waiterKeys())
defer i.waiters.unregister(waiter)
@@ -273,14 +264,10 @@ func (i *FactIndex) settle(ctx context.Context, fallback AuthorResolution) Autho
// awaitsBetterEvidence reports whether a match should be held as a fallback rather than returned.
//
-// It is true for exactly one shape: a REMOVAL matched to a fact that is not about a removal. The
-// sticky removal pointer is about the deletion by construction — only a removal fact is ever filed
-// there — so a match on it ends the wait at once. The
-// per-object tiers are last-writer-wins, so for a collection member — whose delete files one fact
-// about the collection rather than one per object — they hold whoever edited it last. Both
-// collection tiers are about the deletion itself and end the wait, as does a per-object fact whose
-// own verb is a delete: that is the object's own removal fact, which is the strongest thing a
-// removal can hope for.
+// True for exactly one shape: a REMOVAL matched to a fact that is not about a removal. The sticky
+// pointer holds only removal facts, so a match there ends the wait at once. The per-object tiers
+// are last-writer-wins, so for a collection member they hold whoever edited it last. Both
+// collection tiers, and a per-object fact whose own verb is a delete, end the wait.
func (q FactQuery) awaitsBetterEvidence(resolution AuthorResolution) bool {
if q.ExactCapable || resolution.Result == AttributionAbsent {
return false
@@ -309,26 +296,18 @@ func isRemovalVerb(verb string) bool {
// 6. the rv-only escape hatch;
// 7. the (namespace, name) floor.
//
-// The name tier is last because it is the weakest per-object evidence here: a name is reused after a
-// delete and recreate, so it can name the author of a previous object that held it, where a uid
-// cannot and an rv identifies one specific write. Nothing that carries a uid or an rv ever reaches
-// it, so ranking it last costs the stronger tiers nothing and only picks up what they cannot express.
+// The name tier is last because a name is reused after a delete and recreate, so it can name the
+// author of a previous object that held it, where a uid cannot. Nothing carrying a uid or rv
+// reaches it, so ranking it last costs the stronger tiers nothing.
//
-// Precedence is the correctness argument for the collection tiers, and the two of them sit on
-// OPPOSITE sides of the latest tier on purpose.
+// The two collection tiers sit on OPPOSITE sides of the latest tier on purpose. Uid membership
+// outranks it because the two answer different questions: the latest tier says who last WROTE an
+// object, a removal asks who DELETED it, and a collection delete files one fact about the
+// collection, leaving the uid's latest entry holding whoever wrote the object last. Uid membership
+// is the API server stating THIS request deleted THIS object, so nothing weaker may answer first.
//
-// Uid membership outranks it because the two tiers answer different questions. The latest tier says
-// who last WROTE an object; a removal asks who DELETED it. For a single-object delete those coincide,
-// because the delete files its own fact under that uid — but a collection delete files one fact about
-// the collection, so the uid's latest entry is left holding whoever happened to write the object last.
-// Ranking it above the collection's uid set credited a removal to the previous editor and never
-// reached the actor who actually ran the delete, which is the one thing the deleted expander did get
-// right: it overwrote that entry per object. Uid membership is the API server stating that THIS
-// request deleted THIS object, so nothing weaker may answer ahead of it.
-//
-// Scope matching stays below, because it is the weakest evidence here and can name the wrong human:
-// an unrelated delete by another actor during the same window is claimed by its own fact at tier 3
-// and never reaches tier 4.
+// Scope matching stays below because it can name the wrong human: an unrelated delete by another
+// actor in the same window is claimed by its own fact at tier 3 and never reaches tier 4.
func (i *FactIndex) Lookup(query FactQuery) AuthorResolution {
now := time.Now()
cutoff := now.Add(-i.ttl)
@@ -419,16 +398,13 @@ func (i *FactIndex) lookupRemoval(
writeFallback, haveWriteFallback = resolution, true
}
}
- // The object's own delete fact again, this time keyed by NAME, which is the only key it has when
- // the API server answered the delete with a Status rather than the object: there is then no uid
- // to recover from the body (measured in corpus configmap/owner-ref-cascade, where the parent's
- // delete returns Status, against configmap/finalizer-delete, where it returns the ConfigMap).
+ // The object's own delete fact keyed by NAME, the only key it has when the API server answered
+ // the delete with a Status rather than the object (measured: an owner-ref cascade returns
+ // Status, a finalizer delete returns the ConfigMap).
//
- // It has to be reachable HERE, above the write fallback, or it is not reachable at all for a
- // removal: returning the uid tier's write fact ends the lookup, and the caller then holds that
- // fact and waits out the whole grace for delete evidence that was sitting in this tier the entire
- // time. That wait is not free — it blocks the watch shard's serial goroutine, so every later
- // event for the type waits behind it.
+ // It must be reachable HERE, above the write fallback, or not at all for a removal: returning
+ // the uid tier's write fact ends the lookup, and the caller then waits out the whole grace for
+ // evidence sitting in this tier. That wait blocks the watch shard's serial goroutine.
if query.Name != "" {
if fact, found := facts.lookupName(query.Namespace, query.Name, cutoff); found && isRemovalVerb(fact.Verb) {
return AuthorResolution{Fact: fact, Result: AttributionName}
diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go
index 69a4bedf..0861e338 100644
--- a/internal/telemetry/exporter.go
+++ b/internal/telemetry/exporter.go
@@ -49,7 +49,7 @@ var (
// mechanism chose the path (declared / kustomize_root / canonical) and disposition is what
// it did with it (new_file / appended).
//
- // It exists because sibling inference was deleted (docs/design/open-asks-priority.md): a
+ // It exists because sibling inference was deleted: a
// repository with a hand-authored layout now needs a placement.byType line, and
// `source="canonical"` is how its operator learns which type in which target is missing
// one, WITHOUT reading the folder. The (GitTarget, type) labels are the whole point — a
diff --git a/internal/types/cell.go b/internal/types/cell.go
index 97819d00..b53216f0 100644
--- a/internal/types/cell.go
+++ b/internal/types/cell.go
@@ -17,8 +17,7 @@ import (
// actually compared them — the sweep's Matches — ignored the version, so two cells differing
// only in served version were distinct keys but one sweep boundary. A key that does not
// round-trip to the scope it sweeps under is the one class of error that deletes user data,
-// so the version was removed from the identity rather than added to the comparison
-// (docs/design/target-watch-plan.md, "Diff the plan").
+// so the version was removed from the identity rather than added to the comparison.
//
// This matches the identity Git already uses: [ResourceIdentifier.ToGitPath] is versionless,
// so a storage-version bump moves no file. A cell whose identity changed with the served
diff --git a/internal/watch/event_router.go b/internal/watch/event_router.go
index 5908ba1e..eedcf8b8 100644
--- a/internal/watch/event_router.go
+++ b/internal/watch/event_router.go
@@ -310,8 +310,7 @@ func (r *EventRouter) handleScopedResyncError(
// exactly the load it protects against, and it was lowered once on the assumption that the
// render-fidelity condition had made every lost report visible. It has not: this path
// skips the RETENTION report too, and lowering it re-blinded that path in the very local
- // reproduction of Failure B that followed. Lower it again only when B is closed
- // (docs/design/watch-plane-status-convergence-failures.md, §3.4).
+ // reproduction of Failure B that followed. Lower it again only when B is closed.
r.Log.Info("per-type "+kind+" superseded by a newer resync; its roll-up reports were skipped",
"gitDest", gitDest.String(), "cell", cell.String())
return
diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go
index da0bf9d4..a0a5d484 100644
--- a/internal/watch/event_router_test.go
+++ b/internal/watch/event_router_test.go
@@ -338,8 +338,7 @@ func TestServiceCommitRequest_RegisteredWorkerResolvesNoOpenWindow(t *testing.T)
// a buffered channel nobody read. With it went the only calls that mark acceptance, render
// fidelity and retention for the cell, so the render-fidelity scope owed a report under a revision
// no running stream would ever report again — which pins the GitTarget at Ready=False and, through
-// GitTargetReady, every WatchRule pointing at it
-// (docs/design/watch-plane-status-convergence-failures.md, §2.5).
+// GitTargetReady, every WatchRule pointing at it.
//
// The drain must consume the result rather than block on it. Nothing was written, so it also must
// not move any readiness the caller would read as convergence.
diff --git a/internal/watch/git_path_acceptance_test.go b/internal/watch/git_path_acceptance_test.go
index 0fb16d3b..b589619a 100644
--- a/internal/watch/git_path_acceptance_test.go
+++ b/internal/watch/git_path_acceptance_test.go
@@ -165,8 +165,7 @@ func TestReportGitPathRefusal_RenderFidelityKeepsGitPathAccepted(t *testing.T) {
// TestMarkRenderFidelityScopeClean_NamesAResultTheGateWouldNotTake covers the branch that made
// Failure A undiagnosable: the gate answers applied=false for a stale revision, an unknown scope
// or an unknown target, and the caller used to discard that answer without a word — so a scope
-// could owe a report for ever with nothing anywhere saying why
-// (docs/design/watch-plane-status-convergence-failures.md, §2.5).
+// could owe a report for ever with nothing anywhere saying why.
func TestMarkRenderFidelityScopeClean_NamesAResultTheGateWouldNotTake(t *testing.T) {
workerManager := git.NewWorkerManager(nil, logr.Discard(), 0, types.SensitiveResourcePolicy{})
log, lines := recordingLogger()
@@ -210,7 +209,7 @@ func TestMarkRenderFidelityScopeClean_NamesAReportWithNoRevision(t *testing.T) {
// Sibling drains record concurrently and their publishes can reorder, so a drain that observed
// "one scope still pending" could write that over the fresh "True" a later drain had already
// published. Publishing what the GATE currently says instead of what this drain saw removes the
-// race (docs/design/watch-plane-status-convergence-failures.md, §2.10).
+// race.
func TestRenderFidelityStatus_PublishesTheCurrentStatusNotTheObservedOne(t *testing.T) {
workerManager := git.NewWorkerManager(nil, logr.Discard(), 0, types.SensitiveResourcePolicy{})
manager := &Manager{Log: logr.Discard()}
diff --git a/internal/watch/gitpath_events.go b/internal/watch/gitpath_events.go
index 9aca9ea9..b66848a2 100644
--- a/internal/watch/gitpath_events.go
+++ b/internal/watch/gitpath_events.go
@@ -48,7 +48,7 @@ func (m *Manager) enqueueGitTargetReconcile(gitDest types.ResourceReference) {
// stream transitions off it, so a full buffer is not routine -- and the consequence is
// that a GitTarget whose data plane just converged is never told to republish, leaving a
// stale condition standing until its periodic requeue, which for a CONVERGED target is
- // five minutes (docs/design/watch-plane-status-convergence-failures.md, §2.10).
+ // five minutes.
//
// The comment above says a dropped event is harmless because a reconcile is already
// pending. That is true only when the buffer is full BECAUSE this target is already
diff --git a/internal/watch/owner.go b/internal/watch/owner.go
index d5b89f05..57454cb9 100644
--- a/internal/watch/owner.go
+++ b/internal/watch/owner.go
@@ -202,14 +202,12 @@ func (t *watchPlaneTriggers) markDirtyLocked(ref types.ResourceReference, reason
// TriggerRuleChange marks the GitTarget a rule names as needing a new plan pass.
//
-// It replaces the six inline ReconcileForRuleChange call sites. Those did the work — a discovery
-// call, a namespace list, a full re-projection, and then a replan of EVERY running GitTarget —
-// synchronously, on the controller worker that observed the rule. This posts intent and returns.
+// It posts intent and returns, where the inline call sites it replaced did a discovery call, a
+// namespace list, a re-projection and a replan of EVERY running GitTarget synchronously.
//
-// It deliberately does NOT also enqueue the GitTarget for reconcile. The GitTarget already learns
-// that its plan moved from the pass itself, which enqueues on a render-fidelity change — the one
-// moment its answer differs. Enqueueing here too would be a third path to the same channel, firing
-// before anything it would report has changed.
+// It deliberately does NOT also enqueue the GitTarget: the pass itself enqueues on a
+// render-fidelity change, the one moment its answer differs. Enqueueing here would fire before
+// anything it would report has changed.
func (m *Manager) TriggerRuleChange(gitDest types.ResourceReference) {
m.trigger(gitDest, TriggerReasonRuleChange)
}
@@ -502,15 +500,12 @@ func (m *Manager) staleTeardown(action forgetAction) (string, bool) {
// cluster's API catalog and the source-namespace scopes — and then marks dirty only the targets
// the refresh actually invalidated.
//
-// Keeping this apart from replanning a target is the point. A target's plan depends on three
-// inputs, two of them shared, and one rule edit used to re-derive all three and then walk every
-// target. Here a rule edit replans one target and rediscovers nothing.
+// Keeping this apart from replanning a target is the point: a rule edit replans one target and
+// rediscovers nothing, where it used to re-derive all three inputs and walk every target.
//
-// It runs on its OWN goroutine, and the loop does not wait for it. This is the only I/O the watch
-// plane does, so leaving it inline would mean an unreachable source cluster stalling every healthy
-// target, every teardown and every report for the full refresh timeout — the availability failure
-// this design exists to remove, relocated rather than fixed. One refresh runs at a time; a request
-// arriving while one is in flight is held and served by the next.
+// It runs on its OWN goroutine and the loop does not wait. This is the only I/O the watch plane
+// does, so inline it would let an unreachable source cluster stall every healthy target for the
+// full refresh timeout. One refresh at a time; a request arriving during one is served by the next.
func (m *Manager) refreshSharedSnapshotsIfDue(ctx context.Context, log logr.Logger) {
t := m.triggers()
t.mu.Lock()
diff --git a/internal/watch/render_fidelity_gate.go b/internal/watch/render_fidelity_gate.go
index 0f5fbd88..d678d983 100644
--- a/internal/watch/render_fidelity_gate.go
+++ b/internal/watch/render_fidelity_gate.go
@@ -79,8 +79,7 @@ func (m *Manager) MarkTargetRenderFidelityScopeClean(
// TEMPORARY at Info, while Failure A is open. The refusal paths above are logged and the
// accept path was not, so SILENCE from this function was ambiguous between "never called" and
// "called and accepted" -- and the reproduction that finally carried every other diagnostic
- // produced exactly that silence, which decided nothing
- // (docs/design/watch-plane-status-convergence-failures.md, §2.7).
+ // produced exactly that silence, which decided nothing.
//
// It is bounded: a scope accepts one report per revision, and a revision only moves when the
// plan restarts the cell. Lower it to V(1) once A is named.
@@ -99,8 +98,7 @@ func (m *Manager) MarkTargetRenderFidelityScopeClean(
// This is the last silent branch in the roll-up. RecordScope* answers applied=false for three
// different reasons — the target is unknown, the scope is not in the current plan, or the result
// carries a superseded revision — and every caller used to drop that answer on the floor. A scope
-// then owes a report for ever with nothing to say why, which is Failure A's signature
-// (docs/design/watch-plane-status-convergence-failures.md, §2.5).
+// then owes a report for ever with nothing to say why, which is Failure A's signature.
//
// The retention roll-up already logs its refusals, and that asymmetry is exactly why B had
// evidence and A had none. Info, because it is rare by construction: a healthy plan produces one
diff --git a/internal/watch/retention_rollup.go b/internal/watch/retention_rollup.go
index d6492725..9e516889 100644
--- a/internal/watch/retention_rollup.go
+++ b/internal/watch/retention_rollup.go
@@ -30,7 +30,8 @@ type RetentionSummary struct {
Mode v1alpha3.PruneMode
// RetainedDocuments is the sum over the target's currently tracked scopes.
RetainedDocuments int
- // ObservedTime is when the most recent contributing resync reported.
+ // ObservedTime is when the most recent contributing resync reported. It is stamped in the same
+ // mutation that sets a scope's reported flag, so it is non-zero whenever Reported is true.
ObservedTime time.Time
}
@@ -42,8 +43,7 @@ type targetRetentionScope struct {
// reportedRevision is the revision of the report that produced `retained`. It separates a
// stream RE-reporting under the revision it already reported (routine, every resync) from a
// NEW incarnation measuring the cell afresh and arriving at the same number. Only the second
- // is interesting: it is the one an unchanged published count can be hiding
- // (docs/design/watch-plane-status-convergence-failures.md, §3.4).
+ // is interesting: it is the one an unchanged published count can be hiding.
reportedRevision uint64
}
@@ -138,8 +138,7 @@ func (m *Manager) MarkTargetRetention(
// lands and changes nothing an operator would see is DISCARDED whole by mutateWatchPlane, so
// "accepted and unchanged" and "never reported at all" look identical from outside. The
// refusals above have been logged since c24844a1; the acceptances were not, and that asymmetry
- // is why B could be narrowed to this function and no further
- // (docs/design/watch-plane-status-convergence-failures.md, §3.4).
+ // is why B could be narrowed to this function and no further.
//
// Info is reserved for the one shape that is genuinely ambiguous: a cell RE-MEASURED under a
// new revision that arrived at the number already published, so the mutation was discarded and
diff --git a/internal/watch/retention_rollup_test.go b/internal/watch/retention_rollup_test.go
index 9fb34b18..21545398 100644
--- a/internal/watch/retention_rollup_test.go
+++ b/internal/watch/retention_rollup_test.go
@@ -264,8 +264,7 @@ func TestRetentionRollup_AnAcceptedReportLogsNothing(t *testing.T) {
// TestMarkTargetRetention_SaysWhenAnAcceptedReportPublishesNothing closes the roll-up's remaining
// silence. mutateWatchPlane discards the WHOLE mutation when nothing an operator would see moved,
// so an accepted-and-unchanged report and a report that never arrived are the same silence from
-// outside — which is exactly how far Failure B could be narrowed and no further
-// (docs/design/watch-plane-status-convergence-failures.md, §3.4).
+// outside — which is exactly how far Failure B could be narrowed and no further.
func TestMarkTargetRetention_SaysWhenAnAcceptedReportPublishesNothing(t *testing.T) {
log, lines := recordingLogger()
m := &Manager{Log: log}
diff --git a/internal/watch/target_watch.go b/internal/watch/target_watch.go
index dd659a61..63cd4106 100644
--- a/internal/watch/target_watch.go
+++ b/internal/watch/target_watch.go
@@ -55,7 +55,7 @@ func targetWatchClosedErr(ctx context.Context) error {
// targetWatchSet is one GitTarget's running streams, keyed by the cell each one covers. The
// plan is applied cell by cell, so cancellation is too: there is no set-wide cancel, because a
// single one is what made adding a rule replay every unrelated cell into a queue shared with
-// other tenants (docs/design/target-watch-plan.md, "Implementation order", step 2).
+// other tenants.
type targetWatchSet struct {
streams map[types.CellKey]*runningTargetWatch
}
@@ -77,8 +77,7 @@ func (s *targetWatchSet) plan() targetWatchPlan {
}
// stop cancels one cell's stream and drops it. It never touches files: a deselected cell's
-// documents are converged by a Git-side sweep, not by the watch layer
-// (docs/design/target-watch-plan.md, "Removal is a Git-side sweep").
+// documents are converged by a Git-side sweep, not by the watch layer.
func (s *targetWatchSet) stop(cell types.CellKey) {
running, ok := s.streams[cell]
if !ok {
@@ -106,8 +105,7 @@ type targetWatchKey struct {
// Nothing about a stream fences the work it queues. Once an item is on the branch worker's
// FIFO it will be applied, and a canceled stream's goroutine can still be in flight, so a
// short tail of writes from a deselected cell is accepted rather than rejected on arrival. The
-// plan is applied by canceling streams, at the producer
-// (docs/design/target-watch-plan.md, "Cut at the producer").
+// plan is applied by canceling streams, at the producer.
type targetWatchStream struct {
key targetWatchKey
ops OperationSet
@@ -129,7 +127,7 @@ func (s targetWatchStream) sourceCell() types.CellKey {
// its replay runs under, the render-fidelity scope it reports into, and the source cell stamped
// on the work it queues. The served version stays on the key — a stream has to open a watch
// with a concrete version — but it is not part of the cell, so the key always round-trips to
-// the boundary it sweeps (docs/design/target-watch-plan.md, "Diff the plan").
+// the boundary it sweeps.
func (k targetWatchKey) Cell() types.CellKey {
return types.CellKeyFor(k.GVR, k.Namespace)
}
@@ -836,9 +834,9 @@ func (m *Manager) enqueueReplayResync(
return nil
}
// Cancellation has to be prompt on the PRODUCER side: nothing filters the branch worker's
- // queue, so what bounds a retired stream's tail is how quickly it stops enqueuing
- // (docs/design/target-watch-plan.md, "Cut at the producer"). A snapshot gathered for a cell
- // that has since been stopped or restarted has nothing left to report into either.
+ // queue, so what bounds a retired stream's tail is how quickly it stops enqueuing. A snapshot
+ // gathered for a cell that has since been stopped or restarted has nothing left to report
+ // into either.
select {
case <-ctx.Done():
return nil
@@ -860,7 +858,7 @@ func (m *Manager) enqueueReplayResync(
// read -- and with it went the only calls that mark acceptance, render fidelity and retention
// for this cell. The render-fidelity scope then owed a report under a revision no running
// stream would ever report again, which pins the GitTarget at Ready=False and, through it,
- // every WatchRule pointing at it (docs/design/watch-plane-status-convergence-failures.md).
+ // every WatchRule pointing at it.
//
// The stream.key (GVR + namespace) is threaded to the drain for diagnostics. A refused
// Git path acceptance is target-level state, so the drain records GitPathAccepted=False rather
diff --git a/internal/watch/target_watch_plan.go b/internal/watch/target_watch_plan.go
index 7b3beb4c..d35d9aa1 100644
--- a/internal/watch/target_watch_plan.go
+++ b/internal/watch/target_watch_plan.go
@@ -19,8 +19,7 @@ import (
// (see [types.CellKey]), so a storage-version bump is one cell whose spec changed — a
// `restart` — and not the retirement of one key plus the birth of another. Diffing
// `map[targetWatchKey]string` directly would classify it the second way, which would replay
-// the cell AND drop its readiness result rather than replacing the stream in place
-// (docs/design/target-watch-plan.md, "Diff the plan").
+// the cell AND drop its readiness result rather than replacing the stream in place.
type cellSpec struct {
// Operations is the canonical, sorted operation filter, as rendered by operationSpec.
Operations string
@@ -37,8 +36,7 @@ type targetWatchPlan struct {
// named by either plan appears in exactly one of the four lists, each sorted for a stable log.
//
// It is what the streams are driven from: keep leaves a stream and its readiness result alone,
-// start and restart open one, and stop cancels one and drops its key without touching files
-// (docs/design/target-watch-plan.md, "Diff the plan").
+// start and restart open one, and stop cancels one and drops its key without touching files.
type targetWatchPlanDiff struct {
// Keep is the cells whose key and specification are unchanged.
Keep []types.CellKey
diff --git a/internal/watch/watch_plane_state.go b/internal/watch/watch_plane_state.go
index d9c90a93..476d1bbe 100644
--- a/internal/watch/watch_plane_state.go
+++ b/internal/watch/watch_plane_state.go
@@ -19,8 +19,6 @@ import (
// declaredGVRsMu each guarded a value written by one goroutine and read by many, which is a
// snapshot rather than a critical section, and targetWatchesMu / targetRetentionMu guarded maps
// whose only writers are now reports.
-//
-// See docs/design/watch-manager-ownership.md.
type watchPlaneState struct {
// streams is the readiness surface, keyed by GitTarget and CELL — not by the served version
// the stream runs at. See markTargetStreamState for why the version is absent.
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
index 4b35ef1a..7b824ec0 100644
--- a/test/e2e/e2e_test.go
+++ b/test/e2e/e2e_test.go
@@ -235,15 +235,14 @@ func verifyResourceCondition(
// timeout reports only `Expected : False to equal : True` -- which names
// neither what the controller was waiting for nor what it thought was wrong. Two 90s
// failures on this helper cost a round of controller-log archaeology each to learn that one
- // said "0/1 streams running (configmaps)" and the other something else entirely
- // (docs/design/watch-plane-status-convergence-failures.md). The controller already
- // publishes the answer; the assertion just has to print it.
+ // said "0/1 streams running (configmaps)" and the other something else entirely. The
+ // controller already publishes the answer; the assertion just has to print it.
// Built ONLY when the assertion is about to fail. A rule's Ready is a COPY of its
// GitTarget's, not a live view of it: reconcileWatchRuleViaTarget reads the target's stored
// Ready and folds it in as an independent prerequisite. So a rule reporting a
// GitTarget-derived reason is ambiguous — the target may genuinely be stuck, or the target
// may have converged and the rule's copy be stale — and those have completely different
- // searches (docs/design/watch-plane-status-convergence-failures.md, Failure A).
+ // searches.
//
// Laziness is not an optimisation here. This runs inside an Eventually that polls for up to
// 90s, so building it eagerly issued a `kubectl get gittarget` per poll per rule wait —
diff --git a/test/e2e/helmrelease_mirror_edit_e2e_test.go b/test/e2e/helmrelease_mirror_edit_e2e_test.go
index 637ada11..e1b92179 100644
--- a/test/e2e/helmrelease_mirror_edit_e2e_test.go
+++ b/test/e2e/helmrelease_mirror_edit_e2e_test.go
@@ -20,7 +20,6 @@ import (
// version bump, launch use case 2) round-trips into the mirrored file in place,
// preserving hand-authored formatting. The generic-CRD case is already pinned by
// crd_lifecycle_e2e_test.go; this pins a real, named higher-level type.
-// See docs/design/support-boundary/finished/higher-level-krm-documents.md.
var _ = Describe("Manager Higher-Level KRM (HelmRelease)",
Label("manager", "higher-level-krm"), Ordered, func() {
var (
diff --git a/test/e2e/prune_mode_e2e_test.go b/test/e2e/prune_mode_e2e_test.go
index 388529a0..40761dd8 100644
--- a/test/e2e/prune_mode_e2e_test.go
+++ b/test/e2e/prune_mode_e2e_test.go
@@ -278,7 +278,7 @@ var _ = Describe("Manager GitTarget prune policy", Label("manager"), Ordered, fu
// roll-up stopped: mode=Always with a stale count means a report DID land under the new
// mode and a later one was lost, while mode=OnEvent means no post-widen report landed at
// all. Asserting the count alone threw that away and cost a round of controller-log
- // archaeology (docs/design/watch-plane-status-convergence-failures.md, Failure B).
+ // archaeology.
Eventually(func(g Gomega) {
retained := retainedDocumentsOf(g, defaultTarget, testNs)
mode := retentionModeOf(g, defaultTarget, testNs)
diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go
index f2f62e7c..ffe04040 100644
--- a/test/e2e/source_cluster_e2e_test.go
+++ b/test/e2e/source_cluster_e2e_test.go
@@ -13,9 +13,9 @@ import (
. "github.com/onsi/gomega"
)
-// This file is the source-cluster corner for multi-cluster author attribution
-// (docs/finished/multi-cluster-author-attribution.md): a GitTarget names the cluster it mirrors
-// FROM by referencing a cluster-scoped ClusterProvider (spec.clusterProviderRef). The
+// This file is the source-cluster corner for multi-cluster author attribution: a GitTarget names
+// the cluster it mirrors FROM by referencing a cluster-scoped ClusterProvider
+// (spec.clusterProviderRef). The
// ClusterProvider is the home for that cluster's kubeconfig credential (spec.kubeConfig, a Flux
// meta.KubeConfigReference resolved from the operator namespace), namespace-access authorization,
// and connectivity status.
diff --git a/test/e2e/watchrule_configmap_secret_e2e_test.go b/test/e2e/watchrule_configmap_secret_e2e_test.go
index 15397f5a..51b13dfa 100644
--- a/test/e2e/watchrule_configmap_secret_e2e_test.go
+++ b/test/e2e/watchrule_configmap_secret_e2e_test.go
@@ -600,7 +600,6 @@ spec:
// in internal/watch/rule_change_snapshot_test.go; this spec locks the
// observable in at the user-visible layer so a future revamp of the
// reconcile trigger logic can't silently regress it.
- //
It("should backfill pre-existing ConfigMap when WatchRule is added afterwards", func() {
gitProviderName := "gitprovider-normal"
watchRuleName := "watchrule-backfill-test"
diff --git a/test/fixtures/README.md b/test/fixtures/README.md
new file mode 100644
index 00000000..98be1e47
--- /dev/null
+++ b/test/fixtures/README.md
@@ -0,0 +1,25 @@
+# `test/fixtures/` — the two corpora, and which question each one answers
+
+Both folders here are collections of GitOps repository layouts, both are read by Go tests, and
+their names have been confused for each other more than once. They answer opposite questions.
+
+| Corpus | Direction | Asks | Executed by |
+|---|---|---|---|
+| [`gitops-layouts/`](gitops-layouts/README.md) | Git in | **What is this layout, and what does it force us to decide?** Real-world repository shapes, checked in as found. Records observations, never verdicts | `internal/manifestanalyzer` (render-root discovery, solvability), plus a generated baseline |
+| [`layout-corpus/`](layout-corpus/README.md) | Git out | **Given a live object, which file receives it and what does the commit look like?** Our own configuration, a seeded repository, and the exact patch we expect | `TestLayoutCorpus` in `internal/git`, with the controller's half in `internal/controller` |
+
+The short version: `gitops-layouts/` is input we did not write and do not control, and
+`layout-corpus/` is a specification we did write, stated as fixtures so it cannot quietly stop
+being true.
+
+## Why that difference matters when you add a fixture
+
+A new folder in `gitops-layouts/` is evidence. It can be as strange as the repository it came
+from, it needs no configuration of ours beside it, and adding one does not make a claim about
+what the operator supports. The
+[support contract](../../docs/design/support-boundary/support-contract.md) is where verdicts live.
+
+A new folder in `layout-corpus/` is a promise. It needs a `config/` that decodes into the real
+API types, a `repository/` to start from, an `input/` object, and either an `expected-*.patch` or
+an `expected-*-status.yaml` if the right answer is a refusal. If you cannot state the expected
+result, the scenario is not ready to be a fixture yet.
diff --git a/test/fixtures/layout-corpus/README.md b/test/fixtures/layout-corpus/README.md
new file mode 100644
index 00000000..82cb0d66
--- /dev/null
+++ b/test/fixtures/layout-corpus/README.md
@@ -0,0 +1,70 @@
+# The layout corpus — where a document goes in Git, stated as fixtures
+
+Every folder below is a worked example that a test executes. A scenario seeds a worktree from its
+`repository/`, folds the object in its `input/` through the real plan-then-flush write path with
+the configuration in its `config/`, and compares a normalized diff against the committed
+`expected-*.patch`. Nothing here is illustration: if the writer disagrees with a fixture, the build
+goes red.
+
+That is why the corpus lives under `test/` rather than under `docs/`. It used to sit in
+`docs/layout/`, where its READMEs were read by people and its fixtures by nobody. The argument it
+serves still lives in [`docs/layout/`](../../../docs/layout/README.md); this is the evidence.
+
+```bash
+task test # the corpus runs as part of the unit suite
+go test ./internal/git/ -run TestLayoutCorpus -v
+go test ./internal/git/ -run TestLayoutCorpus -update # rewrite expected-*.patch from the observed diff
+```
+
+Use `-update` to see what a change did, then read the resulting diff as the review. Never use it to
+make a red test green without reading it: the patch is the specification, so overwriting one is
+editing the specification.
+
+## What is in it
+
+| Folder | Holds |
+|---|---|
+| [`shapes/`](shapes/README.md) | the cross-product. Flat and tree, each with and without `metadata.namespace` in the committed document, plus one kustomize folder, base-and-overlays, and layered. The same live object is written into all of them, so the only thing that differs between two folders is the configuration that produced it |
+| [`specific-examples/`](specific-examples/README.md) | the remainder: an Argo CD app-of-apps and a Flux two-layer repository, which are ecosystem scenarios rather than folder shapes, plus the shared `GitProvider` prerequisites |
+
+The sibling corpus at [`../gitops-layouts/`](../gitops-layouts/README.md) answers the opposite
+question and is easy to mistake for this one. [`../README.md`](../README.md) is the one-table
+distinction.
+
+## The conventions, and why each one is load-bearing
+
+Three of these are stated in `internal/git/layout_corpus_test.go` as well, because breaking one of
+them is how a corpus quietly stops being one.
+
+**A scenario folder holds `config/`, `repository/`, `input/`, and its expectations.** `repository/`
+is rooted at the repository root rather than at `spec.path`, so a fixture shows the folder a user
+would actually browse. Patches carry no `index` lines, so a diff does not churn on blob hashes.
+
+**`config/` decodes into the real API types, strictly.** A `gittarget.yaml` naming a field the API
+does not have fails to parse rather than being ignored, which is what keeps the worked examples and
+the shipped API the same API. This is not theoretical: it is what the corpus caught during the
+breaking wave, when five fields were removed and a stale fixture would otherwise have kept passing.
+
+**Refusals are fixtures too.** A scenario whose right answer is "we write nothing" asserts an
+`expected-*-status.yaml` instead of a patch, and the harness reads that condition whole: status,
+reason and message. A set of examples in which every write succeeds is advertising rather than
+specification, which is why the refusing halves are here at all.
+
+**A scenario for behavior that is not built yet is written now and skipped, naming the track that
+unskips it.** The corpus is then the definition of done for that track. One such skip is live
+today: shape 8's `images:` authoring is step 1 of
+[`patch-authoring.md`](../../../docs/design/support-boundary/patch-authoring.md).
+
+## Adding a scenario
+
+1. Create the folder with `config/`, `repository/` and `input/`.
+2. Add a row to `layoutCorpus()` in `internal/git/layout_corpus_test.go`.
+3. Run with `-update` to generate the patch, then **read it** and decide whether it is what you
+ meant. That reading is the whole value of the step.
+4. If the right answer is a refusal, write the `expected-*-status.yaml` by hand instead and set
+ `status:` rather than `patch:` on the row.
+
+`TestLayoutCorpus_EveryFixtureFolderIsExecuted` closes the set over the filesystem: a folder with
+an `input/` that no row names fails the build, so a fixture cannot be added and left unrun. A
+folder with no `input/` illustrates prerequisites rather than a write, and is skipped by that
+guard.
diff --git a/docs/layout/shapes/1-flat-serialized/README.md b/test/fixtures/layout-corpus/shapes/1-flat-serialized/README.md
similarity index 95%
rename from docs/layout/shapes/1-flat-serialized/README.md
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/README.md
index 7f101515..dd87f3c8 100644
--- a/docs/layout/shapes/1-flat-serialized/README.md
+++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/README.md
@@ -23,7 +23,7 @@ mirror/prod/
identity path, which is a tree. `"{namespace}-{name}.yaml"` is what asks for one directory. The
`{namespace}` prefix is what keeps two namespaces from colliding on a common name like `config`;
without it, `shop/config` and `billing/config` resolve to one path and
-[append into a multi-document file](../../new-file-placement-rules.md) rather than overwrite — legal,
+[append into a multi-document file](../../../../../docs/layout/new-file-placement-rules.md) rather than overwrite — legal,
shipped, and probably not what the folder wanted.
**`serializeNamespace: true` matches what inference would do here anyway.** No kustomization governs
diff --git a/docs/layout/shapes/1-flat-serialized/config/clusterprovider.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/clusterprovider.yaml
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/config/clusterprovider.yaml
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/config/clusterprovider.yaml
diff --git a/docs/layout/shapes/1-flat-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/config/gittarget.yaml
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml
diff --git a/docs/layout/shapes/1-flat-serialized/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/watchrule.yaml
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/config/watchrule.yaml
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/config/watchrule.yaml
diff --git a/docs/layout/shapes/1-flat-serialized/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/1-flat-serialized/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/expected-checkout-config.patch
diff --git a/docs/layout/shapes/1-flat-serialized/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/input/checkout-config.yaml
diff --git a/docs/layout/shapes/1-flat-serialized/repository/mirror/prod/billing-invoices.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/repository/mirror/prod/billing-invoices.yaml
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/repository/mirror/prod/billing-invoices.yaml
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/repository/mirror/prod/billing-invoices.yaml
diff --git a/docs/layout/shapes/1-flat-serialized/repository/mirror/prod/shop-web.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/repository/mirror/prod/shop-web.yaml
similarity index 100%
rename from docs/layout/shapes/1-flat-serialized/repository/mirror/prod/shop-web.yaml
rename to test/fixtures/layout-corpus/shapes/1-flat-serialized/repository/mirror/prod/shop-web.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/README.md b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/README.md
similarity index 97%
rename from docs/layout/shapes/2-flat-namespace-free/README.md
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/README.md
index 43f6d894..f020c194 100644
--- a/docs/layout/shapes/2-flat-namespace-free/README.md
+++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/README.md
@@ -75,7 +75,7 @@ copy of the information that would have told the two objects apart.
**This is decided, and it is a rule rather than a field: an explicit `serializeNamespace: false`
admits exactly one source namespace, and the second is refused.** The argument is in
[the shapes README](../README.md#a-namespace-free-folder-needs-a-fence-around-one-namespace) and in
-[`model.md`](../../model.md#the-second-guard-one-source-namespace-and-this-one-refuses). Unlike
+[`model.md`](../../../../../docs/layout/model.md#the-second-guard-one-source-namespace-and-this-one-refuses). Unlike
the supplier question above, it is answerable entirely inside the
cluster: the set of source namespaces reaching a target comes from the rules that name it, not from
the folder — so this shape gets a real fence even though its *supplier* stays unverifiable.
diff --git a/docs/layout/shapes/2-flat-namespace-free/config/consumer-flux-kustomization.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/consumer-flux-kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/config/consumer-flux-kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/consumer-flux-kustomization.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/config/gittarget.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/config/watchrule.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/expected-checkout-config.patch
diff --git a/docs/layout/shapes/2-flat-namespace-free/expected-second-namespace-status.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/expected-second-namespace-status.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/expected-second-namespace-status.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/expected-second-namespace-status.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/input/checkout-config.yaml
diff --git a/docs/layout/shapes/2-flat-namespace-free/repository/apps/checkout/web.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/repository/apps/checkout/web.yaml
similarity index 100%
rename from docs/layout/shapes/2-flat-namespace-free/repository/apps/checkout/web.yaml
rename to test/fixtures/layout-corpus/shapes/2-flat-namespace-free/repository/apps/checkout/web.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/README.md b/test/fixtures/layout-corpus/shapes/3-tree-serialized/README.md
similarity index 96%
rename from docs/layout/shapes/3-tree-serialized/README.md
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/README.md
index 842789cd..f8a58491 100644
--- a/docs/layout/shapes/3-tree-serialized/README.md
+++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/README.md
@@ -20,7 +20,7 @@ clusters/home/
```
Two conventions in those paths come from the
-[canonical grammar](../../new-file-placement-rules.md#template-variables): **the core group collapses
+[canonical grammar](../../../../../docs/layout/new-file-placement-rules.md#template-variables): **the core group collapses
to nothing**, so `configmaps` sits directly under the namespace segment where
`rbac.authorization.k8s.io` appears in the ClusterRole path; and **`_cluster` stands in for the
namespace segment** of a cluster-scoped resource. An underscore is invalid in a namespace name, so
diff --git a/docs/layout/shapes/3-tree-serialized/config/clusterprovider.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/clusterprovider.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/config/clusterprovider.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/config/clusterprovider.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/config/gittarget.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/watchrule.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/config/watchrule.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/config/watchrule.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/3-tree-serialized/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/expected-checkout-config.patch
diff --git a/docs/layout/shapes/3-tree-serialized/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/input/checkout-config.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/repository/clusters/home/_cluster/rbac.authorization.k8s.io/clusterroles/homelab-viewer.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/repository/clusters/home/_cluster/rbac.authorization.k8s.io/clusterroles/homelab-viewer.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/repository/clusters/home/_cluster/rbac.authorization.k8s.io/clusterroles/homelab-viewer.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/repository/clusters/home/_cluster/rbac.authorization.k8s.io/clusterroles/homelab-viewer.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/repository/clusters/home/billing/configmaps/invoices.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/repository/clusters/home/billing/configmaps/invoices.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/repository/clusters/home/billing/configmaps/invoices.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/repository/clusters/home/billing/configmaps/invoices.yaml
diff --git a/docs/layout/shapes/3-tree-serialized/repository/clusters/home/shop/apps/deployments/web.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/repository/clusters/home/shop/apps/deployments/web.yaml
similarity index 100%
rename from docs/layout/shapes/3-tree-serialized/repository/clusters/home/shop/apps/deployments/web.yaml
rename to test/fixtures/layout-corpus/shapes/3-tree-serialized/repository/clusters/home/shop/apps/deployments/web.yaml
diff --git a/docs/layout/shapes/4-tree-namespace-free/README.md b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/README.md
similarity index 87%
rename from docs/layout/shapes/4-tree-namespace-free/README.md
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/README.md
index 676e65d5..0dd917f6 100644
--- a/docs/layout/shapes/4-tree-namespace-free/README.md
+++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/README.md
@@ -37,16 +37,16 @@ Two ways out, and this scenario takes the first:
- **Keep the folder single-namespace**, which a template without `{namespace}` guarantees by
construction — and which the
- [one-source-namespace rule](../../model.md#the-second-guard-one-source-namespace-and-this-one-refuses)
+ [one-source-namespace rule](../../../../../docs/layout/model.md#the-second-guard-one-source-namespace-and-this-one-refuses)
turns from construction into enforcement: an explicit `serializeNamespace: false` refuses the
second source namespace rather than writing the merged folder described above.
- **Nested roots, one per namespace subfolder**, each with its own `kustomization.yaml` carrying its
- own `namespace:`. Fact 2 in [`../../model.md`](../../model.md) measured that this renders
+ own `namespace:`. Fact 2 in [`../../model.md`](../../../../../docs/layout/model.md) measured that this renders
correctly, and inference already resolves it per document. That route requires leaving
`serializeNamespace` **unset** rather than `false`: the folder is genuinely non-uniform, which is
what unset is for, and the rule above refuses the explicit claim precisely because it would be a
false one. Nothing creates those roots today, and whether `useKustomize` should is
- [an open question](../../model.md#open-questions), deliberately deferred.
+ [an open question](../../../../../docs/layout/model.md#open-questions), deliberately deferred.
## Scenario contract
@@ -62,10 +62,10 @@ and the configuration that supplies its namespace is a Flux `targetNamespace` or
`destination.namespace` in another cluster. Two deployers may point at this folder and land it in
two different namespaces, both correctly — being unbound is what the shape is for, so nothing here
reports on it. See
-[`model.md`](../../model.md#why-false-needs-no-guard).
+[`model.md`](../../../../../docs/layout/model.md#why-false-needs-no-guard).
The rule that *is* enforced is on the inside of the folder:
-[one source namespace](../../model.md#the-second-guard-one-source-namespace-and-this-one-refuses),
+[one source namespace](../../../../../docs/layout/model.md#the-second-guard-one-source-namespace-and-this-one-refuses),
where two namespaces would collapse onto one namespace-free document. That loss is visible in the
folder the operator owns, so it is refused.
diff --git a/docs/layout/shapes/4-tree-namespace-free/config/consumer-argocd-application.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/consumer-argocd-application.yaml
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/config/consumer-argocd-application.yaml
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/consumer-argocd-application.yaml
diff --git a/docs/layout/shapes/4-tree-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/config/gittarget.yaml
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml
diff --git a/docs/layout/shapes/4-tree-namespace-free/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/watchrule.yaml
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/config/watchrule.yaml
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/watchrule.yaml
diff --git a/docs/layout/shapes/4-tree-namespace-free/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/expected-checkout-config.patch
diff --git a/docs/layout/shapes/4-tree-namespace-free/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/input/checkout-config.yaml
diff --git a/docs/layout/shapes/4-tree-namespace-free/repository/apps/checkout/configmaps/web.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/repository/apps/checkout/configmaps/web.yaml
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/repository/apps/checkout/configmaps/web.yaml
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/repository/apps/checkout/configmaps/web.yaml
diff --git a/docs/layout/shapes/4-tree-namespace-free/repository/apps/checkout/deployments/web.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/repository/apps/checkout/deployments/web.yaml
similarity index 100%
rename from docs/layout/shapes/4-tree-namespace-free/repository/apps/checkout/deployments/web.yaml
rename to test/fixtures/layout-corpus/shapes/4-tree-namespace-free/repository/apps/checkout/deployments/web.yaml
diff --git a/docs/layout/shapes/5-kustomize-single-folder/README.md b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/README.md
similarity index 97%
rename from docs/layout/shapes/5-kustomize-single-folder/README.md
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/README.md
index d4355934..bf41eaa6 100644
--- a/docs/layout/shapes/5-kustomize-single-folder/README.md
+++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/README.md
@@ -63,7 +63,7 @@ is harder to see and impossible for an installer to override. The folder is a po
Flux `targetNamespace` or an Argo `destination.namespace` places it, exactly as it places
[shape 2](../2-flat-namespace-free/README.md). The full argument, and the four other answers that
were considered, is in
-[`../../../design/created-root-namespace.md`](../../../design/created-root-namespace.md).
+[`../../../design/created-root-namespace.md`](../../../../../docs/design/created-root-namespace.md).
What the flag buys here is *structure*: an empty folder becomes a kustomize folder with the first
commit, and every later document joins the same root instead of scattering.
diff --git a/docs/layout/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml
diff --git a/docs/layout/shapes/5-kustomize-single-folder/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/config/gittarget.yaml
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml
diff --git a/docs/layout/shapes/5-kustomize-single-folder/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/watchrule.yaml
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/config/watchrule.yaml
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/watchrule.yaml
diff --git a/docs/layout/shapes/5-kustomize-single-folder/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/expected-checkout-config.patch
diff --git a/docs/layout/shapes/5-kustomize-single-folder/expected-empty-folder-first-write.patch b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/expected-empty-folder-first-write.patch
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/expected-empty-folder-first-write.patch
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/expected-empty-folder-first-write.patch
diff --git a/docs/layout/shapes/5-kustomize-single-folder/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/input/checkout-config.yaml
diff --git a/docs/layout/shapes/5-kustomize-single-folder/repository/apps/checkout/kustomization.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/repository/apps/checkout/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/repository/apps/checkout/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/repository/apps/checkout/kustomization.yaml
diff --git a/docs/layout/shapes/5-kustomize-single-folder/repository/apps/checkout/web.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/repository/apps/checkout/web.yaml
similarity index 100%
rename from docs/layout/shapes/5-kustomize-single-folder/repository/apps/checkout/web.yaml
rename to test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/repository/apps/checkout/web.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/README.md b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/README.md
similarity index 97%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/README.md
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/README.md
index 49a2721d..f788e5b9 100644
--- a/docs/layout/shapes/6-kustomize-base-and-overlays/README.md
+++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/README.md
@@ -74,7 +74,7 @@ and **the base is editable**, because neither write-boundary layer objects:
So a write here lands in the shared base and reaches `test`, `acceptance` and `prod` on their next
sync. That is not a bug in the boundary — it is the boundary working exactly as specified, on a
`spec.path` that says "the shared default is mine". It is also, in effect,
-[Option C of the granularity decision](../../../design/support-boundary/gittarget-granularity-and-cross-environment-edits.md)
+[Option C of the granularity decision](../../../../../docs/design/support-boundary/gittarget-granularity-and-cross-environment-edits.md)
being exercised today: edit the shared default, reach every overlay that does not override that
field. That option was decided as a **later, narrower verb** — for shared defaults, never as the
answer to "edit every environment" — and mounting the base is the unguarded version of it.
@@ -113,7 +113,7 @@ The third row is the one that surprises people, and it is deliberate: the operat
silently invent an overlay override for a field it has no proven way to express. Doing that safely —
authoring a narrow strategic-merge patch into the overlay and proving the rebuild changes that field
and nothing else — is designed and unshipped in
-[`patch-authoring.md`](../../../design/support-boundary/patch-authoring.md). Until it lands, "add a
+[`patch-authoring.md`](../../../../../docs/design/support-boundary/patch-authoring.md). Until it lands, "add a
file to the overlay" works and "change an inherited field" refuses unless it is an image or a replica
count.
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/expected-app-root-status.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/expected-app-root-status.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/expected-app-root-status.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/expected-app-root-status.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/expected-checkout-config.patch
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/input/checkout-config.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/deployment.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/deployment.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/deployment.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/deployment.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/kustomization.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/base/kustomization.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/acceptance/kustomization.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/acceptance/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/acceptance/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/acceptance/kustomization.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/prod/kustomization.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/prod/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/prod/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/prod/kustomization.yaml
diff --git a/docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/test/kustomization.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/test/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/test/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/repository/apps/checkout/overlays/test/kustomization.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/README.md b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/README.md
similarity index 67%
rename from docs/layout/shapes/7-kustomize-layered/README.md
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/README.md
index 0f689e0a..77055604 100644
--- a/docs/layout/shapes/7-kustomize-layered/README.md
+++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/README.md
@@ -48,18 +48,31 @@ the trade the shape buys its reuse with.
## Scenario contract, part two: a refusal
-The half worth reviewing. Someone changes `prometheus.io/scrape` on the live Deployment. The only
-expression of that field in the repository is the patch in `layers/observability`, which is outside
-this target's write scope and consumed by two roots besides.
-
-[`expected-shared-layer-status.yaml`](expected-shared-layer-status.yaml) is the whole result: no file
-is written, and `WriteBoundaryRefused` names the boundary rather than searching for another document
-carrying the same identity. A set of examples in which every write succeeds would be advertising
-rather than specifying, which is why this one is here.
+The half worth reviewing, and the half that was wrong here for as long as nothing ran it.
+
+- Live input: [`input/deployment-scrape-changed.yaml`](input/deployment-scrape-changed.yaml), the
+ rendered Deployment with `prometheus.io/scrape` flipped to `"false"`. The only expression of that
+ field in the repository is the patch in `layers/observability`, which is outside this target's
+ write scope and consumed by two roots besides.
+- Expected result: [`expected-shared-layer-status.yaml`](expected-shared-layer-status.yaml). No file
+ is written and the flush is refused with `WriteBoundaryRefused`.
+
+**The refusal names `base/deployment.yaml`, not the layer.** That is the surprising part and it is
+worth following. The layer's patch document and the base's Deployment carry the same identity, so
+the manifest store keeps one of them (the base) and drops the other as a duplicate. The edit is then
+planned against the base document and refused for escaping the write scope, which is exactly the
+refusal [shape 8](../8-base-owned-field-edit/README.md) produces from a repository with no layer in
+it at all.
+
+So the honest conclusion is a negative one: **adding a shared layer above a base does not add a new
+refusal, and does not change the answer.** This page previously claimed the opposite, in a message
+naming `layers/observability` that the writer never emits. Nothing caught it because the fixture was
+committed but unasserted. It is asserted now, which is the whole argument for keeping refusals as
+fixtures rather than as prose.
Changing that annotation for every environment at once is a **Git-level operation above the
operator** — the
-[cross-environment editing decision](../../../design/support-boundary/gittarget-granularity-and-cross-environment-edits.md)
+[cross-environment editing decision](../../../../../docs/design/support-boundary/gittarget-granularity-and-cross-environment-edits.md)
settles that promotion and factor-into-base are verbs for the layer above, not a reason to widen a
target across environments.
@@ -80,4 +93,4 @@ mistake, and the deeper one reaches more environments.
invent `resources: [../../layers/observability]`, and it certainly cannot invent the layer. A layered
repository is scaffolded by a template or by hand, and GitOps Reverser adopts it afterwards. To see
what that adoption would write before committing to it, point a `GitTarget` at a scratch branch and
-read the commits ([`model.md`](../../model.md#previewing-a-target-point-it-at-a-scratch-branch)).
+read the commits ([`model.md`](../../../../../docs/layout/model.md#previewing-a-target-point-it-at-a-scratch-branch)).
diff --git a/docs/layout/shapes/7-kustomize-layered/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/config/gittarget-prod.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/config/watchrule-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/watchrule-prod.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/config/watchrule-prod.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/watchrule-prod.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/expected-checkout-config.patch b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/expected-checkout-config.patch
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/expected-checkout-config.patch
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/expected-checkout-config.patch
diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/expected-shared-layer-status.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/expected-shared-layer-status.yaml
new file mode 100644
index 00000000..baefe33a
--- /dev/null
+++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/expected-shared-layer-status.yaml
@@ -0,0 +1,26 @@
+# The second half of this scenario: an edit to the pod-template annotation that the shared layer
+# patches in. No file is written, and the whole flush is refused before a byte moves.
+#
+# The refusal names base/deployment.yaml, NOT layers/observability/scrape-annotations.yaml, and
+# that is the part worth reading. The layer's patch and the base's Deployment carry the same
+# identity, so the manifest store keeps one of them (the base) and drops the other as a duplicate.
+# The edit is therefore planned against the base document and refused for escaping the write scope
+# -- the same refusal shape 8 produces, reached by a different route. Adding a shared layer above
+# a base does not add a new refusal; it does not change the answer at all.
+#
+# GitPathAccepted's message is the writer's own and is asserted by the corpus. Stalled is the
+# controller's summary of it.
+status:
+ conditions:
+ - type: GitPathAccepted
+ status: "False"
+ reason: WriteBoundaryRefused
+ message: >-
+ planned write path "base/deployment.yaml" escapes the GitTarget write scope: the operator
+ only ever writes inside spec.path (reads may reach shared context such as ../../base,
+ writes never leave it)
+ - type: Stalled
+ status: "True"
+ reason: WriteBoundaryRefused
+ message: >-
+ the edit had nowhere safe to land; the folder itself is accepted
diff --git a/docs/layout/shapes/7-kustomize-layered/input/checkout-config.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/input/checkout-config.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/input/checkout-config.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/input/checkout-config.yaml
diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/input/deployment-scrape-changed.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/input/deployment-scrape-changed.yaml
new file mode 100644
index 00000000..88ed4020
--- /dev/null
+++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/input/deployment-scrape-changed.yaml
@@ -0,0 +1,27 @@
+# Abridged live object, as envs/prod renders it: the base Deployment, carrying the pod-template
+# annotation that layers/observability patches in, under the namespace the leaf root supplies.
+# ONE field differs from the rendered state: prometheus.io/scrape is now "false".
+#
+# The only expression of that field in the repository is the patch in layers/observability, which
+# envs/prod and envs/test both consume. It is outside this target's write scope AND has fan-in 2,
+# so there is nowhere for the edit to land.
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: checkout
+ namespace: shop-prod
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: checkout
+ template:
+ metadata:
+ labels:
+ app: checkout
+ annotations:
+ prometheus.io/scrape: "false"
+ spec:
+ containers:
+ - name: checkout
+ image: ghcr.io/example/checkout:1.4.0
diff --git a/docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/base/deployment.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/base/deployment.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/base/deployment.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/base/deployment.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/base/kustomization.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/base/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/base/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/base/kustomization.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/envs/prod/kustomization.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/envs/prod/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/envs/prod/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/envs/prod/kustomization.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/envs/test/kustomization.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/envs/test/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/envs/test/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/envs/test/kustomization.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/kustomization.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/kustomization.yaml
diff --git a/docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/scrape-annotations.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/scrape-annotations.yaml
similarity index 100%
rename from docs/layout/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/scrape-annotations.yaml
rename to test/fixtures/layout-corpus/shapes/7-kustomize-layered/repository/apps/checkout/layers/observability/scrape-annotations.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/README.md b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/README.md
similarity index 92%
rename from docs/layout/shapes/8-base-owned-field-edit/README.md
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/README.md
index 235207c1..c32c1ca8 100644
--- a/docs/layout/shapes/8-base-owned-field-edit/README.md
+++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/README.md
@@ -45,13 +45,13 @@ Two details make this more than a convenience, and both are visible in the code
- **`overlayAuthorKustomization` fires only in this exact situation** — the matched document is out
of the write jail *and* the overlay has a supported render root of its own
- ([`plan_flush.go`](../../../../internal/git/plan_flush.go)). For a self-contained folder or an
+ ([`plan_flush.go`](../../../../../internal/git/plan_flush.go)). For a self-contained folder or an
in-jail document it returns `""`, and the source file is edited directly, because there is nothing
to route around.
- **The authored entry is put to the re-render oracle before it can commit.** The proposal is not
trusted because it looks reasonable: the folder is rebuilt with the entry applied, and the result
must render to the live object. A proposal that over-reaches is refused there rather than written
- ([`OverrideEdit.Create`](../../../../internal/manifestanalyzer/overrides_projection.go)).
+ ([`OverrideEdit.Create`](../../../../../internal/manifestanalyzer/overrides_projection.go)).
So the change lands as an **environment-specific declaration**. `test` and `acceptance` still render
`1.4.0`, which is the property the base/overlay split exists for, and which a write into the base
@@ -93,11 +93,11 @@ reason code.
better than committing half of it — but one base-owned field change stalls everything the target
was about to write.
- **It is reported once, on the GitTarget, and not per edit.**
- [`gitPathRefusalReason`](../../../../internal/watch/event_router.go) maps a refusal made purely of
+ [`gitPathRefusalReason`](../../../../../internal/watch/event_router.go) maps a refusal made purely of
write-boundary kinds to `WriteBoundaryRefused` — distinct from the umbrella `UnsupportedContent`,
because the folder is not malformed; the edit had nowhere honest to land. There is **no per-edit
record**: `FullyReflected` and the unreflected set are designed and unbuilt in
- [`unreflectable-edits-and-write-gating.md`](../../../design/support-boundary/unreflectable-edits-and-write-gating.md).
+ [`unreflectable-edits-and-write-gating.md`](../../../../../docs/design/support-boundary/unreflectable-edits-and-write-gating.md).
- **Telling anyone is its own mechanism.** The resync path returns the refusal on its result channel;
the live-event path has none, because a commit window is finalized on a timer, so the branch worker
reports it through a `GitPathRefusalReporter` hook the watch manager installs. Without that hook
@@ -118,7 +118,7 @@ declaration that was not there before, and it proves the result by re-rendering.
The route that would extend part two — authoring a narrow strategic-merge patch into the overlay and
proving the rebuild changes that field and nothing else — is designed and unshipped in
-[`patch-authoring.md`](../../../design/support-boundary/patch-authoring.md). Until it lands, this
+[`patch-authoring.md`](../../../../../docs/design/support-boundary/patch-authoring.md). Until it lands, this
scenario is the line: **`images:` and `replicas:` become overlay declarations, everything else
inherited is refused.**
diff --git a/docs/layout/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/expected-env-change-status.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/expected-env-change-status.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/expected-env-change-status.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/expected-env-change-status.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/expected-image-bump.patch b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/expected-image-bump.patch
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/expected-image-bump.patch
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/expected-image-bump.patch
diff --git a/docs/layout/shapes/8-base-owned-field-edit/input/deployment-env-changed.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/input/deployment-env-changed.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/input/deployment-env-changed.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/input/deployment-env-changed.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/input/deployment-image-bumped.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/input/deployment-image-bumped.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/input/deployment-image-bumped.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/input/deployment-image-bumped.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/repository/apps/checkout/base/deployment.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/repository/apps/checkout/base/deployment.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/repository/apps/checkout/base/deployment.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/repository/apps/checkout/base/deployment.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/repository/apps/checkout/base/kustomization.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/repository/apps/checkout/base/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/repository/apps/checkout/base/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/repository/apps/checkout/base/kustomization.yaml
diff --git a/docs/layout/shapes/8-base-owned-field-edit/repository/apps/checkout/overlays/prod/kustomization.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/repository/apps/checkout/overlays/prod/kustomization.yaml
similarity index 100%
rename from docs/layout/shapes/8-base-owned-field-edit/repository/apps/checkout/overlays/prod/kustomization.yaml
rename to test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/repository/apps/checkout/overlays/prod/kustomization.yaml
diff --git a/docs/layout/shapes/README.md b/test/fixtures/layout-corpus/shapes/README.md
similarity index 95%
rename from docs/layout/shapes/README.md
rename to test/fixtures/layout-corpus/shapes/README.md
index a5f58e35..e06a0609 100644
--- a/docs/layout/shapes/README.md
+++ b/test/fixtures/layout-corpus/shapes/README.md
@@ -1,11 +1,11 @@
# The folder shapes, and the configuration each one needs
> **design**: a specification by example for the layout model in
-> [`../model.md`](../model.md). Both booleans shown here, `spec.serializeNamespace` and
+> [`../model.md`](../../../../docs/layout/model.md). Both booleans shown here, `spec.serializeNamespace` and
> `spec.placement.useKustomize`, are shipped fields, and every folder below is executed against
> the write path by the layout corpus.
> Date: 2026-08-31.
-> Index: [`../../INDEX.md`](../../INDEX.md)
+> Index: [`../../INDEX.md`](../../../../docs/INDEX.md)
[`../specific-examples/`](../specific-examples/README.md) answers *"what does this look like in
Argo CD, or in Flux?"* This folder answers the question underneath it: **what
@@ -132,7 +132,7 @@ writes the `kustomization.yaml`, adopts whatever is already there into its `reso
`metadata.namespace` out of every document it places. The created root carries no `namespace:`
either — the artifact does not encode its deployment namespace, and the installer supplies it, the
same way it does for shapes 2 and 4 (see
-[`../../design/created-root-namespace.md`](../../design/created-root-namespace.md)).
+[`../../design/created-root-namespace.md`](../../../../docs/design/created-root-namespace.md)).
[`5-kustomize-single-folder`](5-kustomize-single-folder/README.md) shows both halves — the same
folder adopted and created — and they differ by two lines of spec.
@@ -148,7 +148,7 @@ to promise something that is not theirs to promise and that nothing could check.
draws instead is **guard what is inside the folder, say nothing about what happens after it
leaves** — which is why the one-source-namespace rule below is enforced and this one does not exist.
See
-[`model.md`](../model.md#why-false-needs-no-guard).
+[`model.md`](../../../../docs/layout/model.md#why-false-needs-no-guard).
**Shapes 6 and 7 cannot be bootstrapped from empty, and the reason is not a missing flag.** An
overlay is not a root plus a namespace; it is a root whose `resources:` names a *relative path to a
@@ -207,7 +207,7 @@ worth seeing that they agree rather than that one of them is the rule.
fires, and L1 is what keeps the base read-only**. The two rules cover different targets rather
than doubling up.
- **A target is a write partition (Option A in the
- [granularity decision](../../design/support-boundary/gittarget-granularity-and-cross-environment-edits.md)).**
+ [granularity decision](../../../../docs/design/support-boundary/gittarget-granularity-and-cross-environment-edits.md)).**
One overlay = one environment = one watch scope = one write scope, so that authorization, audit and
review line up with the environment boundary. "Manage the app as one thing" is a grouping concern
for a layer above the operator, not a wider target.
@@ -248,7 +248,7 @@ works both through on its own fixtures.
source namespace, and the second is refused.** The argument — why
no third boolean, why explicit `false` only, why `useKustomize: true` makes it mandatory rather than
optional, and where the refusal lives — is in
-[`model.md`](../model.md#the-second-guard-one-source-namespace-and-this-one-refuses) and is not
+[`model.md`](../../../../docs/layout/model.md#the-second-guard-one-source-namespace-and-this-one-refuses) and is not
repeated here.
What belongs here is what it means **per shape**:
@@ -296,7 +296,7 @@ Three things follow, and only the first is obvious:
- **In a `KustomizeRoot` folder deleting the manifest is only half the delete.** An entry still
naming a file that does not exist makes `kustomize build` fail, so the `resources:` entry goes in
the same commit
- ([`dropKustomizationResource`](../../../internal/git/plan_flush.go)). If the entry cannot be removed,
+ ([`dropKustomizationResource`](../../../../internal/git/plan_flush.go)). If the entry cannot be removed,
nothing is committed: the re-render precondition rebuilds the tree and refuses rather than
pushing a folder that does not build.
- **In a `KustomizeOverlay`, deleting an object the overlay INHERITS deletes nothing.** The document
@@ -315,18 +315,18 @@ lets a resync infer one. The table describes what happens once a delete is allow
read the commits it makes: the file removals, the `resources:` edits and the `$patch: delete` files
are all right there in a diff. That is the preview, and it is why neither `status.placement` nor
`spec.suspend` tries to be one — see
-[`model.md`](../model.md#previewing-a-target-point-it-at-a-scratch-branch).
+[`model.md`](../../../../docs/layout/model.md#previewing-a-target-point-it-at-a-scratch-branch).
## What this set does not cover
- **Secrets and encryption.** `{sensitiveSuffix}`, the SOPS naming convention, and the rule that a
sensitive resource is never appended into an existing document are specified in
- [`../new-file-placement-rules.md`](../new-file-placement-rules.md) and are orthogonal to the two
+ [`../new-file-placement-rules.md`](../../../../docs/layout/new-file-placement-rules.md) and are orthogonal to the two
flags.
- **Collisions.** Two objects resolving to one path append into a multi-document file; that is
decided and shipped, and shape 1's `"{namespace}-{name}.yaml"` reaches it whenever a ConfigMap and
a Service share a name.
-- **A multi-namespace folder with namespace-free documents.** Fact 2 in [`../model.md`](../model.md)
+- **A multi-namespace folder with namespace-free documents.** Fact 2 in [`../model.md`](../../../../docs/layout/model.md)
proves nested roots make it renderable, and nobody has asked for it. Shape 4 is deliberately
single-namespace, and the deferred question is whether `useKustomize` should ever create a nested
root per directory.
diff --git a/docs/layout/specific-examples/README.md b/test/fixtures/layout-corpus/specific-examples/README.md
similarity index 92%
rename from docs/layout/specific-examples/README.md
rename to test/fixtures/layout-corpus/specific-examples/README.md
index cf6aa542..3110b211 100644
--- a/docs/layout/specific-examples/README.md
+++ b/test/fixtures/layout-corpus/specific-examples/README.md
@@ -1,10 +1,10 @@
# Specific examples: two ecosystems, and the shared prerequisites
-> **design**: worked scenarios for the layout model in [`../model.md`](../model.md). The
+> **design**: worked scenarios for the layout model in [`../model.md`](../../../../docs/layout/model.md). The
> `GitTarget` files use `spec.serializeNamespace` and `spec.placement.useKustomize`, and both are
> shipped fields.
> Date: 2026-08-31.
-> Index: [`../../INDEX.md`](../../INDEX.md)
+> Index: [`../../INDEX.md`](../../../../docs/INDEX.md)
[`../shapes/`](../shapes/README.md) is the cross-product: every folder shape a repository can have,
with the same live object written into all of them, so the only difference between two folders is
@@ -52,6 +52,5 @@ The same as `shapes/`, so one harness reads both:
written to Git.
- `expected-*.patch` — the exact change proposed for Git, without `index` lines.
-[`../model.md`](../model.md#how-it-gets-built) turns both folders into an executable corpus in its
-first PR; [`../../design/build-order.md`](../../design/build-order.md) says when, and what the
-harness seam already is.
+[`../model.md`](../../../../docs/layout/model.md#how-it-gets-built) is where turning both folders
+into an executable corpus was specified. That has shipped: `TestLayoutCorpus` runs them.
diff --git a/docs/layout/specific-examples/homelab-argocd/README.md b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/README.md
similarity index 95%
rename from docs/layout/specific-examples/homelab-argocd/README.md
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/README.md
index 2f3db8aa..62523376 100644
--- a/docs/layout/specific-examples/homelab-argocd/README.md
+++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/README.md
@@ -58,7 +58,7 @@ worth naming: it identifies the Argo CD Application that owns the live object, s
makes the document claim ownership on behalf of another Application and hard-fails that
Application's sync. It is denied by exact key rather than by an `argocd.argoproj.io/` prefix strip,
because the rest of that prefix is user data. See
-[the tracking-id landmine](../../../spec/e2e-bi-directional-corner.md#the-tracking-id-landmine).
+[the tracking-id landmine](../../../../../docs/spec/e2e-bi-directional-corner.md#the-tracking-id-landmine).
## Scenario contract
@@ -89,7 +89,7 @@ For a field that both the Argo CD UI and Git can change, the Application's autom
`selfHeal: false`. The Git host also sends a push webhook to Argo CD so a commit is reconciled back
to the cluster. These are the two settings that let one declaration have a live editing path and a
Git reconciliation path; see
-[Argo CD and bi-directional GitOps](../../../design/support-boundary/argocd-bi-directional.md).
+[Argo CD and bi-directional GitOps](../../../../../docs/design/support-boundary/argocd-bi-directional.md).
This is a declaration-editing scenario. It does not reverse Argo-generated application resources,
nor does it reverse a Helm chart rendered by an Application.
diff --git a/docs/layout/specific-examples/homelab-argocd/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/config/gittarget.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml
diff --git a/docs/layout/specific-examples/homelab-argocd/config/watchrule.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/watchrule.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/config/watchrule.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/watchrule.yaml
diff --git a/docs/layout/specific-examples/homelab-argocd/expected-paperless.patch b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/expected-paperless.patch
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/expected-paperless.patch
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/expected-paperless.patch
diff --git a/docs/layout/specific-examples/homelab-argocd/input/paperless.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/input/paperless.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/input/paperless.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/input/paperless.yaml
diff --git a/docs/layout/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-jellyfin.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-jellyfin.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-jellyfin.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-jellyfin.yaml
diff --git a/docs/layout/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-nextcloud.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-nextcloud.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-nextcloud.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/application-nextcloud.yaml
diff --git a/docs/layout/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/kustomization.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/kustomization.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/kustomization.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-argocd/repository/bootstrap/argocd-applications/kustomization.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/README.md b/test/fixtures/layout-corpus/specific-examples/homelab-flux/README.md
similarity index 96%
rename from docs/layout/specific-examples/homelab-flux/README.md
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/README.md
index 43a89a73..37d52e82 100644
--- a/docs/layout/specific-examples/homelab-flux/README.md
+++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/README.md
@@ -14,7 +14,7 @@ resulting Git change without asking GitOps Reverser to reverse a chart.
`gotk-components.yaml`, `gotk-sync.yaml`, and a `kustomization.yaml` listing both. An operator that
adds `resources:` entries there is a second writer in a folder Flux's own sync loop reconciles,
which is the two-writers-one-folder failure the
-[support contract](../../../design/support-boundary/support-contract.md) exists to prevent. The
+[support contract](../../../../../docs/design/support-boundary/support-contract.md) exists to prevent. The
targets below point somewhere else, and the bootstrap directory appears in the tree only to be left
alone.
@@ -122,7 +122,7 @@ The configuration captures the layer a person would edit in Git:
This is Flux declaration editing, not Helm inversion. A chart folder is skipped as a unit, and the
operator never turns a rendered Deployment edit into a speculative values change. The current
-[support contract](../../../design/support-boundary/support-contract.md) owns that boundary.
+[support contract](../../../../../docs/design/support-boundary/support-contract.md) owns that boundary.
A free-standing values file is a separate planned projection, so it is absent from this first
scenario. Inline `HelmRelease.spec.values` are KRM and stay inside the declaration surface.
diff --git a/docs/layout/specific-examples/homelab-flux/config/gittarget-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/config/gittarget-media.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/config/gittarget.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/config/watchrule-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule-media.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/config/watchrule-media.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule-media.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/config/watchrule.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/config/watchrule.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/expected-bitnami.patch b/test/fixtures/layout-corpus/specific-examples/homelab-flux/expected-bitnami.patch
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/expected-bitnami.patch
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/expected-bitnami.patch
diff --git a/docs/layout/specific-examples/homelab-flux/input/bitnami.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/input/bitnami.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/input/bitnami.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/input/bitnami.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/repository/apps/home/media/helmrelease-jellyfin.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/apps/home/media/helmrelease-jellyfin.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/repository/apps/home/media/helmrelease-jellyfin.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/apps/home/media/helmrelease-jellyfin.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/repository/apps/home/media/kustomization.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/apps/home/media/kustomization.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/repository/apps/home/media/kustomization.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/apps/home/media/kustomization.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/repository/infrastructure/home/sources/gitrepository-homelab.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/infrastructure/home/sources/gitrepository-homelab.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/repository/infrastructure/home/sources/gitrepository-homelab.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/infrastructure/home/sources/gitrepository-homelab.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/repository/infrastructure/home/sources/helmrepository-jellyfin.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/infrastructure/home/sources/helmrepository-jellyfin.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/repository/infrastructure/home/sources/helmrepository-jellyfin.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/infrastructure/home/sources/helmrepository-jellyfin.yaml
diff --git a/docs/layout/specific-examples/homelab-flux/repository/infrastructure/home/sources/kustomization.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/infrastructure/home/sources/kustomization.yaml
similarity index 100%
rename from docs/layout/specific-examples/homelab-flux/repository/infrastructure/home/sources/kustomization.yaml
rename to test/fixtures/layout-corpus/specific-examples/homelab-flux/repository/infrastructure/home/sources/kustomization.yaml
diff --git a/docs/layout/specific-examples/prerequisites/README.md b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md
similarity index 96%
rename from docs/layout/specific-examples/prerequisites/README.md
rename to test/fixtures/layout-corpus/specific-examples/prerequisites/README.md
index 3542d1e9..b0d1ea82 100644
--- a/docs/layout/specific-examples/prerequisites/README.md
+++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md
@@ -37,4 +37,4 @@ whether those namespaces' `GitTarget`s may look outside their own.
This example names a credentials Secret and `known_hosts` ConfigMap but does not include either
object. Repository credentials and SSH host keys are operational inputs, not a layout convention.
The current setup contract is in
-[GitProvider configuration](../../../configuration.md#gitprovider).
+[GitProvider configuration](../../../../../docs/configuration.md#gitprovider).
diff --git a/docs/layout/specific-examples/prerequisites/config/gitprovider.yaml b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml
similarity index 95%
rename from docs/layout/specific-examples/prerequisites/config/gitprovider.yaml
rename to test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml
index d189a555..26188ab4 100644
--- a/docs/layout/specific-examples/prerequisites/config/gitprovider.yaml
+++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml
@@ -12,5 +12,5 @@ spec:
allowedBranches:
- main
commit:
- author:
+ committer:
name: GitOps Reverser
diff --git a/test/mutationlab/e2e/workload_scenarios_test.go b/test/mutationlab/e2e/workload_scenarios_test.go
index 980eeb3c..cb1d6be0 100644
--- a/test/mutationlab/e2e/workload_scenarios_test.go
+++ b/test/mutationlab/e2e/workload_scenarios_test.go
@@ -177,8 +177,7 @@ func gracefulPod(s scenario, name string) *corev1.Pod {
// have twice generalised this row into "a graceful pod delete produces no audit event at all" and
// built arguments on it, so the distinction is worth stating where the measurement is taken. What
// this row actually demonstrates is the shape of an AUDIT-EXCLUDED type, which is the population
-// that costs the attribution resolver its whole grace window on every removal
-// (docs/design/attribution-removal-wait-options.md).
+// that costs the attribution resolver its whole grace window on every removal.
//
// The audited equivalent of this two-step removal is TestFinalizerDelete: a configmap held by a
// finalizer takes the same deletionTimestamp-then-DELETED path and IS audited, so that is the row