Skip to content

feat(kustomize)!: attribute overrides with a dyed render, verify by re-render, delete the re-implementation#233

Merged
sunib merged 13 commits into
mainfrom
feat/kustomize-projection-swap
Jul 14, 2026
Merged

feat(kustomize)!: attribute overrides with a dyed render, verify by re-render, delete the re-implementation#233
sunib merged 13 commits into
mainfrom
feat/kustomize-projection-swap

Conversation

@sunib

@sunib sunib commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Finishes the kustomize-renderer workstream. This is the code that decides which file a user's edit gets committed to, and it was the last of the hand-rolled kustomize re-implementation.

renderImage, imageSuppliers, simulateImageRender and isReplicaKind are gone. The projection now inverts against what kustomize actually renders, and against which entry kustomize's own dye came out of.

The honest number first

Production code is net +1011 lines. The brief said "delete ~400 lines"; it deleted them and then added roughly a thousand (the dye, the attribution reader, the oracle, and the writer plumbing to feed it).

The trade is not lines. It is that none of the new code contains a model of kustomize's semantics, so none of it can drift from kustomize. The 400 lines it replaces were a second opinion about what a folder renders to, and that second opinion shipped three bugs and caused two more (#229, #231).

Attribution by dye

A render says web:2.0 but never says who supplied the 2.0, and kustomize keeps no field-level provenance anywhere. So attribution can only be inferred by questioning it: write a unique nonce into every declared override entry, render a second time, and read the nonces off the output. Wherever a dye lands, that entry supplied that field.

The obvious alternative (remove an entry, see what moves) is blind where it matters most. A base at app:v1 under an overlay pinning newTag: v1, which is the state every repo is in the moment a release lands in both places, moves nothing when the entry is removed. So removal concludes the source file owns the tag, writes the user's next tag into the base, and the overlay overrides it straight back. Forever. Removal probes the value, and values collide. A nonce nothing else can produce does not. That case and a tie between two entries are both tests now.

Verification by re-render

simulateImageRender replayed our chain over the images our projection had planned. A check that shares the blind spot of the thing it checks cannot fail where that thing is wrong, so it did not catch a bad attribution, it made one confident.

It is replaced by a real re-render of every root, once per flush, requiring every written document to reproduce its live object while every object the flush did not write stays byte-identical. That second half is what makes it safe for attribution to be a guess. A proposal that fails refuses the flush loudly (GitPathAccepted=False / WriteBoundaryRefused, naming the file and the object) rather than being absorbed into a silent skip.

Bugs deleted (all shipped, all measured; see docs/UPGRADING.md)

  • B1: our image matcher was string equality, kustomize's is a regex over the whole image string. - name: "ap." matches app in kustomize and did not in us, so we wrote the rendered value into the source manifest, which then no longer matched the entry and silently killed the override.
  • B2: isReplicaKind forgot ReplicationController. kustomize's fieldspec has four kinds and says so in its own error message. A scale on an RC was written into the source document, where the transformer overrode it back.
  • B3: kustomize rewrites volumes[].image.reference (measured) and does not rewrite ephemeralContainers (measured). We had it backwards on both.
  • A kustomization.yml (not .yaml) render root got no provenance at all, so its override chain read as empty and governed images were written into source manifests.
  • Deleting a resource left a dangling resources: entry, which kustomize refuses to build over, making the folder undeployable. The entry now comes out with the file, but only when the file is actually gone: a file holding several documents keeps its entry, or the other resources in it would be un-deployed.

These are not fixed, they are deleted, along with the fieldspec we had no business keeping a second opinion about.

The test net was rebuilt, because it had to be

The 12 TestSplitDesired_* tests constructed (gitRaw, desired, overrides) by hand, so they asserted what we believed a folder renders to. That is the belief that was wrong twice in shipped code, and they could not have caught B1. Every case now builds a real tree, renders it with kustomize, reads attribution off a dyed render, and drives the projection with the result.

