Skip to content

feat(api)!: move commit batching onto GitTarget, delete allowedSourceNamespaces, and make sourceNamespace "*" cluster-wide - #330

Merged
sunib merged 9 commits into
mainfrom
feat/gittarget-api-breaking-wave
Sep 2, 2026
Merged

feat(api)!: move commit batching onto GitTarget, delete allowedSourceNamespaces, and make sourceNamespace "*" cluster-wide#330
sunib merged 9 commits into
mainfrom
feat/gittarget-api-breaking-wave

Conversation

@sunib

@sunib sunib commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

PR 3 of the GitTarget API track: the breaking wave, and the only PR in the queue
that costs consumers a coordinated bump. One bump, one release — 0.43.0.

Specified by docs/design/gittarget-api-wave.md
and docs/design/source-scope-simplification.md;
sequenced by docs/design/build-order.md.

The API comes out smaller than it went in: -829 lines of non-test Go, -607
of tests and -72 of generated CRD, against +501 of documentation.

The envtest that gated the planning, and its answer

gittarget-api-wave.md § "The envtest that has to run before any of this is
planned" asked whether the controller can write a status update onto a stored
ClusterWatchRule whose spec.rules[].scope is Namespaced, or whether the
apiserver rejects it 422. If a refused object cannot publish status, the object
that most needs to explain itself is the one that cannot.

It can. Measured on Kubernetes 1.36 and on 1.31 with
CRDValidationRatcheting explicitly on and explicitly off — the status
subresource does not re-validate spec at all, so the property never depended on
ratcheting. A create carrying the same value is still rejected. No enum
widening, no fallback. (Also recorded: from 1.33 the gate cannot be turned off —
kube-apiserver refuses to start on CRDValidationRatcheting=false.)

Pinned as a regression test, because ClusterWatchRule.spec.rules[].scope still
depends on it.

What changed

B4 — commit batching and message templates move onto GitTarget

GitProvider.spec.push.commitWindow  ->  GitTarget.spec.commit.window
GitProvider.spec.commit.message     ->  GitTarget.spec.commit.message

GitProvider is the connection: a URL, a credential, the branches it will
accept. How a folder's writes are batched and phrased describes the folder, and
two GitTargets sharing one GitProvider had no way to disagree about either.
Grouped under spec.commit rather than landing as two top-level fields — the
move is breaking either way, so the grouping is free here and would cost a bump
in any later release. commit.committer and commit.signing stay on
GitProvider; both describe the identity that talks to the remote.

The window resolves per open window, not per worker. A window is bound to
exactly one GitTarget by construction — it finalizes the moment the target
changes — so a (provider, branch) worker serving two targets honours two
cadences. Template validation moved with the field onto the GitTarget's
Validated gate and now covers the window string too.

spec.commit.message keeps its three templates rather than collapsing to the
single template the design's example YAML shows. That example uses a
{{ .Summary }} variable which exists in none of them; the three render
genuinely different things with different variable sets, so one template could
only have been a fourth thing. Flagged as a judgement call.

The source-scope deletion

GitTarget.spec.allowedSourceNamespaces               removed
ClusterProvider.spec.allowSourceNamespaceOverride -> spec.allowAnySourceNamespace
ClusterProvider.spec.allowedNamespaces            -> spec.accessFrom
sourceNamespace: "*"   every namespace the GitTarget admits
                    -> every namespace the credential can read, one cluster-wide watch

Four parts, shipped together because "*" was defined in terms of the field
being deleted.

allowedSourceNamespaces presented itself as a destination policy and could not
be one: targetRef is namespace-local, providerRef is namespace-local, and
spec.path is immutable, so the chain from a Git folder back to the object that
fills it never leaves one namespace. Its selector half was evaluated against
Namespace labels in another cluster, and that single choice produced the
three-valued verdict, the SourceScopeUnavailable degradation path, five
condition reasons, the establishing/maintaining retention memory, and the
operator's need for source-cluster Namespace get/list/watch. All of it goes.
The operator now reads no Namespace objects in a source cluster at all, and
internal/authz's verdict collapses from three-valued to two.

