Skip to content

fix(placement): register a new file with its nearest ancestor kustomization - #319

Merged
sunib merged 1 commit into
mainfrom
fix/placement-nearest-ancestor-kustomization
Aug 28, 2026
Merged

fix(placement): register a new file with its nearest ancestor kustomization#319
sunib merged 1 commit into
mainfrom
fix/placement-nearest-ancestor-kustomization

Conversation

@sunib

@sunib sunib commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #295. Split out of #318 so the fix can ship on its own — #318 stays open for the layout docs and the maintainer-review feedback.

What was wrong

A declared path into a subdirectory of a kustomize folder produced a document that no kustomization.yaml lists, so kubectl apply -k never rendered it. One byType line was enough to put a file in Git that nothing applies:

placement:
  byType:
    v1/configmaps: "configmaps/{name}.yaml"

governingKustomization looked only in the file's own directory, plus a special case for the write scope's root, so registration depended on whether render-root scoping happened to be in force — a difference the user cannot see.

What changed

  • It now walks up from the file's own directory to the nearest kustomization, bounded by the write jail, because a kustomization above the write scope is a read-only base whose resources: list is not ours to edit. That also reaches namespaceIsInheritedFromContext, which was unreachable for these paths: the document used to carry a metadata.namespace line the folder's own documents omit.
  • IdentityCompletePlacementTemplate no longer requires {version}. The built-in canonical path is deliberately versionless, because two served versions of one group/resource are the same object, so a version segment separates no identities. Requiring it judged the canonical shape as not identity-complete, which is what would make any spec-level default fail our own validation gate.

Acceptance

  • A byType path into a subdirectory of a kustomize folder is registered with the root that governs it, with a test that fails before the change.
  • placement_kustomization_entries_total no longer counts these as failed.
  • IdentityCompletePlacementTemplate accepts the versionless canonical shape; the Secret-safety gate still rejects what it should.

task lint, task test and task test-e2e all pass locally (e2e: 80 passed, 0 failed, 23 skipped opt-in corners).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added documentation explaining how new files are registered with the nearest applicable kustomization.
    • Updated placement validation to accept the versionless canonical path format.
    • New files placed in subdirectories are now registered with their governing ancestor kustomization.
  • Bug Fixes

    • Improved placement handling for declared paths and kustomization resource registration.
    • Ensured namespace transformations continue to apply correctly to newly created files.

…zation

A declared path into a subdirectory of a kustomize folder produced a document
that no kustomization lists, so `kubectl apply -k` never rendered it: one
byType line was enough to put a file in Git that nothing applies.
governingKustomization looked only in the file's own directory, plus a special
case for the write scope's root, so registration depended on whether
render-root scoping happened to be in force — a difference the user cannot see.

It now walks up from the file's own directory to the nearest kustomization,
bounded by the write jail, because a kustomization above the write scope is a
read-only base whose resources: list is not ours to edit. That also reaches
namespaceIsInheritedFromContext, which was unreachable for these paths: the
document used to carry a metadata.namespace line the folder's own documents
omit.

Drop the {version} requirement from IdentityCompletePlacementTemplate. The
built-in canonical path is deliberately versionless, because two served
versions of one group/resource are the same object, so a version segment
separates no identities. Requiring it judged the canonical shape as not
identity-complete, which is what would make any spec-level default fail our own
validation gate.

Fixes #295

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 33042447-b31a-4229-be27-004d1dd9ab5d

📥 Commits

Reviewing files that changed from the base of the PR and between 870ee7e and 73d1e7f.

📒 Files selected for processing (6)
  • docs/spec/gittarget-new-file-placement-rules.md
  • internal/controller/gittarget_placement_validation_test.go
  • internal/git/placement_metrics_test.go
  • internal/git/placement_test.go
  • internal/manifestanalyzer/placement.go
  • internal/manifestanalyzer/placement_test.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change fixes declared placement registration in kustomize subdirectories and updates identity validation for the versionless canonical template. Documentation and regression tests cover ancestor registration, metrics, rendered content, and validation.

Changes

Placement corrections