The corpus differential is replaced by a stronger claim across both corpora: an in-sync folder must project to a complete no-op, which is exactly the property #231's corruption violated. The support baseline is byte-for-byte unchanged: this changes how attribution is derived, not what is supported.

Two bugs an adversarial review found in this diff (8a71c37)

Both were in the new code, both now have a test that fails without the fix.

  1. The write and the intent are different objects. createNew strips metadata.namespace from the bytes it writes when the directory supplies the namespace (correct), but the writers built the verification intent from that same stripped object. The render puts the namespace back, so the oracle refused a flush it had just planned perfectly, losing the new resource and any governed write batched with it. It fired on the most ordinary kustomize layout there is.
  2. A new resource could not reach the oracle at all, so one whose image an entry overrides was committed at a value the folder will never render. It still is not routed (that needs attribution for a document that does not exist yet), but it is now refused loudly instead of committed silently.

The review cleared the dye itself: nonce collision, by-position alignment, and tag/digest routing all held.

Validation

fmtgeneratemanifestsvetlinttesttest-e2e, all green (e2e: 55 passed, 0 failed, including a new spec that deletes a resource from a kustomize overlay and then asks the cluster to apply -k the folder). Corpus baseline regenerated, unchanged.

Design

docs/design/support-boundary/render-attribution.md §3 (the dye), §5 (attribution may be heuristic, verification may not), §6 (the bug ledger), §7 (stages 1-3 shipped; stage 4 is not).

🤖 Generated with Claude Code

sunib and others added 9 commits July 14, 2026 17:03
…atch changed

The patch argued for in patching-kustomize.md has now been built and measured on
feat/build-trace-observer in external-sources/kustomize (290d04199, on top of
upstream 79bb1aa2b): 638 lines added across 11 files, upstream's api suite still
green.

Building it corrected the doc on three points, now recorded in a new section 9:

- the patch should be an *additive* krusty.Options.Observer, not a change to the
  transformer annotation's semantics -- no golden churn, no kubectl output change,
  and it carries the entry index and field paths the annotation structurally
  cannot;
- the zero-cost gate is structural rather than merely safe, and is now asserted by
  TestBuildTraceDoesNotChangeTheBuild;
- two facts only the running code produced: matching a resource across a rename
  needs an EffectiveNamespace-normalised key, and ConfiguredIn is relative to the
  build root rather than the repo root.

The measured trace also settles section 3's headline claim by test rather than by
argument: an idempotent pin produces no event at all, which is the case the dye
was invented for and the one leave-one-out probing cannot answer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderFilesystem asked for buildMetadata only on a root spelled exactly
"kustomization.yaml". Every other part of the analyzer accepts kustomization.yml
too (isKustomizationFile), so a repository whose root is spelled .yml built
happily with no buildMetadata at all: no config.kubernetes.io/origin, no
alpha.config.kubernetes.io/transformations.

Downstream that is not a missing annotation, it is a wrong answer. Every rendered
object came back with an empty OriginPath and an empty transformation list, which
reads as "this document is governed by no kustomization" -- so the images:/replicas:
override chain was empty, and a live tag bump governed by an entry was written into
the SOURCE MANIFEST, where the overlay shadows it straight back on the next render.
Non-converging drift, silently, on every reconcile.

Measured against main by the new test, which fails there with OriginPath="" and an
empty chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
renderRoot becomes renderRootWith over an empty overlay. The new call builds a render
root with in-memory replacements layered over the scanned files, so a question about
what kustomize would do with a DIFFERENT tree goes through the same sandbox, the same
pre-build refusals and the same krusty invocation as the render we ship.

It is the primitive the rest of this workstream is made of. Both remaining questions
are this call with a different overlay: dye an entry and see where the nonce lands
(attribution), or apply a proposed write and see what it renders (verification).

No behaviour change on its own: every existing caller renders with no overlay.

See docs/design/support-boundary/render-attribution.md section 7.

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

