Skip to content

feat(applier): hold the applier, resuming a suspended Kustomization for each apply - #420

Merged
sunib merged 68 commits into
docs-bi-directional-rightfrom
feat/suspended-appliers
Oct 19, 2026
Merged

sunib merged 68 commits into
docs-bi-directional-rightfrom
feat/suspended-appliers

Conversation

@sunib

@sunib sunib commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #419 (base docs-bi-directional-right). The two release together, so spec.applier from #419 is never released: this branch replaces it outright.

The applier never applies on its own. A Flux Kustomization is held suspended while idle and resumed for one apply at a time; an Argo CD Application keeps automated sync off. An apply trigger holds the applier, and acts only while an eligible GitTarget references it. The design, with the kustomize-controller v1.9.6 and Argo CD 3.5.0 facts it rests on, is docs/design/apply-trigger.md; the user guides are bi-directional-flux.md and bi-directional-argocd.md, behind the overview bi-directional.md.

Apply triggers

"Applier" keeps its meaning from docs/definitions.md: the Flux Kustomization or Argo CD Application itself. The resource that holds one is an apply trigger:

kind: FluxTrigger                 # or ArgoCDTrigger, with applicationRef and no quietPeriod
metadata: {name: checkout, namespace: team-a}
spec:
  clusterProviderRef: {name: default}                        # default "default", immutable
  kustomizationRef: {namespace: flux-system, name: checkout} # required, immutable
  quietPeriod: 30s                                           # default 30s, at most 1h
status:
  conditions: [Configured, Held, Ready, Reconciling, Stalled]  # Held: FluxTrigger only
  source: {gitRepository: {...}, url: ..., branch: main, otherConsumers: [...], prefetch: true}
  operation: {phase, reason, commit, token, suspendToken, appliedRevision, requestedAt, finishedAt, message}
  repairPending: false
---
kind: GitTarget
spec:
  applyTriggerRef: {kind: FluxTrigger, name: checkout}       # same namespace
  • A trigger acts only while an eligible GitTarget references it. Eligible: in the trigger's namespace, not being deleted, naming it, admitted by its ClusterProviders, and writing the repository and branch the applier fetches; never judged from the GitTarget's Ready. A trigger no GitTarget names reads and writes nothing in the applier's cluster, is not in line for its applier, and is Configured=Unknown, Ready=True, both NotReferenced (so a Flux health check waiting on it does not deadlock against the Kustomization that creates the GitTarget). With several GitTargets, one eligible binds it; none eligible while one cannot be judged yet is NotBound (progressing); each refused is SourceMismatch, NamespaceNotAuthorized or NotBound (stalled), every GitTarget in the message. status.source is published only while bound.
  • Configured is otherwise ApplierNotFound, UnsupportedSource, SuspendOwnedElsewhere, AutomatedSyncEnabled, Forbidden, NamespaceNotAuthorized, ClusterProviderNotFound, ApplierNamespaceNotAllowed or HeldByAnotherTrigger.
  • Held (FluxTrigger), from the trigger's side: Suspended (suspended under our field manager, last suspend handled), Applying (an operation of ours in flight and followed), Suspending (a suspend sent, not handled yet), Releasing (handing it back), NotReferenced, NotConfigured (whose message says when the Kustomization is left suspended). It is a printer column. The operation phase keeps Running, and Settling.
  • The GitTarget reports ApplyTriggerReady: the trigger's Ready, plus the binding check against its own repository and branch (SourceMismatch), ApplyTriggerNotFound, or Unknown (NotReferenced) until the trigger has observed the reference. status.applier and ApplierConfigured are gone.
  • The trigger reconciler watches what eligibility reads: GitTargets (before and after an update), GitProviders, ClusterProviders and namespace labels.

Cross-namespace grants (breaking)

ClusterProvider.spec.allowCrossNamespace groups the two powers an admin grants an admitted namespace outside itself, both default false:

  • sources: a WatchRule may watch other namespaces, "*" included. It replaces allowAnySourceNamespace.
  • appliers: a trigger may name a Kustomization or Application in another namespace, such as flux-system. Without it, Configured=False (ApplierNamespaceNotAllowed); the dispatcher refuses too.

It is a delegation to every admitted namespace, not owner consent, and the guides say so. allowAnySourceNamespace is never read; it stays in the schema without a default only so a spec-level CEL rule refuses true on create and on any spec change (ratcheting cannot pass a stored true once spec moves), while false, which v0.52 stored everywhere, and removing the field stay accepted. A WatchRule that relied on it reports SourceNamespaceNotAllowed naming the new field. The chart gains clusterProvider.default.allowCrossNamespace.{sources,appliers}, and UPGRADING.md has the entry.

The Flux applier, suspended

  • Adoption. A Configured FluxTrigger puts its finalizer in place, then suspends its Kustomization with a forced server-side apply under the field manager gitops-reverser and a requestedAt token, and is Held=True (and Ready) once that token is handled. Another manager that applies spec.suspend is SuspendOwnedElsewhere; a one-off update such as flux resume is taken back. A trigger that never adopted has no finalizer and deletes at once.
  • One apply is one operation. On a quiet branch: make the artifact hold the commit (fetch if needed), read it again and recheck the decision, record the operation on the trigger, then resume with spec.suspend: false and a token T1, guarded by the uid and resourceVersion just read. When T1 is handled, read Ready and lastAppliedRevision, then suspend with a second token T2 (Settling). T2 handled proves no reconcile started before the suspend is still running. A failed apply is suspended too, so Flux's retryInterval retry cannot fire; the worker retries on its backoff.
  • Succeeded needs a reconcile that ran resumed: Ready's lastTransitionTime or the top status.history entry must have moved since the resume. An applied revision other than the commit is Succeeded only when the GitRepository holds it now.
  • Resumed only while held. The dispatcher reads the trigger uncached just before the resume and resumes nothing unless it is Held=True, not being deleted, and first in line.
  • Recorded before it starts. The operation reaches the trigger only through StatusWriter.RecordApplyOperation, before the resume (or the Argo CD sync); the writer matches operations by token, never by time.
  • Nothing is left resumed and unwatched. An abandoned operation is suspended before it is let go. An operation nobody follows for a minute is suspended by the trigger, completes Failed, and sends an OperationUnfollowed Event. A graceful stop of the operator suspends a Kustomization it had resumed, once and without waiting for it to be handled; after a crash, the apply that was running finishes and the next process suspends it.
  • External resume. A held Kustomization found resumed with no operation of ours in flight is suspended again, every time, with a ResumedElsewhere Event, and recorded as an Unresolved operation that completes once that suspend is handled.
  • Hand-back. Deleting the trigger, or the last eligible GitTarget naming it, hands the Kustomization back: Held=Releasing, suspended first if an apply has it resumed, then spec.suspend released (a forced suspend: false first, since our apply over a flux suspend shares the field). Until that is done the trigger stays the holder in line. A reference returning before the release holds it again; one after adopts it anew. A ClusterProvider that is gone does not end it: it goes on through whatever client still reaches the cluster, and only when none does is the trigger let go, with a ReleaseFailed Warning. Past 10 minutes from Held turning Releasing, the same.
  • Restart recovery. Before its first live event, a worker follows every operation its triggers record as not completed, by its tokens, with the apply window open; repairPending is restored.
  • Shared sources. status.source.otherConsumers lists the Kustomizations and HelmReleases on the same GitRepository that no trigger holds, information only. Appliers sharing a source have one operation at a time. With no other consumers and its own Kustomization held (prefetch: true), a push seen while the branch is busy is fetched at once, after the dispatcher checks on the cluster that every consumer is still held, and only into the GitRepository whose consumers it checked: the Kustomization's live sourceRef must still be that one.