accessFrom stays, renamed. It is the one boundary available nowhere else:
source RBAC bounds what a credential may read and cannot express which
control-plane tenant may wield it.

"*" is now one cluster-wide list and watch, refused while
allowAnySourceNamespace is false. The plumbing existed — CellKey documents
the empty-namespace case and both openTargetWatch and openTargetList already
branch on it — so this is a deletion in the compile path plus one router fix. A
cluster-wide cell stays a peer of a named-namespace cell, never a
replacement, because each rule carries its own operations filter; CellKey's
doc comment records the bug from the previous attempt.

Superseded fields are deleted outright, not retained and refused

This reversed mid-review, deliberately. The wave was built first with the repo's
retained-and-refused convention — keep the field, reject it, for one release —
and that is now removed in favour of deleting the fields and putting the
migration in a document.

The reason is reach, not taste: one known consumer, and 17 release-asset
downloads across the last twelve releases
. At that size the refusal machinery
(457 lines in four files plus wiring at four call sites) is more surface than the
population it protects.

ClusterWatchRule.spec.rules[].scope is untouched and stays
retained-and-refused. It is a narrowed enum rather than a removal, its refusal is
three lines, and it is pre-existing.

What this trades away, since the trade is the point. An object still carrying
an old spelling is accepted with the value pruned, and nothing warns. Four of the
five prune fail-closed and loud — a missing accessFrom admits nothing, a
dropped delegation stalls every cross-namespace rule. One fails open and
quiet
: dropping allowedSourceNamespaces widens a "*" rule to every
namespace its credential can read, with no condition to notice.
docs/UPGRADING.md says so in those words, and leads with a pre-upgrade
inventory because of it.

Migration

docs/UPGRADING.md gains a "Safe upgrade order" checklist, and the ordering
is measured rather than assumed: an envtest confirmed a value stored under the
old schema is invisible immediately once the field leaves the structural
schema — not after the next write, straight away. So the inventory cannot be
taken afterwards, and the guide says to recover from Git or an etcd backup if you
upgrade first. Every documented command was run against a live cluster.

Two breaking semantic changes are stated rather than papered over:

  • allowAnySourceNamespace: false is not exactly today's posture. A declared
    allowedSourceNamespaces was exhaustive with no self-namespace exception, so
    it could deny a rule's own namespace. The new default matches the no-policy
    path, which is what a default install ran.
  • "*" widens. It keeps its spelling and changes its meaning, so it gets its
    own paragraph rather than a shim — there is nothing to rename. UPGRADING.md
    carries a jq one-liner to find the affected rules.

Source-side label selectors are lost, with no replacement. That is the real
capability cost, accepted rather than overlooked.

New reference: docs/facts/crd-upgrade-strategies.md

The API-server behaviour this wave established is written up as a facts page,
because CRDs are this product's configuration surface and the cost of an API
change is not the code — it is what happens to objects that already exist, almost
none of which is visible from the Go types.

It carries a decision matrix keyed on the question that actually decides the
strategy: when the field is pruned, does the object do more or less? Less
is an outage — loud and recoverable. More is a silent widening. Four measured
facts, each naming its test and separated from the inferred ones, plus why a
conversion webhook could have automated only two of this wave's five changes
(conversion is per-object: it cannot move a field to another kind, invent intent
for a redefined value, or restore a deleted capability).

Not in scope

  • The SelfSubjectAccessReview pass — out by specification, not by trimming:
    source-scope-simplification.md § "The one thing to build" puts it outside this
    PR because it is additive and should not widen the release that costs a bump.
  • The riders (#5 asserted CommitRequest.spec.author, the CommitRequest
    lifecycle hole, meta.LocalObjectReference for the six reference shapes, the
    TooManyStreams cap) — trimmed under the wave document's own rule, since
    nothing depends on them and each is under-specified or substantial in its own
    right. TooManyStreams in particular is explicitly "not planned before the
    * change lands", and that change is in this PR.
  • The default ClusterProvider message (B6) — already shipped. The
    existing ClusterProviderNotFound message already names both fixes. Confirmed,
    not assumed.
  • v1alpha4. Stays v1alpha3. A version bump automates nothing here, and
    serving two versions would reintroduce the silent prune. v1alpha4 is for
    sweeping accumulated residue, not for carrying one change.