simulateImageRender "verified" the projection's inversion by replaying OUR chain over
the images OUR projection had planned. A check that shares the blind spot of the thing
it checks cannot fail where that thing is wrong -- it agrees with itself -- so it did
not catch a wrong attribution, it made one CONFIDENT. Both shipped image bugs (#231's
digest/tag corruption, and the regex-vs-equality matcher) sailed straight through it.

Replace it with the real thing. A flush that routes anything through a kustomization now
re-renders every render root twice -- as it found the tree, and as it would leave it --
and proves both halves of the oracle:

  1. every document the flush writes renders to exactly the live object, and
  2. every object it does not write comes out byte-for-byte unchanged.

(2) is the half that makes it safe for the projection to guess. A kustomization is
shared context: an images: entry edited to converge one Deployment governs every other
object it matches. A proposal that fixes its own target and moves a second object has
written a live value into a file another render root also reads.

It runs ONCE PER FLUSH, not once per resource, and that is correctness rather than
economy. Asked resource by resource, "did anything else move?" is YES for the first of
two Deployments that share an image and are bumped together -- so a per-resource oracle
refuses a write that converges perfectly well. Only the whole batch can see that the
sibling is moving because it, too, is being written to exactly where it now lives. That
is why every document a flush touches declares a WriteIntent, and why a governed one
declares it even when its own bytes did not change.

A refused proposal REFUSES THE FLUSH -- an AcceptanceRefusedError surfacing as
GitPathAccepted=False / WriteBoundaryRefused, naming the file and the object. It is not
absorbed into a silent skip: a write that does not survive the re-render is one the entry
overrides straight back on the next render, so absorbing it would leave the resource
un-mirrored forever with nothing to show for it (render-attribution.md section 7).

Two writes are declared UNCHECKED rather than compared, and it is stated rather than
hidden: a SENSITIVE document (SOPS ciphertext -- no plaintext live object can equal it)
and a bounded FIELD PATCH (a few audited assignments, never a whole object). Both are
still held to disturbing nothing else.

Attribution is untouched here and still comes from renderImage, so no routing changes.
That is the point: the check now cannot share the blind spot of what it checks, which is
what makes the attribution swap that follows safe to make at all.

See docs/design/support-boundary/render-attribution.md section 5 and
docs/design/support-boundary/render-root-scoping.md section 3.

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

Deletes the last of the hand-rolled kustomize: renderImage, imageSuppliers and
isReplicaKind, the ~400 lines the write path used to decide which file an edit belongs
in. The projection now inverts against what kustomize ACTUALLY renders, read off the
renderer, instead of against our re-derivation of it.

HOW IT WORKS. A render says web:2.0; it never says who supplied the 2.0, and kustomize
keeps no field-level provenance anywhere, at any visibility level. So attribution cannot
be READ out of it -- only inferred by questioning it. Write a unique nonce into every
declared override entry, render the root a second time, and read the nonces off the
output: wherever a dye lands, THAT entry supplied THAT field. Two builds per root,
constant in the number of entries.

WHY NOT LEAVE-ONE-OUT. Removing an entry and seeing what moves is blind exactly where it
matters. A base at app:v1 under an overlay pinning newTag: v1 -- the state every repo is
in the moment a release lands in both places -- moves NOTHING when the entry is removed,
so the probe concludes the source file owns the tag, writes the user's next tag into the
base, and the overlay overrides it straight back. Forever. Removal probes the VALUE, and
values collide. A nonce nothing else can produce does not. Both cases are now tests
(idempotent pin, and a tie between two entries).

GUARDRAILS, all measured, none optional:
- the nonce charset is a CORRECTNESS requirement. kustomize does not validate a tag, but
  it MATCHES on the image with a regex, so an out-of-charset dye leaves the image
  un-matchable and every later entry SILENTLY STOPS FIRING. Tag dyes stay in
  [a-zA-Z0-9_.{}-]; digest dyes carry the mandatory sha256: prefix.