Layer / File(s) Summary
Versionless identity-template validation
internal/manifestanalyzer/placement.go, internal/manifestanalyzer/placement_test.go, internal/controller/gittarget_placement_validation_test.go
IdentityCompletePlacementTemplate now accepts the versionless canonical shape when it contains {groupPath} and {resource}. Tests cover accepted and rejected templates.
Nearest ancestor kustomization registration
internal/manifestanalyzer/placement.go, internal/git/placement_test.go, internal/git/placement_metrics_test.go
governingKustomization walks upward to the nearest kustomization within the write jail. Tests verify resource registration and successful metrics for declared subdirectory paths.
Declared-path registration rules
docs/spec/gittarget-new-file-placement-rules.md
The specification documents ancestor registration for declared and fallback paths, including write-jail and render-root bounds.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🔵 Low · up to 73d1e

When creating a new manifest, a failure to update its governing kustomization can still commit the file without rendering it, and later updates may not repair that registration; the change is otherwise confined to the writable subtree, so it is mergeable with explicit owner awareness or follow-up.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 10 functions across 5 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #295 by registering declared subdirectory placements with the governing kustomization, covering the metric regression, and accepting the versionless canonical identity-comple…
Out of Scope Changes check ✅ Passed The documentation, implementation, and regression tests directly support the objectives in issue #295. No unrelated code changes are evident.
Title check ✅ Passed The title clearly and concisely describes the primary change: registering new files with their nearest ancestor kustomization.
Description check ✅ Passed The description explains the defects, implementation changes, linked issue, acceptance criteria, regression tests, and local test results. It does not reproduce every template section, but it provides…
Full details: Linked Issues check

Explanation

The changes satisfy issue #295 by registering declared subdirectory placements with the governing kustomization, covering the metric regression, and accepting the versionless canonical identity-complete template.

Full details: Docstring Coverage

Explanation

Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 10 functions across 5 files. (1 skipped: 1 unsupported.)

Full details: Description check

Explanation

The description explains the defects, implementation changes, linked issue, acceptance criteria, regression tests, and local test results. It does not reproduce every template section, but it provides the required review context.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/placement-nearest-ancestor-kustomization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/manifestanalyzer/placement.go 92.3% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@sunib
sunib merged commit 5b7536b into main Aug 28, 2026
19 checks passed
@sunib
sunib deleted the fix/placement-nearest-ancestor-kustomization branch August 28, 2026 15:02
sunib added a commit that referenced this pull request Aug 28, 2026
…t express

Reverses this document's own thesis. It argued that a path template is the wrong
primitive and proposed replacing spec.placement with a spec.layout discriminated
union. That argument rested on five points, and three of them were retired by
#319: a new file is now registered with the nearest kustomization that governs
it, whatever chose its path. Once the template is no longer asked to express
"beside this folder's one kustomization", the discriminator has nothing left to
discriminate — registration was always the best idea in the model, and it is the
part that already shipped.

So the template stays and two additive fields join it, each with a default equal
to today's behavior.

serializeNamespace (Auto, Always, Never) is the one thing a path genuinely cannot
express: kustomize takes metadata.namespace from either the document or a
governing root, and where the file sits decides neither. It is not called
writeNamespace because "write" is this API's most loaded word — mode: Write, the
write boundary, the write jail, WriteBoundaryRefused — so writeNamespace: Never
invites the reading "never write to this namespace", a permission, which is
exactly what the neighbouring sourceNamespace fields are.

kustomizeRoot (Adopt, Create, Require) answers "do we want kustomize" on one
axis: what to do when no kustomization governs the path. When one does, every
value registers, because that is the invariant. Adopt is today's behavior, Create
is the empty-repository bootstrap that was the last surviving argument from the
old thesis, and Require is the safety value the Never guard needs — if the root
disappears, stop writing rather than commit files nothing renders.

Four kustomize facts are measured rather than recalled, and three contradict
assumptions the earlier model was built on: a root does not require a flat folder,
nested roots work one per subfolder and supply their own namespace, there is no
ambient pickup (globs and bare directories both fail), and an unlisted file in a
listed subdirectory renders nothing. The first is why the path may be anything;
the third is why registration must be an invariant.