Ownership and races

  • One trigger per applier. Of the triggers in line for one applier in one cluster (an eligible reference, or a FluxTrigger carrying its finalizer), one holds it: one with an apply in flight first, then one holding it already, then the oldest. An older trigger with the wrong repository, or not admitted, keeps no younger one out; a holder is never displaced mid-apply or mid-release.
  • An unanswered Argo CD update is followed by its token, and settled by an resourceVersion-guarded refresh write: once that lands the lost update can never land, and it is Failed and asked again. No time bound.
  • Quietness counts work in hand, across sibling workers; a shared source is serialized across workers of GitProviders on the same repository and branch.
  • The apply window. A write by the applier's field managers alone, newer than an API edit made since the attempt, is withheld, and a Repair is pending. An entry in the same second is ambiguous and mirrored; an older one is the API's undo of an edit (an update that removes every field a manager owns deletes its entry) and is mirrored. A new attempt keeps the earlier window's edits until it would have closed.
  • A newly named applier gets a CatchUp, so a commit observed before it was named is applied once the branch is quiet (Current when it already is). An Application gets no CatchUp, and waits for the next push.
  • Argo CD: a sync of ours whose .operation was removed while Running (the stuck-hook workaround) is Failed once overdue; a request finds such a state, unchanged for its whole wait, no operation in progress.

Operations, for both appliers

Each apply is followed as an operation on the trigger's status.operation: Requested, Running, Settling (Flux), then Succeeded or Failed. One not completed after 5 minutes is Unresolved (Ready=False, OperationUnresolved, an ApplyUnresolved Event, apply_operations_unresolved); it is still followed, and a Flux one is suspended at once.

Validation

task lint and task test pass on b987bdd8. CI on d9b53208 was green on every leg: Lint, Unit tests, and the six e2e legs (full-manager, full-core, bi-directional, source-cluster, quickstart-install, image-refresh); later commits are e2e hardening, one render-fidelity fix, the "pending" vocabulary and a last docs pass. Locally, task test-e2e passed 99 of 100 specs, the one failure being Flux Restart Recovery before its spec learned that a graceful stop now suspends the Kustomization (it then passed alone), and task test-e2e-bi-directional passed 9 of 9.

Each fix of the seventh review has a regression test that failed without it: a ClusterProvider gone before its trigger, a stop mid-apply, an applier named after the merge it needs, an undo inside the apply window, and an Argo CD .operation removed while Running. The rule has its own test file (applytrigger_rule_test.go), the removed field an envtest that stores a true before the CEL rule exists, and both guides' complete examples are checked against the admission they need. The Manager Flux Applier e2e spec now creates the trigger before any GitTarget names it, sees it refused for the wrong branch, adopted once the source is fixed, handed back when the reference is dropped, and adopted again.

Known limits: on Flux older than v1.7 (no status.history) the only proof of a reconcile is Ready's transition time at one-second precision; a request that arrives while a trigger is still adopting fails once with an ApplyRequestFailed Warning and is retried; ExternalArtifact consumers of the source are not listed; the binding compares the GitProvider URL, not write access, so allowCrossNamespace.appliers is a delegation; the gap between prefetch's consumer check and its stamp of the GitRepository is accepted (milliseconds, and the worst case fetches early what a consumer would fetch on its own interval).

🤖 Generated with Claude Code

sunib and others added 3 commits October 17, 2026 18:33
…ew 3

The third review of #419 found that the 24h-interval setup reduces Flux's
own applies but never hands their timing to GitOps Reverser: the interval,
a controller restart, a retryInterval retry, a spec change and a shared
source's new artifact all still apply at moments nobody checked.

The measured suspend lifecycle answers that at about 50 ms per apply. The
plan makes it the direction: a Flux Kustomization stays suspended while
idle and is resumed per apply, as Argo CD runs with automated sync off.
kustomize-controller v1.9.6 records a requestedAt as handled on every
return, so one patch of suspend plus a token marks the end of the apply,
success or failure, and the same on re-suspend fences any reconcile still
running. The applier becomes typed resources (FluxApplier, ArgoCDApplier)
holding the operation, the owed Repair and the suspension.

Built as a stack on #419 and released with it, so spec.applier is never
released.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A write by the applier and another manager in the same second is mirrored,
because it may be the API edit itself. It was recorded as an edit only when
the object had been edited before it, so a tie as the first write in the
window left the object unrecorded, and the applier's write alone a second
later (its apply reverting that edit) was mirrored as the applier's commit.

A tie is now always recorded as an edit; it counts as ambiguous only when it
collides with an earlier edit. Review 3, finding 5.

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

An attempt that asked an applier to apply is now an operation the branch
worker follows to its end, not a single "Delivered" report. The dispatcher
reports it as it moves (Requested, Running) until it completes (Succeeded or
Failed), and the worker keeps the attempt out and the apply window open until
then. Settling is in the vocabulary for the suspended Flux lifecycle.

An operation not completed after 5 minutes is Unresolved instead of
"delivered anyway": a timer that closes the window does not stop the apply.
It is still followed, at an interval doubling to a minute, each read bounded
by its own timeout, and holds its applier's next attempt back until it
completes. A deleted applier object completes it as Failed; an applier no
GitTarget names any more abandons it (ApplyRequest.Abandoned). It records an
ApplyUnresolved Event and shows in the new apply_operations_unresolved gauge.

A completed operation that failed (an Argo CD sync ending Failed or Error, or
cleared before it recorded an outcome) gives back what it carried and is
retried on the applier's backoff, with an ApplyFailed Event for the first
failure of a run. The could-not-ask outcome is renamed RequestFailed.

A late apply the window withholds owes its applier a Repair: an apply of the
tip at the next quiet moment, with no quiet period, for Flux and Argo CD
alike. An Argo CD Application, which never catches up, used to stay on the
reverted value until something else synced it. Repair is a new ApplyReason,
ranked between Refusal and ForeignCommit, and a Flux Repair stamps the
Kustomization as a Refusal does.

Review 3, findings 2 and 4.

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

coderabbitai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 236 files, which is 136 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: Advanced
  • Run ID: b78a7d61-b195-49b7-863f-b5a7c0f6aa27
📥 Commits

Reviewing files that changed from the base of the PR and between 6efbf4d and 2a6c1ab.

