Skip to content

feat: render-root scoping + tolerate patches and patched overlays reading ../../base - #239

Merged
sunib merged 7 commits into
mainfrom
feat/render-root-scoping
Jul 15, 2026
Merged

feat: render-root scoping + tolerate patches and patched overlays reading ../../base#239
sunib merged 7 commits into
mainfrom
feat/render-root-scoping

Conversation

@sunib

@sunib sunib commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

Delivers the two connected halves that make a patched kustomize overlay reading ../../base work end-to-end — folding in the tolerate-patches work from #235, which is incomplete on its own (see below). Implements render-root-scoping.md §7 steps 1, 4, and 6 (step 3, the oracle, already shipped).

Two capabilities, deliberately combined because neither is useful for a real overlay without the other:

1. Read ../../base as render context (render-root scoping)

A GitTarget whose spec.path is an overlay may now read a base outside its subtree via ../../base, while writes stay inside spec.path. The writer re-roots its scan at renderBase — the lowest common ancestor of spec.path and every base it reaches (internal/git/render_scope.go) — so the store, attribution, and the render oracle run in one coordinate system with no ..-escaping paths. The write jail (writeSubdir) keeps a planned write inside spec.path. When a subtree reads no out-of-scope base, this is the identity — every existing layout is byte-identical to before.