Two values are considered and not taken, with reasons. Ignore is rejected:
spec.path already expresses it, since the ancestor walk is bounded by the write
jail, and it is the only candidate that changes what happens when a root IS
present — the half that should be invariant. CreatePerDirectory is deferred with
its trigger recorded, because fact 2 proves it would work and it is what would
make Never safe in a multi-namespace tree.

Also takes the maintainer review's status findings, since they are decisions this
document owes: renderRootReason becomes a condition reason rather than a bespoke
field, the accumulating counters go to metrics where placements_total already
carries them, and conditions plus observedGeneration are shown.

The headline is what this deletes: spec.layout, kind, scope, kustomize.create,
the LayoutProfile question, the immutability machinery, and the migration. The
layout model was the largest breaking change in the queue; on this shape it is
not a breaking change at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sunib added a commit that referenced this pull request Aug 28, 2026
The plan still described the #295 correctness fixes as PR 1's first half and
called PR 1 "this PR". Both fixes are on main and released in 0.42.1 via #319:
the ancestor walk and the versionless identity gate. PR 1 is the corpus alone
now, and the shipped fixes become the first thing it asserts, which makes the
corpus regression cover from its first commit rather than scaffolding for later
PRs. Worth stating plainly that the ancestor walk is also why this plan reversed.

The visibility page kept two claims the reversal invalidated. It said the CRD
default for placement.default was superseded because layout.kind is the
defaultable thing; there is no layout.kind, the template stays, and the question
is live again — with a weaker objection than before, because a defaulted path
into a subdirectory is now registered with the kustomization that governs it. And
status.layout is status.placement since spec.layout stopped existing, so the
section says so once at the top rather than being renamed throughout, since this
page is still where the field is argued.