📒 Files selected for processing: 236
  • .coverage-baseline
  • .docs-lint-scope
  • README.md
  • api/v1alpha3/applytrigger_types.go
  • api/v1alpha3/argocdtrigger_types.go
  • api/v1alpha3/audit_route_test.go
  • api/v1alpha3/clusterprovider_types.go
  • api/v1alpha3/examples_test.go
  • api/v1alpha3/fluxtrigger_types.go
  • api/v1alpha3/gittarget_types.go
  • api/v1alpha3/helpers_test.go
  • api/v1alpha3/namespace_matcher_test.go
  • api/v1alpha3/status_accessors.go
  • api/v1alpha3/watchrule_types.go
  • api/v1alpha3/zz_generated.deepcopy.go
  • charts/gitops-reverser/README.md
  • charts/gitops-reverser/templates/_helpers.tpl
  • charts/gitops-reverser/templates/applier-rbac.yaml
  • charts/gitops-reverser/templates/clusterprovider-default.yaml
  • charts/gitops-reverser/templates/deployment.yaml
  • charts/gitops-reverser/templates/services.yaml
  • charts/gitops-reverser/values.schema.json
  • charts/gitops-reverser/values.yaml
  • cmd/main.go
  • cmd/main_chart_receiver_test.go
  • cmd/receiver.go
  • config/clusterprovider-default.yaml
  • config/crd/bases/configbutler.ai_argocdtriggers.yaml
  • config/crd/bases/configbutler.ai_clusterproviders.yaml
  • config/crd/bases/configbutler.ai_fluxtriggers.yaml
  • config/crd/bases/configbutler.ai_gittargets.yaml
  • config/crd/bases/configbutler.ai_watchrules.yaml
  • config/crd/kustomization.yaml
  • config/rbac/argocd-applier-role.yaml
  • config/rbac/flux-applier-role.yaml
  • config/rbac/kustomization.yaml
  • config/rbac/role.yaml
  • config/samples/README.md
  • config/samples/argocdtrigger.yaml
  • config/samples/clusterprovider.yaml
  • config/samples/fluxtrigger.yaml
  • docs/INDEX.md
  • docs/README.md
  • docs/TODO.md
  • docs/UPGRADING.md
  • docs/api-first-publication.md
  • docs/architecture.md
  • docs/argocd.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/design/apply-trigger.md
  • docs/design/azure-devops-multi-ack.md
  • docs/design/branch-worker-event-model.md
  • docs/design/data-plane-triggering.md
  • docs/design/event-pipeline-overview.md
  • docs/design/gittarget-branch-worker-pending-writes.md
  • docs/design/gittarget-parent-branch.md
  • docs/design/gittarget-parent-hardening.md
  • docs/design/gittarget-state-of-affairs.md
  • docs/design/label-selection-follow-ups.md
  • docs/design/open-asks-priority.md
  • docs/design/placement-visibility-and-declared-defaults.md
  • docs/design/push-cooldown.md
  • docs/design/push-notification-and-reconcile-trigger.md
  • docs/design/reconcile-triggering.md
  • docs/design/source-scope-simplification.md
  • docs/design/support-boundary/README.md
  • docs/design/support-boundary/admission-consent.md
  • docs/design/support-boundary/argocd-bi-directional.md
  • docs/design/support-boundary/expansion-boundary-and-corpus-organisation.md
  • docs/design/support-boundary/render-fidelity-scenarios.md
  • docs/design/support-boundary/render-fidelity.md
  • docs/design/target-watch-plan.md
  • docs/design/vocabulary-cleanup.md
  • docs/design/watch-plane-status-convergence-failures.md
  • docs/flux.md
  • docs/future/config-surface-for-a-structured-repository.md
  • docs/future/git-api-three-way-comparison.md
  • docs/future/ha-gittarget-distribution-plan.md
  • docs/interpreting-metrics.md
  • docs/layout/new-file-placement-rules.md
  • docs/rbac.md
  • docs/security-model.md
  • docs/spec/commitrequest-design.md
  • docs/spec/e2e-bi-directional-corner.md
  • docs/spec/e2e-serial-registry.md
  • docs/spec/gitpath-foreign-content-stringency.md
  • docs/spec/status-conditions-guide.md
  • hack/crdfields/fields.yaml
  • hack/e2e/install-plain-manifests.sh
  • hack/e2e/wait-crds-established.sh
  • internal/applier/applier.go
  • internal/applier/argocd.go
  • internal/applier/argocd_guide_test.go
  • internal/applier/argocd_test.go
  • internal/applier/dispatcher.go
  • internal/applier/dispatcher_test.go
  • internal/applier/flux.go
  • internal/applier/flux_consumers.go
  • internal/applier/flux_consumers_test.go
  • internal/applier/flux_guide_test.go
  • internal/applier/flux_lifecycle_test.go
  • internal/applier/flux_test.go
  • internal/applier/fluxsim/fluxsim.go
  • internal/applier/fluxsim/fluxsim_test.go
  • internal/applier/holder.go
  • internal/applier/holder_test.go
  • internal/applier/references.go
  • internal/applier/status_writer.go
  • internal/applier/status_writer_test.go
  • internal/audit/outcome/outcome.go
  • internal/audit/outcome/outcome_test.go
  • internal/authz/clusterprovider_admission.go
  • internal/authz/clusterprovider_admission_test.go
  • internal/authz/source_namespace.go
  • internal/authz/source_namespace_test.go
  • internal/controller/applytrigger_controller.go
  • internal/controller/applytrigger_controller_test.go
  • internal/controller/applytrigger_holder_test.go
  • internal/controller/applytrigger_rule_test.go
  • internal/controller/applytrigger_schema_test.go
  • internal/controller/condition_helper.go
  • internal/controller/fluxtrigger_hold.go
  • internal/controller/fluxtrigger_hold_test.go
  • internal/controller/fluxtrigger_suspend_apiserver_test.go
  • internal/controller/gittarget_applier.go
  • internal/controller/gittarget_applier_schema_test.go
  • internal/controller/gittarget_applier_test.go
  • internal/controller/gittarget_apply_trigger.go
  • internal/controller/gittarget_apply_trigger_test.go
  • internal/controller/gittarget_controller.go
  • internal/controller/gittarget_parent_branch_test.go
  • internal/controller/gittarget_parent_recovery.go
  • internal/controller/gittarget_worker_repoint_test.go
  • internal/controller/removed_allow_any_source_namespace_test.go
  • internal/controller/resource_condition_metrics.go
  • internal/controller/status.go
  • internal/controller/suite_test.go
  • internal/controller/superseded_fields_admission_test.go
  • internal/controller/watchrule_controller.go
  • internal/controller/watchrule_source_namespace_test.go
  • internal/git/ado_multiack_test.go
  • internal/git/apply_attempt_test.go
  • internal/git/apply_groups.go
  • internal/git/apply_groups_test.go
  • internal/git/apply_operation_test.go
  • internal/git/apply_recovery.go
  • internal/git/apply_recovery_test.go
  • internal/git/apply_request.go
  • internal/git/apply_siblings.go
  • internal/git/apply_trigger.go
  • internal/git/apply_trigger_test.go
  • internal/git/apply_window.go
  • internal/git/apply_window_test.go
  • internal/git/branch_worker.go
  • internal/git/branch_worker_base_trust_test.go
  • internal/git/branch_worker_fetch_metric_test.go
  • internal/git/deleted_remote_branch_test.go
  • internal/git/dirty_worktree_recovery_test.go
  • internal/git/git_path_refusal.go
  • internal/git/git_push_advertisement_test.go
  • internal/git/git_request_ledger_test.go
  • internal/git/git_roundtrip_ledger_test.go
  • internal/git/inherited_delete.go
  • internal/git/intake.go
  • internal/git/parent_recovery.go
  • internal/git/parent_recovery_test.go
  • internal/git/pending_writes_loop.go
  • internal/git/placement_metrics_test.go
  • internal/git/placement_test.go
  • internal/git/plan_flush.go
  • internal/git/publication.go
  • internal/git/publication_test.go
  • internal/git/push_notification.go
  • internal/git/refresh.go
  • internal/git/refusal_observation_test.go
  • internal/git/refusal_reapply.go
  • internal/git/refusal_reapply_test.go
  • internal/git/render_fidelity_drops.go
  • internal/git/render_fidelity_drops_test.go
  • internal/git/render_fidelity_gate.go
  • internal/git/render_fidelity_gate_test.go
  • internal/git/replay_failure_test.go
  • internal/git/replay_refusal_test.go
  • internal/git/reset_cleanliness_test.go
  • internal/git/retry.go
  • internal/git/retry_test.go
  • internal/git/testdata/git-roundtrip-ledger.golden
  • internal/git/types.go
  • internal/git/worker_manager.go
  • internal/git/worker_manager_test.go
  • internal/git/worker_repo_identity_test.go
  • internal/git/write_gate_test.go
  • internal/manifestanalyzer/placement_test.go
  • internal/queue/fact_index_test.go
  • internal/telemetry/apply_requests.go
  • internal/telemetry/apply_window.go
  • internal/telemetry/exporter.go
  • internal/telemetry/gauges.go
  • internal/watch/applier_dynamic_client_test.go
  • internal/watch/cluster_context.go
  • internal/watch/event_router.go
  • internal/watch/event_router_test.go
  • internal/watch/git_path_acceptance_test.go
  • internal/watch/last_field_managers_test.go
  • internal/watch/owner.go
  • internal/watch/owner_test.go
  • internal/watch/render_fidelity_gate.go
  • internal/watch/source_dynamic_client_test.go
  • internal/watch/source_namespace_test.go
  • internal/watch/target_watch.go
  • internal/watch/watch_plane_state.go
  • internal/webhook/audit_fact_publish_test.go
  • internal/webhook/audit_handler.go
  • test/e2e/Taskfile-e2e.yml
  • test/e2e/apply_trigger.go
  • test/e2e/argocd_bi_directional_e2e_test.go
  • test/e2e/audit_route_attribution_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/refusal_reapply_e2e_test.go
  • test/e2e/setup/argocd/README.md
  • test/e2e/setup/argocd/values.yaml
  • test/e2e/source_namespace_e2e_test.go
  • test/fixtures/layout-corpus/shapes/1-flat-serialized/config/clusterprovider.yaml
  • test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml
  • test/fixtures/layout-corpus/shapes/3-tree-serialized/config/clusterprovider.yaml
  • test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml
  • test/fixtures/layout-corpus/specific-examples/prerequisites/README.md
  • test/utils/utils.go

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
sunib added this pull request to stack #421 October 17, 2026 19:31
@codecov