- newName is not a pure sink -- it is the join key for every later entry -- so it is dyed
  only when no other entry's name: matches it. And "matches" is asked of kustomize's own
  compiled pattern, not of string equality, because an entry name is a REGEX.
- only a field an entry already DECLARES is dyed; injecting one would fabricate a supplier.
- baseline first, then dye. A dyed build that fails where the real one succeeded means the
  dye hit something that is not a sink, and the answer is then NO ATTRIBUTION -- never a
  fallback to another heuristic. Nothing routes, and the oracle from the previous commit
  adjudicates whatever the source document alone can carry.

THREE SHIPPED BUGS DIE WITH THE CODE THAT CAUSED THEM (see docs/UPGRADING.md):
- B1: our image matcher was string equality; kustomize's is a regex over the whole image
  string. `- name: "ap."` matches `app` in kustomize and did not in us, so we believed the
  folder rendered one thing while it rendered another -- and wrote the difference into the
  source manifest, killing the entry.
- B2: isReplicaKind listed Deployment, ReplicaSet, StatefulSet. kustomize's fieldspec also
  includes ReplicationController -- it says so in its own error message. A scale on an RC
  was written into the source document, where the transformer overrode it back.
- B3: we collected ephemeralContainers (kustomize does NOT rewrite them) and missed
  volumes[].image.reference (kustomize DOES -- measured). Both directions mis-attributed.

None of these are fixed. They are DELETED, along with the fieldspec we had no business
keeping a second opinion about. The dye does not decide what kustomize touches; it reads
where kustomize's own nonces came out.

THE TEST NET IS REBUILT, which the signature change required and the change deserved. The
12 TestSplitDesired_* tests constructed (gitRaw, desired, overrides) BY HAND -- they
asserted what we BELIEVED a folder renders to, which is the belief that was wrong twice in
shipped code, so they could not have caught either bug and did not. Every case now builds a
real tree, renders it with kustomize, reads the attribution off a dyed render, and drives
the projection with the result. TestRenderImage_MatchesKustomizeOnTheHardCases is gone with
its subject; the corpus differential is replaced by a stronger invariant across both
corpora: AN IN-SYNC FOLDER MUST PROJECT TO A COMPLETE NO-OP -- which is exactly the
property #231's digest/tag corruption violated.

One more landmine confirmed while building it: a rendered object is not even a valid
unstructured. kustomize hands numbers back as Go `int`, so DeepCopyJSON PANICS on one
("cannot deep copy int") and NestedInt64 reports found=false. The projection reads replica
counts with a type switch, and the corpus test normalises through JSON.

The corpus support baseline is byte-for-byte unchanged: this changes how attribution is
derived, not which folders are supported.

See docs/design/support-boundary/render-attribution.md sections 3, 6 and 7.

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

UPGRADING.md gets the entry B1, B2 and B3 are owed. All three shipped, and all three had
the same shape: we believed a folder rendered one thing while kustomize rendered another,
and the projection wrote the difference into the user's source manifest as though they had
typed it. They arrive as deletions rather than fixes, which is the point.

It also records the behaviour change that comes with them: a write routed through a
kustomization is now re-rendered before it is committed, and one that does not reproduce the
live object refuses the flush instead of landing.

render-attribution.md marks section 7 done through stage 3, and records the two things
building it corrected:

- B4 is worse than the ledger said. A rendered object is not merely awkward to read a number
  off, it is not a valid unstructured at all: DeepCopyJSON PANICS on the Go int kustomize
  hands back.
- the rename-chain guard has to use kustomize's own compiled regex, not the string equality
  section 3 proposed -- an entry name is a regex, so `mirror/ap.` matches `mirror/app`
  without equalling it, and an equality guard would dye the name, kill the next entry, and
  mis-attribute the tag.

The ledger itself is kept as written rather than deleted, because how each bug was found is
the whole argument for the method: not one came from reading kustomize's source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three docs about the kustomize build-trace fork (patching-kustomize,
generated-repo-map, render-plan-artifact) now live in the fork itself, under
plans/ in ConfigButler/kustomize-tracer, next to the code they describe and the
tests that verify them.

