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>
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