codecov Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

sunib and others added 3 commits October 17, 2026 19:45
…iggers

Suspension is the only Flux mode, a shared source is information only,
an external resume is always fenced and re-suspended, CatchUp stays as
convergence, and our resources are triggers (FluxTrigger, ArgoCDTrigger,
GitTarget.spec.applyTriggerRef, ApplyTriggerReady), because "applier"
already names the Kustomization or Application itself.

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

An apply trigger holds the applier of a folder: a FluxTrigger holds a
Flux Kustomization (kustomizationRef, quietPeriod), an ArgoCDTrigger an
Argo CD Application (applicationRef), each in the cluster its required,
immutable clusterProviderRef names. A GitTarget names one in its own
namespace with spec.applyTriggerRef {kind, name}, replacing the
unreleased spec.applier and status.applier outright.

The trigger reconciler checks the applier alone (Configured: exists, a
source it can drive, writable, automated sync off; UnsupportedSource for
a pinned or non-Git source) behind its ClusterProvider's accessFrom, and
publishes status.source. The GitTarget's ApplyTriggerReady mirrors the
trigger's Ready and adds the binding check against its own repository
and branch (SourceMismatch), so the GitTarget no longer reaches the
applier's cluster at all.

The branch worker resolves appliers through their triggers and records
each operation on the trigger's status.operation and status.repairOwed
at every transition, through a coalescing StatusWriter off its loop, so
the status is current without a reconcile. The dispatcher's in-memory
ledger is gone. An Unresolved operation makes the trigger Ready=False
(OperationUnresolved).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
UPGRADING, the architecture overview, the bi-directional guide, the
metrics page, the e2e corner spec and the design pages now describe the
FluxTrigger and ArgoCDTrigger kinds, spec.applyTriggerRef and
ApplyTriggerReady. The plan records what step 3 settled as built.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib sunib changed the title feat(applier): suspended appliers, with each apply followed as an operation feat(applier): suspended appliers behind apply triggers, each apply followed as an operation Oct 17, 2026
sunib and others added 5 commits October 17, 2026 20:39
An omitted clusterProviderRef on a FluxTrigger or ArgoCDTrigger is now
the ClusterProvider named "default", with the same schema default
({name: default}) and the same fallback for a literal built in code as
GitTarget.spec.clusterProviderRef. It stays immutable.

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

A Flux Kustomization no longer applies on its own. Its FluxTrigger
suspends it (adoption), and every apply GitOps Reverser starts is one
server-side apply of spec.suspend: false with a requestedAt token. The
operation completes when the reconcile that read the token has ended,
read from Ready and lastAppliedRevision, and once a second apply,
suspending it again with a fence token, is handled: nothing that
started before the suspend is still running. A failed apply is
suspended too, so Flux's retryInterval retry cannot fire; the worker
owns the retry.

- The worker takes the token and records the Requested operation as it
  sends the attempt; the dispatcher writes it through
  (StatusWriter.PersistApplyTriggerStatus) after the recheck and before
  the resume, so a restart finds every apply that may have started.
- Just before the resume the artifact and the decision are read again
  (measurement point 6); a fetch that ends elsewhere is Stale or a
  failure. Current needs a suspended Kustomization that applied the
  commit. An overdue operation is suspended at once.
- spec.suspend applied by another field manager is Configured=False,
  SuspendOwnedElsewhere. The reconciler fences an external resume every
  time, with a Normal Event, recorded as an Unresolved operation, and
  the trigger's finalizer hands the Kustomization back on deletion.
- Gone: the stamp outcomes, the lastAttemptedRevision heuristic and the
  handled-while-suspended bug (measurement point 3).
