Skip to content

feat(api): explicit access grants between providers, branches and their users - #441

Merged
sunib merged 34 commits into
mainfrom
feat/provider-access-rules
Oct 24, 2026
Merged

sunib merged 34 commits into
mainfrom
feat/provider-access-rules

Conversation

@sunib

@sunib sunib commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

BEGIN_COMMIT_OVERRIDE
feat(api)!: explicit access grants between providers, branches and their users (#441)

BREAKING CHANGE: who may use a GitProvider, a GitBranch or a KubeProvider is a list of grants on that object: GitProvider.spec.allowedGitBranches (replaces spec.allowedBranches), GitBranch.spec.allowedGitTargets, and KubeProvider.spec.allowedGitTargets and spec.allowedApplierClaims (replace ClusterProvider spec.accessFrom and spec.allowCrossNamespace). Each grant names its consumers in a required consumerNamespaces block (from: Same, Names, Selector or All). On a GitBranch or a KubeProvider an omitted list admits the object's own namespace; a GitProvider's list has no default, so omitted it admits no GitBranch. See docs/UPGRADING.md, "Grants say who may use a provider or a branch".
END_COMMIT_OVERRIDE

Implements docs/future/provider-access-rules.md: who may use a provider or a branch, and for what, before accessFrom ships in a release.

The contract

Consent lives on the object that lends something, in a list named allowed plus the referring kind:

Grant list Admits Scope
GitProvider.spec.allowedGitBranches GitBranches claiming through it branchPatterns (required)
GitBranch.spec.allowedGitTargets GitTargets writing through it none
KubeProvider.spec.allowedGitTargets GitTargets mirroring through it sourceNamespaces and/or clusterResources
KubeProvider.spec.allowedApplierClaims applier claims holding an applier through it applierNamespaces (required)
  • Every grant has a required consumerNamespaces block with a required enum from: Same | Names | Selector | All (Gateway API's shape), and the scope beside it; see The grant shape. CEL refuses names without Names, selector without Selector, selector: {}, a consumer variable or * in names, empty scope lists, and a KubeProvider grant that grants nothing. No booleans.
  • Nothing is automatic, the own namespace included. Omitted or empty admits nobody; grants combine by union; an explicit scope is the whole scope.
  • Branch patterns: whole-name match, * crosses /, {consumer.namespace} and {consumer.name} must each be a whole path segment (team-a never matches teams/team-ab/...), and {consumer.name} needs {consumer.namespace} beside it. CEL names an unknown variable.
  • clusterResources is decided on the ClusterWatchRule's text: a wildcard item needs "*", named pairs are checked pair by pair, an omitted apiGroups is checked by its discovered group. One forbidden type refuses the rule (ClusterResourceNotAllowed). The planner also filters by the compiled grant, so discovery never widens it.
  • Removed outright: GitProvider.accessFrom/allowedBranches, KubeProvider.accessFrom/allowCrossNamespace, NamespaceMatcher, LegacySourceNamespace. GitBranch.spec.gitProviderRef is namespaced.

The grant shape

allowedGitBranches:                 # GitProvider
  - consumerNamespaces:
      from: Selector
      selector: {matchLabels: {platform.example.com/tenant: "true"}}
    branchPatterns: ["teams/{consumer.namespace}/*"]
allowedGitTargets:                  # GitBranch
  - consumerNamespaces: {from: Same}
allowedGitTargets:                  # KubeProvider
  - consumerNamespaces: {from: Names, names: [team-a]}
    sourceNamespaces: ["{consumer.namespace}"]
allowedApplierClaims:               # KubeProvider
  - consumerNamespaces: {from: Names, names: [team-a]}
    applierNamespaces: [flux-system]

Why a nested "who" block, on all four lists. A flat names didn't say what it named: on a GitBranch, {from: Names, names: [team-b]} reads just as well as "the GitTarget called team-b". In both KubeProvider lists, the control-cluster "who" also sat flat beside a scope in the provider's cluster, and only the field prefixes told the two clusters apart. Under consumerNamespaces, names can only mean namespace names, and the fields beside the block are the scope. Fixing one list would have left three with the old shape. branches had the same flaw: inside allowedGitBranches it reads as GitBranch names. Its values are patterns, so it is now branchPatterns, the term the type (BranchPattern), the CEL refusals and the docs already used.

Gateway API comparison. The block is Listener.allowedRoutes' shape: a namespaces block (from, selector) with a restriction (kinds) beside it. Three deliberate differences:

  • The block is named for the consumer. A bare namespaces would sit next to sourceNamespaces without saying which cluster it means. consumerNamespaces also pairs with {consumer.namespace}.
  • Nothing defaults. Gateway API defaults an omitted namespaces, and an omitted from, to Same. Here consumerNamespaces is +required on every grant type and from is +required inside it, with no default for either. A missing block, consumerNamespaces: null, consumerNamespaces: {} and a block with names but no from are all refused, and the admission suite checks those four cases on each list. This goes one step further in the direction Gateway API itself took: its newer allowedListeners defaults to None.
  • A selector beside the wrong from is refused, where Gateway API ignores it. from: Names is our addition; Gateway API has none.

A list of grants combined by union stays, like ReferenceGrant's from list. A Listener has a single allowedRoutes, so "team B or every tenant" could only be expressed through labels. ReferenceGrant's from/to wrappers are not adopted: the list name already says the consumer kind, and the holder is the "to" side.

What a scope means (the CRD descriptions now say this). The sourceNamespaces doc comment, which is the kubectl explain text, said only that "*" "also allows" a "*" rule. The code does more than that. For sourceNamespaces, and likewise for clusterResources and applierNamespaces:

  • The scope is a union. It is the union over every grant that admits the consumer, with variables expanded. No single grant restricts on its own.
  • It is a permission, not a selection. Listing a namespace watches nothing until a WatchRule names it, and an omitted sourceNamespace (the rule's own namespace) needs it too.
  • "*" is not a filter. A sourceNamespace: "*" item needs "*" in at least one admitting grant. It is refused, never narrowed to the listed namespaces: the check is a string match. With "*" granted, the item is one cluster-wide watch, bounded only by the credential's RBAC.
  • One item outside the scope refuses the whole rule (SourceNamespaceNotAllowed, Stalled=True, streams stopped). Nothing is trimmed.
  • It is checked when the rule reconciles, never at admission. The rule recovers once a grant covers it, and stops if a grant is narrowed.
  • It bounds reading, never Git placement.

applierNamespaces also loses "source-cluster" from its description: the same KubeProvider can be a GitTarget's source cluster and an applier claim's management cluster.

Code. NamespaceGrant is now ConsumerNamespaces and carries its five CEL rules once. Each grant type holds it as a named field, because an inline-embedded struct's type-level rules do not carry over. Grant types are named <Holder><Consumer>Grant (GitProviderGitBranchGrant, GitBranchGitTargetGrant, KubeProviderGitTargetGrant, KubeProviderApplierClaimGrant), so the two allowedGitTargets types no longer share a name. The scope types are KubeProviderGitTargetScope and KubeProviderApplierClaimScope. The evaluator keeps its shape. admitAll, describeAdmitted and expandScope keep their signatures, sourcesOf is now consumersOf, and each adapter is a one-line accessor. Refusal reasons (BranchNotAllowed included), condition names and evaluation are unchanged.

Considered and not done. Renaming sourceNamespaces/applierNamespaces to a bare namespaces: a reader could not tell what it meant. A selector on the scope side (sourceNamespaceSelector): it would be an additive sibling field later, with its own revocation and label-read contract. A "*" that narrows instead of refusing: that changes the meaning of a value users already write.

Behavior

  • authz: one decision per act; ApplierAdmitted no longer skips the claim's own check when claim and target share a provider. Refusals name the grant list and what it admits.
  • Claim and worker: GitBranch admission is checked before the push-session proof. A revoked GitBranch takes the finalize path (Release, retire worker, drain) but keeps its finalizer; the successor is re-checked before it is woken. WorkerRequest carries the provider's resolved namespace, so a cross-namespace GitBranch pushes with its provider's credential and receiver routing reaches it.
  • Watches: GitBranches indexed by resolved provider key; Namespace watches on the GitBranch and WatchRule controllers.
  • Applier hand-back: an explicit denial takes the existing retire path; a read error keeps the hold; a regrant during the hand-back lets it finish, then re-adopts.
  • Chart: local grants nothing by default; quickstart.enabled grants exactly its namespace; values-playground.yaml grants every namespace its own. No default grants clusterResources.

For the reviewer

  • GitBranch.spec.gitProviderRef immutability compares the reference as written: CEL cannot read metadata.namespace, and there is no mutating webhook to default it, so spelling out the own namespace where it was omitted is refused. Same as GitTarget.spec.kubeProviderRef today.
  • A denial-driven hand-back is its own InControl reason, Revoking, so a regrant finishes it before re-adopting.
  • e2e: the suite grants its access on the install's local provider in setup (the chart grants nothing); a new spec covers a tenant claiming teams/<team>/live, sharing it, and being refused main and another team's branch.

🤖 Generated with Claude Code

sunib and others added 9 commits October 23, 2026 15:47
Replace accessFrom and allowCrossNamespace with one grant list per
referring kind, kept on the object being referenced:
GitProvider.gitBranchesFrom, GitBranch.gitTargetsFrom, and
KubeProvider.gitTargetsFrom and applierClaimsFrom. None of these names
is released yet, so the rename is free until the next release.

The plan carries the grant contract and its semantics table, the
allowedBranches comparison (K: keep it as the limit on every claim is
recommended), the phasing, revocation, and the limit no grant on one
provider can close. Also record what the one-writer-per-branch plan
delivered and what it still misses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fold in the second review of the access plan:

- Nobody gets access automatically, the referenced object's own
  namespace included; an omitted or empty grant list grants nothing.
- Scopes are required and exact: branches on gitBranchesFrom,
  sourceNamespaces and applierNamespaces on KubeProvider. {namespace}
  is the referring object's namespace everywhere.
- allowedBranches goes; the claim policy lives in gitBranchesFrom.
- Claiming a branch delegates it, sharing included: the GitBranch's
  gitTargetsFrom decides who writes, with no provider-level writer
  policy beside it.
- Cluster-scoped mirroring needs clusterScoped on the grant.
- One PR, no phases. Refusal messages name the grant that applies.

Add the whole picture: every reference in the API, what it lends, and
which grant covers it. References that stay in their namespace and
lend nothing need none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fold in the third review of the access plan:

- The chart's local provider grants nothing by default.
  quickstart.enabled grants exactly the quickstart namespace, and a
  shipped values-playground.yaml grants every namespace its own, as
  today. No default grants clusterScoped.
- Branch patterns get a small language of our own instead of
  filepath.Match: whole-name matching, * crosses /, {namespace} must be
  a whole path segment, and no escaping. The syntax and the error
  style follow placement templates.
- Revocation promises to stop new work once the change is observed,
  defines "accepted" for the drain, and hands a held applier back on
  an explicit denial.
- The four open questions are decided, grant lists are atomic, and
  priority is the oldest authorized candidate, then UID.

Add a checklist of the tests the PR must add.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fold in the fourth review of the access plan, read against Flux's own
sources:

- Say plainly that explicit same-namespace consent is stricter than
  Flux's ACL, and why.
- Every operation runs as the provider's identity: what a source or
  applier grant permits, and what bounds it instead (the provider's
  read access, the applier's service account; targetNamespace only
  places).
- Add a platform and tenant example: the platform's reconciler owns
  providers, grants, labels and RBAC; a tenant's Flux reconciler may
  only create the objects that use them, which is what keeps the
  grants and the one-provider limit holding.
- The applier hand-back promises retries and visible outcomes, keeps
  a prior suspension, and takes the existing retire path with its
  deadline.
- Controller refusals name API fields, not Helm values.
- Test recovery as well as refusal, including after a restart.

Shorten the interim workaround section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every grant gets a required from: Same, Names, Selector or All, after
Gateway API's allowedRoutes.namespaces.from. Same admits the object's
own namespace without naming it, so a kustomize overlay that moves the
object keeps working; All makes admitting everyone visible. One grant
has one source, and the old names-OR-selector rule goes with
NamespaceMatcher.

clusterScoped: true becomes clusterResources, a list like
sourceNamespaces, so it can narrow to resource names. names may not
hold {namespace}, which would admit every referrer.

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

With a from in every grant, gitBranchesFrom read "from" twice.
"allowed" plus the referring kind, then from, is Gateway API's own
allowedRoutes.namespaces.from, where from was borrowed. It also carries
on from the removed allowedBranches, which now says who may claim too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fold in the fifth review of the access plan:

- Open with a walkthrough: four objects (GitProvider, KubeProvider,
  GitBranch, GitTarget), an allowed/refused table, and the difference
  between from: Same (the object holding the grant) and {namespace}
  (the object asking). The reference inventory and "why now" move
  later.
- Define clusterResources identity: resource.group, bare names for the
  core group only, every served version, exact names or "*". It is
  decided on the ClusterWatchRule's text, as sourceNamespace "*" is, so
  discovery never widens a narrow grant; one forbidden type refuses
  the whole rule.
- Refuse from: Selector with an empty selector; everyone takes
  from: All. Describe the Gateway API borrowing accurately (there from
  is optional, defaulting to Same).
- Fix the ownership summary: branch owners manage writer grants.
- Drop the "settled" claim from the introduction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitProvider.spec.allowedGitBranches, GitBranch.spec.allowedGitTargets,
KubeProvider.spec.allowedGitTargets and KubeProvider.spec.allowedApplierClaims
are lists of grants. Each grant has a required enum from (Same, Names,
Selector, All); names goes only with Names and selector only with Selector,
and an empty selector is refused. Scopes are lists: branches, sourceNamespaces
and clusterResources, applierNamespaces.

Branch patterns are matched against the whole branch name; * crosses /, and
{namespace} must be a whole path segment. CEL checks the grammar and names an
unknown variable.

GitBranch.spec.gitProviderRef becomes a NamespacedObjectReference. The old
fields stay until the admission switch in the next commit.

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

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 200 files, which is 100 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
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e7954f9d-b97c-41a7-9b50-806ef4710f37
Commits

Reviewing files that changed from the base of the PR and between 7c84db7 and 7f41206.

Files selected for processing: 200
  • Taskfile-build.yml
  • api/v1alpha3/access_grants.go
  • api/v1alpha3/access_grants_test.go
  • api/v1alpha3/argocdclaim_types.go
  • api/v1alpha3/audit_route_test.go
  • api/v1alpha3/fluxclaim_types.go
  • api/v1alpha3/gitbranch_types.go
  • api/v1alpha3/gitprovider_types.go
  • api/v1alpha3/gittarget_types.go
  • api/v1alpha3/helpers_test.go
  • api/v1alpha3/kubeprovider_types.go
  • api/v1alpha3/kubeprovider_types_test.go
  • api/v1alpha3/namespace_matcher.go
  • api/v1alpha3/namespace_matcher_test.go
  • api/v1alpha3/watchrule_types.go
  • api/v1alpha3/zz_generated.deepcopy.go
  • charts/gitops-reverser/README.md
  • charts/gitops-reverser/templates/applier-rbac.yaml
  • charts/gitops-reverser/templates/kubeprovider.yaml
  • charts/gitops-reverser/templates/quickstart.yaml
  • charts/gitops-reverser/values-playground.yaml
  • charts/gitops-reverser/values.schema.json
  • charts/gitops-reverser/values.yaml
  • cmd/main.go
  • cmd/main_chart_kubeprovider_test.go
  • cmd/main_chart_receiver_test.go
  • config/crd/bases/configbutler.ai_argocdclaims.yaml
  • config/crd/bases/configbutler.ai_fluxclaims.yaml
  • config/crd/bases/configbutler.ai_gitbranches.yaml
  • config/crd/bases/configbutler.ai_gitproviders.yaml
  • config/crd/bases/configbutler.ai_gittargets.yaml
  • config/crd/bases/configbutler.ai_kubeproviders.yaml
  • config/crd/bases/configbutler.ai_watchrules.yaml
  • config/kubeprovider-local.yaml
  • config/rbac/flux-applier-role.yaml
  • config/samples/README.md
  • config/samples/argocdclaim.yaml
  • config/samples/fluxclaim.yaml
  • config/samples/kubeprovider.yaml
  • config/samples/quickstart-gitbranch.yaml
  • config/samples/quickstart-gitprovider.yaml
  • config/samples/quickstart-gittarget.yaml
  • docs/INDEX.md
  • docs/TODO.md
  • docs/UPGRADING.md
  • docs/architecture.md
  • docs/attribution-setup-guide.md
  • docs/azure-devops-getting-started.md
  • docs/bi-directional-argocd.md
  • docs/bi-directional-flux.md
  • docs/bi-directional.md
  • docs/components.md
  • docs/configuration.md
  • docs/definitions.md
  • docs/demo/commit-window.md
  • docs/design/apply-trigger.md
  • docs/design/gitbranch-and-kubeprovider-plan.md
  • docs/design/one-writer-per-branch-plan.md
  • docs/finished/provider-access-rules.md
  • docs/future/ha-gittarget-distribution-plan.md
  • docs/layout/model.md
  • docs/quickstart.md
  • docs/rbac.md
  • docs/security-model.md
  • docs/spec/e2e-bi-directional-corner.md
  • docs/spec/status-conditions-guide.md
  • docs/spec/where-validation-lives.md
  • hack/crdfields/fields.yaml
  • hack/e2e/load-image.sh
  • internal/applier/applier.go
  • internal/applier/dispatcher.go
  • internal/applier/dispatcher_test.go
  • internal/applier/flux.go
  • internal/applier/flux_applied_by.go
  • internal/applier/flux_applied_by_test.go
  • internal/applier/holder_test.go
  • internal/authz/gitbranch_admission_test.go
  • internal/authz/kubeprovider_admission.go
  • internal/authz/kubeprovider_admission_test.go
  • internal/authz/source_namespace.go
  • internal/authz/source_namespace_test.go
  • internal/controller/access_grants_admission_test.go
  • internal/controller/applierclaim_controller.go
  • internal/controller/applierclaim_controller_test.go
  • internal/controller/applierclaim_holder_test.go
  • internal/controller/applierclaim_revocation_test.go
  • internal/controller/applierclaim_rule_test.go
  • internal/controller/applierclaim_schema_test.go
  • internal/controller/argocdclaim_hold.go
  • internal/controller/argocdclaim_hold_test.go
  • internal/controller/clusterwatchrule_admission_test.go
  • internal/controller/clusterwatchrule_controller.go
  • internal/controller/constants.go
  • internal/controller/dependency_watches.go
  • internal/controller/dependency_watches_test.go
  • internal/controller/fluxclaim_hold.go
  • internal/controller/fluxclaim_hold_test.go
  • internal/controller/fluxclaim_suspend_apiserver_test.go
  • internal/controller/git_branch_helpers_test.go
  • internal/controller/gitbranch_access_test.go
  • internal/controller/gitbranch_controller.go
  • internal/controller/gitbranch_controller_test.go
  • internal/controller/gitprovider_controller.go
  • internal/controller/gitprovider_controller_test.go
  • internal/controller/gitprovider_immutability_test.go
  • internal/controller/gittarget_controller.go
  • internal/controller/gittarget_controller_test.go
  • internal/controller/gittarget_gitbranch_test.go
  • internal/controller/gittarget_immutability_test.go
  • internal/controller/gittarget_placement_validation_test.go
  • internal/controller/gittarget_shared_branch_test.go
  • internal/controller/gittarget_source_cluster.go
  • internal/controller/gittarget_source_cluster_test.go
  • internal/controller/grant_helpers_test.go
  • internal/controller/suite_test.go
  • internal/controller/superseded_fields_admission_test.go
  • internal/controller/watchrule_controller.go
  • internal/controller/watchrule_controller_test.go
  • internal/controller/watchrule_kstatus_test.go
  • internal/controller/watchrule_source_namespace.go
  • internal/controller/watchrule_source_namespace_test.go
  • internal/controller/wiring/access_grants_wiring_test.go
  • internal/controller/wiring/readiness_wiring_test.go
  • internal/git/branch_owner.go
  • internal/git/branch_owner_test.go
  • internal/git/claim_helpers_test.go
  • internal/git/git_branch_ref_test.go
  • internal/git/worker_branch_owner_test.go
  • internal/git/worker_manager.go
  • internal/rulestore/object_selector_test.go
  • internal/rulestore/store.go
  • internal/rulestore/store_test.go
  • internal/watch/bootstrap.go
  • internal/watch/bootstrap_admission_test.go
  • internal/watch/cluster_context.go
  • internal/watch/cluster_resources_test.go
  • internal/watch/config_plane_split_review_fixes_test.go
  • internal/watch/event_router_test.go
  • internal/watch/git_branch_helpers_test.go
  • internal/watch/manager_startup_test.go
  • internal/watch/object_selector_test.go
  • internal/watch/source_namespace_test.go
  • internal/watch/watched_type_helpers_test.go
  • internal/watch/watched_type_metrics_test.go
  • internal/watch/watched_type_resolver.go
  • internal/watch/watched_type_resolver_test.go
  • internal/watch/watchrule_compile.go
  • internal/webhook/receiver_test.go
  • test/e2e/Taskfile-e2e.yml
  • test/e2e/applier_claim.go
  • test/e2e/argocd_bi_directional_e2e_test.go
  • test/e2e/audit_route_attribution_e2e_test.go
  • test/e2e/bi_directional_echoes_e2e_test.go
  • test/e2e/bi_directional_push_race_e2e_test.go
  • test/e2e/bi_directional_restart_e2e_test.go
  • test/e2e/bi_directional_timing_e2e_test.go
  • test/e2e/e2e_suite_test.go
  • test/e2e/flux_applier_e2e_test.go
  • test/e2e/flux_bi_directional_e2e_test.go
  • test/e2e/flux_restart_recovery_e2e_test.go
  • test/e2e/gitprovider_validation_e2e_test.go
  • test/e2e/helm-values-suite-grants.yaml
  • test/e2e/helpers.go
  • test/e2e/namespace.go
  • test/e2e/new_branch_parent_e2e_test.go
  • test/e2e/provider_access_e2e_test.go
  • test/e2e/refusal_reapply_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/shared_gitbranch_e2e_test.go
  • test/e2e/source_cluster_e2e_test.go
  • test/e2e/source_namespace_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/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml
  • test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
  • test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml
  • test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml
  • test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml
  • test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml
  • test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml
  • test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml
  • test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml
  • test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml
  • test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml
  • test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml
  • test/fixtures/layout-corpus/specific-examples/prerequisites/README.md
  • test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml
  • test/mutationlab/e2e/configmap_scenarios_test.go
  • test/mutationlab/e2e/crd_conversion_test.go
  • test/mutationlab/e2e/workload_scenarios_test.go
  • 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
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 and others added 10 commits October 23, 2026 17:28
Every admission now reads the grant list on the referenced object, and nothing
is automatic, the own namespace included:

- a GitTarget mirrors through a KubeProvider while its allowedGitTargets admit
  the target's namespace, and writes through a GitBranch while that branch's
  allowedGitTargets admit it;
- a GitBranch claims through a GitProvider while its allowedGitBranches admit
  the namespace and a pattern matches the branch (BranchNotAllowed otherwise),
  checked before the push-session proof;
- an applier claim holds an applier while allowedApplierClaims admit it and
  the applier's namespace is in applierNamespaces. ApplierAdmitted no longer
  skips that check when the claim and the target share a provider;
- a WatchRule item watches only namespaces in the grant's sourceNamespaces,
  the rule's own included. LegacySourceNamespace is gone.

Refusals name the grant list and what it admits. GitProvider.accessFrom and
allowedBranches, KubeProvider.accessFrom and allowCrossNamespace, and
NamespaceMatcher are removed. Samples, guides and fixtures carry grants.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A GitBranch that loses its grant takes the deletion path without being
deleted: it releases its claim (Release, not a failed proof, so it stops being
a candidate), retires its worker, which pushes or refuses what it held, keeps
its finalizer and reports Stalled with the grant's reason. The successor is
checked again before it is woken, so one edit that revokes several candidates
leaves none of them holding the branch.

WorkerRequest carries the GitProvider with its namespace resolved, so a
GitBranch claiming through a provider in another namespace pushes with that
provider's credential, and receiver routing reaches its worker. The
branch-target metric names the same provider.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GitBranches are indexed by the GitProvider they claim through, its namespace
resolved, so a provider's grant edit reaches GitBranches in every namespace,
and their GitTargets with them. The GitBranch and WatchRule controllers watch
Namespace labels: a Selector grant reads them, and a namespace can lose the
grant giving a wider scope while its GitTarget stays admitted.

The WatchRule and ClusterWatchRule controllers share one helper for the
watches they have in common. Wiring tests drive revocation and recovery by
watch events alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A ClusterWatchRule compiles only for the cluster-scoped types the grants that
admit its GitTarget give in clusterResources (resource.group, a bare name for
the core group, or "*"). The decision is on the rule's text: an item with "*"
in apiGroups or resources needs "*", named pairs are checked pair by pair, and
an item with apiGroups omitted is checked by the group discovery resolves it
to. One forbidden type refuses the whole rule (ResourcesResolved=False,
ClusterResourceNotAllowed) and the message names it.

The compiled rule keeps the granted identities, and the planner skips any match
outside them, so discovery serving a new type later never widens a grant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An applier claim that its KubeProvider's allowedApplierClaims refuse, by
namespace or by applierNamespaces, takes the existing retire path: it stays
the holder (Held=False, Releasing) while it hands back, keeps a Kustomization
that was suspended before adoption suspended, and gives up after
releaseDeadline with a Warning Event. A read error keeps the hold.

A regrant during such a hand-back lets it finish; the applier claim then
adopts its applier anew.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The chart renders kubeProvider.allowedGitTargets and allowedApplierClaims onto
its local provider, both empty by default, so nothing mirrors or holds an
applier through the operator's identity until the installer grants it.
quickstart.enabled adds a grant for exactly the quickstart namespace, and
values-playground.yaml grants every namespace its own namespace. No default
grants clusterResources. The quickstart GitProvider and GitBranch carry their
own grants.

config/kubeprovider-local.yaml, the config-dir e2e install, grants what the
suite needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The suite grants what it needs on the install's KubeProvider in its setup,
whatever the install mode, since the chart grants nothing by default:
every namespace mirrors its own namespace and any cluster-scoped resource,
and holds an applier in its own namespace. Specs that need appliers elsewhere
widen allowedApplierClaims. Fixtures carry allowedGitBranches and
allowedGitTargets in place of allowedBranches, accessFrom and
allowCrossNamespace.

A new spec has a team claim teams/<team>/live through a platform provider
and write it, share it with a second team through its GitBranch, and be
refused main and another team's branch with messages that name the grant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
configuration.md leads the sharing section with one tenant end to end, then
the grant lists, from, the semantics of each scope, branch patterns, cluster
resources, refusal messages, revocation, the boundaries grants do not
replace, the limit of another provider on one repository, and a platform with
its tenants. The GitProvider, GitBranch, KubeProvider, GitTarget, WatchRule
and ClusterWatchRule sections, the guides, definitions, security model and
RBAC pages follow. UPGRADING.md carries one entry for the change, and the
unreleased KubeProvider and GitBranch entries stop naming accessFrom.

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

A ClusterWatchRule leaves the rule store when its type leaves the grant's
clusterResources, and returns when it is restored, on the KubeProvider event
alone. A provider admits no applier claim in its own namespace by itself. The
plan's test checklist is ticked, naming the two items covered only in part.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A kubectl patch of the chart's KubeProvider made kubectl-patch a co-owner of
its grant fields, so a later server-side helm upgrade (the README quickstart
spec) refused the conflict. The Helm install now carries the grants as
values, and the suite patches them in only for the other install modes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib
sunib marked this pull request as ready for review October 23, 2026 19:15
sunib and others added 8 commits October 23, 2026 20:35
Releasing the holder elected the next candidate at once, on an admission
it was given before the grant edit. A concurrent GitTarget reconcile could
then start that successor's worker before the GitBranch reconciler's
successor walk re-checked its grant, and the walk released the claim but
left the worker running.

Release now leaves the branch unheld and returns the GitBranches waiting
for it, which relinquish wakes once the old worker has retired. Each takes
the branch only through its own reconcile, so the grant check in evaluate
is the only one; the successor walk and its second admission check are
gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An item that omits apiGroups was resolved by resource name alone, and let
through when two groups served the name, on the assumption that the planner
would watch nothing. The planner filters by apiVersions first, so it could
watch one of them. The compile now checks the types the planner's own
matcher selects for the item (ClusterResourcesSelectedFor).

The plan fingerprint also omitted the granted clusterResources the planner
filters by, so a recompile under a narrower grant could keep a revoked type
in the cached plan. It carries them now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
applier_claim.go is not a test file, so calling a function defined in
e2e_suite_test.go broke go build ./....

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Records where the code departs from the plan, including the two review
fixes, and keeps the two recovery tests still missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hand-back that a denial by allowedApplierClaims began was told apart
from an ordinary one by the opening words of its Held=Releasing message,
which the controller read back. It now reads Held=False, reason Revoking,
so the state lives in the reason and the message is free text again. A
denial during a release continues the same hand-back, with the same
deadline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…esources refusal text

- sourcesOf replaces six copies of the grant-to-source loop; slices.Sort
  and slices.Compact replace two hand-written sorted-unique helpers.
- notAdmitted loses a branch that built the same message as the general
  case.
- requestsFor replaces four list-to-request loops in the mappers.
- WorkerRequest.provider() is gone: it returned the field.
- releasingForDenial is revoking, after the Held reason it reads.
- The ClusterWatchRule clusterResources refusal joined clauses that each
  ended in "which is" and appended "not allowed" once, so a refusal of
  two items, or of a '*' item, read as broken text.
- Comments rewrapped, and the last delegation wording dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The WatchRule and applier-namespace tests still described the removed
delegation flag and allowCrossNamespace.appliers; one claimed an
own-namespace rule needs no grant, which the fixture contradicts. They
are renamed and reworded after what they now check.

branchesFromSame, grantNames (authz), pollCondition (wiring) and
gitBranchWritersYAML (e2e) replace repeated literals and loops;
allowCrossNamespaceAppliers is grantAppliersInAnyNamespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sunib and others added 7 commits October 24, 2026 04:24
A closed docker socket mid-stream left the node's ctr import waiting on
stdin, and k3d never returned: the full-manager leg sat in attempt 1 of 3
until the 30m suite timeout and ran 0 specs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sumer.name}

A grant's scope variable was {namespace}. It did not say whose namespace
it was, of the three a grant involves, and it collided with the placement
template's {namespace}, the mirrored resource's. The object a grant admits
is now the consumer: {consumer.namespace} replaces {namespace}, and
{consumer.name} lets one grant give each GitTarget, applier claim or
GitBranch the source namespace, applier namespace or branch segment named
after it. Both are allowed alone in sourceNamespaces and applierNamespaces,
and each as a whole path segment in a branch pattern; {namespace} is now
refused as an unknown variable. The Admits methods take the consumer as a
NamespacedName.

FluxClaim status.source.otherConsumers is status.source.alsoAppliedBy, and
Flux.Consumers is Flux.AppliedBy, so consumer keeps one meaning.
configuration.md explains the variables, and definitions.md defines the
consumer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…a branch pattern

The consumer picks its own name, so a scope bounded by {consumer.name}
alone bounds nothing: a GitTarget named kube-system would reach
kube-system, and env/{consumer.name} would let any admitted namespace
claim env/prod. {consumer.name} is now refused in sourceNamespaces and
applierNamespaces, and a branch pattern holding it must also hold
{consumer.namespace}, whose segment does the bounding. Beside it the name
is a naming convention, and the docs say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Held" named two things that disagree on one state: holding the applier
(first in line, carrying the finalizer, what HeldByAnotherClaim reports)
and the condition (GitOps Reverser decides when the applier applies). A
claim that is Releasing or Revoking still holds its applier but reported
Held=False. InControl names only the second, so "hold" keeps the line's
meaning. The printer column is now CONTROL; the reasons are unchanged.

docs/definitions.md defines "in control" by the question it answers and
points to the reason tables in the Flux and Argo CD guides instead of
listing every reason again. It also drops the stale "FluxClaim only" for
a condition both kinds have reported since Argo CD support landed.

No users yet, so this is a hard cut with no alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The scope field doc comments become the kubectl explain text, and they
undersold what the code does. sourceNamespaces said "*" "also allows" a
"*" rule item; the code requires it: scopeAllows is a string match, so a
"*" item is refused under a narrower union, never narrowed to it.

Each scope field (sourceNamespaces, clusterResources, applierNamespaces,
and WatchRule rules[].sourceNamespace) now states, checked against
internal/authz/source_namespace.go, internal/watch/watchrule_compile.go
and KubeProvider.AdmitsGitTarget/AdmitsApplierClaim:

- the effective scope is the union across admitting grants;
- it is a permission, not a selection;
- "*" needs "*" in at least one admitting grant, refused, never narrowed;
- one item outside the union refuses the whole rule;
- it is checked at reconcile, never at admission;
- it bounds reading, never Git placement.

applierNamespaces also loses "source-cluster": the same KubeProvider can
be a GitTarget's source cluster and an applier claim's management
cluster, so it now says "namespaces in this provider's cluster".

Descriptions only; no schema or behaviour change.

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

BREAKING CHANGE: every grant in GitProvider.spec.allowedGitBranches,
GitBranch.spec.allowedGitTargets, KubeProvider.spec.allowedGitTargets and
KubeProvider.spec.allowedApplierClaims moves from/names/selector into a
required consumerNamespaces block, and allowedGitBranches[].branches is
renamed branchPatterns:

    allowedGitTargets:
      - consumerNamespaces:
          from: Names
          names: [team-a]
        sourceNamespaces: ["{consumer.namespace}"]

Why the block (docs/reviews/20261024-grant-shapes.md, F1 to F3):

- A bare `names` did not say what it named. On a GitBranch,
  `allowedGitTargets: [{from: Names, names: [team-b]}]` reads as well as
  "the GitTarget called team-b". Under consumerNamespaces, names can only
  mean namespace names, in all four lists.
- In both KubeProvider lists the control-cluster "who" sat flat beside a
  scope in the provider's cluster; only field prefixes told the two
  clusters apart. The block marks the control side, so the fields beside
  it are the scope.
- It is Gateway API's AllowedRoutes shape (a namespaces block with a
  restriction beside it), named for the consumer so it does not sit as a
  bare `namespaces` next to sourceNamespaces, and so it pairs with
  {consumer.namespace}.
- `branches` inside allowedGitBranches read as GitBranch object names, the
  same flaw; the values are patterns, which the type (BranchPattern), the
  CEL refusals and the docs already call them.

Nothing defaults, the reverse of Gateway API: consumerNamespaces is
+required on every grant type, from stays +required inside it, and
neither has a +kubebuilder:default. A missing block, consumerNamespaces:
null, consumerNamespaces: {} and a block with names but no from are all
refused; the admission suite now checks those four cases on each of the
four lists, since the block's own CEL rules cannot make it required.

Code: NamespaceGrant becomes ConsumerNamespaces and carries the five CEL
rules once, held as a named field so the type-level rules apply; the
three copies of from/names/selector and their rules are gone. Grant types
are named <Holder><Consumer>Grant (GitProviderGitBranchGrant,
GitBranchGitTargetGrant, KubeProviderGitTargetGrant,
KubeProviderApplierClaimGrant), so the two allowedGitTargets types no
longer share a name, and SourceScope/ApplierScope follow
(KubeProviderGitTargetScope, KubeProviderApplierClaimScope). admitAll,
describeAdmitted and expandScope keep their signatures; sourcesOf is
consumersOf, and each source() adapter is a one-line consumers() accessor
("source" is reserved for the source cluster).

Evaluation, refusal reasons (BranchNotAllowed included) and condition
names do not change.

Everything a unit test reads moves with it: the chart (rendered by
cmd/main_chart_kubeprovider_test.go; quickstart.gitProvider
.allowedGitBranches, a list of patterns, is now
quickstart.gitProvider.branchPatterns), config/samples, the layout
corpus, the playground and test/e2e/setup (strict-decoded by
TestExamplesDecodeStrictly), the bi-directional guide examples, and the
generated settings index in docs/configuration.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a scope permits

The e2e manifests, templates, Helm values and kubectl patches, and every
grant in the docs, now use consumerNamespaces and branchPatterns.

configuration.md:

- the grants section introduces the two halves of a grant (the
  consumerNamespaces block, and the scope beside it), says that nothing
  defaults, unlike Gateway API, and lists consumerNamespaces in the field
  table;
- an object's scope is the union across the grants admitting it, and a
  scope is a permission, not a selection: a request outside the union is
  refused whole, never narrowed;
- the sourceNamespaces "*" row now says "*" is required, in at least one
  admitting grant, for rules[].sourceNamespace: "*", which is one
  cluster-wide watch (it said "also allows");
- {consumer.namespace} in a namespace scope names the same-named
  namespace in the provider's cluster and selects nothing on its own;
- the holder-vs-consumer table stays, with a sentence that
  consumerNamespaces: {from: Same} is the holder's namespace, not the
  consumer's.

definitions.md: the Grant entry names consumerNamespaces and lists
branchPatterns among the scope fields; the source-namespace direction is
bounded by the union of the admitting grants.

UPGRADING.md's grants entry belongs to this same unreleased change, so its
examples move to the shape the release ships with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib
sunib merged commit 1c4fade into main Oct 24, 2026
23 checks passed
@sunib
sunib deleted the feat/provider-access-rules branch October 24, 2026 07:41
sunib added a commit that referenced this pull request Oct 24, 2026
…lity folder

The render-fidelity spec stalls a GitTarget on a Flux postBuild token. It
now repairs the folder in Git, sets reconcile.configbutler.ai/requestedAt,
and expects RenderMatchesLive=True, the live state the closed gate kept out
in Git, and a later edit mirrored: the way out docs/configuration.md gives.

docs/TODO.md drops what this branch resolves (R1, R4, #428's override, the
live-edit hold, F-a to F-e, F2, A2) and keeps what is left: the #441 and
#419 changelog overrides, F3, and the apply window's remaining clock limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sunib added a commit that referenced this pull request Oct 24, 2026
* fix(applier): hold a joining applier's edits from the flush mark, and restart a replaced worker's snapshots

R1: a flush records the API edits decided after its mark per GitTarget, not
per applier due when it began. An applier that comes due while the flush
waits for its publication is sent in the same flush, so its window now holds
every edit since the mark; before, an edit decided between the mark and its
joining was taken for the apply landing, and no Repair put it back.

R4: the watch side keeps each GitTarget's settled-snapshot record under a
generation. A branch worker's replacement restarts the record of every
GitTarget it served, a snapshot the old worker answers late settles nothing,
and the new worker asks no applier for anything until that recovery was
delivered. Before, it read the old worker's record, passed the barrier at
once, and could ask for a folder the new repository does not hold.

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

* fix(applier): create a newly claimed GitTarget's folder before asking, and say when the barrier holds

F-d: the initial-snapshot barrier now also requires the folder of every
GitTarget an applier covers to be on the branch. A GitTarget that named its
claim after its snapshots settled empty had none, and its applier was asked to
apply a missing path. The worker asks for one fresh snapshot of it, whose
resync creates the folder, and uses that snapshot even while Git is ahead,
since the apply held for the folder would wait on it.

F-a: a hold is no longer silent. Each applier the barrier holds a reason back
for is logged at Info when the hold begins, changes reason or lifts, and
counted in the new appliers_held gauge.

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

* fix(applier): judge a stamped-over fetch and a vanished Argo CD token by what ran

A Flux fetch waited only for its own token on the GitRepository. When
another stamp replaced it, source-controller never handled ours, and the
request failed after the whole wait though the artifact held the commit.
The fetch now ends once the artifact holds the commit, or once the stamp
written over ours was handled.

An Argo CD sync whose operationState a later sync replaced read as gone,
so Failed, and the worker synced the commit again, running its hooks
twice. A status.history entry of the commit, initiated by GitOps
Reverser, recorded since the operation was set (above the history id the
Application held then, or for a recovered operation, started no earlier
than its request's second) is now that sync succeeding.

The apply window compared an applier's managedFields time, the API
server's clock, with the operator's send. With the server clock behind,
an apply over a newer edit was dated before the send, taken for an undo,
and its revert mirrored. With an edit recorded, the window now tells the
undo from the edit's time alone. Clock-skew tests cover both directions.

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

* docs: correct the grant wording, the 0.53.0 upgrade procedure and the release gate

A written allowedGitTargets list admits the local namespace only if it
grants it access: Names, Selector or All can, so "without from: Same"
never meant "shut out".

The 0.53.0 upgrade procedure now says that the chart's schema rejects
leftover clusterProvider values and maps each to its kubeProvider
value, that spec.kubeProviderRef is required where 0.53.0 filled in the
"default" provider, and that allowedGitBranches is optional with no
default rather than required.

The TODO release gate drops the loud-rejection fields and the CRD
wording (cleanup, not release defects), and the automatic render
fidelity recovery keeps its dependency on the retained-intent ordering
barrier.

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

* fix(git): hold live edits while a GitTarget's render fidelity is pending

A live event that met a pending render-fidelity gate was dropped for good:
the watch had moved past it. The gate is per GitTarget, so an edit to any
collection was lost while another one replayed (a new or restarted
collection, every collection at operator start, a snapshot F3 discarded).

Such an event is now judged as it arrives (ownership, the apply windows) and
held. Once the gate opens, the held events go first, in order, and are
committed with their authors. A divergence drops them, as before. An applied
snapshot of a collection supersedes the events of it held before it was taken
off the queue, except a DELETE of an object it does not hold: under the
default prune mode a snapshot never removes a document. A held create is
noted for adoption when it is released or superseded, not when it arrives,
so the CatchUp follows a flush that publishes it (F-c).

The hold is bounded: 10 minutes per GitTarget, and the branch's
retained-byte budget, which held events count against. Past either, what was
held is dropped and counted in git_writes_dropped_total, and a fresh snapshot
of the GitTarget is asked for, which commits the current state as the
operator. Atomic writes are still dropped at a closed gate.

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

* test(e2e): prove the explicit recheck recovers a repaired render-fidelity folder

The render-fidelity spec stalls a GitTarget on a Flux postBuild token. It
now repairs the folder in Git, sets reconcile.configbutler.ai/requestedAt,
and expects RenderMatchesLive=True, the live state the closed gate kept out
in Git, and a later edit mirrored: the way out docs/configuration.md gives.

docs/TODO.md drops what this branch resolves (R1, R4, #428's override, the
live-edit hold, F-a to F-e, F2, A2) and keeps what is left: the #441 and
#419 changelog overrides, F3, and the apply window's remaining clock limits.

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

* docs(git): count held live events as unpushed work in hasUnpushedWork's comment

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

* fix(applier): take no same-second history entry as a recovered Argo CD sync

A recovered operation never read the Application's history, so it is
judged by time. An entry started in the second it was asked for may be an
earlier sync of the same commit, and taking it skipped an apply the request
never made. Only an entry started in a later second proves it now; a
same-second one leaves the commit to be synced again.

UPGRADING names the chart's provider by kubeProvider.name, local by
default, wherever the steps reference it.

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

* fix(git): repair an apply's overwrite of a held edit once the edit is in Git

An apply sent while a GitTarget's live events were held for its render
fidelity applied a commit without them. Its window recorded only the
flush's edits, so the applier's write over a held edit was taken for the
apply landing: the edit later reached Git with the cluster still running
the overwritten value, and no Repair followed.

The window now holds the held events as edits from the send, so that
write is withheld. Its Repair waits for the edit to reach Git, by release
or by a snapshot superseding it. Sent at once, it would apply a commit
without the edit and be spent; pending, it reads as Git ahead, so F3
would discard the very snapshot the gate waits for.

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

* fix(applier): take no status.history entry as proof of a recovered Argo CD sync

A recovered operation never read the Application's history, so it was
judged by when an entry started. That is the controller's clock: with it
ahead, an earlier sync of the same commit read as started after the
request, and a request that crashed before its update was Succeeded with
the sync never made. No entry proves a recovered sync now; its update is
settled as never made, and the worker asks again. A recovered sync that
ran and was then replaced is synced again: a repeat, never a skipped
apply. An operation this process set is still proven by the history id.

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

---------

Co-authored-by: Claude Opus 5.5 (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