design: a proposal, not a plan of record. Nothing here binds until scheduled. Index:
../INDEX.mdDate: 2026-08-29. Supersedes this document's own earlier thesis, which argued that a path template is the wrong primitive and should be replaced by aspec.layoutdiscriminated union. That argument 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, for the Argo CD and Flux cases, and inshapes/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.
Placement today is a ladder of four rungs, three of which are path templates and one of which is not:
byType -> default -> the folder's one kustomize root -> canonical
The proposal is to keep that, and add the two things a path genuinely cannot express: whether
this folder maintains a kustomization.yaml, and whether metadata.namespace is written into the
document.
spec:
path: apps/demo
serializeNamespace: false # optional bool, unset = infer
placement:
byType: # unchanged, as shipped
v1/secrets: "secrets/{name}{sensitiveSuffix}"
default: "{namespace}/{resource}/{name}.yaml" # unchanged, as shipped
useKustomize: true # bool, default falseTwo booleans. No discriminator, no spec.layout, no kind, no scope, no new CRD, and no enum.
Both are additive: an object that says nothing behaves exactly as it does today.
They sit at different levels, and the line between them is retroactivity. spec.placement
decides where a new document goes; everything already written is match-first and never moves,
which is the guarantee that makes a template safe to change. useKustomize keeps that property — it
decides whether a new file's directory has a root to join, and creates one if not — so it belongs
inside placement.
serializeNamespace does not. It governs the bytes of every write, not just the first one, and
the code already has two separate paths for it: plan_flush.go
strips metadata.namespace from a new document using the placement result, and strips it from an
update using the document's own observed namespace source. 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 those effects nested inside a struct documented as "new files
only" is a trap: changing it rewrites existing documents as they are next touched. So it is
spec.serializeNamespace, one level up, where its blast radius is legible.
The earlier thesis rested on five arguments. Three were retired by #319, which shipped the ancestor walk: a new file is registered with the nearest kustomization that governs it, whatever chose its path.
| The argument against templates | Still true? |
|---|---|
byType into a subdirectory produces a file no kustomization lists |
No. That was #295, and it is fixed |
placement.default does the same to every type at once |
No. Same bug, same fix |
| A CRD default cannot be added: a non-empty template shadows the kustomize-root rung | Weakened, and placement-visibility-and-declared-defaults.md already concedes the fix turns this from a correctness wall into a legibility trade |
| "Where do my files go" needs a metric and a status field to be legible | True — but that is an argument for status, not against templates |
| Nothing can create structure, so an empty repository cannot be bootstrapped | True, and untouched by either design. Creating a kustomization.yaml is not a path question |
The sentence the whole redesign rested on was "a path template cannot express 'beside this folder's one kustomization'". That is still true and no longer matters, because the template is no longer asked to express it. Registration became an invariant rather than a rung — which was always the best idea in the layout model, and it is the part that already shipped.
What is left is one real gap (bootstrap), one status gap, and one flag that was always missing.
Four facts, measured against kustomize v5.8.1 rather than recalled, because three of them contradict assumptions the earlier model was built on.
- A root does not require a flat folder. A
kustomization.yamllistingconfigmaps/cache.yamlandapps/deployments/web.yamlbuilds, and the root'snamespace:transformer applies to both. So "Kustomizemeans flat files beside one root" was our rule, never kustomize's. - Nested roots work, one per subfolder. A parent listing
mediaandmonitoring, each holding its ownkustomization.yamlwith its ownnamespace:, renders each document into its own namespace. A multi-namespace folder can therefore omitmetadata.namespacesafely — if something owns those child roots. - There is no ambient pickup.
resources: [cms/*.yaml]fails (evalsymlink failure), and a bare directory fails unless it contains its own kustomization. Every file is named explicitly or lives under a nested root. - An unlisted file in a listed subdirectory renders nothing. The #295 class, unchanged.
Fact 3 is why registration must be an invariant: in a kustomize folder there is no other way for a
new file to be applied. Fact 1 is why the path may be anything the user wants. Together they say the
two questions are independent, which is exactly what a single kind discriminator could not express.
The flag that says this folder is a kustomize folder and the operator maintains its root.
| A kustomization governs the path | Nothing governs the path | |
|---|---|---|
unset / false (default) |
register the new file in its resources: |
write the file, touch nothing |
true |
register | create kustomization.yaml at spec.path, then register |
Registering into a root that is already there is not what this flag controls. It happens in both columns, because a file a kustomization does not list is a file nothing renders — that was #295, and #319 made it an invariant. The flag has exactly one job: what to do when there is no root.
That is also the honest reading of the name, which says less than the field does: useKustomize: false does not mean "leave kustomize alone". If you do not want a folder's root touched at all, 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.
Creating the root is the only genuinely new machinery in this proposal, and it is what makes an empty
repository bootstrappable — the last surviving argument from the earlier thesis, now one boolean
rather than a reason to redesign the primitive. A created root carries resources:, and
namespace: when the folder is single-namespace, which is what makes it a meaningful
kustomization rather than an empty file. That pairing is the whole point of the second flag.
Whether the committed document carries its own metadata.namespace. A path decides where the file
sits; it cannot decide what is inside it, and kustomize takes the namespace from exactly one of two
places — the document, or a governing kustomization's namespace:.
| Value | Meaning |
|---|---|
| unset (default) | infer, which is today's behavior |
true |
always write it |
false |
never write it |
It is an optional boolean, and unset is not the same as false. No plain default preserves
today's behavior: defaulting to false breaks a flat folder, whose documents must carry their own
namespace or they are ambiguous, and defaulting to true writes a redundant line into every
kustomize folder that already supplies one. So the field is a *bool and nil means infer — the
ordinary Kubernetes shape for a three-state switch that has to keep an existing default.
What "infer" already does, and why it is worth keeping as the default. The inference is not a
guess. placement.go omits metadata.namespace
only when the governing kustomization sets a namespace: and sets it to this resource's own
namespace; in every other case it writes the namespace explicitly, because omitting it there would
hand the document to a different namespace and the mirror would claim to hold an object it does not.
An explicit setting is therefore an override of a correctness rule, which is why false needs a
guard and unset does not.
The two settings exist for the two shapes a user actually declares:
truefor a flat folder. Nothing downstream supplies a namespace, so every namespaced document has to carry one. It also keeps a document portable: it means the same thing pasted anywhere.falsefor a folder whose namespace is chosen where it is installed. The supplier is a FluxKustomization.spec.targetNamespace, an ArgoApplication.spec.destination.namespace, or akustomization.yamlin the folder that a person wrote.useKustomize: truedoes not change that: a root the OPERATOR creates carries nonamespace:, because the setting says this artifact does not encode its deployment namespace and creating a root must not silently re-encode it one file up. See../design/created-root-namespace.md, which reverses an earlier revision of this bullet.
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. serializeNamespace names the moment the decision is made, and cannot
be read as policy.
It governs namespaced resources only. A ClusterRole has no namespace, so the field is ignored
for cluster-scoped documents rather than being an error — worth stating in the field documentation,
because a tree folder is the type most likely to carry both.
Setting the flag says something about every namespaced document in the folder, which sounds like a strong claim until you notice it is the claim the ecosystem's two commonest folder shapes already make.
- "No document carries its namespace" is the portable-artifact convention. It is what a kustomize
base is: written namespace-free, with an overlay's
namespace:transformer stamping one at build time. Flux says the same thing from outside the repository withKustomization.spec.targetNamespace, Argo CD withApplication.spec.destination.namespace, and a Helm chart by templating.Release.Namespacerather than hard-coding a namespace. A folder that follows it can be deployed into any namespace; a folder that half-follows it cannot, and nothing warns you. - "Every document carries its namespace" is the convention for a folder applied directly. A flat
mirror handed to
kubectl apply -f, or a cluster-state tree, has nothing downstream to supply one, so a document without a namespace is ambiguous rather than portable.
Two things keep the claim from being coarser than reality. Cluster-scoped resources are exempt,
so a folder holding both a ClusterRole and a Deployment is ordinary rather than mixed. And a
folder is genuinely allowed to be non-uniform when it is a tree of nested roots — fact 2 above —
each subtree taking its namespace from its own kustomization.yaml. That case is exactly what the
default handles: inference runs per document against the root that governs that path, so a tree
resolves each subtree correctly without anyone declaring anything.
So the uniform claim is what an explicit setting is for, and the non-uniform folder is what unset
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 and
shape 4 — 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
correctly land the same folder in two different namespaces. A rule demanding that something in the
folder supply the namespace would therefore report a fault on a folder doing exactly what it was
built to do, and a field naming the supplier would ask the user to promise something that is not
theirs to promise. Neither exists.
Nothing is lost by that, because none of it was ever the last line of defence. The render check at
the write path already refuses a write whose document does not render to the live object, and it
holds for both shapes where the store's view and kustomize's disagree: a document naming a namespace
the governing transformer overrides, and a folder whose nested roots both assign one. Both are
measured, not assumed, and both are pinned by
namespace_context_refusal_test.go with the
read-side halves in the contextual-namespace corpus.
The division this draws runs through the whole model: guard what is inside the folder, say nothing
about what happens after it leaves. The one-source-namespace rule below is on the inside of that
line — two namespaces collapsing onto one namespace-free document is a loss the operator can see, in
the folder it owns — and it refuses. It also reaches status, which publishes no supplier and no
serializeNamespace: see status.placement.
A GitTarget with an explicit serializeNamespace: false admits exactly one source namespace.
The second is refused.
The question is not whether a supplier exists — that is unanswerable, above — but how many namespaces
one supplier can be asked to speak for, and the answer can only ever be one: a document with no
metadata.namespace takes its namespace from a single supplier, so two source namespaces reaching
the folder is a contradiction in the setting itself. What follows is not a collision but a match —
shop/config and billing/config both resolve to a config.yaml whose bytes carry no namespace, so
their manifest identities are equal, the bundling rule never fires, and each write flips one document
between two live objects. Everywhere else in this model, losing a distinction produces a refusal or a
bundle; only here does it produce a match, which is why this guard refuses where the other reports.
It is derived from one setting, not inferred from two, which is why it needs no
enforceSingleNamespace boolean of its own. The path plays no part: a deployer applies bytes rather
than filenames, so the rule keys on the field's own meaning rather than on a correlation between
serializeNamespace and a template that happens to omit {namespace}.
Explicit false only. Inference is never constrained by it. That asymmetry is what makes the
refusal safe next to kustomize, and it is the same line Is a folder-wide claim reasonable? already draws. A tree of nested roots (fact 2) is
legitimately multi-namespace and namespace-free in its documents, and it is the case unset exists
for: inference resolves each document against the root governing its own path. Such a folder must
never declare false, because false is a folder-wide uniform claim and that folder is not uniform.
The refusal therefore costs that user nothing — it pushes them onto the setting that was already
correct for them.
With useKustomize: true the multi-namespace case is worse, not better. A created root carries
namespace: only when the folder is single-namespace; with two namespaces reaching the target the
operator can write no namespace: at all, so it creates a root that supplies nothing and then places
namespace-less documents beneath it — the operator actively constructing the silent-mislabel folder.
This is the one place refusing is not merely permissible but the only defensible behavior.
It needs no scan and no repository state. The set of source namespaces reaching a target is
{the target's own namespace} ∪ {the explicit rules[].sourceNamespace names of every WatchRule pointing at it}, all of it in the config cluster.
A sourceNamespace: "*" item is refused outright and statically, with no enumeration. * is one
cluster-wide watch
(definition of record),
so it cannot be proven to be one namespace from the spec alone — and it could not under the previous
reading either, which is why the redefinition changed nothing here.
This is deliberately not the job the deleted GitTarget.spec.allowedSourceNamespaces did, and it
never depended on that field. That was an authorization fence, deleted because the chain from a
folder back to the object that fills it never leaves one namespace, so RBAC on watchrules already
answers who may write. This rule asks whether the folder still means what it claims — a correctness
question about the bytes, untouched by that argument and homeless after it.
fails needs a subject, and it is both. Refusing only the write leaves a target that refuses
forever with no obvious fix; refusing the second WatchRule at admission is atomic feedback at the
moment the mistake is made, and is what the fail-open webhook is for. So: an admission check for the
feedback, and a write-plan precondition as the correctness layer, since admission is one-shot and
cannot see a serializeNamespace flipped to false after the rules were created — the same
reasoning ../spec/where-validation-lives.md applies to every
other gate in this repository.
What happens when two resources resolve to the same path is specified and shipped in
new-file-placement-rules.md: a unique path is a new file, a colliding
path appends into a plaintext multi-document file, a sensitive resource whose path already holds a
document is refused rather than appended, encrypted files are never appended into in either
direction, and existing documents stay match-first, so an object living inside a bundle is updated
where it is rather than moved out of it.
So "one file per object" and "a bundle per type or per namespace" are both expressible today, by writing a template that distinguishes identities or one that deliberately does not.
| Question | Answered by |
|---|---|
| Is this a kustomize folder? | useKustomize |
Do my documents carry metadata.namespace? |
serializeNamespace |
| Which folder do new files go in? | the directory part of the template. Fact 1: this is not constrained to flat |
| Is this folder one namespace or many? | whether {namespace} appears in the template. A template without it is single-namespace by construction |
No field in that table exists to answer a question a user did not ask, which is the test the earlier
kind/scope pair failed: scope: SingleNamespace restated in an enum what the template already
said, and then required an admission rule to keep the two in agreement.
spec.layout and its discriminator, kind, type, the Auto/Kustomize/Tree/Flat/Template
values, layout.scope and the admission rule keeping it in agreement with the source-namespace policy,
kustomize.create, and with them four findings of the maintainer review (L3, L4, L5, L8) — not
renamed, gone. The LayoutProfile question goes too: without a layout block the only thing left to
share is the byType map, and whatever generates thirty GitTargets repeats two booleans for free.
And the migration. spec.placement keeps its meaning and gains two optional members, so there is
no loud rejection, no feat(api)! on this axis, and no coordinated consumer bump for the layout work.
The layout model was the largest breaking change in the queue; on this shape it is not a breaking
change at all.
spec.placementis mutable and stays mutable. Existing files never move, so a template change affects only files written afterwards, and a folder can hold documents placed under two templates. Match-first identity keeps finding and updating them in place. The immutability-plus-CEL-widening machinery an earlier draft proposed was invented to protect a discriminator that no longer exists.placements_totalkeeps itssourcelabel — todaydeclared,kustomize_rootandcanonical, with thedeclaredsplit intobyType/defaultstill queued. It names the rung that answered rather than a resolved layout kind, so nothing here breaks a label.{kindLower}and the versionless identity fix are template features and stay queued.
Placement only ever affects new documents, so there is nothing to preview by inspecting the folder. The way to see what a target would do is to let one do it, somewhere harmless:
spec:
providerRef: {name: homelab}
branch: gitops-preview # not main
path: apps/checkoutIt commits. You read the commits. That is the actual bytes, the actual resources: registrations,
the actual $patch: delete files, in a diff you can review and hand to someone else. It costs one
field value, needs nothing built, and the branch is disposable.
spec.branch is immutable (the destination fields are), so the shape of
this is declare a preview target, look, delete it, declare the real one — not flip a branch on a
live target. That is a feature: the preview target and the real one are different objects, and
deleting the preview cannot disturb the real folder. For inspecting a repository with no cluster at
all, the manifest-analyzer CLI is the other half of the answer.
Two things follow, and they are what keeps the rest of this proposal small:
spec.suspendis a panic knob. One field that stops a target writing without deleting it or unpicking the watch configuration that would have to be rebuilt afterwards. It is not a preview mechanism: a target that writes nothing has nothing to show. It still scans while suspended, for a different reason — a valve that stopped looking as well as writing would freezestatus.placementat whatever the folder looked like the moment someone panicked, which is exactly when a stale answer costs the most.status.placementexplains rather than predicts. See below.
The stanza has one job: explain why a write took the shape it did, or why it was refused.
status:
observedGeneration: 4
conditions:
- type: LayoutResolved
status: "True"
reason: SingleKustomization # SingleKustomization | Ambiguous | None
message: 'render root "." governs new files; it renders ../../base, which is read-only input'
observedGeneration: 4
placement:
mode: KustomizeOverlay # Plain | KustomizeRoot | KustomizeOverlay
renderRoot: .
readOnlyBases: ["../../base"] # non-empty exactly when mode is KustomizeOverlay
resolvedAtRevision: 9f3c1ab
resolvedAt: "2026-07-30T09:14:22Z"The rule that decides what belongs here, and the one to hold a proposed field against: a status
field earns its place only if a reader cannot get it from the spec in the same GET, and it
varies with this folder. A copy of spec.serializeNamespace, a count of the byType map, and a
list of illustrative destinations for a fabricated object all fail it, and none of them is here.
mode is the field a reader cannot work out for themselves. Whether a folder is written as
plain files, as a self-contained kustomize root, or as an overlay over a base it may not write to is
a fact about the repository, and it predicts every behaviour that surprises people:
Plain |
KustomizeRoot |
KustomizeOverlay |
|
|---|---|---|---|
| a new document | written | written and registered in resources: |
same |
| a delete | file removed | file removed and its resources: entry dropped |
same, unless the object is inherited |
| deleting an object the folder inherits | n/a | n/a | a $patch: delete is authored into the overlay; nothing is removed |
| editing a field the base owns | n/a | n/a | authored into the overlay for images:/replicas:, refused otherwise |
| can kustomize itself refuse the write | no | yes, via the re-render oracle | yes |
Those behaviours are constants of the mode, so they are documented on the field and not
enumerated per folder in status. readOnlyBases is the exception that is genuinely per-folder: it
names the directories a WriteBoundaryRefused will fire on, which turns that refusal from a
surprise into something a reader could have predicted. mode is absent under Ambiguous, along
with renderRoot: there is no single answer, and naming one of several roots would be the guess
that verdict exists to refuse.
Three decisions are taken here rather than deferred, because each is cheaper to take before the field ships:
- The resolution reason is a condition reason, not a field.
renderRootReasonwould have been a reason enum in a bespoke field, and every consumer in this ecosystem already reads reasons fromconditions. - No counters.
placedResources,overriddenTypesandrefusedResourcesare metrics;placements_totalcarries 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. resolvedAtRevision/resolvedAtdate the RESOLUTION, not the last scan. An unchanged resolution is not republished, so a timestamp well in the past means the folder's shape has been stable rather than that scanning stopped. The names say so, becauseobserved*would read as "last looked".
The post-scan validation pass ships with the stanza, because it is the same scan, and it is one
rule: a folder covering two render roots is Ambiguous rather than silently picking one. There is
no supplier rule — false needs no guard.
The one-source-namespace rule is
not part of this pass, though it guards the same field. Its input is the set of WatchRule
objects naming the target, which is in the config cluster and needs no scan, and its outcome is a
refusal rather than a report. It ships with the field, in PR 2.
While a target is suspended, status.retention is not published at all. The resync stops before
the mark-and-sweep, so nothing is swept and nothing is counted, and a published zero would read as
"converged" when it means "not measured". Absent already means "no resync has reported", which is
exactly the truth.
LocateNew is not rewritten. The four-rung ladder is a single function,
LocateNew, with a single caller in
plan_flush.go, and everything downstream of the path decision —
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, 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.
| PR | Content | Breaking |
|---|---|---|
| 1 | The worked examples as an executable corpus, spec.suspend and the reconcile-request annotation, status.placement, and the post-scan pass's Ambiguous rule |
no |
| 2 | useKustomize and serializeNamespace, with the one-source-namespace refusal (#322) |
no |
PR 1's four parts are one review because the corpus is what proves the other three: spec.suspend,
status.placement and the reconcile-request annotation are each small, and each is only credible
against a worked example that pins what the folder actually does. The exception is the Ambiguous
rule, which gates: a folder covering several render roots stops placing new documents, where
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 carries the
before-and-after.
The post-scan pass lands whole in PR 1: it is the Ambiguous rule and nothing else, and that rule
reads only the scan.
Neither PR is breaking, so neither waits for a coordinated consumer bump. What is breaking on
GitTarget is unrelated to placement and is sequenced in
gittarget-api-wave.md.
PR 1 is the corpus, and it is the reason the rest is reviewable.
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
internal/git already do this at a smaller scale. Per
scenario: seed a worktree from repository/, build the write event from input/, derive the flush
policy from config/gittarget.yaml, flush, and compare the normalized diff with expected-*.patch.
Blob hashes and index lines are noise; a -update flag that rewrites the patches keeps the corpus
cheap to extend. Scenarios describing behavior PR 2 introduces are written now and skipped with the
PR that unskips them named in the skip message, so PR 2 is finished when its own skips are
gone. Not every skip is PR 2's: shape 8's images: authoring belongs to track C and is skipped
naming it, so it stays after PR 2 lands and is not a defect in that PR's completion.
config/gittarget.yaml uses fields that do not exist yet, so it 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.
PR 2 builds the true half of each flag. Everything else is already there: registration into an
existing root shipped in #319, and inference is what
namespaceIsInheritedFromContext already does. What
is new is writing a kustomization.yaml that does not exist, with namespace: set when the folder
is single-namespace, and registering into it in the same commit. Build that last and on its own: it
is the one thing that writes a file nobody asked for by name.
The one-source-namespace refusal ships in PR 2 too, and it is the cheapest thing in it: no scan,
no repository state, no new field. Build the write-plan precondition first, because it is the
correctness layer and it holds whatever admission did; the admission check on WatchRule is the
feedback half and can follow in the same PR. It also decides the useKustomize question above — a
created root's namespace: is written when the folder is single-namespace, and under this rule an
explicit serializeNamespace: false guarantees it is.
Refusals are fixtures too, and they are part of PR 1 even where the rule is not: a set of worked
examples in which every write succeeds is advertising rather than specification. Three assert an
expected-*-status.yaml instead of a patch — a serializeNamespace: false target a second source
namespace reaches, a folder covering two roots, and the base-owned field edit. Only the two-roots
one asserts a rule PR 1 ships; the second-namespace one is written and skipped naming PR 2.
- Should a
useKustomize: truefolder create a nested root per directory the template writes into, each carrying its ownnamespace:? Fact 2 proves it works. Deferred, and now more firmly: it was the thing that would have madeserializeNamespace: falsesafe in a multi-namespace tree, and the one-source-namespace rule removes that motivation by refusing the combination outright. Such a tree is an unset folder, which inference already handles per document. Re-open trigger: someone who needs a multi-namespace folder whose documents omit their namespaces, and for whom leaving the field unset is not enough. - Should the operator ever refuse a write when a root that used to govern the path is gone,
rather than reporting
LayoutResolved: Noneand carrying on? Report first; escalate if someone says the status was not enough. Such a folder is also amodetransition (KustomizeRoottoPlain), which republishes, so the change is already visible without a refusal. - Should
placement.defaultgain a CRD default now that a defaulted template no longer produces unrendered files? The remaining objection is legibility, not correctness. - Namespace-local
GitProvider: the homelab examples put aGitTargetinargocd,flux-systemandhomelab-config, each needing its ownGitProvider— three copies of one credential in a single-owner cluster. Recorded as a gap, not a blocker.