- apply_revision_mismatches_total counts an apply of another revision.
- internal/applier/fluxsim is the fake that applies the source facts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The trigger's status.operation is written before an apply can start, so
a worker that starts reads it back before it mirrors its first live
event. An operation that has not completed is followed to its end by its
recorded tokens, asking the applier for nothing (ApplyRequest.Recovered),
with its apply window open from the recovery: a late write over an edit
made after the restart is withheld, and the Kustomization is suspended
again before the operation completes. repairOwed is restored.

A worker's own record is read back once, since later reads can lag its
own writes; an apply the trigger's reconciler recorded for somebody
else's resume (no request token) is followed whenever it is listed, so
its window opens while the process runs too. A recorded token that is
nowhere on the Kustomization ends the operation Failed after a fence:
whether its resume was made cannot be told.

The Flux Restart Recovery e2e spec (Serial) restarts the manager while a
wait: true apply runs, and checks that the same operation completes and
the Kustomization ends suspended.

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

A FluxTrigger publishes, in status.source.otherConsumers, the
Kustomizations and HelmReleases that apply its GitRepository and that no
FluxTrigger on the same ClusterProvider holds, information only, and
status.source.prefetch when there are none: a new artifact then applies
nothing on its own.

Flux appliers on one branch that apply one GitRepository have one
operation at a time, so a sibling's fetch cannot land between an
operation's recheck and its reconcile. A push somebody else made,
observed while the branch is busy, has each prefetching source fetch at
once, so the artifact is ready when the apply is due; the dispatcher
holds it back while any FluxTrigger on that source has an operation in
flight. The applier role gains list on kustomizations and helmreleases.

The third review's A/B probe (an edit to B during A's fetch keeps its
value) holds: B, suspended, ignores the artifact A's request fetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Flux guide now describes the applier the code drives: the
FluxTrigger holds its Kustomization suspended and resumes it for one
apply at a time, the manifest omits spec.suspend, flux resume is the
override, and nothing applies while GitOps Reverser is down. Its
example drops retryInterval and keeps a long source interval, which the
guide test now holds it to.

apply-trigger.md corrects its Flux lessons (a long interval is not
exclusive control; Flux has no fetch-only mode unless its consumers are
suspended) and takes in the plan and the measurement it rested on: the
kustomize-controller source facts, the operation, who owns
spec.suspend, restart recovery, shared sources and prefetch, the
measured cost and the decisions. Both pages it folds are removed, and
every reference points at its section instead. The bi-directional,
configuration, architecture, Argo CD and upgrade pages follow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib sunib changed the title feat(applier): suspended appliers behind apply triggers, each apply followed as an operation feat(applier): hold the applier, resuming a suspended Kustomization for each apply Oct 17, 2026
sunib and others added 12 commits October 18, 2026 06:09
The configuration reference now carries the Suspended condition, the
SuspendOwnedElsewhere reason, the consumer and fence fields, the
finalizer and hand-back, the list grants, and the FluxTrigger's own
re-read cadence. Correct the flux reconcile advice, the sent outcome,
the external-resume Unresolved timing, and which rejections take the
15s backoff floor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sendDue walked the appliers by name, so the first applier of a group went
again after each of its own operations: a push somebody else made while it
was out is owed to the whole group. Pushes arriving faster than one
operation lasts starved its siblings. The group's next attempt now goes to
the applier whose last turn is oldest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Consumers missed a HelmRelease whose spec.chartRef names a HelmChart built
from the GitRepository, and listed HelmReleases only at v2, so a Flux that
serves v2beta* read as having none. Both made prefetch true wrongly.

HelmReleases and HelmCharts are now listed at the newest version Flux has
served that the cluster serves. A cluster serving no HelmRelease version has
none only when no HelmChart is built from the source; otherwise the
consumers are unknown and Consumers returns an error (no prefetch).

The Flux applier role gains list on source.toolkit.fluxcd.io helmcharts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…aw it when the apply never started

The worker published the Requested operation the moment it sent an attempt, so a request the
dispatcher then refused (Rejected, Dropped, Stale, RequestFailed) left a phantom Requested record on
the trigger: the rollback carried an older requestedAt, which the writer never wrote over. The same
rule let Record return nil without writing over a newer external-resume record, and a released
trigger whose publication the background writer had dropped still recorded "successfully".

The operation now reaches the trigger first through RecordApplyOperation, written through just
before the resume or sync, and is published only once the requester reports it. The writer matches
operations by identity (token and requestedAt) instead of by time: it moves the same operation on,
writes over none, never replaces another, and puts back what a record displaced when the worker
withdraws it. Record fails for a released trigger, and when the status holds another operation in
flight, so nothing is resumed.

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

completeExternalOperation deep-copied the cached trigger's status.operation, which can still be
nil while the uncached read the hold decided on shows the operation, and then dereferenced nil.
It now completes the live operation and does nothing without one.

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

A FluxTrigger counted as holding its Kustomization whether or not it held it: one that is not
Configured (SuspendOwnedElsewhere), or not fenced yet, left a Kustomization Flux still applies out
of otherConsumers, so a sibling published prefetch: true. A trigger now holds its Kustomization
only while its Suspended condition is True; its own Kustomization is never another consumer, and
prefetch also needs it held in this pass.

That status is up to a minute old, so the dispatcher reads the source's consumers again before it
stamps the source: each must be a Kustomization a FluxTrigger on the cluster names, suspended, held
by GitOps Reverser and fenced now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A forbidden Suspend of a resumed Kustomization was retried forever, while a forbidden read or
release let the trigger go; it now lets go the same way, with a ReleaseFailed Warning. And a
suspend that is never handled (kustomize-controller down) held the trigger with no end: past
10 minutes after the deletionTimestamp the finalizer goes anyway, with a ReleaseFailed Warning
that says what the Kustomization is left as.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a real API server a forced apply of the value a field holds shares the field with the manager
that set it, so a suspend that took over a `flux suspend` co-owned spec.suspend with that Update
entry, and Release's empty apply left the Kustomization suspended while the Released Event said it
resumed (measured in envtest). Release now takes such an update's share first with a forced
spec.suspend: false, and returns the manager that still keeps it suspended (one that applies the
field), which the Released Event names.

fluxsim gave every field one owner; it now keeps ownership as the API server does.

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

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