What PR 2 left that this touches

The one-source-namespace rule needed no rewrite, and each site was read rather
than taken on trust:

Test coverage

test/e2e/source_namespace_e2e_test.go is rewritten rather than deleted:
the placement claim (Git placement follows the mirrored object's own namespace,
never the WatchRule's), the refusal path, and a wildcard spec that asserts the
widening — two namespaces that nothing names both arrive, which is what the
redefinition means and what no API shape shows. The planner and stream-summary
suites under internal/watch/ are likewise kept and adapted.

An envtest pins that the replacement spellings round-trip rather than being
silently pruned — a typo in accessFrom or spec.commit is the failure this
design can no longer catch any other way.

Validation

task lint, task test (77.0%, ratchet holding) and task test-e2e (84 passed,
0 failed) all pass locally; CI is green on all 16 checks including the six e2e
legs. The API-comment placement check from AGENTS.md was run — regenerated with
controller-gen into a scratch dir and diffed against config/crd/bases with
every description stripped: only descriptions moved, no marker displaced.

Two findings worth flagging, because neither was reachable from lint or the unit
suite:

  • The first local e2e run failed 42 specs and caught that
    allowSourceNamespaceOverride carried +kubebuilder:default=false, so the
    apiserver had written it into every stored ClusterProvider — including the
    chart-owned default one, on installs that never used the feature. Verified
    against a live cluster that kubectl apply cannot remove a server-defaulted
    field. Under the retained-and-refused design that was an unfixable upgrade.
  • A fresh-cluster e2e is not an upgrade test. Both findings needed a cluster
    carrying real upgrade residue; CI seeds from current manifests and would not
    have reproduced either.

sunib and others added 3 commits September 1, 2026 18:27
…ec.commit

GitProvider is the connection: a URL, a credential, the branches it will accept.
How a folder's writes are batched into commits and how those commits are phrased
are properties of the folder, and two GitTargets sharing one GitProvider had no
way to disagree about either.

  GitProvider.spec.push.commitWindow  -> GitTarget.spec.commit.window
  GitProvider.spec.commit.message     -> GitTarget.spec.commit.message

They are grouped under spec.commit rather than landing as two top-level fields:
the move is breaking either way, so the grouping is free in this release and
would cost a bump in any later one. commit.committer and commit.signing stay on
GitProvider, because both describe the identity that talks to the remote.

Both fields are retained in the GitProvider schema and REFUSED rather than
deleted. CRD pruning happens on write, so a deleted field would be dropped from
a re-applied manifest with no error at all, silently changing a folder's commit
cadence. A field-level CEL rule rejects a new apply naming the replacement, and
a stored object is refused by the reconciler (Stalled, CommitFieldsRelocated)
rather than half-honoured.

The commit window is resolved per open window rather than once per worker: a
window is bound to exactly one GitTarget by construction, since it finalizes the
moment the target changes. Template validation moves with the field, onto the
GitTarget's Validated gate, and now covers the window string too.

Also pins the apiserver property the whole loud-rejection pattern rests on: a
status update onto an object whose STORED spec no longer validates is accepted,
so a refused object can still explain itself. Measured on 1.31 with
CRDValidationRatcheting explicitly on and off, and on the version this module
builds against - the status subresource does not re-validate spec at all, so the
pattern does not depend on ratcheting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r grants, and make sourceNamespace "*" cluster-wide

Four changes that ship together, because the last one is defined in terms of
the first.

  GitTarget.spec.allowedSourceNamespaces               removed
  ClusterProvider.spec.allowSourceNamespaceOverride -> spec.allowAnySourceNamespace
  ClusterProvider.spec.allowedNamespaces            -> spec.accessFrom
  sourceNamespace: "*"  every namespace the GitTarget admits
                     -> every namespace the credential can read, one cluster-wide watch

allowedSourceNamespaces presented itself as a destination policy and could not
be one: a WatchRule's targetRef is namespace-local, a GitTarget's providerRef is
namespace-local, and spec.path is immutable, so the chain from a Git folder back
to the object that fills it never leaves one namespace. Whoever can create a
WatchRule there could already write into that folder. What it actually bounded
was which source namespaces the folder's own tenant may READ, which for a
credential-scoped provider restates the credential in the one place that cannot
revoke it.

Its selector half was evaluated against Namespace labels in ANOTHER cluster, and
that single choice produced the three-valued verdict, the SourceScopeUnavailable
degradation path, five condition reasons, the establishing/maintaining retention
memory, and the operator's need for source-cluster Namespace get/list/watch. All
of it goes: ~2,900 lines net, and the operator now reads no Namespace objects in
a source cluster at all.

accessFrom STAYS, renamed. It is the one boundary available nowhere else: source
RBAC bounds what a credential may read and cannot express which control-plane
tenant may wield it. Its selector reads control-cluster labels, locally, with no
cross-cluster call, so both halves keep working.

"*" had to be redefined rather than left alone: it was defined in terms of the
deleted field, and RBAC cannot supply the missing definition (it answers "may I
watch X in namespace Y", never "which namespaces may I watch"). It is now one
cluster-wide list and watch, refused outright while allowAnySourceNamespace is
false. The plumbing existed - CellKey documents the empty-namespace case and both
openTargetWatch and openTargetList already branch on it - so this is a deletion
in the compile path plus one router fix, not new machinery. A cluster-wide cell
stays a PEER of a named-namespace cell, never a replacement, because each rule
carries its own operations filter.

Every removed or renamed field is retained in the schema and REFUSED with a
message naming its replacement, verified against the generated CRDs. Pruning
happens on write, so deleting them would drop the value from a re-applied
manifest with no error - and for allowSourceNamespaceOverride: true that would
silently revoke a delegation.

Two breaking semantic changes are stated rather than papered over in
docs/UPGRADING.md: a declared allowedSourceNamespaces could deny a rule's own
namespace, so allowAnySourceNamespace: false is not exactly today's posture; and
"*" widens for anyone who had declared a policy. Source-side label selectors are
LOST with no replacement, and that is the real capability cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es, and scope the wildcard spec

Two fixtures still set GitProvider.spec.push.commitWindow, which this release
refuses: the playground GitProvider and the two demo-only manifests. The refusal
worked as designed and the e2e run caught them, so the values move to
GitTarget.spec.commit.window where they now live.

The wildcard e2e spec asked for too much. `sourceNamespace: "*"` now mirrors
every namespace the credential can read, so watching configmaps AND secrets
cluster-wide filed every service-account token in a live k3d cluster into the
fixture repository, and the render-fidelity gate had 42 secrets' worth of scopes
to settle inside a 90s window. The spec now watches ONE type, and asserts the
gate plus the two files that prove the reach - not whole-target Ready, which
over a cluster-wide mirror is a throughput property rather than a statement
about what "*" means.

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

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 113 files, which is 13 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 7c2ca016-2a56-44fa-885c-8e47cc9f3695

📥 Commits

Reviewing files that changed from the base of the PR and between 18d4ac7 and 16d1209.

📒 Files selected for processing (113)
  • .coverage-baseline
  • .markdownlint-cli2.jsonc
  • README.md
  • api/v1alpha3/audit_route_test.go
  • api/v1alpha3/clusterprovider_types.go
  • api/v1alpha3/clusterwatchrule_types.go
  • api/v1alpha3/gitprovider_types.go
  • api/v1alpha3/gittarget_types.go
  • api/v1alpha3/helpers_test.go
  • api/v1alpha3/namespace_matcher.go
  • api/v1alpha3/namespace_matcher_test.go
  • api/v1alpha3/shared_types.go
  • api/v1alpha3/watchrule_types.go
  • api/v1alpha3/zz_generated.deepcopy.go
  • charts/gitops-reverser/README.md
  • charts/gitops-reverser/templates/clusterprovider-default.yaml
  • charts/gitops-reverser/templates/quickstart.yaml
  • charts/gitops-reverser/values.schema.json
  • charts/gitops-reverser/values.yaml
  • config/clusterprovider-default.yaml
  • config/crd/bases/configbutler.ai_clusterproviders.yaml
  • config/crd/bases/configbutler.ai_clusterwatchrules.yaml
  • config/crd/bases/configbutler.ai_gitproviders.yaml
  • config/crd/bases/configbutler.ai_gittargets.yaml
  • config/crd/bases/configbutler.ai_watchrules.yaml
  • config/samples/clusterprovider.yaml
  • config/samples/quickstart-gitprovider.yaml
  • config/samples/quickstart-gittarget.yaml
  • docs/INDEX.md
  • docs/UPGRADING.md
  • docs/architecture.md
  • docs/attribution-setup-guide.md
  • docs/components.md
  • docs/configuration.md
  • docs/design/build-order.md
  • docs/design/gittarget-api-wave.md
  • docs/design/source-scope-simplification.md
  • docs/facts/crd-upgrade-strategies.md
  • docs/layout/model.md
  • docs/layout/shapes/1-flat-serialized/config/clusterprovider.yaml
  • docs/layout/shapes/1-flat-serialized/config/gittarget.yaml
  • docs/layout/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
  • docs/layout/shapes/3-tree-serialized/config/clusterprovider.yaml
  • docs/layout/shapes/3-tree-serialized/config/gittarget.yaml
  • docs/layout/specific-examples/prerequisites/README.md
  • docs/rbac.md
  • docs/security-model.md
  • docs/spec/commit-window-refactor.md
  • docs/spec/status-conditions-guide.md
  • docs/spec/where-validation-lives.md
  • internal/authz/clusterprovider_admission.go
  • internal/authz/clusterprovider_admission_test.go
  • internal/authz/source_namespace.go
  • internal/authz/source_namespace_test.go
  • internal/controller/clusterprovider_controller_test.go
  • internal/controller/clusterwatchrule_admission_test.go
  • internal/controller/clusterwatchrule_controller.go
  • internal/controller/constants.go
  • internal/controller/gitprovider_controller_test.go
  • internal/controller/gitprovider_controller_unit_test.go
  • internal/controller/gittarget_commit_validation.go
  • internal/controller/gittarget_commit_validation_test.go
  • internal/controller/gittarget_controller.go
  • internal/controller/gittarget_source_cluster.go
  • internal/controller/gittarget_source_cluster_test.go
  • internal/controller/stored_superseded_value_status_test.go
  • internal/controller/suite_test.go
  • internal/controller/superseded_fields_admission_test.go
  • internal/controller/watchrule_controller.go
  • internal/controller/watchrule_kstatus_test.go
  • internal/controller/watchrule_source_namespace.go
  • internal/controller/watchrule_source_namespace_test.go
  • internal/git/branch_worker.go
  • internal/git/branch_worker_loop_test.go
  • internal/git/branch_worker_test.go
  • internal/git/commit_test.go
  • internal/git/namespace_policy.go
  • internal/git/pending_writes.go
  • internal/git/resync_flush.go
  • internal/git/source_namespaces.go
  • internal/git/types.go
  • internal/manifestanalyzer/source_namespace_fence.go
  • internal/rulestore/store.go
  • internal/watch/bootstrap.go
  • internal/watch/bootstrap_admission_test.go
  • internal/watch/manager.go
  • internal/watch/manager_startup_test.go
  • internal/watch/owner.go
  • internal/watch/source_namespace_planning_test.go
  • internal/watch/source_namespace_scope.go
  • internal/watch/source_namespace_stream_summary_test.go
  • internal/watch/source_namespace_test.go
  • internal/watch/watched_type_resolver.go
  • internal/watch/watchrule_compile.go
  • test/e2e/audit_route_attribution_e2e_test.go
  • test/e2e/commit_request_e2e_test.go
  • test/e2e/commit_window_batching_e2e_test.go
  • test/e2e/e2e_test.go
  • test/e2e/helpers.go
  • test/e2e/prune_mode_e2e_test.go
  • test/e2e/quickstart_framework_e2e_test.go
  • test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml
  • test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml
  • test/e2e/signing_e2e_test.go
  • test/e2e/source_cluster_e2e_test.go
  • test/e2e/source_namespace_e2e_test.go
  • test/e2e/suspend_e2e_test.go
  • test/e2e/templates/gitprovider-signing.tmpl
  • test/e2e/templates/gitprovider.tmpl
  • test/e2e/templates/gittarget.tmpl
  • test/e2e/templates/manager/gittarget-prune.tmpl
  • test/playground/config/gitprovider.yaml
  • test/playground/config/gittarget.yaml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing

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.

…nswer

source-scope-simplification.md and gittarget-api-wave.md were written as
proposals and are now descriptions of what shipped, except the additive
SelfSubjectAccessReview pass and the trimmed riders. build-order.md's PR 3 row
and INDEX.md follow.

The wave's step-1 envtest has an answer, and it belongs at the top of the page
that asked the question: a status update onto a stored object whose spec no
longer validates is ACCEPTED, so the fallback that page describes was not needed.

The "*" section stays the definition of record; the reading it called "decided
for the wave" is now the shipped one, and the superseded reading is kept because
the migration note and several code comments still refer to it.

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

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.48819% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/controller/gittarget_controller.go 25.0% 6 Missing ⚠️
internal/git/pending_writes.go 91.6% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

sunib and others added 5 commits September 1, 2026 20:37
…erage baseline for the deletion

The coverage ratchet failed in CI at 76.8% against a 77.4% baseline. The cause is
the deletion, not a testing regression, and it is measurable rather than a
matter of opinion: the two source-scope files this wave removes outright carried
336/371 statements covered (90.6%) on main, and removing exactly those from
main's own coverage profile takes the total from 77.22% to 76.96% by itself. The
files that shrank rather than vanished were covered similarly well. Deleting
well-covered code lowers a whole-tree average without anything becoming less
tested.

The rewritten gate is at or near 100%: ResolveWatchRuleSourceScope, decide,
overrideDelegated, aggregateSourceScope and summariseAdmitted are all fully
covered, as are the new commit-config validation and the per-target commit
window.

Before lowering anything, the two real gaps this left are closed and one piece of
dead surface is removed:

- SourceNamespaceDecision.Admitted had no callers once the verdict became
  two-valued. Deleted rather than left as an untested accessor.
- NamespacesFor's bounds check is pinned. It is indexed by an item's position in
  spec.rules, and the resolved scope and the spec are two objects, so a caller
  reading them apart must get nil rather than a panic.
- The aggregate message's deduplication is pinned, along with the cluster-wide
  cell being spelled out instead of rendered as the empty string an operator
  would read as a missing value.

The baseline moves to the measured 76.9%. CI's 76.8% sits inside the 0.5%
tolerance, and the gate ratchets up again from here on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y at admission

Review found that the retained-and-refused pattern was only half implemented.
Admission rejected each removed or renamed field, which covers the WRITE path and
nothing else: an object written by an earlier release keeps its value in etcd and
is never re-admitted, so admission alone missed exactly the population that needs
telling.

Three holes, all confirmed by reading the code rather than assumed:

- A stored GitTarget.spec.allowedSourceNamespaces was fully inert. Nothing
  outside the type declaration read it, so it stayed on the object reading like a
  bound on which source namespaces reach a folder while enforcing nothing - and
  the moment its ClusterProvider is migrated, a `sourceNamespace: "*"` rule under
  it widens from that declared set to every namespace the credential can read.
- A stored GitProvider.spec.push / spec.commit.message marked the provider
  Stalled and stopped nothing. validateProviderAndBranch checks only existence
  and allowedBranches, and GitProviderReady is published AFTER worker wiring and
  DeclareForGitTarget, so the target kept writing - at the default cadence and
  wording, since neither value is read any more. UPGRADING claimed "nothing is
  silently reinterpreted"; that was false as shipped.
- Not in the review, found while fixing it: a stored ClusterProvider.
  spec.allowedNamespaces leaves accessFrom absent, which is deny-by-default, so
  every GitTarget through it failed with a message blaming its namespace rather
  than naming the rename.

One shared authz.SupersededFieldRefusal now serves all three kinds under one
reason, SupersededFieldStored, wired at the three places that make it a gate
rather than a remark: the GitTarget's Validated gate (ahead of provider/branch
validation, so it returns before worker wiring and DeclareForGitTarget), both
compile paths (which is what closes the bootstrap window - bootstrap seeds the
store before the first reconcile on every restart), and GitTargetAdmitted, ahead
of the accessFrom policy read. The GitProvider reconciler's private copy of the
message is folded into the shared helper; it would have drifted.

A DEFAULTED field is refused only at its meaningful value, and e2e is what
caught it. allowSourceNamespaceOverride carried +kubebuilder:default=false, so
the apiserver wrote it into EVERY stored ClusterProvider - the chart-owned
"default" one included, on installs that never used the feature. Refusing every
non-nil value refused all of them, and re-applying the clean manifest does not
remove it, because a server-defaulted field was never in the user's manifest to
remove. Verified against a live cluster, not argued. That is an upgrade nobody
could complete, so only `true` is refused, which is also what this repo's own
DeclaresNamespacedScope already does with its retained field.

Docs, which the review was right to call out separately: docs/spec is declared
binding on the code, and two spec pages still described the old behaviour.
commit-window-refactor.md named GitProvider.spec.push.commitWindow as the
user-facing control and carried a "why it lives there" rationale this release
reverses; status-conditions-guide.md documented three condition reasons that no
longer exist and a three-valued precedence table. architecture.md still said a
wildcard expands to one stream per admitted namespace in the WatchedTypeTable
section - the one component the change is about. UPGRADING now states the
operational consequence it was missing: an unmigrated object stops writing until
it is edited, which is deliberate, because a folder committing on settings
nobody chose is worse than one that has visibly stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ining and refusing them

Product decision: with one known consumer and 17 release-asset downloads across
the last twelve releases, ~700 lines of refusal machinery is more surface than
the population it protects. The fields go, and docs/UPGRADING.md carries the
migration instead.

Removed from the schema entirely, rather than retained-and-refused:

  GitTarget.spec.allowedSourceNamespaces
  GitProvider.spec.push  (and the PushStrategy type)
  GitProvider.spec.commit.message
  ClusterProvider.spec.allowedNamespaces
  ClusterProvider.spec.allowSourceNamespaceOverride

With them go authz.SupersededFieldRefusal, the GitTarget refusal helper, the
wiring in both compile paths, the GitTarget Validated gate, the GitProvider
reconciler check and the ClusterProvider admission check: 1,132 lines deleted
against 124 added.

ClusterWatchRule.spec.rules[].scope is UNTOUCHED and stays retained-and-refused.
It is a narrowed enum rather than a removal, its refusal is three lines, and the
envtest pinning the apiserver property it depends on stays with it.

What this trades away, stated plainly because the trade is the point: an object
still carrying an old spelling is now accepted with the value pruned, and nothing
warns. Three of the five prune fail-closed and loud (a missing accessFrom admits
nothing; a dropped delegation stalls every cross-namespace rule). One fails OPEN
and quiet: dropping allowedSourceNamespaces widens a `sourceNamespace: "*"` rule
to every namespace its credential can read. UPGRADING says so in those words.

The migration guide therefore leads with a pre-upgrade inventory, and that
ordering is measured rather than assumed: an envtest confirmed a value stored
under the old schema is invisible IMMEDIATELY once the field leaves the
structural schema, not merely after the next write. So the inventory cannot be
taken afterwards, and the guide says to recover from Git or an etcd backup if you
upgrade first. Both documented commands were run against a live cluster.

Also addresses two review points:

- The coverage baseline moves to the measured 77.0%, up from 76.9%. Restoring
  77.4% as asked would fail CI: when local measured 76.9% CI measured 76.8%, and
  77.4% belongs to a tree that still had ~1,400 statements of very-well-covered
  source-scope code. Reaching it now would mean writing tests for unrelated
  pre-existing gaps (syncWithRemote, AuthFromSecretData, convergesAs), which is
  not this PR's business.
- The ClusterProvider status/contract mismatch is resolved by the deletion: there
  is no longer a refusal for its conditions to fail to report.

The envtest that pinned the removed refusals is replaced by one that pins the
REPLACEMENT spellings round-trip rather than being silently pruned, which is the
failure this design can no longer catch any other way.

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

CRDs are this product's configuration surface, so the cost of an API change is
not the code - it is what happens to objects that already exist, and almost none
of that is visible from the Go types. This wave established several of those
facts the expensive way; they belong somewhere more durable than a PR thread.

The page leads with a decision matrix keyed on the question that actually decides
the strategy and is the one most often skipped: when the field is pruned, does
the object do MORE or LESS? Less is an outage - loud and recoverable. More is a
silent widening. Of the five fields this wave removed, four pruned fail-closed
and exactly one failed open, so one row carried nearly all the risk.

Measured facts, each naming the test that produced it, and separated from the
inferred ones so a reader knows which is which:

- A status update does not re-validate spec, on 1.36 and on 1.31 with
  CRDValidationRatcheting explicitly on AND off. Without this, retain-and-refuse
  is unusable: you could reject a value and never report the rejection on the
  object carrying it.
- The ratcheting gate cannot be disabled from 1.33; kube-apiserver refuses to
  start. Test the "gate off" case on <=1.32 or not at all.
- A field removed from the structural schema stops being SERVED immediately, not
  after the next write. This is the opposite of the common assumption and it puts
  a hard ordering constraint on every migration guide: the inventory must be
  taken before the new CRDs are applied.
- A previously-defaulted field cannot be removed by `kubectl apply`, verified
  against a live cluster. So refusing every non-nil value of one refuses every
  object that has ever existed, and the operator cannot fix it. The rule that
  follows - refuse a defaulted field only at its meaningful value - is what
  ClusterWatchRuleSpec.DeclaresNamespacedScope was already doing.

It also records why a conversion webhook was not the answer here (conversion is
per-object, so it cannot move a field to another kind, invent intent for a
redefined value, or restore a deleted capability - two of this wave's five
changes were mechanically convertible, both trivial renames), and the test for
whether a version bump is worth it: read the migration guide you would write
either way, and if it does not get shorter, the bump is not paying for anything.

The closing section states when the delete-and-document choice expires - a second
consumer, an uncontactable one, or a field that fails open on prune - so the
reversal is not cargo-culted at a size where it stops being safe.

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

Three corrections to the upgrade guide, which is now the only migration guard and
therefore has to be right. All three were verified against the code and the
commands against a live cluster.

Step 4 gave the wrong reason and the wrong order. It said to apply GitProvider
first "because a GitTarget reads its commit settings from it" - which is exactly
backwards after this wave, since commitWindowFor and the message resolution both
read the TARGET now. That was leftover reasoning from the old object model, and
following it would leave targets on defaults until their own change landed. There
is no ordering requirement at all: nothing here reads its migrated value from
another object, so the advice is now one sync, which makes the stall a single
reconcile.

Step 3 claimed "mirrors do not pause", which was too broad. It is true for a
pruned GitTarget or GitProvider field and false for a pruned ClusterProvider
policy: an absent accessFrom is deny-by-default, so GitTargetAdmitted denies,
Validated goes False and the target stops writing. The two outcomes are opposites
and the guide now says so - and says which one to hurry for, because the one that
keeps running is the one not telling you anything.

Step 5 verified only the migrated window, so a pruned message template could pass
unnoticed - a target with no templates commits perfectly happily under the
built-in ones. It now reads back the whole spec.commit. Checked on a live
cluster: a migrated target renders map[message:map[...] window:30s] and a
half-migrated one renders map[window:0s], which is the difference the step tells
you to look for.

Step 5's framing needed the same correction as step 3 while I was there: Ready
does catch a pruned ClusterProvider policy, and does not catch a pruned
GitTarget or GitProvider field. Only the second needs the value read back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sunib
sunib merged commit f994a90 into main Sep 2, 2026
19 checks passed
@sunib
sunib deleted the feat/gittarget-api-breaking-wave branch September 2, 2026 09:27
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