They remain in this repository's history; this removes them going forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
render-attribution.md linked patching-kustomize.md, which now lives in the fork
(ConfigButler/kustomize-tracer, plans/). Left as a plain reference so the link
checker does not chase it into a repository it cannot see.

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

Both were in the new code, and both are proved by a test that fails without the fix.

1. THE WRITE AND THE INTENT ARE DIFFERENT OBJECTS.

createNew strips metadata.namespace out of the bytes it writes when the destination
inherits its namespace from a kustomization's namespace: transformer -- correct, because
the transformer puts it back, and every sibling in that directory follows the convention.
But the three writers then built the WriteIntent from that same stripped object. The render
HAS the namespace, so the intent demanded a render that did not, and the oracle refused a
flush it had just planned perfectly: the new resource was lost, and so was any governed
write batched with it. Resync replays the same batch, so it never recovered.

patchExisting had it right all along -- it strips a LOCAL copy and hands the untouched
event to the intent -- which is exactly the asymmetry that gave the bug away. Intent
recording moves out of the three writers, which only ever see the mutated object, and up to
the callers, which still hold both.

This fires on the most ordinary kustomize layout there is: a per-env folder with a
namespace: and an images: entry, plus any new resource.

2. A NEW RESOURCE COULD NOT REACH THE ORACLE AT ALL.

Its intent was never Governed (Governed came from dm.Overrides -- a PRE-batch store
document, which a new document by definition does not have), so a flush containing only new
resources never turned the oracle on. And createNew does not route a new document's values
onto an entry: it has no override chain yet. So a new Deployment whose image an existing
images: entry matches was committed at its live tag, the entry rewrote it on the next
render, and the mirror asserted a state the folder does not produce -- forever. Silently.
That is precisely the failure this whole path exists to prevent, in the one write path the
oracle could not see.

It still does not route (that needs attribution for a document that does not exist yet), but
it is now put in front of the oracle, which turns a silent non-converging commit into a
reported refusal naming the file and the object. "We cannot express this here" is an answer.
Quietly writing a lie is not.

Turning the oracle on is now a separate question from WriteIntent.Governed, because the two
are not the same claim: Governed additionally ASSERTS the document is rendered, and a new
document is not entitled to that -- its resources: entry can legitimately fail to be added
(a kustomization with no resources: sequence), leaving the file written but outside every
render. Conflating them refused that case too, which the placement suite caught.

Found by an adversarial review of the diff. It also cleared the dye itself: nonce collision,
by-position alignment, and the tag/digest routing all held.

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

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 12 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: 48790368-de4a-4857-879c-b0751191af17

📥 Commits

Reviewing files that changed from the base of the PR and between 8a71c37 and db3216d.

📒 Files selected for processing (11)
  • docs/UPGRADING.md
  • docs/design/support-boundary/next-prompt-simple-patches.md
  • docs/design/support-boundary/render-attribution.md
  • docs/design/support-boundary/render-root-scoping.md
  • internal/git/kustomize_delete_test.go
  • internal/git/manifestedit/kustomization.go
  • internal/git/manifestedit/kustomization_test.go
  • internal/git/plan_flush.go
  • internal/manifestanalyzer/override_chain.go
  • internal/manifestanalyzer/overrides_test.go
  • test/e2e/new_file_placement_e2e_test.go
📝 Walkthrough

Walkthrough

The write path now derives image and replica attribution from real kustomize renders, projects live changes through attributed overrides, and verifies planned batches with before/after renders before writing. Render mismatches become write-boundary refusals.

Changes

Kustomize render attribution