A token handled is no proof of an apply: a suspend between the resume and
its reconcile (flux suspend, the trigger's own) makes that reconcile a
suspended return, and the old Ready read as Succeeded. A Succeeded now needs
Ready's lastTransitionTime or the latest status.history snapshot to have
moved since the resume (review 4, E).

An applied revision other than the commit is Succeeded only when the source
holds it now; an older artifact read from kustomize-controller's cache is
Failed, so the worker asks again (G). A recovered token stamped over is
judged on what ran since it was asked for, not failed outright (I).

A resume whose write failed without an answer (timeout, lost connection) is
an Unconfirmed operation, followed and fenced, not a RequestFailed that left
the Kustomization resumed (B). fluxsim models history, Ready transitions, a
lagging source cache and a paused queue.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review 4, B: an abandoned operation now fences its Kustomization before the
attempt lets it go, and a recovered attempt asked for before the dispatcher
starts waits for Start instead of failing once. The dispatcher reports which
operations it follows (Following); the FluxTrigger reconciler leaves a
Kustomization resumed for a worker's operation only while it is followed, or
for a minute after it first saw nobody following it, and then fences it and
completes the operation Failed.

F: a resume needs the trigger Suspended=True, read uncached (Ask.Held), and
the reconciler reads the operation before and after the Kustomization, so an
operation completing in between is no external resume. The trigger is also
read uncached before each step that may suspend, so a stale cached read
cannot re-suspend a Kustomization a hand-back released.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The design doc and the Flux guide now say what the code does after review 4: an operation reaches
the trigger only through its record and is withdrawn when nothing started; a Succeeded verdict
needs a reconcile that ran resumed; an older artifact and an unfollowed operation are asked again
or fenced; prefetch counts only triggers that are Suspended; the hand-back takes a shared
spec.suspend and gives up after 10 minutes; and the Flux grant lists helmcharts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib
sunib marked this pull request as ready for review October 18, 2026 07:21
ApplierDynamicClient serves a declared cluster through the client its watches run on, which is all
SourceDynamicClient did for production; its rationale moves to ApplierDynamicClient.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sunib and others added 23 commits October 18, 2026 12:03
… duplicates

Behaviour is unchanged. Gone: ApplyTriggerReconciler.newFlux and its factory
type (never set), inflightAttempt.reason/commit/sentAt and
PrefetchRequest.Branch (written, never read), Suspension.Token (read by one
test), Dispatcher.PollTimeout (never varied; now a const), and three guards that
cannot fail. Collapsed: one request-token fallback, APIReader defaulted in
NewDispatcher, Flux.fence through Suspend, one keptCondition for three
identical helpers, one DescribeCommit, BranchWorker.branchKey. SourceClients is
applier.Clients, and "source cluster" is the applier's cluster wherever it
meant that. Comments still describing the old requestedAt patch, or a
reconciler that never writes status.operation, now describe the code.

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

bi-directional.md is now the overall picture, and opens with who needs it: only
a folder that GitOps Reverser writes and Flux or Argo CD applies. Mirroring a
folder nothing applies, and Flux or Argo CD applying other folders, need no
trigger and keep their usual configuration.

flux.md and argocd.md become bi-directional-flux.md and bi-directional-argocd.md.
The guide tests follow the rename and still hold each complete example to the
code.

State diagrams for the quiet branch, the life of an apply, how a FluxTrigger
holds its Kustomization, and the Application and its sync operation. The receiver
steps live once, in the overview.

The apply-trigger section of configuration.md keeps the fields, the status
shape and the conditions, and leaves the lifecycle to the guides. The Argo CD
support-boundary record drops what now repeats the guide.

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

prefetch checks the trigger's status.source GitRepository, but Flux.Prefetch
stamped whatever the Kustomization's spec.sourceRef names now. A Kustomization
repointed since its trigger last published leaves the old source with no
consumer to fail the check, and its new source, never checked, was stamped:
a consumer Flux applies on its own would apply the commit at once.

Prefetch now takes the checked source and stamps nothing when the
Kustomization applies another, as readAgain already refuses on the apply path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Suspended condition described the Kustomization, and read Suspended=True
while an apply had it resumed. It is now Held, with one reason per state:
Suspended (suspended under our field manager, the last suspend handled),
Applying (an operation of a branch worker in flight and followed),
Suspending (a suspend sent, not handled yet) and NotConfigured. A Held
printer column shows the reason at a glance.

"Fence" is retired for "suspend": status.operation.fenceToken becomes
suspendToken, and the Go names follow (SuspendToken, Suspension.Handled,
suspendAbandoned, suspendUnfollowed, followedSuspend,
applyTriggerSuspendingInterval, ReasonSuspending). The operation phases,
Running and Settling included, are unchanged.

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

A trigger no GitTarget names serves nobody, so it now does nothing: it reads
and writes nothing in the applier's cluster, is not in line for its applier,
and reports Configured=Unknown and Ready=True, both NotReferenced. Ready=True
keeps a Flux health check that waits on the trigger from deadlocking against
the Kustomization that creates the GitTarget naming it.

A GitTarget is eligible when it is in the trigger's namespace, not being
deleted, names the trigger, is admitted by its ClusterProviders, and writes
the repository and branch the applier fetches; never judged from its Ready.
One eligible binds the trigger. None eligible while one cannot be judged yet
is Configured=Unknown NotBound (progressing); each refused is
Configured=False with the shared reason (SourceMismatch,
NamespaceNotAuthorized) or NotBound (stalled), every GitTarget in the
message. status.source is published only while the binding is satisfied.

A FluxTrigger puts its finalizer in place just before its first adoption
write, and keeps it until it has handed the Kustomization back or over, also
while its configuration is invalid (Held=Unknown NotConfigured then says the
Kustomization is left suspended). Once no eligible GitTarget references it,
conclusively, it retires through the same path as a deletion: Held=False
Releasing, suspended first if an apply has it resumed, then released or left
for the next in line, within 10 minutes of Held turning Releasing. It stays
the holder until that is done; a reference returning before then holds the
Kustomization again. An operation still recorded in flight that nobody here
follows completes Failed.

The line for an applier ranks only triggers in line: an eligible reference,
or a FluxTrigger carrying its finalizer. A FluxTrigger with an apply in
flight comes first, then one that holds the Kustomization already, then the
oldest, so an older trigger with the wrong repository, or not admitted,
keeps no younger one out, and a holder is never displaced mid-release.

The reconciler watches what eligibility reads: GitTargets (before and after
an update), GitProviders, ClusterProviders (every trigger) and namespace
labels, and the triggers in line re-run when one moves in or out of it. A
GitTarget reads ApplyTriggerReady=Unknown NotReferenced until its trigger
has observed the reference.

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

A FluxTrigger could suspend any Kustomization its admitted namespace named,
flux-system/flux-system included, and with the chart default accessFrom
every namespace is admitted. ClusterProvider.spec.allowCrossNamespace now
groups the two powers an admin grants an admitted namespace outside itself:

- sources: a WatchRule may watch other namespaces, "*" included. It
  replaces allowAnySourceNamespace.
- appliers: a FluxTrigger or ArgoCDTrigger may name a Kustomization or
  Application in another namespace. Without it, a trigger naming one
  elsewhere is Configured=False ApplierNamespaceNotAllowed (stalled) and
  never asks the applier's cluster. The trigger's own namespace is always
  allowed; in a remote cluster, the namespace of the same name. The check
  is part of ApplyTriggerAdmitted, so the dispatcher refuses too.

Both default to false. It is a delegation to every admitted namespace, not
owner consent, and the guides say so.

allowAnySourceNamespace is removed, not migrated: it is never read, and it
stays in the schema without a default only so a spec-level CEL rule refuses
true ("allowAnySourceNamespace was removed; set allowCrossNamespace.sources").
On spec, any spec change re-runs the rule, so ratcheting cannot pass a
stored true through; false, which the old default stored everywhere, stays
accepted, and so does removing the field. An envtest pins all of it,
including a true stored before the rule existed. A WatchRule that relied on
the old field reports SourceNamespaceNotAllowed naming the new one.

The chart gains clusterProvider.default.allowCrossNamespace.{sources,
appliers}; the e2e applier specs grant appliers on the default provider, as
a user must for a Kustomization in flux-system.

BREAKING CHANGE: ClusterProvider.spec.allowAnySourceNamespace is gone, and
true is refused on any spec change. Set spec.allowCrossNamespace.sources:
true instead; until then, cross-namespace WatchRules stop mirroring. A
trigger whose applier is in another namespace needs
spec.allowCrossNamespace.appliers: true.

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

A folder holding both a ClusterProvider and a FluxTrigger is removed, and
the provider goes first: the hand-back was skipped with an Info log line,
the finalizer came off, and the Kustomization stayed suspended under
gitops-reverser for good. For a provider without kubeConfig its cluster is
still reachable, so the hand-back was possible.

A provider that is gone no longer ends the hand-back: it goes on through
whatever client still resolves. Only when none does is the trigger let go,
with a Warning ReleaseFailed saying the Kustomization is left suspended and
that `flux resume` resumes it.

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

An apply is in flight and the operator is stopped (`helm upgrade`, a node
drain; the chart's maxSurge 0 means the old pod is gone before the new one
starts). The dispatcher's own context ended, so the abandoned path never
ran, and the Kustomization stayed resumed until the next process recovered
the operation, roughly 30-90 s in which Flux applies a new artifact, or a
drift reconcile reverts live edits, on its own.

On the stop path, a Flux operation this process resumed and has not
suspended again is now suspended once, under context.WithoutCancel with a
5 s timeout, without waiting for it to be handled: recovery already handles
a suspend of its own the process did not report. A trigger being deleted is
left to hand its Kustomization back itself.

The promise is narrowed with it: a Kustomization waits suspended while
GitOps Reverser is down, a graceful stop suspends what it had resumed, and
after a crash the apply that was running finishes and the next process
suspends it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A merge to the branch observed while no GitTarget named an applier owed
nobody, so its ForeignCommit was taken and dropped. When a GitTarget then
named a trigger, adoption suspended the Kustomization before its
GitRepository fetched the merge, and the new applier state was owed
nothing: the merge was never deployed until some later push. Separately,
with zero appliers the worker stopped listing its GitTargets, so the new
name was not seen until another event.

A newly created applier state now owes what a first observation owes, a
CatchUp where its kind catches up: for a Kustomization already at the tip
it costs nothing (Current). The worker lists its GitTargets on every pass;
the listing is cached. An Application gets no CatchUp, by the earlier
decision (a sync runs its hooks), so a newly bound one waits for the next
push, refusal or repair, and the guide says so.

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

"The latest managedFields entries are the applier's alone" was taken as
proof that the applier wrote. It is not: an update that removes every
field a manager owns deletes that manager's entry (structured-merge-diff,
merge/update.go), and the newest entry left is often kustomize-controller's
from an earlier apply. Inside a window, `kubectl label cm/x tmp=1` was
mirrored, `kubectl label cm/x tmp-` then looked like the applier alone over
a newer edit and was withheld, and the Repair put tmp=1 back: the undo was
lost on the cluster and in Git.