Also fixes a Go comment pointing at docs/design/manifest/version2/, a path that
has not existed for some time and that the folder move only half-corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sunib added a commit that referenced this pull request Aug 28, 2026
#293 is closed with the evidence that reversed it, and its successor is #322:
serializeNamespace and kustomizeRoot as additive fields, which is why the row
leaves the wave. The Tier 1 placement-correctness row shipped in 0.42.1 via #319,
and B2 carries the status.placement rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sunib added a commit that referenced this pull request Sep 1, 2026
…d shrink the source-scope surface (#325)

* docs(layout): reverse the layout model onto two optional booleans, and shrink the source-scope surface

The layout question had grown to eight documents across `spec/`, `design/` and
`future/`, so following the argument meant knowing which folder each step lived
in. `docs/layout/` now holds it: two current-behavior contracts, one design
page, and the worked examples. Everything else stays where it was.

**The model reverses its own earlier thesis and leads with the reversal.** #319
shipped the ancestor walk, which made registration an invariant and retired
three of the five arguments against path templates. So the template stays, and
what it cannot express becomes two optional booleans on the existing
`spec.placement`:

- `useKustomize` — create and maintain the folder's `kustomization.yaml`.
  Registering a new file with a root that already governs it is an invariant,
  not a setting; the flag's one job is what to do when there is no root. Its
  `true` half is the only genuinely new machinery here, and it is what makes an
  empty repository bootstrappable.
- `serializeNamespace` — whether the document carries `metadata.namespace`. A
  `*bool`, because no plain default preserves today's behavior: `false` breaks a
  flat folder, `true` writes a redundant line into every kustomize folder that
  already supplies one. Unset means infer, which is not a guess — the existing
  inference omits the namespace only when the governing kustomization sets it to
  this resource's own namespace, so an explicit `false` is an override of a
  correctness rule and gets a post-scan guard.

The two pair up: with `useKustomize: true` the operator owns the root it writes
`namespace:` into, which is what makes both the omission provable and the
created kustomization meaningful rather than an empty file.

`spec.layout`, `kind`, `scope`, `kustomize.create`, the LayoutProfile question,
the migration and four maintainer-review findings are deleted with it. Nothing
in the placement work is breaking, so none of it waits for a coordinated
consumer bump.

**Source scope.** Declines Flux-style impersonation and deletes
`GitTarget.spec.allowedSourceNamespaces` with its selector machinery (4,569
lines, and the only cross-cluster read in the authorization path), on an API
reading rather than a security one: the chain from a Git folder back to the
object that fills it never leaves one namespace. Keeps `allowedNamespaces`,
renamed `accessFrom`, reversing an earlier draft. Redefines
`sourceNamespace: "*"` as one cluster-wide list and watch.

Also raises F9's envtest to Tier 1 — the only queue item whose answer is unknown
rather than whose work is unscheduled — and prices staying on `v1alpha3` as a
one-consumer countdown rather than a constant.

The Go changes are comment-only: doc-path citations following the move.
`task lint` and `task test` pass; e2e is unaffected because no executable line
changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(layout): lift serializeNamespace to the spec's top level, and say why a folder-wide claim is ordinary

`spec.placement` means "where a NEW document goes"; everything already written
is match-first and never moves, which is what makes a template safe to change.
`serializeNamespace` does not have that property, and the write path already
proves it: plan_flush.go strips metadata.namespace from a new document using the
placement result and from an UPDATE using the document's own observed namespace
source, and a document whose namespace is inherited is located in the file bytes
by a namespace-less identity. A field that rewrites existing documents as they
are next touched, and decides how they are found, does not belong inside a
struct documented as new-files-only. So it is `spec.serializeNamespace`.

`useKustomize` stays in `spec.placement`, because it passes the same test: it
decides whether a new file's directory has a root to join, and creates one when
there is none. Group by blast radius, not by topic.

Also answers whether a folder-wide namespace claim is a strange thing to state.
It is not: "no document carries its namespace" is the portable-artifact
convention behind every kustomize base, Flux's targetNamespace, Argo's
destination.namespace and a chart's .Release.Namespace, while "every document
carries it" is the convention for a folder applied directly. Cluster-scoped
resources are exempt, and the one genuinely non-uniform shape — a tree of nested
roots, each supplying its own namespace — is what the unset default already
handles per document. The uniform claim is what an explicit setting is for; the
non-uniform folder is what unset is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(namespace): pin the two folder shapes where reading the folder is not enough

Answers whether the layout situations under discussion can hurt us, by measuring
rather than reasoning. Two shapes exist where the manifest store's view of a
document's namespace and the namespace kustomize renders it into can disagree,
and neither is refused when the folder is read:

- a document carrying metadata.namespace: beta inside a folder whose
  kustomization sets namespace: alpha. An explicit namespace is authoritative as
  written and the transformer is never consulted, so the store indexes beta with
  NO diagnostic while the folder renders alpha.
- a parent root and its child root that both assign. kustomize is deterministic
  here (the parent transformer runs last and wins); we decline to guess, so the
  document is namespace-less, unmatchable by identity, and placement treats the
  live object as new.

Both are caught, and nothing reaches the worktree: the first refuses with "does
not render to the live object after the write", and the second gets as far as
proposing a second file before kustomize's own ID conflict fails the build —
neither the file nor the resources: entry survives. So the render check at the
write path is the backstop for this whole class, and it holds. Nothing pinned
that until now: a change that relaxed the check would have turned a refusal into
a silent write with no test failing.

Adds the read-side halves to the contextual-namespace corpus, which
docs/layout/contextual-namespace.md says is meant to grow one folder per "can we
support X?" question, plus the multi-namespace tree that was missing from it —
a parent assigning nothing and each child root carrying its own namespace, which
is the shape the model leaves to inference rather than to a flag.

The remaining gap is legibility, not safety: both failures surface as an opaque
render error rather than as the one fixable thing that is wrong, which is what
the post-scan validation pass is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(layout): fence namespace-free folders, and merge the two fixture folders

Five things, all documentation, on the two-booleans branch.

**The one-source-namespace rule.** A GitTarget with an explicit
serializeNamespace: false admits exactly one source namespace; the second is
refused. Replaces the proposed enforceSingleNamespace boolean: once the
template half is dropped (a deployer applies bytes, not filenames) the refusal
follows from one field's own meaning rather than from a correlation between two,
so it needs no new API surface. Keys on explicit false only, never on inference,
which is what keeps a nested-root tree — legitimately multi-namespace and
namespace-free — working. Under useKustomize: true the multi-namespace case is
the one place the operator would actively construct the silent-mislabel folder,
so refusing there is the only defensible behavior. Ships with the field in PR 4.

**One definition of record for sourceNamespace: "*".** The cluster-wide
redefinition was decided but unbuilt, so the shipped reference docs correctly
describe the old semantics and are deliberately left alone. Marked the section
in source-scope-simplification.md as the single home, carrying both readings,
and cut the three restatements. One of those (TODO.md) was actively harmful: it
proposed a competing direction the wave deletes.

**docs/design/build-order.md.** The five in-flight changes were spread across
five sequencing lists, none of which could see the other four. They resolve to
three tracks that do not block each other, with the two real couplings named and
three that people keep assuming and that do not exist — notably that PR 4 does
not wait for the wave, since the rule reads WatchRules rather than the policy
field being deleted. Records that the corpus is read by no Go file today, and
that the harness seam already exists at both levels.

**Merged the two fixture folders.** Four of six examples/ scenarios were
use-case retellings of shapes that specify the same behavior more precisely and
with the refusal halves included; deleted, with their two salvageable points
folded into shapes 3 and 5. The two ecosystem scenarios and the shared
prerequisites moved to specific-examples/. One fixture convention now, not two.

**Fixture and reference corrections.** Three GitTargets used sourceNamespace
overrides with no allowedSourceNamespaces and no ClusterProvider specimen, which
is a terminal SourceNamespaceAuthorized=False; three examples still spoke the
deleted spec.layout vocabulary (Auto/Kustomize/Tree/MultiNamespace); the claim
that model.md superseded placement-visibility's Question 2 was backwards, since
model.md reversed and reopened it; nine api-wave.md link texts named a path that
does not exist; and shapes/ was titled "Seven folder shapes" over eight rows.

task lint-docs passes: 221 markdown files, every reference resolves. Docs-only,
so the full suite does not apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(plan): cut the queue to three PRs, and de-duplicate the layout set

Squashes track A's four PRs into two and states the whole queue as three,
because merging is cheap where the parts share a property and expensive where
they do not.

**PR 1 is the corpus, spec.suspend, status.placement and the post-scan pass's
Ambiguous rule.** They are one review rather than four because none of them
changes what the operator writes: suspend with no status shows nothing, status
without suspend arrives after the first write, and the corpus is what proves
either behaves as written. PR 2 is the two booleans with the supplier rule and
the one-source-namespace refusal; PR 3 is the breaking source-scope wave, last
only because it is the one that spends a coordinated bump.

Two consequences the old cut did not have. The post-scan pass no longer lands
whole -- its Ambiguous rule reads only the scan, while its supplier rule reads a
field PR 2 introduces -- so the pass exists in PR 1 with one rule in it. And
since every PR here is squashed, the internal commits exist for the review and
nowhere afterwards: bisect and revert granularity is now the PR, and the
changelog entry is the PR title. Ordering work inside a PR still shapes what a
reviewer reads, which is why creating a kustomization.yaml stays the last commit
of PR 2 -- but it buys nothing after the merge, and saying otherwise would be
false comfort. The written-and-skipped scenario rule is the one property the
merge cannot erode, because the test suite enforces it rather than history.

Track C (patch authoring) is deliberately not one of the three: it blocks
nothing and would tie a field rename to a fortnight of patch machinery. The
alternative that keeps a literal count of three -- merging PRs 2 and 3 -- is
recorded and refused, since it makes additive placement work breaking by
association.

**De-duplication.** Six passages had been stated in two or three places each,
including one verbatim sentence across model.md and shapes/README.md. Resolved
by one rule: the cross-cutting argument lives in one place and each shape states
its own applied consequence in a line. model.md keeps the one-source-namespace
argument, shape 2 keeps the worked failure, shapes/README keeps the per-shape
table; "why only a leaf" and the overlay-bootstrap finding move to
shapes/README with shapes 6 and 7 keeping what is specific to them; shapes 1 and
3 stop re-explaining what a target namespace does to a folder. build-order.md
had begun duplicating itself -- a track table restating the PR table, and the
independence claim made three times -- which is what its own header warns
against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
sunib added a commit that referenced this pull request Sep 1, 2026
…ze, and fence a namespace-free folder to one source namespace (#328)

* feat(api): add spec.serializeNamespace, and honour it on every write

A path decides where a document sits; it cannot decide what is inside it.
spec.serializeNamespace does: a *bool at the top level of GitTargetSpec, where
nil keeps inferring per document (today's behavior exactly), true always writes
metadata.namespace, and false never does. It is not inside spec.placement
because it governs the bytes of every write rather than the destination of a
new one.

The override reaches all three sites that decide whether the namespace is in
the bytes: a new document's placement, an in-place update, and the identity a
managed document is located by.

The third one needed more than an override. A namespace-free document that no
kustomization governs belongs, as the folder reads it, to no namespace at all,
so the live object it mirrors matches nothing and the NEXT write of the same
object appends a second copy of it beside the first. The store now attributes
such a document to the target's single source namespace when the target
declared the folder namespace-free (WithDeclaredNamespace, NamespaceDeclared),
which is exactly what the one-source-namespace rule guarantees is single.
Where the answer is not single, two namespaces or a wildcard, nothing is
attributed.

Shapes 2 and 4 of the layout corpus run unskipped. Their expected patches lose
a pair of quotes the writer never emitted: every executed fixture already said
`timeout: 15m`, and only the two skipped ones said `"15m"`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(git): refuse a second source namespace against an explicit serializeNamespace: false

The write-plan precondition of docs/layout/model.md's second guard. A target
that declared its folder namespace-free admits exactly one source namespace,
and the second is refused before a byte moves.

What two namespaces produce 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 one
document flips between two live objects with nothing in Git recording that it
happened. 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 one refuses.

Only an EXPLICIT false is fenced. Inference is never constrained by it: a tree
of nested roots is legitimately multi-namespace and namespace-free in its
documents, and that is the case unset exists for. A rules[] item naming "*" is
refused statically, under either reading of "*", with nothing enumerated.

It is the correctness layer and it holds whatever admission did, so it lands
first and alone; the WatchRule admission check follows. It is raised after the
layout is published rather than with the acceptance gate: the folder is fine
and its shape is still worth reporting, and what is wrong is the configuration
pointed at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(webhook): reject a WatchRule that brings a second source namespace

The feedback half of the one-source-namespace rule. The precondition that
shipped with it refuses the write, which leaves a target refusing every commit
until someone reads its status; this rejects the rule at the moment the mistake
is made, which is what an admission webhook is for.

It is not the enforcement and must not be mistaken for it. The check is
one-shot, so it cannot see a serializeNamespace flipped to false after the
rules were created; the webhook is fail-open; and the operator runs perfectly
well with no admission server at all. failurePolicy therefore stays Ignore even
though this endpoint can now reject: the write path refuses the same
configuration whether or not the webhook ran.

Every way of failing to evaluate allows, deliberately. A rule naming a
GitTarget that does not exist yet is ordinary rather than wrong, and a
rejection the handler cannot justify would be a rejection of the user's object
on the strength of a GitTarget nobody read. An UPDATE is judged on what the
rule would become, excluding its own current value, so the edit that narrows a
rule back to one namespace is not blocked by the namespace it is removing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(api): add placement.useKustomize, creating the root a folder has none of

The one thing in this operator that writes a file nobody asked for by name, so
it lands last and on its own. Its only job is the empty case: when no
kustomization governs the path a new document lands at and the target declared
useKustomize, a kustomization.yaml is created at spec.path and the document is
registered in it in the same commit. Registering into a root that is already
there happens either way; that is #319's invariant, not this flag.

Three things follow from creating a root, and each is a real change rather than
a detail:

The new document goes BESIDE the root that is about to exist, not at the
canonical path. Creating a root and then placing the document in a tree no
resources: graph can reach would be worse than not creating one. It reports the
same placement source as the rung it stands in for, adding no member to a
metric label set that is a public contract.

The created root carries namespace: only when exactly one source namespace
reaches the target. That is what makes it a meaningful kustomization rather
than an empty file, and it is what makes an accompanying serializeNamespace:
false provable rather than trusted: the operator owns the file the omission
depends on.

The render oracle now treats a root that did not EXIST before the flush as
having rendered nothing, rather than as a root that failed to build. The two
were the same code path and they are not the same fault.

This also deletes the corpus's harness-local GitTarget and the filter that hid
the two unbuilt fields from the real type. Every scenario now decodes strictly
into v1alpha3.GitTarget, which is what makes "the API the worked examples
describe" and "the API that got built" one thing. Every skip naming PR 2 is
gone; shape 8's images: authoring still names track C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: describe both layout fields, and stop calling them unshipped

configuration.md gains a section per field: what each value writes, why unset
is not the same as false, and the one rule that refuses. The shapes and
specific-examples sets lose the banner saying neither field exists in the
current release, because both now do and every folder in them is executed
against the write path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(placement): make a created kustomization adopt the folder it is written into

Review finding: enabling useKustomize on a folder that already holds manifests
wrote a root listing only the new document. Every other file stayed in Git and
fell out of every render the moment a consumer ran kustomize build — mirrored
to look at, applied by nothing, with no signal that it had happened. The
oracle could not catch it either: a created root has no before-render to
compare against.

A created root now lists every managed document already in the folder as well
as the new one, at the paths those files already have. Nothing is moved,
rewritten or re-encoded. Turning a folder into a kustomize folder means the
folder.

Two boundaries come with it. A folder that already has a render root never
gains a SECOND one, even when a declared template puts the new document
outside the first: two render roots is the Ambiguous case, and an ambiguous
folder stops accepting new documents at all, which is a far larger fault than
one unregistered file. And a file some other kustomization governs is never
adopted, because listing it twice is a duplicate resource kustomize refuses to
build.

The oracle stops reading adoption as a blast radius. Objects rendering for the
first time under a root that did not exist before are rendering because the
root was created, not because the flush touched them — it touched no byte of
those files. The root must still build with the write applied, and every write
intent must still render to its live object; only the before/after comparison
for objects nobody targeted is skipped, and only for a root this flush wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(placement): stop a created kustomization from pinning the namespace

A created root now carries an apiVersion, a kind and its resources: list, and
nothing else. spec.serializeNamespace: false means the artifact does not encode
its deployment namespace; writing it into a root we authored honoured the
letter of the field and broke its meaning, pinning the namespace one file up
where it is harder to see and impossible for an installer to override. The
namespace comes from the documents when the field is unset or true, and from
whatever installs the folder when it is false.

docs/design/created-root-namespace.md records the five answers considered, how
likely each is to serve a real user, and why this one won. It also records the
three facts an earlier revision got wrong: a namespace-less kustomization.yaml
is ordinary (three already ship in our own corpus), both Flux and Argo supply
one downstream, and what refused the namespace-less root was our own fidelity
gate rather than kustomize.

That gate is the real change. It compared the rendered namespace against the
live object's whenever a flush touched a kustomization, so one declaration got
two different answers depending only on whether a root file happened to exist:
a namespace-free flat folder was never checked, and the same folder with a root
was refused. The comparison now ignores metadata.namespace only when the render
itself supplies none, and it is scoped by the RENDER rather than by the
setting: a root declaring namespace: shop still rejects a live billing object,
because that is a relocation and is exactly what the gate is for. Every other
field is compared in every case.

The second half of the review: under useKustomize, a placement no kustomization
would render is now refused (GitPathAccepted=False, UnrenderedPlacement)
instead of committed. It arises where the folder already has a render root and
a template puts the document outside it — a second root would make the folder
ambiguous, so the choice was to write a file nothing applies or to say so.
Targets that never declared useKustomize are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say how to choose between the two layout fields

The reference sections describe each field on its own, which leaves a reader
who has not decided anything yet with two independent switches and no way in.
Two questions, three combinations people actually reach for, and the one
combination to avoid: serializeNamespace: false on a folder nothing installs,
where every document lands in default and nothing in the repository can warn
you, because the installer is not in the repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] A declared placement path in a kustomize subdirectory is never rendered, and the identity gate rejects the versionless canonical path

1 participant