spec: current behavior, reviewed at
670032d2on 2026-10-27. The code depends on this document; change one, change the other. Index:../INDEX.md
A CommitRequest is a one-shot “save now” command for one GitTarget. It does not mirror the
CommitRequest object. Instead, it can attach a message to a matching open commit window and asks the
worker to close that window after the requested collect delay.
A CommitRequest is the one operator kind that can also live on a source cluster. It is picked up
in one of two ways, and both hand it to one core
(commitrequest_core.go), which decides everything
else once:
- On the control cluster, the
CommitRequestReconcilerserves it with the operator's own client, as any operator kind. - On a source cluster, the
CommitRequestServerwatches the requests of everyKubeProviderwhosespec.commitRequestsisReconcileorReconcileAndDeleteFinished, through that provider's credential, in the namespaces it lends itsGitTargets inspec.allowedGitTargets. It writes status, and withReconcileAndDeleteFinisheddeletes finished requests, through the same credential. A rotated kubeconfig restarts the watch on a client built from the new credential, so watches and status writes never keep the old one. It never writes the CRD, and reports its state in theKubeProvider'sCommitRequestsServedcondition.
The core takes one step at a time, each decided again from the object as it is stored. Its
responsibilities, in order: admission by spec.allowedCommitRequests; the request's author; the
attach and its outcome; the apply catchUp: AfterPush asked for; and retention.
The named GitTarget's KubeProvider alone picks the path that serves a request, its owner:
| The request is on | The GitTarget's KubeProvider |
What happens |
|---|---|---|
| the control cluster | inCluster |
the control-cluster path serves it |
| the control cluster | a kubeconfig, with a Reconcile value |
left alone: the kubeconfig may point back at the control cluster, and then its watch serves it |
| the control cluster | a kubeconfig, with commitRequests: Ignore |
NotAllowed, saying to create the request on that cluster |
| a source cluster that provider P watches | P | P's watch serves it |
| a source cluster that provider P watches | any other provider | left alone |
| a source cluster | gitTargetRef names no namespace |
not picked up, and no status |
| either | the GitTarget does not exist |
GitTargetNotFound once attachTimeout has passed since creation |
Three rules keep several observers of one request safe:
- A path writes only what it knows to be true. Only the owner serves a request and writes its
progress. A path that is not the owner writes only
GitTargetNotFound, and only into an empty status, because nothing else is true for every observer. - Every status write is conditional on the resourceVersion its decision was made from. On a
conflict the writer reads the request again and decides again, so a
GitTargetNotFounddecided from an older version never overwrites an owner that has started. - The owner claims a request by writing its first status, and attaches only after that. The
first write records the author and
Ready=Unknown, reasonSaving. If it conflicts and the fresh object already holds an outcome, the owner stops without saving. After the claim only the owner writes, so a later conflict comes from its own writes, and an outcome the worker produced is written onto the fresh object as it is; the save is never restarted.
Ownership is structural, not "the process that wrote the status": after a restart, the owner is
still the path the KubeProvider picks, finds its own claim, and resumes with the author it
recorded.
A control-cluster request for a GitTarget that mirrors another cluster is refused rather than
served, because its author would come from the control cluster's audit and its edits' from the
other cluster's. A request on a source cluster must name the GitTarget's namespace, because that
namespace belongs to the control cluster; the path cannot refuse a request without one, since a
kubeconfig pointing back at the control cluster sees the control cluster's requests, where an
omitted namespace is valid. Known gap: a control-cluster request whose GitTarget's provider has a
Reconcile value and points somewhere else gets no status from anyone.
A grant in the GitTarget's spec.allowedCommitRequests sets exactly one of two fields, enforced in
CEL. consumerNamespaces admits the request's namespace on the control cluster, by its labels there,
and only on the control-cluster path. sourceNamespaces admits it on a source cluster, by name or
"*", only on the source-cluster path, and only when the KubeProvider lends that namespace to the
GitTarget in spec.allowedGitTargets. Omitted, the list is [{consumerNamespaces: {from: Same}}].
A request no grant admits is NotAllowed, with a message naming the field that applies. The
GitTarget that admitted a request is recorded in status.gitTargetUID: a GitTarget of that name
with another UID replaced it and must admit the request again, and its refusal is written only once
no worker holds the request.
The request identifies the target in spec.gitTargetRef, may provide spec.message, and sets
spec.window (which window to attach to, how long to wait for one, and the timers that close it),
spec.whenNothingToCommit and spec.catchUp. It is handled by the target’s single branch worker,
so resource events and the attach request share one FIFO.
The worker attaches a request only when all of these match an open window:
- GitTarget name and namespace;
- the named actor, if either side has one; and
- whether each side names an actor (
AttributionOutcome.NamesActor).
A request with no named author can attach to either a configured-author or an unresolved live window, but never to a named actor’s window. A request with a named author can attach only to that actor’s named window. Therefore one user’s request never finalizes another user’s work.
A window is only ever opened by a write; a request attaches to one and never opens one. On first
registration, the worker sets the attach deadline to registration plus window.attachTimeout, and
repeated reconciles are idempotent: they restart nothing and, for attach: Next, close nothing again.
With CurrentOrNext the request attaches to the author's window already open at registration, before
any deadline is consulted, so attachTimeout: 0s can attach at all. Otherwise it waits. A request
whose deadline has passed resolves and never takes a later window; when its deadline and a matching
write are ready on the same wake, expiry wins.
On attach, the request's idleTimeout and maxDuration replace the window's timers: maxDuration
runs from the attach, and so does the first idle interval. A waiting request gets a new window before
the target's own timers are applied, so a target with idleTimeout: 0s does not close the window
before the request can attach.
attachTimeout defaults to "5s" rather than "0s", and counts from the worker's registration,
which is after the request's own author was found. The write a request exists to publish reaches
the worker through the same audit join, and on a busy cluster after it, so a zero wait is shorter
than the window-open latency the pipeline can produce.
The two durations are separate settings with separate clocks: attachTimeout runs from
registration, maxDuration from the attach. Each is a pointer, so an omitted value and an explicit
"0s" stay distinguishable. A zero attachTimeout attaches to a window already open, or gives up at
once, and never shortens the collection that follows; a zero maxDuration finalizes right after the
attach.
Other window closers can close an attached window early, carrying its message: an author or target
change, the byte cap, a resync, or shutdown. An apply coming due does not. While a request is
registered, from its registration until it resolves, the branch worker makes no
flush and sends no apply request, because a save
defines its own commit boundary: an apply landing between the request and its writes would revert
its first edits before they reach Git. A Git host's push notice leaves an attached window open too.
Each request attaches
to at most one window and cannot rename a finalized commit, including one waiting for push. A bundle
provides no ordering guarantee. The wait does not reserve a transaction. Competing requests are
not queued: a later request of the same author on the same GitTarget is refused with
SaveInProgress while the earlier one waits or collects, except that a Next request closes a
collecting window under the earlier message and then waits for its own.
spec.message is literal, including template-like text and surrounding spaces. It is never parsed
as a template, so a request author cannot execute one. Omission uses
GitTarget.spec.commit.message.liveTemplate. A target may set
GitTarget.spec.commit.message.requestTemplate to frame the message with the window's resources;
the message still arrives unaltered, as .RequestMessage, and a template that does not render it
is rejected at admission, so the request's bytes always reach the commit. A requestTemplate that
fails to render commits the message verbatim rather than losing the window. A present value accepts 1–1024 Unicode characters;
newline is allowed, other ASCII controls and whitespace-only text are rejected. Validation never
truncates accepted text. With the default whenNothingToCommit: Resolve, a no-op creates no commit;
CommitEmpty records the message in an empty commit instead. The message does not change Git
identities.
Automation stops on Ready=True or Stalled=True. Require Pushed=True and status.commit when a
pushed commit is required; Ready=True also includes successful no-commit outcomes.
A request's author comes from the audit event that created it, on the cluster it was created on, the
same way a watched write's does: the core looks it up in the same fact index, on the audit route of the
GitTarget's KubeProvider, by the request's UID, and waits up to --author-attribution-grace. Only a
fact marked as the creating write answers: a create, or a patch answered 201 Created
(server-side apply of a new object). A later write to the request, such as a controller patching its
labels before the request is claimed, names who made that write, so it never names the author and
never moves the request to that actor's window. It acquires the fact stream of commitrequests on that
route, so the fact follower reads those audit facts at all.
| Outcome | The request |
|---|---|
| attribution is off | names no author, and nothing waits for one |
| a fact names the creator | names them, in status.author |
no fact, on a provider whose AuditFactsReceived is not True |
names no author: the cluster sends no audit |
| no fact, on a provider that has received facts | Ready=True, reason AuthorUnresolved: its fact went missing, so it saves nothing rather than take the window that should stay unattributed |
The wait runs whenever attribution is on; whether the provider has received facts is checked only after it, so a request is not refused while its provider's first audit event is still in transit. The author is written in the claim, and read from there afterwards.
The author decides which window the request attaches to. The matched window decides the Git author:
- configured-author, replay, and resync windows use the configured committer;
- a resolved live attribution fact uses the authenticated actor; and
- a live attribution miss uses
unknown (attribution unresolved) <attribution-unresolved@gitops-reverser.invalid>.
See architecture: author identity for the three Git author states.
The owner claims the request, attaches it at once, and polls the worker.
| Outcome | Conditions |
|---|---|
| Commit pushed | Ready=True, Pushed=True, reason Committed; status.commit and status.branch are set |
| No same window before deadline | Ready=True, Pushed=False, reason NoWindow or WindowMismatch |
Nothing to commit, with whenNothingToCommit: CommitEmpty |
Ready=True, Pushed=True, reason NoWindow or AlreadyPresent; status.commit is the empty commit |
| Window produced no diff, and the remote agreed | Ready=True, Pushed=False, reason AlreadyPresent |
| Terminal write failure, withdrawal before the worker holds it, shutdown with an unresolved held write, or a target that may not be written | Ready=False, Pushed=False, Stalled=True, reason FinalizeFailed |
| Refused by the grants, or made on the wrong cluster | Ready=False, Stalled=True, reason NotAllowed |
The GitTarget did not exist within attachTimeout |
Ready=False, Stalled=True, reason GitTargetNotFound |
An earlier save of the same author on the GitTarget still in progress |
Ready=False, Stalled=True, reason SaveInProgress |
| No audit fact names the creator, on a cluster that sends them | Ready=True, Pushed=False, reason AuthorUnresolved |
A retryable materialization or push failure keeps a held request in WaitingForPush; it is not a
terminal outcome. Publication has to settle the request or the worker must establish that it will
no longer execute it before the controller can report failure.
Ready=Unknown with Reconciling=True is the normal in-progress state, with the phase the worker
reports as its reason: Saving (claimed, not yet registered), WaitingForWorker, WaitingForWindow,
CollectingWindow, WaitingForPush. Once the
request is CollectingWindow or WaitingForPush the worker holds it, and only the worker ends it. Before
that, a request still unresolved attachTimeout + maxDuration + 120s after creation is withdrawn from the
worker, which cancels it for good, and fails with FinalizeFailed; a GitTarget that never started a worker
is named in the message. The withdraw rides the worker's queue behind every attach, and is a no-op for a
request the worker holds, so the reported failure and the worker's answer cannot disagree.
The withdraw goes to the worker that accepted the request's attach, not to whichever worker the GitTarget names now: a worker shared by several GitTargets outlives a deleted one, and a retired worker finishes its last push after the manager stopped listing it. Only a request no worker holds falls back to the GitTarget, and only then does "no GitTarget" or "no worker" mean that nothing can commit it. Once the controller starts withdrawing it sends no further attach, and the worker keeps a request's outcome while any attach for it is still queued, so a late attach can never register a withdrawn request again. A worker whose loop has exited, including one that never started because its GitProvider could not be read, gives back every request it never acted on and answers a withdraw at once.
Committed and AlreadyPresent are decided by the push, including the no-commit one.
(WindowMismatch is decided locally, at the deadline: no window was attached, so there is nothing
for a push to say. So is NoWindow under Resolve. Under CommitEmpty a NoWindow request records
its message in an empty commit, and it resolves when that commit reaches the remote.) A window that
produced no diff used to resolve AlreadyPresent at finalize, on the strength of the local plan.
That was only sound while every cycle fetched before it planned. It no longer does (see
how the branch worker reads the remote),
so the plan may have run against a tree the remote has moved past, and the replay that follows a
rejected push can turn the same captured object into a commit. "Already present" is a claim about
the remote, so the remote settles it: a no-diff request resolves AlreadyPresent when the push confirms there was
nothing to add, and Committed when the replay produced a commit after all.
A request whose write is decided stays identifiable until publication. It remains attached to
that write, and the controller keeps re-sending its attach every couple of seconds until it reads
an outcome. The worker keeps that attachment even when local materialization is deferred, so
the re-send is recognized as the same request: it cannot register afresh and expire into
NoWindow while its commit waits out the push cooldown, and it cannot claim the next same-author
window and stamp its message on somebody else's commit. Its attach deadline is spent and no longer
arms anything.
A worker that stops while still holding the write fails the request, because the controller does not time out a request the worker holds, and "the worker stopped before the commit reached the remote" says what happened.
Two gates stop a GitTarget being written at all: spec.suspend, and a render-fidelity check that
has not passed. One branch worker serves every GitTarget on its branch, and each gate applies per
GitTarget, so a sibling target's save is unaffected. A third condition, an unreachable remote, is
not a gate but a retry:
| Path | Suspended | Render fidelity not established | Remote unreachable (commit or push) |
|---|---|---|---|
| Live window, no request | The window still scans, writes nothing | Writes dropped on arrival, and the window dropped at close; the next resync re-derives them | The decided write is kept and published by a later push |
| Window a request is attached to | The request fails; the window still scans | The request fails | The request stays WaitingForPush |
Request no window reached, Resolve or CommitEmpty |
The request fails; no empty commit | The request fails; no empty commit | CommitEmpty: the record is kept, WaitingForPush |
| A gate closes after the request's window closed | The write is pushed and the request resolves with it | Same | Same, once a push lands |
A request fails with FinalizeFailed and the gate as its message, under either
whenNothingToCommit: NoWindow would say the save saw no writes, and a target that drops or
suppresses its writes cannot say that. WindowMismatch is the exception, because another author's
window refused that request and its reason says so.
The gates are read when the window closes, which is when the write is decided and normally when it is committed. A write decided before a gate closed is pushed rather than kept back, because a write that never left the worker would surface later, out of order.
A decided write is kept when the remote cannot be reached, whether that happens at its commit (a
rebuild of the checkout that needs a fetch) or at its push. The worker retries on its own schedule
(10s, doubling to 5m), with parent recovery owning attempts while it remains open. A
request riding the write stays WaitingForPush until publication resolves it or worker shutdown
fails it. An unavailable remote can leave it pending indefinitely, but retries no longer depend on
another commit arriving. Only a failure of the write itself fails the request: a refused plan, or a
write that cannot be made. A missing parent branch is a remote that cannot take the write yet, and
holds the request the same way, and its WaitingForPush message says why the push has not landed,
in the words its GitTarget uses. While an outage has paused the branch's intake, a new request is
refused at admission and the controller sends it again; past its safety window, it fails with
FinalizeFailed only after withdrawal confirms that the worker does not hold it.
A finished request (Ready=True or Stalled=True) is stamped configbutler.ai/delete-after, now
plus --commit-request-ttl, and deleted once that time has passed; the delete is conditional on the
version that was judged. The annotation is the contract: removing it keeps the request, and a value
already there is never replaced. A request that asked to catch up is deleted only once Applied is
settled. On the control cluster this always applies. On a source cluster only
commitRequests: ReconcileAndDeleteFinished stamps and deletes; with Reconcile nothing is stamped,
and an annotation stamped before a switch back to Reconcile stays without effect.
Retention belongs to the path that owns the request, or that refused it because no path serves its
GitTarget. Another path that deletes finished requests, such as a second provider watching the same
namespace, or the control cluster under a kubeconfig that points back at it, leaves the request
alone, whatever delete-after it carries. Once the GitTarget is gone no path owns the request,
and any path that deletes finished requests may.
The budget charges every kept item its payload bytes, its message, and a fixed overhead for its bookkeeping, so a stream of empty saves fills it too. It counts queued work, retained writes, and requests still waiting for a window. A refusal pauses the branch's intake until a push lands; see the branch-worker pending-writes plan. A refused request is sent again by the controller.
For a request the worker already holds, saturation does not change its window membership or replace its decided write with a newer snapshot. A refused replay entry fails its own request; unrelated retained writes can still publish. Requests accepted but still waiting for a window keep their existing attach deadline and no-window behavior.
An unaccepted request is retried within the controller's safety bound. Neither an API object created successfully nor a later source snapshot proves that the worker accepted that save. Resource watches and save commands arrive independently: a save is not a barrier covering every Kubernetes mutation before its creation.
Pausing the live worker preserves accepted work in memory. Restarting the process does not preserve its attachments or outcome receipts. Startup replays current watched state, subject to pruning, but cannot recreate the same save history. A push accepted immediately before a crash can therefore be followed by another empty commit when the request is resent. A lost push response also needs remote evidence; equal tree content alone cannot prove that an empty save's message was published. Durable save recovery belongs to the future HA plan.
The complete status vocabulary is in the status conditions guide.