Layer / File(s) Summary
Dyed rendering and attribution
internal/manifestanalyzer/dye.go, internal/manifestanalyzer/overrides_attribution.go, internal/manifestanalyzer/kustomize_render.go, internal/manifestanalyzer/override_chain.go
Override fields are replaced with deterministic nonces, rendered, and mapped back to image and replica suppliers.
Rendered-value projection
internal/manifestanalyzer/overrides_projection.go, internal/manifestanalyzer/overrides_projection_test.go, internal/manifestanalyzer/kustomize_render_test.go
Image and replica edits use rendered attribution, including volume images, regex names, rename chains, and replication controllers.
Plan-then-flush integration
internal/manifestanalyzer/render_verify.go, internal/git/plan_flush.go, internal/git/kustomize_oracle_test.go
Planned writes declare intents and are rejected when post-write kustomize renders introduce unintended changes or fail to reproduce intended objects.
Refusal reporting and documentation
internal/manifestanalyzer/acceptance.go, internal/watch/event_router.go, docs/UPGRADING.md, docs/design/support-boundary/render-attribution.md
Render refusals are classified as write-boundary failures, and the shipped behavior and bug corrections are documented.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Event
  participant PlanFlush
  participant VerifyBatchRenders
  participant Kustomize
  participant Worktree
  Event->>PlanFlush: build write intents
  PlanFlush->>VerifyBatchRenders: verify planned batch
  VerifyBatchRenders->>Kustomize: render before and after trees
  Kustomize-->>VerifyBatchRenders: rendered objects
  VerifyBatchRenders-->>PlanFlush: success or render refusal
  PlanFlush->>Worktree: write files after successful verification
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it does not follow the required PR template sections like Type of Change, Testing, Checklist, or Related Issues. Rewrite the PR description using the repository template and fill in the required sections, especially Type of Change, Testing, Checklist, and Related Issues.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is specific and accurately summarizes the main kustomize attribution and re-render verification change.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/kustomize-projection-swap

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 Jul 14, 2026

Copy link
Copy Markdown

Registering a new resource in a kustomization's resources: list was only half the job.
Nothing ever took the entry back out, so deleting the manifest left an entry naming a file
that no longer exists -- and kustomize refuses to build over that:

    accumulating resources ... '/scan/bundle.yaml' doesn't exist

The folder became undeployable, and the GitTarget was refused on the next reconcile.

RemoveKustomizationResource is AppendKustomizationResource read backwards, with the same
all-or-nothing semantics: idempotent, and a kustomization it cannot edit (multi-document,
no resources: sequence, unparseable) is skipped with a diagnostic rather than having
structure invented for it.

THE ENTRY COMES OUT ONLY WHEN THE FILE ACTUALLY GOES. A file holding several documents
survives the deletion of one of them, and its resources: entry must survive with it --
pulling it would un-deploy every OTHER resource in that file, which nobody asked to touch.
That invariant is the first test, and it is one that bites: dropping the entry on any
delete rather than only on the last one fails it immediately.

Three unit tests at the writer (the multi-document guard, the last-document removal, and a
delete batched with a governed write -- which without this could not build the counterfactual
tree at all, so the oracle refused the whole flush), three at the editor, and an e2e that
closes the round trip against the real controller: the placement spec creates a ConfigMap in
a kustomize overlay and asserts the entry appears; the new spec deletes it, asserts the entry
is gone, and then asks the cluster to `apply -k` the folder -- which is the whole point, and
which a dangling entry fails.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
internal/manifestanalyzer/render_verify.go (1)

220-223: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add a fast path to avoid serialization overhead for unchanged objects.

sameObject is called frequently (for every object in every render root). In the !intended (blast-radius) case, both before.Object and after.Object come directly from Kustomize renders and share the same internal numeric types (e.g., int). This means reflect.DeepEqual will succeed for unmodified objects.

Adding a reflect.DeepEqual fast path avoids the allocation and serialization overhead of json.Marshal for the vast majority of objects across a repository.

⚡ Proposed fast path optimization