2. Tolerate a patch, without authoring one (folded from #235)

A path-based strategic-merge patches: is now read-only build context: the folder is accepted, the patch file is retained (never managed/swept), images:/replicas: edit-through works in a patched folder, and an edit to a field the patch owns is refused per object. Inline / JSON6902 / out-of-tree patches refuse by name (patches-inline, patches-json6902, patches-outside-tree).

Why combine them

#235's own finding: "Tolerating patches accepts zero new candidates — every patched overlay in the corpus also reads a base from outside its own folder, so patches was masking the real refusal. Render-root scoping, not patches, is the single blocker on the corpus's most tractable layout." Conversely, render-root scoping alone leaves a patched overlay refused on the patches: key. Together, flux-monorepo/apps/{production,staging} (patch + ../../base) is handled by the writer: rendered, accepted, image/replica edit-through, patch-owned edits refused per-object.

Effects on the corpus baseline

Fixture Before After
flux-monorepo/apps/{staging,production} refused-structural: patches kustomize-overlay / overlay-fan-out-unsupported
kustomize-overlays/apps/frontend/overlays/* …, patches, … patches drops out (still refused for generators + name(pre/suf)fix)
2-rendered/kustomize-overlay-minimal (new) kustomize-overlay / overlay-fan-out-unsupported

Also

  • fanInPrecondition generalised to any file reachable from more than one render root (ReachedByMultipleRenderRoots), not only the override-ambiguous case.
  • New minimal-overlay corpus fixture — the first to surface the kustomize-overlay verdict (every other overlay tripped refused-structural first).

Tests

Writer + analyzer unit tests exercise the write path against real kustomize (krusty) builds: overlay-base resolution, image edit-through to the overlay entry with the base untouched, in-sync no-op, base-field refusal, shared-base fan-in refusal, base-escaping-repo-root refusal; plus the patches tests (tolerated path: merge, refused inline/json6902/out-of-tree, "patch file is never a render origin", deprecated spellings not folded).

Validation

task fmt / generate / manifests / vet / lint ✓ · task test ✓ (unit coverage → 76.2%, baseline bumped) · task test-e2e running locally + on CI.

Deferred to a follow-up

  • Discovery-side flip: scan_repo still classifies these overlays as overlay-fan-out-unsupported; this PR changes the live writer, not the discovery/onboarding report.
  • Dedicated cluster e2e for the overlay round-trip (write path is covered by real-kustomize unit tests).
  • render-root-scoping.md §7 step 5 (entry creation) and the deeper patch stages (attribute/route into a patch, per-field accounting).

Folds in and supersedes #235.

🤖 Generated with Claude Code

Render-root scoping (docs/design/support-boundary/render-root-scoping.md §4):
a GitTarget whose spec.path is a kustomize overlay may now read a base OUTSIDE
its subtree via ../../base, while writes stay inside spec.path.

The writer re-roots its scan at renderBase — the lowest common ancestor of
spec.path and every base it reaches — so the store, attribution, and the
render oracle run in one coordinate system with no ..-escaping paths. The
write jail (writeSubdir) keeps a planned write inside spec.path. When a
subtree reads no out-of-scope base, renderBase == spec.path and
writeSubdir == "", so every existing layout is byte-identical to before.

Effects:
- an overlay pointed at ../../base now renders and is accepted (was refused
  for a kustomize build failure);
- a base-derived object materialises, so an images:/replicas: bump routes to
  the overlay's own entry and the read-only base keeps its bytes;
- a base-owned field edit an overlay cannot express is refused and reported,
  never silently written through;
- fanInPrecondition is generalised to any file reachable from more than one
  render root (ReachedByMultipleRenderRoots), not only the override-ambiguous
  case.

Adds the minimal-overlay corpus fixture (2-rendered/kustomize-overlay-minimal),
the first to surface the kustomize-overlay / overlay-fan-out-unsupported
verdict; support-today.md regenerated (existing rows unchanged). Unit tests
exercise the write path with real kustomize builds.

Deferred to a follow-up: the discovery-side flip (scan_repo still reports the
overlay unsupported) and a dedicated cluster e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sunib, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 57e48638-7efc-497a-b6ac-380c930c8182

📥 Commits

Reviewing files that changed from the base of the PR and between 8444cd0 and 1f0d1d0.

📒 Files selected for processing (33)
  • .coverage-baseline
  • docs/UPGRADING.md
  • docs/architecture.md
  • docs/configuration.md
  • docs/design/support-boundary/next-prompt-simple-patches.md
  • docs/design/support-boundary/render-root-scoping.md
  • docs/design/support-boundary/support-contract.md
  • internal/git/inplace_overrides_test.go
  • internal/git/kustomize_delete_test.go
  • internal/git/patches_test.go
  • internal/git/placement_test.go
  • internal/git/plan_flush.go
  • internal/git/render_scope.go
  • internal/git/render_scope_test.go
  • internal/git/resync_flush.go
  • internal/git/write_boundary_precondition_test.go
  • internal/manifestanalyzer/acceptance.go
  • internal/manifestanalyzer/kustomization_parse.go
  • internal/manifestanalyzer/overrides_test.go
  • internal/manifestanalyzer/patches_test.go
  • internal/manifestanalyzer/placement.go
  • internal/manifestanalyzer/placement_test.go
  • internal/manifestanalyzer/render_scope.go
  • internal/manifestanalyzer/render_scope_test.go
  • internal/manifestanalyzer/scan_repo.go
  • internal/manifestanalyzer/store.go
  • test/fixtures/gitops-layouts/2-rendered/kustomize-overlay-minimal/README.md
  • test/fixtures/gitops-layouts/2-rendered/kustomize-overlay-minimal/base/deployment.yaml
  • test/fixtures/gitops-layouts/2-rendered/kustomize-overlay-minimal/base/kustomization.yaml
  • test/fixtures/gitops-layouts/2-rendered/kustomize-overlay-minimal/base/service.yaml
  • test/fixtures/gitops-layouts/2-rendered/kustomize-overlay-minimal/overlays/production/kustomization.yaml
  • test/fixtures/gitops-layouts/2-rendered/kustomize-overlay-minimal/overlays/staging/kustomization.yaml
  • test/fixtures/gitops-layouts/support-today.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/render-root-scoping

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.

sunib added 2 commits July 15, 2026 14:12
A `patches:` block used to refuse the whole GitTarget. Not the edit — the TARGET.
A folder whose patch pins a replica count also lost images:/replicas: edit-through,
which the patch has nothing to do with. It is the single biggest refusal cause in
the layout corpus.

A strategic-merge patch named by `path:` is now READ-ONLY BUILD CONTEXT:

  - the folder is accepted, and what it renders is mirrored;
  - the patch FILE is retained, never managed. This is the part nothing else in the
    store would have got right: a sparse patch IS a KRM document, so materialised it
    would be indexed as a manifest, matched to a live object, mirrored over with a
    whole Deployment, or swept as an orphan nothing in the cluster answers to. That
    a patch is not a resource is not our claim — it is the RENDER's: a patch file
    never appears as a rendered object's origin;
  - images:/replicas: edit-through works in a patched folder, exactly as anywhere;
  - an edit to a field the PATCH owns is refused per OBJECT, not per folder.

Tolerating a patch is not authoring one. Nothing is ever written into a patch file.

Exactly one shape is tolerated; the rest refuse BY NAME, because "unsupported" is not
something a user can act on and "your patch is inline, and we can only read one from a
file" is: patches-inline (which is also where an inline JSON6902 op list arrives, since
it decodes into the same field), patches-json6902 (a path to an op list — a YAML
sequence, not a sparse KRM document), patches-outside-tree.

MEASURED, NOT ASSUMED: FixKustomization folds `bases` into resources and `imageTags`
into images, but it does NOT fold `patchesStrategicMerge` or `patchesJson6902` into
`Patches`. They keep refusing under their own names, and a test pins that so a kustomize
bump cannot silently widen what we accept.

THE CORPUS SAYS SOMETHING THE PLAN DID NOT EXPECT, and it is the deliverable here:
tolerating patches accepts ZERO new candidates. Every patched overlay in the corpus also
reads a base from OUTSIDE its own folder, so `patches` was masking the real refusal.
flux-monorepo/apps/{staging,production} now report overlay-fan-out-unsupported — the
verdict render-root-scoping.md §5 records as never having been observed, because
refused-structural always fired first and hid it. Render-root scoping, not patches, is
the single blocker on the corpus's most tractable layout.

The mechanism itself is proven by fixtures at both levels: a self-contained patched root
is accepted, its image bump routes to the entry, its in-sync state is a no-op, and its
patch-owned scale is refused.
The prompt's stage 1 shipped, and three of its premises did not survive contact
with the measurement: the gate was the projection rather than the deny-list,
FixKustomization does not fold the deprecated spellings, and tolerating patches
accepted zero new corpus candidates because every patched overlay in the corpus
is really blocked on render-root scoping.
@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.26462% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/git/render_scope.go 94.2% 5 Missing and 5 partials ⚠️
internal/manifestanalyzer/placement.go 83.3% 3 Missing and 2 partials ⚠️
internal/manifestanalyzer/kustomization_parse.go 97.4% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

The tolerate-patches work changed parseKustomization's signature (adding the
file tree it resolves patch paths against). Adapt render-scope's
KustomizationResourceEntries caller — it reads only the resources graph, so a
nil tree is correct. Regenerate the layout baseline over both changes: with
patches tolerated, flux-monorepo and kustomize-overlays drop `patches` from
their refusals and now report the render-root-scoping barrier
(overlay-fan-out-unsupported) that patches was masking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sunib sunib changed the title feat: render-root scoping — read ../../base as context for overlay GitTargets feat: render-root scoping + tolerate patches — patched overlays reading ../../base Jul 15, 2026
sunib and others added 2 commits July 15, 2026 14:49
Codecov flagged the render-scope error/edge paths as the PR's coverage gap.
Add tests for the branches the happy-path suite missed: a transitively-read
base (overlay -> ../../base -> ../shared) with a .yml base kustomization,
foreign content in the overlay re-keyed to render coordinates, a base's
non-manifest file skipped, a remote base skipped rather than scanned, and the
common-ancestor / relative-under / minimal-dirs helpers at their boundaries.
Raises render_scope.go coverage (rekeyScan 71%->93%; several helpers to 100%);
the residual is OS-error paths that need fault injection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t, dual-role patches

Five review findings on render-root scoping + tolerate-patches:

- F1/F3: resolve the EXACT reachable file set of the resources/patches graph instead
  of walking whole base directories. An external resource FILE
  (resources: [../../shared/x.yaml]) is now pulled into the render scope (was
  refused); unrelated content a base does not reference is no longer imported (it
  could wrongly refuse a buildable folder); and an ancestor base no longer re-scans
  overlay-local files into a duplicate refusal. Adds KustomizationBuildRefs
  (resources + patch path files).
- F2: placement now rebases a declared/canonical path under the write jail
  (WriteScope) instead of resolving against renderBase and being skipped, so an
  overlay GitTarget's new objects land inside spec.path as documented.
- F4: a file listed as both a resource and a patch is materialised and mirrored, not
  silently retained as a patch-only build input.
- F5: configuration.md / architecture.md / support-contract.md updated — path-based
  patches are tolerated and overlays reading ../../base are shipped; the scan-repo
  discovery flip is called out as the remaining follow-up.

Tests: external resource file, transitive base, unrelated-content exclusion,
write-jail placement (canonical + declared), dual-role path mirrored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase kustomizations

- P1 (blocker): a new object in an overlay could be committed to a file no
  kustomization includes — never rendered, and the oracle skipped (silent
  divergence), introduced when F2 replaced the earlier safe skip with a rebased
  write. Placement now (a) restricts sibling inference to WRITABLE siblings so it
  never infers beside a read-only base doc, (b) restricts the single-kustomization-
  root fallback to WRITABLE kustomizations so an overlay resolves to its own root,
  and (c) governs a new overlay object by the write scope's own render root when its
  subdirectory has none. The object lands beside the overlay kustomization, gains a
  resources: entry, renders, and is oracle-verified. All gated on WriteScope != "",
  so self-contained placement is unchanged.
- P2 (dual kustomization files): a base holding both kustomization.yaml and .yml is
  now imported whole, so the render reaches kustomize's own "multiple kustomization
  files" refusal instead of masking it by reading only the first.
- P2 (symlink): readKustomization goes through the guarded reader (Lstat +
  regular-file), so external-base discovery never follows a symlinked kustomization
  outside the worktree.

Tests: new object in an overlay renders (end-to-end), dual-file base imported both,
symlinked base kustomization not followed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sunib sunib changed the title feat: render-root scoping + tolerate patches — patched overlays reading ../../base feat: render-root scoping + tolerate patches and patched overlays reading ../../base Jul 15, 2026
@sunib
sunib merged commit e9d6be1 into main Jul 15, 2026
18 checks passed
@sunib
sunib deleted the feat/render-root-scoping branch July 15, 2026 16:18
sunib added a commit that referenced this pull request Jul 15, 2026
…rrides and $patch:delete (#244)

* test(overlay): prove new-object placement into an external-base overlay; fix stale docs

render-root-scoping.md §4 labelled "new object -> overlay-local file plus
resources: entry" as Planned, but #239's render-root scoping already wired it:
WriteScope threads the overlay's write jail into placement, the base
kustomization is skipped as out-of-jail (writableCohort / resolveKustomizeRoot),
appendKustomizationResource adds the resources: entry in the same commit, and
the render oracle verifies the result. It simply had no test proving it and the
docs still read "Planned".

Add TestPlacement_ExternalBaseOverlay_NewObject: a GitTarget rooted at an overlay
that reads ../../base gains a new ConfigMap. It asserts the file lands inside the
overlay, the overlay's OWN kustomization gains the resources: entry (never the
base's), the read-only base is untouched byte-for-byte, and the render oracle
accepts the flush.

Correct the stale docs (render-root-scoping §1/§4/§5/top, README, support-contract,
kustomize-support-boundary) to mark new-object creation shipped. What remains for an
overlay: adding a MISSING images:/replicas: declaration (no source value to attribute
yet) and base-owned-field patch authoring.

No production code changed — this proves and documents existing behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(overlay): author a missing images:/replicas: entry when a base value is changed per-environment

An external-base overlay could edit an EXISTING images:/replicas: entry, but when the
base itself supplied an image or replica count, changing it in one environment tried to
write the read-only base and was refused ("planned write path base/deployment.yaml
escapes the GitTarget write scope"). So "edit the image in dev" did not work unless the
overlay already declared an entry.

Author the entry instead. When the projection finds a diverging image component or replica
count whose supplier is the SOURCE document and that source is out of the write jail (a base
an overlay reads), it emits a Create OverrideEdit into the overlay's own kustomization rather
than writing the base. The writer applies it with a new
manifestedit.AppendKustomizationOverride primitive (creating the images:/replicas: section if
the overlay has none), and the base source form stays unchanged so nothing is written to the
base.

Every authored entry is put to the existing re-render oracle before it can commit, so a
proposal that over-reaches (an images: name shared by another object) is refused there, not
written. Idempotent on resync: once the entry exists the store sees it and the change routes
to it, no duplicate.

Tests: manifestedit primitive unit tests; overrides projection call-site updates; and writer
integration TestOverlayAuthors_{ImageEntry,ReplicaEntry,Idempotent}. Docs updated
(render-root-scoping, support-contract, README, kustomize-support-boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(overlay): author $patch: delete for an object inherited from a base

Deleting an object an external-base overlay inherits from its base tried to delete the base
document (out of the write jail, shared by other environments) and was refused. Author a
`$patch: delete` in the overlay instead: when the delete target is out of the write jail and
the overlay has a supported render root, applyDelete writes a small `$patch: delete` document
inside spec.path, names it in the overlay's own patches: (new manifestedit.AppendKustomizationPatch
primitive, creating the patches: section if absent), and keeps the existing Removed intent so the
re-render oracle proves the object leaves the render. The base is never touched.

The patch pins the object by apiVersion/kind/namespace/name; a patch that does not match makes
kustomize fail to build and the oracle refuses the flush (proven: the namespace-less first cut
was correctly refused, the namespaced one commits). It needs no field attribution, so it does not
wait on strategic-merge field-patch authoring — which stays refused.

This is a deliberate, narrow widening of the patch-authoring boundary for the delete slice only;
noted in patch-authoring.md and the render-root-scoping/support-contract records.

Tests: AppendKustomizationPatch unit tests; integration TestOverlayAuthors_DeletePatch_ForInheritedObject.
Coverage baseline bumped 76.4% -> 76.5%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sunib added a commit that referenced this pull request Jul 21, 2026
Sibling inference could file one namespace's objects under another namespace's
folder, and then collapse an entire resource type into a single file.

Step 2 of inference ("same type, any namespace") is guarded by the P4 rule: a
cohort may only be reused for a namespace the target has never written if it has
proven itself namespace-agnostic. The bundle branch enforced that properly, via
`spansMultipleNamespaces`. The singleton branch only asked `allSameDir`, which is
"trivially true for zero or one element" — so ONE directory holding ONE
namespace passed the guard, even though it is exactly as consistent with a
per-namespace-segmented layout whose second namespace had simply never been
written. Absence of contrary evidence was being read as proof.

The consequence was worse than one misplaced file, because it cascades. Objects
that exist under the same name in every namespace (kube-root-ca.crt) make the
inferred path collide EXACTLY with the first namespace's file, so the second
namespace's object is appended as another document. That file now genuinely
spans two namespaces, so every later object of the type legitimately prefers the
bundle — and the whole type collapses into one file holding several namespaces'
objects. Observed end to end: four ConfigMaps from two namespaces in a single
file, which was the only ConfigMap file in the tree.

Both branches now require the same positive proof. Declining costs nothing: the
canonical path builds the correct namespace segment directly, which is the
layout the target already uses for the namespace it has written.

This is pre-existing and not introduced by the source-namespace work — the write
path is untouched by it, and placement.go last changed in #239. It is reachable
today wherever one GitTarget receives one type from two namespaces. It matters
now because `sourceNamespace: "*"` reaches that shape from a single rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sunib added a commit that referenced this pull request Jul 21, 2026
…ster-only ClusterWatchRule (#259)

* feat(watch): add WatchRule.spec.sourceNamespace and its authorization gate

Adds `WatchRule.spec.sourceNamespace` so a rule can watch a namespace other
than its own on the GitTarget's source cluster — the shared-config-plane
multi-tenancy case where a tenant's config namespace and source namespace
cannot share a name. PR 4 of docs/design/watchrule-source-namespace/.

API (v1alpha3):
- `WatchRule.spec.sourceNamespace` (optional; omitted = the rule's own
  namespace), plus a priority-1 `SourceAuthorized` printer column.
- `GitTarget.spec.allowedSourceNamespaces` — a deny-by-default ceiling on which
  source namespaces may be mirrored into a target, by any rule kind. Declared is
  exhaustive with no self-namespace exception; empty != omitted.
- `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` — false-by-default
  delegation flag; without it a WatchRule may watch only its own namespace.
- Generalize `AllowedNamespaces` into a shared `NamespaceMatcher` (JSON field
  name unchanged); `ClusterProvider.AllowsNamespace` and
  `GitTarget.AllowsSourceNamespace` become thin wrappers so the control-cluster
  and source-cluster policies cannot drift.

Gate and data plane:
- The three-part gate (provider admits the target, provider delegates, target
  policy admits the namespace) lives in internal/authz with a three-valued
  result (admitted / denied / cannot-say-yet / permanently-unevaluatable), so a
  transient source-cluster outage never becomes a terminal Stalled=True.
- Route every WatchRule compilation through one gated path
  (`watch.CompileWatchRule`) used by both the reconciler and the startup
  bootstrap, so the gate cannot be bypassed on restart.
- Source-scope service (manager-owned per-source-cluster Namespace snapshot,
  refreshed on the existing reconcile cadence) evaluates selector policies and
  enqueues affected rules on a label change; exact-name policies need no
  source-cluster access.
- Carry the effective source namespace through the compiled rule, the watch
  selection, the fingerprint, and the stream-readiness lookup, so an override
  watches — and reports Ready on — the right namespace.
- `SourceNamespaceAuthorized` condition (kstatus-compatible; an additional
  prerequisite of Ready), a ClusterProvider→WatchRules mapper, and a
  source-cluster Namespace channel.

Docs: configuration.md, status-conditions-guide.md, and the design pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: starting to consider alternative approach

* docs: include the deletion modes

* docs: redirect PR 4 to scope-by-kind and align field names

Replace the rejected alternatives and the superseded PR 6 sketch with a single
PR 4 design: ClusterWatchRule becomes cluster-scope-only and WatchRule gains a
per-item rules[].sourceNamespace, with the selector-backed "*" wildcard bounded
by GitTarget.spec.allowedSourceNamespaces.

Rename the ClusterProvider delegation boolean to allowSourceNamespaceOverride.
Once ClusterWatchRule has no source-namespace choice, the WatchRule prefix
disambiguates nothing, and the field is unreleased so the rename needs no shim.

Record the prior art: Flux's AccessFrom ACL independently lands on namespace
labels, ORed selectors, deny-when-absent, empty-selector-matches-everything, and
a typed AccessDeniedError distinct from "cannot evaluate". It never enumerates a
namespace set from a selector, and upstream has been shrinking the field rather
than growing it, which is why the wildcard's invalidation path is the half of
this design with no precedent to lean on.

Keep the shipped-but-unreleased top-level implementation as a baseline record so
the reusable authorization, status, and reactivity work is not rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api)!: move sourceNamespace onto WatchRule rules[], narrow ClusterWatchRule scope

Scope is now carried by the rule KIND. `WatchRule` selects namespaced resources
and each `spec.rules[]` item names the source namespace it watches;
`ClusterWatchRule` selects cluster-scoped resources and has no scope or
namespace choice. PR 4 of docs/design/watchrule-source-namespace/.

- `ResourceRule.sourceNamespace`: omitted (the rule's own namespace), an exact
  DNS-1123 name, or `"*"` for every namespace the GitTarget admits. Validated
  structurally so a malformed namespace is rejected at admission rather than
  resolving to nothing at compile time.
- `EffectiveSourceNamespace()` / `OverridesSourceNamespace()` move down onto the
  item; `"*"` always counts as an override, so it needs the delegation flag even
  against a policy listing only the rule's own namespace.
- `NamespaceMatcher.SelectorAdmits` exposes the selector half alone, for the
  enumeration a wildcard needs (a nil selector admits nothing; an empty one
  admits everything).
- `ClusterProvider.spec.allowWatchRuleSourceNamespaceOverride` renamed to
  `allowSourceNamespaceOverride`. Unreleased, so a plain rename with no shim.

Both superseded fields are RETAINED for one release as loud rejections, not
deleted. Deleting is the silent option: CRD pruning happens on write, so a
re-applied legacy manifest would be accepted with the value dropped and the rule
would quietly change what it mirrors.

- `WatchRule.spec.sourceNamespace` — rejected by an XValidation rule naming its
  replacement; `DeclaresRemovedSourceNamespace()` keeps a stored value readable.
- `ClusterResourceRule.scope` — optional, defaults to `Cluster`, enum narrowed to
  `Cluster`; `DeclaresNamespacedScope()` keys the compile-time refusal on the
  STORED value, never on what the selector happens to resolve.

BREAKING CHANGE: `ClusterWatchRule` is cluster-scope-only and
`WatchRule.spec.sourceNamespace` moved to `spec.rules[].sourceNamespace`. A
namespaced `ClusterWatchRule` becomes a `WatchRule` in the tenant namespace; its
namespaced items become `sourceNamespace: "*"` (or explicit names). A target
that declares no `allowedSourceNamespaces` admits NOTHING to a `"*"` item, so
converting without also declaring that policy narrows production data. See
docs/UPGRADING.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(authz): resolve a WatchRule's whole per-item source scope

`WatchRuleSourceNamespaceAdmitted` decided one namespace for a whole rule.
Scope now lives on `spec.rules[]`, so the gate answers the whole rule at once:
`ResolveWatchRuleSourceScope` returns a `ResolvedSourceScope` carrying one
`SourceNamespaceDecision` per item (its index, what it requested, and the
namespace set it resolved to).

- The three-part ordering — GitTarget policy, then ClusterProvider delegation,
  then the rule's own namespace — is unchanged, factored into an `itemGate` that
  memoises the ClusterProvider verdict. The verdict is identical for every item,
  so it is fetched once per rule rather than once per item.
- A `"*"` item enumerates: the policy's literal `names` are answered locally,
  the selector half is enumerated against the SOURCE cluster's namespaces, and
  the two are unioned. A selector that cannot be evaluated makes the whole item
  Unknown/Unavailable — never a partial set, because a partial set silently
  narrows what a rule mirrors.
- `NoAdmittedSourceNamespaces` is a distinct True reason so a wildcard that
  resolves to nothing reports that it watches nothing instead of looking healthy.
- `SourceNamespaceFieldRemoved` refuses a stored `spec.sourceNamespace`
  terminally.
- `aggregateSourceScope` collapses the items with a stated precedence: denied
  beats unavailable beats unknown beats allowed. One denied item refuses the
  whole rule, and the message names which item. `Fingerprint()` renders the
  resolved sets so callers can key cached work on them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(watch): carry the resolved source scope through compile and planning

A compiled rule used to hold one source namespace for the whole object. It now
holds one resolved namespace SET per item, and every stage from compile to the
watched-type table is keyed on that set.

- `CompiledRule.SourceNamespace` (string) is replaced by
  `CompiledResourceRule.SourceNamespaces []string`. `AddOrUpdateWatchRule` takes
  an index-aligned `sourceNamespaces [][]string` and replaces the whole rule
  atomically, so a rule is never half-recompiled. `GetWatchRule` is added for
  callers that need the compiled form back.
- `GetMatchingRules` filters the event's namespace per ITEM instead of against
  the rule object's own namespace, so one rule can legitimately match events
  from several namespaces.
- Wildcard expansion happens at the SELECTION site, not as a read-side filter:
  `collectWatchRuleSelections` emits one selection per (record x resolved
  namespace). A read-site filter would have to re-resolve the policy on every
  event.
- `watchRuleFingerprint` hashes each item's RESOLVED namespace set. This is the
  hazard the design doc calls out: a `"*"` item's inputs are the GitTarget policy
  and the source cluster's namespaces, neither of which is rule state, so
  byte-identical rule objects must still re-project the table when the policy
  moves. Hashing the resolved set is what makes that happen.
- Retention is keyed by `SourceScopeSpecHash` (the rule's namespace plus every
  item's REQUESTED value). An edit or a reorder discards the grant, so a rule
  cannot inherit a scope it no longer asks for, while an unevaluatable policy
  keeps the last resolved set instead of sweeping the tree.
- `Manager.EnumerateSourceNamespaces` implements the resolver's enumeration half
  against the source cluster; `unusableSnapshot` is shared by both resolver
  entry points.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(watch)!: ClusterWatchRule becomes cluster-scope-only

A ClusterWatchRule now selects cluster-scoped types and nothing else. Three
enforcement points, deliberately redundant because each one covers a path the
others cannot:

- Admission — the narrowed `scope` enum rejects `Namespaced` on write.
- Compile — `CompileClusterWatchRule` is the single shared path for BOTH the
  reconciler and bootstrap. It refuses in a fixed order: first the
  ClusterProvider GitTarget-namespace gate, then `DeclaresNamespacedScope()`.
  The refusal keys on the STORED value, so a rule admitted by an older CRD is
  still refused rather than silently re-interpreted.
- Resolution — `collectClusterWatchRuleSelections` and
  `ResolveClusterWatchRuleResources` always match `ResourceScopeCluster`, so a
  selector like `resources: ["*"]` cannot pull a namespaced type back in.

Bootstrap and the reconciler sharing one path is the point: on restart, rules
are seeded before any status exists, and a bootstrap that did not refuse would
open a namespaced watch the reconciler would only close later.
`ClusterWatchRuleReconciler.gateGitTargetAdmission`/`refuseUnauthorizedGitTarget`
collapse into `gateClusterWatchRule`/`refuseClusterWatchRule`, and the reconciler
no longer calls `AddOrUpdateClusterWatchRule` itself.

Test fixtures move to genuinely cluster-scoped types (namespaces, nodes, CRDs,
storageclasses) because a ClusterWatchRule now resolves nothing else.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(status): aggregate source-namespace authorization per rule item

`SourceNamespaceAuthorized` stays one condition per object, now summarising every
`spec.rules[]` item. `gateSourceNamespace` publishes the aggregate produced by
`ResolveWatchRuleSourceScope`, so the precedence a reader sees is stated once and
applied everywhere: a denied item refuses the whole rule and the message names
which item lost, an unevaluatable policy reports Unknown rather than quietly
watching less, and a wildcard that admits nothing says so.

`StreamSummaryForWatchRule` now reads the COMPILED rule instead of the spec. A
wildcard's expected stream count is not derivable from the spec — it depends on
how many namespaces the item resolved to — so reading the spec left a wildcard
rule permanently short of its expected streams and therefore permanently
not-Ready. An uncompiled rule correspondingly expects zero streams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: scope by kind, plus the PR 4 migration guide

Documents the model and covers the migration end to end in tests.

Docs: `configuration.md`, `architecture.md`, `security-model.md` (a new "Which
namespaces a rule may read from"), `rbac.md` (a new section on `namespaces` RBAC
against a REMOTE source cluster), `status-conditions-guide.md` (the aggregation
order and `NoAdmittedSourceNamespaces`), and a new `UPGRADING.md` entry: the two
removed capabilities, the conversion, a `jq` one-liner to find affected objects,
and the warning that converting without declaring `allowedSourceNamespaces`
narrows what a rule mirrors.

Tests: `superseded_fields_admission_test.go` asserts against the GENERATED CRDs
that `scope: Namespaced` and `spec.sourceNamespace` are rejected, that
`rules[].sourceNamespace` including `"*"` is accepted, that a malformed
namespace is rejected, and that an omitted `scope` defaults. The e2e source-
namespace spec moves to `rules[].sourceNamespace` and gains a wildcard case
asserting against real commits that an admitted namespace arrives and an
unadmitted one never does. The restart fixture becomes a WatchRule and keeps
covering the `apiVersions: ["*"]` startup-snapshot regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): make the wildcard spec prove the wildcard

The spec passed vacuously in one half and could never pass in the other.

- It probed for `<secret>.yaml`, but Secrets are committed SOPS-encrypted as
  `<name>.sops.yaml` and `findFileByBasename` matches basenames exactly. The
  positive assertion could therefore never succeed — and the `Consistently`
  proving an UNADMITTED namespace never arrives was searching for a filename
  that could not exist either way, so it could never fail. That half is the
  load-bearing one, which made the vacuous pass the more dangerous defect.
- The positive assertion created its object in the namespace the PRECEDING
  spec's rule already watches by exact name, so it would have passed even if
  `"*"` expanded to nothing at all.

The granted GitTarget now admits a second namespace that no rule item ever
names, so only a wildcard can reach it, and the probe objects are ConfigMaps so
the assertion does not also ride on the encryption filename convention (the
dedicated Secret-encryption spec already covers that).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(placement): prove namespace-agnosticism before reusing a cohort

Sibling inference could file one namespace's objects under another namespace's
folder, and then collapse an entire resource type into a single file.

Step 2 of inference ("same type, any namespace") is guarded by the P4 rule: a
cohort may only be reused for a namespace the target has never written if it has
proven itself namespace-agnostic. The bundle branch enforced that properly, via
`spansMultipleNamespaces`. The singleton branch only asked `allSameDir`, which is
"trivially true for zero or one element" — so ONE directory holding ONE
namespace passed the guard, even though it is exactly as consistent with a
per-namespace-segmented layout whose second namespace had simply never been
written. Absence of contrary evidence was being read as proof.

The consequence was worse than one misplaced file, because it cascades. Objects
that exist under the same name in every namespace (kube-root-ca.crt) make the
inferred path collide EXACTLY with the first namespace's file, so the second
namespace's object is appended as another document. That file now genuinely
spans two namespaces, so every later object of the type legitimately prefers the
bundle — and the whole type collapses into one file holding several namespaces'
objects. Observed end to end: four ConfigMaps from two namespaces in a single
file, which was the only ConfigMap file in the tree.

Both branches now require the same positive proof. Declining costs nothing: the
canonical path builds the correct namespace segment directly, which is the
layout the target already uses for the namespace it has written.

This is pre-existing and not introduced by the source-namespace work — the write
path is untouched by it, and placement.go last changed in #239. It is reachable
today wherever one GitTarget receives one type from two namespaces. It matters
now because `sourceNamespace: "*"` reaches that shape from a single rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): one GitTarget with two source namespaces keeps them apart

The wildcard spec proved that `"*"` resolves to the admitted set, but every
object it created lived in a single namespace, so it could not have caught a
placement defect that only appears once a target holds a SECOND one.

This adds that case with two ordinary rules and explicit sourceNamespace values
— the same shape a wildcard expands to, reached without the wildcard. Keeping it
wildcard-free is the point: it separates a defect in wildcard EXPANSION from one
in how a target handles more than one source namespace at all, which is what
told us the collapse fixed in the previous commit was not caused by this
feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(watch): key a source-namespace policy on the target's own cluster

A GitTarget's allowedSourceNamespaces is a statement about ITS source cluster,
but both resolver entry points looked the cluster up through
clusterIDForGitTarget, which defaults an undeclared GitTarget to the config
plane. That default is right for the read paths it was written for — a status
read racing the first Declare — and wrong for authorization: the WatchRule
reconciler gates as soon as it has resolved the GitTarget, while Declare is the
GitTarget controller's job, and after a restart the two run concurrently. In
that window a REMOTE target's selector was evaluated against CONFIG-PLANE
Namespace labels, so a namespace could be admitted because a same-named
namespace here happened to carry the right label.

Key on target.SourceCluster() instead. The two can never disagree: the
controller passes exactly that value to DeclareForGitTarget. This also fixes a
latent enqueue miss, since enqueueSourceNamespaceChange matches the armed
cluster id against the same map — an undeclared target armed "" and so could
never match itself.

Seven existing tests had to move their snapshot onto the target's own cluster.
They passed only because the resolver read the wrong one, which is the clearest
evidence the defect was real; the new test uses DIVERGENT labels, because with
both clusters labelled alike it would pass against the bug.

Also bound the refresh loop that maintains those snapshots. It walked clusters
serially, and the Namespace list runs on a config that deliberately carries no
rest.Config.Timeout (its watches must stay open) with only its dial bounded, so
a cluster that accepts the connection and then hangs blocked
ReconcileForRuleChange forever — and with it the watched-type tables and target
watches for every tenant. Each cluster now lists under its own deadline, with
the same bounded concurrency refreshRemoteCatalogsConcurrently already applies
to the catalog for exactly this reason.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(controller): forget a deleted WatchRule's retained source scope

The delete path removed the rule from the store but never called
ForgetSourceScopeGrant, whose own docstring says it runs "on a REFUSAL or a
deletion". Only the refusal half was wired.

The grant is what distinguishes a rule MAINTAINING an already-resolved scope
from one ESTABLISHING its first, and the two branches are deliberately
opposite: maintaining retains the last known-good set and reports Unknown,
while establishing must refuse with a terminal, actionable Stalled. A grant
left behind by a deleted rule is inherited by the next rule created under that
name and spec — a name a different tenant may now own — so an unevaluatable
policy reads as maintaining. The rule then sits Unknown and Reconciling
indefinitely instead of saying why it will never run.

The consequence is narrower than "it keeps running": the maintaining branch
compiles nothing, and the deleted rule was already out of the store, so no
stream resurrects. The damage is a rule stuck silently in progress.

The test recreates a byte-identical rule, which is the case the spec hash
cannot catch — only forgetting the grant can — and was verified to fail against
the unfixed controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api)!: reject a namespace pattern in a policy, and trim CRD descriptions

NamespaceMatcher.names accepted `*`, and expandWildcard copies policy names
verbatim into a rule's resolved scope. The effect is NOT the privilege
escalation it looks like: Kubernetes treats `*` as a LITERAL namespace name — a
list or watch against namespaces/* matches nothing, measured against a live
cluster — and matchesSourceNamespace is exact string equality with no glob. So
a policy of names: ["*"] resolved a `sourceNamespace: "*"` item to a namespace
that cannot exist. The rule reported itself authorized, planned a stream, and
mirrored NOTHING, which is precisely the silent no-op the
NoAdmittedSourceNamespaces reason exists to make loud.

Entries now validate as DNS-1123 labels, which both NamespaceMatcher users
inherit from the shared type, plus a defensive ValidateNames() for policies
already in etcd. The gate treats a failure as Unavailable — a policy that
cannot be evaluated, not a smaller one — so one bad entry condemns the whole
policy rather than silently narrowing to the well-formed remainder. The
"every namespace" declaration is, and remains, `selector: {}`.

BREAKING CHANGE: an allowedSourceNamespaces or allowedNamespaces entry that is
not a valid namespace name is now rejected at admission. `*` was never a
pattern there; it silently matched nothing. Use `selector: {}` to admit every
namespace.

Also trims the CRD descriptions, which had grown to 46 KB of schema text —
allowedSourceNamespaces alone was 47 lines of design rationale in `kubectl
explain`. Descriptions now carry the contract and point at docs/configuration.md
for the full resolution table; rationale moves to comment blocks detached from
the doc comment by a blank line, so it stays in the source and out of the
schema. Total description text drops to 38 KB and nothing of ours exceeds 12
lines. Verified to change descriptions ONLY: placing such a block between a
type's markers and its doc comment silently drops the markers, which cost
ClusterWatchRule its scope=Cluster (the CRD flipped to Namespaced) and both
rule kinds their printer columns before the diff caught it. AGENTS.md records
the convention and that check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: correct the PR 4 migration guidance and record the review follow-ups

Three corrections, all found by reviewing PR 4 against the code:

- The migration warning said a target with no declared policy "admits nothing".
  ReasonLegacySourceNamespace disagrees: a WatchRule whose every item watches
  its own namespace keeps working with no policy and no delegation flag. Stated
  unqualified, UPGRADING.md would tell every existing operator their rules
  break on upgrade, which is false and the more expensive of the two possible
  errors. The denial applies to converted wildcard and cross-namespace items.
- The historical baseline said PR 4 defers selector-backed wildcards. PR 4
  ships them and resolves them from the source-namespace snapshot.
- A facts doc still said ClusterWatchRule watches namespaced resources.

Adds pr4-review-followups.md, the intake for both review passes. Each item was
re-verified against the code before being accepted, and one reported blocker
did not survive that check — its premise (that Kubernetes reads `*` as an
all-namespaces watch) is false, though its recommendation stands for a
different reason. The doc records what landed and which test holds it.

Also carries architecture.md revisions from a parallel review pass:
per-ClusterProvider source contexts, the control-plane/source-cluster split in
the flow diagrams, and the distinction between the object mark-and-sweep and
the Namespace-label listing that maintains selector authorization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api)!: delete WatchRule.spec.sourceNamespace instead of deprecating it

The field was carried as a "loud rejection" — a CEL guard, a
SourceNamespaceFieldRemoved reason, and a compile-path refusal — on the same
reasoning as ClusterResourceRule.scope: deleting a field is the SILENT option,
because CRD pruning happens on write, so a re-applied legacy manifest is
accepted with the value dropped and the rule quietly changes what it mirrors.

That reasoning is about STORED objects, and it does not apply here.
spec.sourceNamespace has never existed on main — not in the Go type, not in the
generated CRD — so no cluster's etcd can hold one and no manifest in the wild
sets one. The only population it protected was this repo's own pre-release
fixtures, which move with the change. Retaining a rejection for a field nobody
can have set is ceremony, and it leaves a phantom in `kubectl explain` that
readers have to be told to ignore.

ClusterResourceRule.scope keeps its rejection: that field DID ship, so a stored
`Namespaced` value is a real object in a real cluster.

Removes the field, the CEL guard, DeclaresRemovedSourceNamespace(), the
SourceNamespaceFieldRemoved reason and its refusal path, and the four tests
that covered them. UPGRADING.md now describes one retained shim rather than
two, with the never-shipped field stated as simply absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <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.

1 participant