The Event now carries the time of its latest managedFields entries, and the
window records each edit's time. A write of the applier's alone is withheld
only when its entry is strictly newer than the edit: a late apply moves the
applier's entry forward, an undo does not. A same-second tie is ambiguous
and mirrored, as before; an older entry is the API's undo, mirrored as an
edit.

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

Removing .operation is the community workaround for a stuck hook. Argo CD
then processes the Application no further (it acts on an operation only
while .operation is set, and clears it only once the state completed), and
its operationState stays Running. GitOps Reverser followed such a sync of
its own forever, and refused every later request as "in progress".

Our sync, Running or Terminating with .operation gone, is now Failed once it
is overdue (by then any controller pass in flight has written), and the
worker asks again. A request that finds such a state, unchanged across its
whole wait, treats it as no operation in progress and syncs over it; a
retry pending keeps .operation, so it is still waited out. The test of the
retry-pending shape now carries .operation, as Argo CD writes it.

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

- onRefusal: Reapply said every Kustomization on the GitRepository applies a
  refetch and advised a separate GitRepository. Under the suspended model
  only the consumers no trigger holds, status.source.otherConsumers, apply
  it. Reworded in the API (and the CRD) and in configuration.md.
- The chart README's uninstall now says to delete the GitTargets or the
  triggers while the operator still runs: a FluxTrigger that holds its
  Kustomization keeps a finalizer only the operator removes, and one left
  after helm uninstall wedges namespace and CRD deletion. fluxtriggers and
  argocdtriggers join the CRD lists.
- The status guide said kstatus never reads Ready. It does, as a fallback
  when Reconciling and Stalled decide nothing, which is why an idle trigger
  is Ready=True.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The restart-recovery spec asserted that no process had suspended the
Kustomization after a rollout restart mid-apply. A graceful stop now
suspends a Kustomization the stopping process had resumed, so the spec
asserts that instead; the restarted manager still completes the same
operation, Succeeded, and the Kustomization stays suspended.

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

A pass over every document the trigger work touches, against the code as
built:

- "owed" is gone from the user docs; the life of an apply calls the state
  before a request Due, held only in the branch worker's memory.
- Both guides' complete examples declare the default ClusterProvider with
  allowCrossNamespace.appliers, since their applier is in flux-system or
  argocd; the guide tests check it through ApplyTriggerAdmitted.
- The rule (a trigger acts only while an eligible GitTarget references
  it), the line of triggers, the release exits, a gone ClusterProvider,
  the first-naming CatchUp and the newer-than-the-edit withholding rule
  are stated where the guides, configuration.md, architecture.md and
  interpreting-metrics.md describe them; definitions.md gains Eligible
  and In line, and Held lists every reason.
- Troubleshooting rows for ApplierNamespaceNotAllowed, NotBound and an
  idle NotReferenced trigger; the test tables name the new tests.
- The design doc records the decisions of 2026-10-19.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The branch worker, the applier dispatcher, the watch plane and their docs
described state with accounting words (owed, owes, debt, obligation) that
name no state. They now say what is pending, for whom, and what ends it.

The status field status.repairOwed on FluxTrigger and ArgoCDTrigger is now
status.repairPending, with the RepairPending printer column. It was never
released (it arrives with the trigger kinds in this stack).

In the branch worker: applyDebt is applyInbox, the mailbox other goroutines
write observations and attempt reports into; reapplyOwed/owedRefusal are
unsentRefusals/unsentRefusal, a Refusal request decided but not yet sent.
The render-fidelity condition message says "waiting for revision N"
instead of "owes revision N".