Make sure to add "reflect" to the import block.

 func sameObject(a, b *unstructured.Unstructured) bool {
 	if a == nil || b == nil {
 		return a == b
 	}
+	if reflect.DeepEqual(a.Object, b.Object) {
+		return true
+	}
 	left, err := json.Marshal(a.Object)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/manifestanalyzer/render_verify.go` around lines 220 - 223, Update
sameObject to return immediately when reflect.DeepEqual reports the two non-nil
objects equal, before JSON serialization; add the reflect import and preserve
the existing serialization comparison for objects that are not deeply equal.
internal/git/plan_flush.go (1)

706-857: 🚀 Performance & Scalability | 🔵 Trivial

Render precondition re-renders every kustomize root in the repo on any governed write.

renderPrecondition calls VerifyBatchRenders with the whole-tree before/after file sets whenever putToKustomize is true, and VerifyBatchRenders iterates renderTargets(parseKustomizations(after)) — i.e. every render root found anywhere, not just the ones touched by this batch. For a repository with many independent kustomize roots, a single governed edit in one folder pays the render cost of every other root too. This is presumably an accepted correctness/perf tradeoff given the design's "verification may not be heuristic" stance, but worth keeping in mind as the fleet of managed roots grows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/git/plan_flush.go` around lines 706 - 857, Limit renderPrecondition
verification to kustomize roots affected by the current write batch instead of
passing the entire repository to VerifyBatchRenders for every governed change.
Update the render-target selection around writeBatch.renderPrecondition and the
VerifyBatchRenders call so independent untouched roots are excluded, while
preserving complete before/after validation for all roots touched by the batch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/design/support-boundary/render-attribution.md`:
- Around line 9-11: Update the blockquote in the section containing the
renderRootWith status note by replacing the bare blank separator with a
blockquote-prefixed blank line, preserving the quoted formatting used by the
surrounding separators.

In `@internal/git/plan_flush.go`:
- Around line 980-985: Update applyDelete around the wb.intend call to set
wb.putToKustomize for deletes that require kustomize rendering, ensuring
renderPrecondition invokes VerifyBatchRenders even when deletion is the only
kustomize-relevant batch step and dangling resources entries are rejected.

In `@internal/manifestanalyzer/override_chain.go`:
- Around line 233-255: Update record to set anyOverrides based on ov != nil or
rd != nil, including the initial assignment and subsequent accumulation so
rendered-only overrides are recognized. Add a test in overrides_test.go covering
ov == nil with non-nil RenderedOverrides and verifying the resulting assignment
is treated as ambiguous when roots differ.

---

Nitpick comments:
In `@internal/git/plan_flush.go`:
- Around line 706-857: Limit renderPrecondition verification to kustomize roots
affected by the current write batch instead of passing the entire repository to
VerifyBatchRenders for every governed change. Update the render-target selection
around writeBatch.renderPrecondition and the VerifyBatchRenders call so
independent untouched roots are excluded, while preserving complete before/after
validation for all roots touched by the batch.

In `@internal/manifestanalyzer/render_verify.go`:
- Around line 220-223: Update sameObject to return immediately when
reflect.DeepEqual reports the two non-nil objects equal, before JSON
serialization; add the reflect import and preserve the existing serialization
comparison for objects that are not deeply equal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8d974081-344f-49ca-a0bf-70e3a3d5ecf1

📥 Commits

Reviewing files that changed from the base of the PR and between 960e3bd and 8a71c37.

📒 Files selected for processing (22)
  • docs/UPGRADING.md
  • docs/design/support-boundary/generated-repo-map.md
  • docs/design/support-boundary/patching-kustomize.md
  • docs/design/support-boundary/render-attribution.md
  • internal/git/kustomize_oracle_test.go
  • internal/git/plan_flush.go
  • internal/manifestanalyzer/acceptance.go
  • internal/manifestanalyzer/analyzer_test.go
  • internal/manifestanalyzer/dye.go
  • internal/manifestanalyzer/dye_test.go
  • internal/manifestanalyzer/kustomize_render.go
  • internal/manifestanalyzer/kustomize_render_hostile_test.go
  • internal/manifestanalyzer/kustomize_render_semantics_test.go
  • internal/manifestanalyzer/kustomize_render_test.go
  • internal/manifestanalyzer/override_chain.go
  • internal/manifestanalyzer/overrides.go
  • internal/manifestanalyzer/overrides_attribution.go
  • internal/manifestanalyzer/overrides_projection.go
  • internal/manifestanalyzer/overrides_projection_test.go
  • internal/manifestanalyzer/render_verify.go
  • internal/manifestanalyzer/store.go
  • internal/watch/event_router.go
💤 Files with no reviewable changes (2)
  • docs/design/support-boundary/generated-repo-map.md
  • docs/design/support-boundary/patching-kustomize.md

Comment thread docs/design/support-boundary/render-attribution.md Outdated
Comment thread internal/git/plan_flush.go
Comment thread internal/manifestanalyzer/override_chain.go
render-root-scoping.md still listed renderImage and isReplicaKind as "still there" with
their divergences described in the present tense, and still planned the oracle as future
work whose differential test was to be a comparison against simulateImageRender. All three
are deleted, so those rows and that step now describe code that does not exist.

render-attribution.md loses its "one step left" framing and the long status block: the
workstream is done, so the doc is a record of how attribution was decided, not a plan.

No content is removed that still says something true. The bug ledger stays exactly as
written, because how each bug was found is the argument for the method.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sunib sunib changed the title feat(kustomize)!: the projection swap — attribute by dyed render, verify by re-render, delete the re-implementation feat(kustomize)!: attribute overrides with a dyed render, verify by re-render, delete the re-implementation Jul 14, 2026
sunib and others added 2 commits July 14, 2026 19:09
…k both questions in anyOverrides

Three findings from the review. One was right, one was wrong but pointed at a real gap
next to it, and one was a lint break.

1. anyOverrides only asked whether a CHAIN existed, never whether an ATTRIBUTION did.

A document can render an image with no images:/replicas: entry governing it: the chain is
nil, the attribution is not. Two roots disagreeing only there would diverge in the
fingerprint while ambiguous() stayed false, so the document would be silently un-routed with
no diagnostic and no fan-in refusal. Routing was already safe (record() nils the attribution
on any divergence), but the guard was not.

The divergence is not constructible today -- a nil chain means no image transformer ran,
which means the rendered value IS the source value, which is the same in every root -- so
this changes no current behaviour. It is fixed because the invariant is not obvious enough
to leave resting on that argument. Test added, as asked.

2. The claim was that applyDelete never sets putToKustomize, so a delete could commit a
dangling resources: entry. That is not so: dropKustomizationResource sets the flag BEFORE
attempting the removal, so a removal that fails still puts the flush to the oracle. Measured:
forcing RemoveKustomizationResource to fail refuses the flush with kustomize's own
"'/scan/bundle.yaml' doesn't exist".

But the instinct was right about the case beside it. A delete that does NOT empty the file
never set the flag at all, so the rule was "verified only when our own bookkeeping says the
file went" rather than "verified because it is inside a render root". The whole point of the
oracle is that it does not take our word for anything, so the rule is now uniform: any delete
of a document whose file is named in a resources: list goes to the oracle. The lookup that
decides that is now one function shared with the entry removal, so the two cannot drift.

3. MD028: a bare blank line ended the blockquote instead of continuing it.

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

patches is the biggest refusal cause in the layout corpus (8, against namePrefix 6 and
configMapGenerator 6), it refuses the whole GitTarget rather than the edit, and the dye
shipped in #233 is the mechanism its milestone was waiting for.

Carries the four things worth measuring rather than re-deriving, the load-bearing one being
that patches run BEFORE the images:/replicas: transformers and the transformers WIN -- so a
patch that sets a field an entry also governs is dead text. That is the fact that makes
'just edit the patch' wrong, and the reason the dye should be extended into the patch rather
than the patch being read and reasoned about.

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