docs/definitions.md lists the words under "Words we do not use", and
defines pending, carries and answers for apply reasons.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestDispatcher_LooksTheApplierUpInItsCluster failed about 1 run in 10 with
two resume patches where it expected one. Its Kustomization was not
suspended, so the simulated kustomize-controller reconciled it as soon as
the fetch landed the new artifact, and that status write could move the
resourceVersion between the resume's read and its apply. The resume was
then refused with a Conflict and made again, which is what the dispatcher
should do, but patches() lists the refused apply too.

The Kustomization a trigger holds is suspended, as every other dispatcher
test's is (behindSource). Suspended, the fetch changes nothing the resume
reads, and the test checks only what it is about: the lookup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestFactIndex_RewritingOneKeyDoesNotStrandTheEvictionOrder failed in CI
on d9b5320 ("2969" is not less than "2000"). Its barrier waited for the
eviction order's capacity to pass the rewrite count, but every rewrite
appends two references (exact and latest) and the follower stores the
batch one fact at a time under the lock. The capacity passes 4000 when
4200 to 5500 of the 8000 references have landed (measured), so the sweep
could run mid-batch, compact what had landed, and leave the rest of the
batch to grow the order again before the assertion read it.

Wait for the order's length to reach 2*rewrites instead, and keep the
capacity check as a precondition.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The bi-directional leg is the e2e leg that fails most. 8 of its CI runs
between 2026-09-30 and 2026-10-19 died in `_argocd-installed` before any
spec ran: argocd-redis sat in ImagePullBackOff because
ecr-public.aws.com answered "429 Too Many Requests". 4 more from
2026-09-28 and 2026-09-29 stopped the same way ("argocd-redis not ready,
Available: 0/1"), from before the task dumped pod events, so their cause
is not on record. The argo-cd chart pulls redis from ECR Public, which
rate-limits anonymous pulls by source IP, and GitHub's runners share
IPs. The kubelet's own back-off and the task's second helm attempt both
ran into the same limit, and so did reruns.

mirror.gcr.io, Google's Docker Hub cache, serves the same image. The
values pin the digest ECR refused (sha256:08ad0b1d..., the chart's
8.2.3-alpine), so the change only moves where the bytes come from. The
README says to re-pin them on a chart bump.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
prepare-e2e aborted in `_crds-installed` on CI runs 34824995173 and
35235710425, eight seconds in, with

  .status.conditions accessor error: <nil> is of the type <nil>,
  expected []interface{}

The task applies the project CRDs and runs `kubectl wait
--for=condition=Established` straight after. Until the apiextensions
controller writes its first conditions, a new CRD has
`status.conditions: null`, and kubectl's condition check returns an
error on that instead of waiting (ConditionalWait.checkCondition in
k8s.io/kubectl pkg/cmd/wait/condition.go), so the wait fails well inside
its timeout. A jsonpath wait fails the same way on the null list.

hack/e2e/wait-crds-established.sh runs the wait again on that error, and
only that one, within the original deadline. A real timeout or any other
failure still fails at once. The three waits that follow their own CRD
apply use it: _crds-installed, the plain-manifests install, and the demo
CRDs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The full-manager leg of CI run 39246816224 sat 28 minutes in prepare-e2e
until the 30-minute suite timeout killed it, and left nothing to go on:
utils.Run collects a command's output with CombinedOutput and returns it
only when the command ends, so the timed-out BeforeSuite's report held
the `running: "task ... prepare-e2e"` line and nothing after it.

utils.RunStreaming also copies the output to GinkgoWriter as it comes,
and a timed-out node's report includes what GinkgoWriter received. The
next hang then names the task it stopped in. A failed prepare now
prints its output once, in order, instead of as one quoted string
inside the error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A live event queued behind a replay resync could be dropped for good. The
branch worker answered the resync and took the next item at once, while
the scope was recorded clean only by the caller, on another goroutine,
after it read the answer. The event met a gate that was still closed, was
dropped at V(1), and was never replayed: the watch cursor had moved past
it. The e2e spec for a rules[].sourceNamespace override failed 9 times in
CI this way, on a sibling scope whose resync sat ahead of the event.

The worker now records the scope clean itself, once the resync applies and
before it answers. The request carries the stream's render-fidelity
revision, so a replay from a retired stream still opens nothing. The drain
records the same result again, which the gate takes unchanged, and still
publishes the condition. This also opens the gate for a replay the remote
held back: its caller was answered with the outage, recorded nothing, and
the GitTarget stayed closed after the snapshot landed.

A write the closed gate drops is now counted in
gitopsreverser_git_writes_dropped_total{kind, reason}, kind event or
atomic, reason render_fidelity_pending or render_fidelity_diverged. The
first drop under each gate status is logged at Info, naming the scopes the
gate waits for. Drops while another collection of the GitTarget is still
replaying are unchanged and now visible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A last pass over the docs, the CRD descriptions and the comments before
#419 and #420 merge, so nothing describes the intermediate spec.applier
model or behaviour that is gone.

- Guides: a trigger no GitTarget names reads nothing; one named only by
  ineligible GitTargets does (NotBound or a refusal). The ArgoCDTrigger
  also goes Ready=False on OperationUnresolved. Samples say they need
  allowCrossNamespace.appliers.
- Reference: architecture, configuration, definitions, security-model,
  components, rbac, metrics and status-conditions describe triggers that
  hold their applier, the Repair reason, the receiver, the optional
  applier ClusterRoles, and the ratcheting answer for a stored
  PushEmptyCommit.
- Chart: helm upgrade never upgrades crds/, so the README says to apply
  the release's crds.yaml first; applierAccess comments describe holding.
- Design: apply-trigger.md matches the code; superseded receiver and
  reconcile-trigger mentions elsewhere point at it.
- push-notification-and-reconcile-trigger.md goes back to design/: Go
  code cites it by section, and INDEX keeps such pages there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sunib and others added 2 commits October 19, 2026 19:04
…like the other servers

Every other listener lives under servers.<name> with bindAddress and
port; the receiver was a top-level receiver.bindAddress with its port
parsed out of the address. It is now servers.receiver.{enabled,
bindAddress, port}, shaped like servers.admission: enabled (default
false) switches the flag, the containerPort and the <release>-receiver
Service on together, bindAddress defaults to :9292 as the kustomize
install binds, and port is the containerPort. The Service stays on port
80, as Flux's webhook-receiver is, since a route sits in front of it.

receiver.bindAddress was never released, so nothing migrates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sume is never written away

The fake source cluster wrote a status by reading the object, changing it
and updating it on the tracker, while a client patch did the same in its
reactor, with nothing ordering the two. A resume (suspend: false and a
requestedAt token) that landed between a reconcile's read and its status
update was written away by it: the token was never handled, and
TestFluxProgress_AnApplyOfAnOlderArtifactIsAskedAgain failed in CI with
the operation still Running after 5s. The API server's resourceVersion
check makes such a write fail instead; one mutex around both
read-modify-writes stands in for it here.

TestSim_AStatusWriteNeverWritesAResumeAway holds a status write between
its read and its write and sends a resume in the gap; without the lock
the resume lands and is lost.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sunib
sunib merged commit 519b483 into main Oct 19, 2026
21 checks passed
@sunib
sunib deleted the feat/suspended-appliers branch October 19, 2026 20:37
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