API-first, not API-only. Anyone can still write to the branch. The Kubernetes API decides only what happens when both sides change the same object.
GitOps Reverser treats changes persisted through the Kubernetes API as its normal input. It captures selected live objects, writes their desired state into Git, and publishes quickly while grouping bursts into useful commits. Another writer moving the remote branch is an expected exception: the worker fetches that new base and replays the writes it has not yet published.
The short version. A captured object, meaning the live resource as the watch saw it, is the complete desired content of the YAML document that holds it. When an API change and a Git change reach the same object, publication writes the API object over it, including the fields only Git changed; every other document keeps what Git says. There is no merge and no conflict state to clear, and a branch that someone else moved costs a replay within the three-attempt cycle. Story 2 walks that case, and the deferred merge investigation records what preserving both sides would require.
How the rest is arranged. The structures name the moving parts once. Four stories then follow a single change each: a normal publication, a branch that moved underneath one, a save request, and an idle target. The timing sections after them are for tuning and debugging.
Publishing before reading the branch is a design decision. A remote request can take a second in a particular deployment; requiring a fetch before every push would charge every API edit for the less common case of a competing Git writer. The push already checks the remote branch, so a healthy worker can plan locally and spend its network budget on publication.
This guide describes the shipped behavior. Retained work is retried on the worker's own backoff; failure timing says what that means in practice. The push receiver lets a Git host report a push, the applier claim design covers what follows one, and architecture covers the wider operator.
One branch worker serializes writes for each repository branch. Targets on that worker share its queue, checkout, and push cooldown. Their commit settings and destination paths remain target-specific.
flowchart LR
CP["KubeProvider<br/>source connection and authorization"] --> API["Kubernetes API"]
WR["WatchRule / ClusterWatchRule<br/>resource selection"] --> WATCH["Watch manager"]
API -->|"persisted object events"| WATCH
AUDIT["Optional audit facts<br/>actor attribution"] -.-> WATCH
GT["GitTarget<br/>source, branch, path, commit window"] --> WATCH
WATCH --> QUEUE["One FIFO per provider and branch"]
CR["CommitRequest<br/>close a matching window"] --> QUEUE
QUEUE --> WINDOW["Open window<br/>one author and one target"]
QUEUE --> SNAP["Snapshot / atomic write"]
WINDOW --> PENDING["Retained PendingWrite records"]
SNAP --> PENDING
PENDING --> LOCAL["Plan, edit, and commit locally"]
GP["GitProvider<br/>remote and credentials"] --> PUSH["Compare-and-swap push"]
LOCAL --> PUSH
PUSH --> REMOTE[("Remote branch")]
PUSH -. "remote moved: fetch and replay" .-> PENDING
| Structure | Responsibility |
|---|---|
KubeProvider |
Selects the source cluster and its access boundary |
WatchRule, ClusterWatchRule |
Select resources and route them to a target |
GitTarget |
Binds source state to a Git path and branch; sets commit shaping |
GitProvider |
Supplies repository access and commit identity settings |
BranchWorker |
Owns branch ordering, local repository operations, and publication |
| Open window | Coalesces consecutive events from one author for one target |
PendingWrite |
Retains captured content and resolved write metadata until publication |
CommitRequest |
Claims a matching window and reports its eventual outcome |
The watch stream supplies object state. Audit facts supply authorship when attribution is enabled.
Admission captures the submitter of a CommitRequest. These inputs have separate jobs; mirroring
continues without actor attribution. See the attribution contract.
The queue and retained writes are in memory. They are sufficient for replay during the worker's lifetime, but do not form a durable event log across a process crash. A restarted watch rebuilds current state; it cannot reconstruct every unpublished intermediate edit or its original author.
A burst of edits by one actor normally becomes one commit after the target's silence window. Repeated changes to the same resource within that window retain the latest captured value. Changing author or target closes the current window, preserving commit attribution and scope.
sequenceDiagram
actor Editor
participant API as Kubernetes API
participant Watch as Watch and attribution
participant Worker as Branch worker
participant Git as Remote branch
Editor->>API: Edit a selected resource
API-->>Watch: Persisted watch event
Note over Watch: Skip unchanged sanitized updates<br/>Optionally await an author fact
Watch->>Worker: Enqueue captured object
Note over Worker: Group matching events until silence<br/>Plan against the last known usable base
Worker->>Worker: Create local commit and retain its input
Note over Worker: Wait only for any remaining push cooldown
Worker->>Git: Read receive-pack advertisement
Git-->>Worker: Advertised branch SHA
Worker->>Git: Send commits with expected old SHA
Git-->>Worker: Accept update
Worker->>Worker: Release retained writes
On the healthy path, there is no pre-publication fetch. The client checks the advertised SHA against the cycle's base, and the server checks the expected old SHA when accepting the update. The server check covers a competing push that happens after the advertisement was read.
The first usable base still requires a fetch, reached through the first write or resync. Starting the worker alone opens no Git connection. Repository preparation handles an existing target branch, a fallback to the default branch, or an empty repository. Snapshot reconciliation gathers the selected live resources and applies its scoped mark-and-sweep only after the initial snapshot is complete. See watch-list reconciliation.
The default 5s commit window is a rolling silence timer. The fixed 5s push cooldown starts when
a publication succeeds. These clocks overlap.
| Control | Value | Starts or resets when | Purpose |
|---|---|---|---|
GitTarget.spec.commit.window.idleTimeout |
Default 5s; 0s disables waiting |
A same-author, same-target event enters the open window | Group an editing burst into one commit |
GitTarget.spec.commit.window.maxDuration |
Default 1m |
The window opens | Stop continuous activity from postponing a commit |
PushCooldown |
Fixed 5s per worker |
A publication succeeds, including a verified no-op | Accumulate finalized commits for one push |
| Identity boundary | Immediate | Author or target changes | Keep commit attribution and scope separate |
| Atomic write | Immediate local processing | A caller-defined batch arrives | Preserve a batch after finalizing earlier open work |
| Retained-byte threshold | Default 8Mi |
Estimated open plus retained bytes reach the threshold | Close the open window under memory pressure |
| Shutdown | Immediate attempt | The worker exits | Attempt to publish finalized work without waiting for cooldown |
| Apply due | Immediate | An applier on the branch has an apply due | Close the open window and publish every write decided so far without waiting for cooldown, so the apply includes them |
The first publication can push as soon as a commit is ready. There is no initial five-second
push wait. Setting commit.window.idleTimeout: 0s makes local commits immediately; subsequent publications
still share the branch cooldown. Multiple targets do not each receive a separate push allowance.
For a healthy branch with no apply due, a useful approximation is:
commit ready = last matching event at the worker + commit window + local planning time
push starts = max(commit ready, previous successful publication finished + 5s)
visible in Git = push starts + Git publication time
Watch delivery, attribution, queue backlog, and contention add time outside that approximation. Timers make work eligible; they cannot interrupt a Git operation already running on the worker.
sequenceDiagram
participant E as Events reaching the worker
participant C as Local commits
participant P as Remote publication
Note over E,P: Illustrative times, idleTimeout = 2s<br/>Previous successful push finished at t=0
E->>C: t=1: Alice edits
E->>C: t=2: Alice edits again, silence deadline moves to t=4
C->>C: t=4: Finalize one commit
Note over C,P: One second of cooldown remains
C->>P: t=5: Start one push
Note over E,P: With the default 5s window, this burst finalizes at t=7<br/>The cooldown has already elapsed by then
The idle timeout restarts on every matching edit, so continuous edits keep a window open until its
maxDuration (1m by default, counted from the window opening) or another closing condition, such
as an identity change or the byte threshold. A five-second idle timeout therefore does not promise
publication within five seconds of the first edit. A CommitRequest that attaches replaces both
timers with its own idleTimeout and maxDuration, both counted from the attach; an omitted
idleTimeout leaves maxDuration alone to close the window. Its attachTimeout is separate: it
runs from the worker's registration of the request and only bounds the wait for a window to attach
to. The commit-window contract owns the full rules.
Three gates sit between a captured object and a commit. Each fails differently, and only one of them is retried automatically, so it is worth knowing which one you are looking at.
| Gate | What it protects | What happens when it closes |
|---|---|---|
| Sensitive-resource encryption | Secrets and configured sensitive types must never reach Git in plaintext | The write fails rather than falling back to plaintext. There is no opt-out |
| Acceptance of the Git path | The target's folder must be content the writer can edit safely | The plan is refused and reported as GitPathAccepted=False on the GitTarget. The periodic refresh raises the same condition off a READ, so folder content somebody else pushed is reported before a write goes looking for it. The target re-checks roughly every ten seconds; recovery requires an accepted resync after correcting the folder or live state, or — for a refusal a read raised — a later read that finds the folder clean |
| Render fidelity | A target whose render-vs-live epoch is pending or divergent must not take live writes | While the gate is pending (a collection of the GitTarget is replaying, which closes all of them), live events are held and committed in order, with their authors, once it opens; a replay of their collection supersedes the ones held before it, except a deletion of an object it does not hold. The hold gives up after 10 minutes, or past the branch's retained-byte budget, by dropping what it held and asking for a fresh snapshot. A divergence drops live events, and atomic writes are dropped while the gate is closed. Every drop is counted by gitopsreverser_git_writes_dropped_total, and the first under each gate status is logged at Info. Resync stays allowed so it can measure and repair Git |
A refusal is not a transient error and is not retried into success. It is the common reason a live
edit never appears in Git while the worker looks healthy, so check the GitTarget conditions before
the worker logs.
spec.onRefusal: Reapply asks the target's applier to
reapply Git for eligible refused edits. It leaves these gates in force, and a delivered request does
not establish that the live value was restored or the target recovered.
Separately, a window whose finalize fails is dropped, not retried: its events are gone until
the next resync re-derives them from the current live state. That is a deliberate choice, because
the events are already lost to the failed flush and replaying a broken state every cycle helps
nobody. It is counted by gitopsreverser_git_commit_failures_total.
The worker preserves the new remote history and rebuilds its unpublished writes on top of it. The replay inputs are the captured objects and metadata retained since the last successful publication. The known base identifies the Git revision underlying those retained writes. Replay uses those inputs without querying a historical API event log.
flowchart TD
A["Plan retained API writes on known base B"] --> P["Attempt compare-and-swap push"]
P --> MATCH{"Remote still at expected base?"}
MATCH -->|yes| DONE["Publish and release retained writes"]
MATCH -->|no| HASH["Use SHA already read by the rejected push"]
HASH --> FETCH["Fetch current remote tip R and reset checkout"]
FETCH --> REPLAY["Apply every retained write's decided actions, in order, on R"]
REPLAY --> DIFF{"Captured state still differs?"}
DIFF -->|yes| COMMIT["Create replacement local commits"]
DIFF -->|no| NOOP["Retain the no-op until remote confirmation"]
COMMIT --> RETRY["Retry within the three-attempt cycle"]
NOOP --> RETRY
RETRY --> P
REPLAY -->|error| HOLD["Keep writes; require successful recovery"]
Replay applies, against the fetched tree, the actions each write decided when it was first committed. A write keeps only the objects whose plan changed its tree then, so an object that changed nothing is never planned onto R, where its old content would revert what R changed; a write with no action left settles at once and is never replayed. A resync keeps its upserts and the documents its sweep removed, and never sweeps again on R. A save keeps every object it captured. Replay can change unpublished commit SHAs or turn a write into a no-op. It retains the captured author, target, and resolved write configuration; prune permissions can tighten before replay. It does not perform a three-way field merge.
For example, Alice changes replicas through the API while Bob changes image in Git on the
same object. Alice's captured object contains both fields. Replaying it writes her replicas and
also the image she was looking at, so Bob's change to that object does not survive, and a
field Bob added that the cluster never had is removed outright. Bob's commit remains in history.
Separate objects are independent; separate fields of one object are not. Unrelated files are
preserved within the supported layout rules. See
shared-path behavior.
This is the consequence of API-first ownership: the captured API object drives the supported manifest edit. A Git-side change reaches the cluster through an applier such as Flux or Argo CD. Reverser does not apply Git to Kubernetes itself; with an applier claim it decides when the applier does.
The deferred three-way comparison investigation records the exact merge rules and the alternatives: timestamps, retained API contents, Git field differences, applier confirmation, and audit request intent. Implementation is deferred while development remains API-first.
Kubernetes apply and field-ignore policies govern the other reconciliation direction. Ignoring replicas during apply can preserve an API scale change, but Reverser still captures the whole object, including an old image. The Flux and Argo CD source review explains why these policies help with field authority without providing a concurrent-edit merge.
The worker needs two pieces of state because a successful push cannot answer every question about its local checkout.
| State | Meaning | Recovery consequence |
|---|---|---|
baseTrusted |
The last observed base remains usable for optimistic planning | Fetch if untrusted and no writes are retained |
checkoutApplied unknown |
A failed write may have left partial filesystem or index changes | Reset; replay retained writes if present |
checkoutApplied below the retained writes |
A reset discarded retained commits and their rebuild is incomplete | Block commits and publication until the retained writes are rebuilt |
“Trusted” does not mean the remote cannot have changed since the last observation. Detecting that change is the compare-and-swap's job. Similarly, a clean checkout can still be missing the commits represented by retained writes.
The recovery rule is to fetch, reset, and replay before proceeding with retained work whose local representation is unsafe. The count drops to zero before the reset rather than after it, because a reset can move the branch reference and then fail while rewriting the worktree: the commits behind the retained writes are already unreachable at that point, and a flag set only on the success path would miss exactly that case. The cost of dropping it too eagerly is one fetch on the next cycle.
A fourth piece of state is not a flag: the trust above, like everything else the worker holds, is
about one repository, and the worker carries that repository's identity. A GitProvider is
repointed by deleting and recreating it, which does not restart the worker — so the worker goes on
using its own repository's URL, and the reconcile replaces it with one built for the new
repository. Trust from the previous repository is never carried into the new one, because the two
are never held by the same worker.
A CommitRequest attaches to a matching author-and-target window and closes it. It does not
bypass the push cooldown. Two settings with separate clocks set the timing: attachTimeout runs
from the worker's registration of the request and bounds the wait for a window, and maxDuration
runs from the attach and bounds the collection that follows. Both default to 2s and accept 0s
to 5m. A repeat attach restarts neither clock.
sequenceDiagram
participant C as CommitRequest controller
participant W as Branch worker
participant G as Git remote
C->>W: Register request
Note over W: Registration starts attachTimeout (default 2s)
W->>W: Attach to a matching window
Note over W: Attach starts maxDuration (default 2s)
W->>W: Finalize window, keep request with retained write
C->>W: Poll / repeat attach every 2s
W-->>C: Still pending while awaiting publication
W->>G: Push or verify a no-op
G-->>W: Remote confirmation
W-->>C: Committed with SHA, or AlreadyPresent
A local no-diff result still consults the remote before reporting AlreadyPresent. A competing
Git edit can make that same captured API object require a real commit after replay.
A worker that stops while still holding the write fails the request with an error rather than leaving it to time out, so a shutdown mid-publication is reported as a failure and not as silence.
Use Pushed=True plus status.commit when a particular commit must exist in Git. Ready=True also
covers successful outcomes with no commit. Status visibility follows the controller's polling
cadence and Kubernetes status writes. A request the worker holds (attached to a window, or committed
and waiting for the push) has no controller-side timeout: it ends when the worker says so, however
long a down remote takes. For a request the worker has not acted on within
attachTimeout + maxDuration + 120s of creation, the controller asks the worker to withdraw it, and
reports whatever the worker answers: failed when the worker cancelled it, or the real outcome when
the worker resolved it first. A request reported failed is therefore never committed later. The
worker keeps resolved outcomes for 15m.
See the request contract.
The two-second attachTimeout is not a guarantee that an earlier API edit has reached the
worker. Attribution can wait up to three seconds per event, and queueing adds more. A request
whose matching event arrives after its deadline finishes with NoWindow. Increasing
attachTimeout permits a longer wait; it does not reserve an API transaction.
A healthy idle branch worker generates no Git traffic of its own. Its commit and push timers are
armed for work, and neither is a periodic remote poll. What does look is the refresher: on the
target's reconcile tick, a branch nothing has proved within --git-refresh-interval (10m by
default) costs one ref advertisement, and a fetch only if it has moved. That bounds how long an
idle target can hold an old view after a foreign push; --git-refresh-interval=0 removes the look
and restores the old behaviour, where the view is corrected by a later publication, resync, or
explicit recheck.
The push receiver tells the worker that somebody pushed, in seconds rather than at the next refresh. A delivery is queued on the worker like a refresh and is only a hint: on an idle branch the worker reads the advertisement and fetches only if the branch moved; on a busy one it closes its open window and publishes, and the compare-and-swap push finds the move. The healthy publication optimization works without a receiver.
There is a separate, existing manual mechanism: changing
reconcile.configbutler.ai/requestedAt on a GitTarget requests a fresh reconcile. That performs
a cluster-to-Git snapshot after fetching. It can write the cluster's current values over the
fetched manifests, which is why a receiver delivery never triggers one.
When Flux or Argo CD applies the same path on its own schedule, its refresh and apply cadence also affects whether a live edit survives long enough to be published. An applier claim takes that clock away: the applier then applies only when GitOps Reverser asks, after a flush that publishes every write decided before the apply came due. An edit decided after the flush is recorded in the apply window, so an apply that overwrites it is withheld and repaired. A push of GitOps Reverser's own asks for no apply. The bi-directional guide describes the integration choices and limitations.
These timers operate at different layers. Adding all their defaults together does not produce an end-to-end latency budget.
| Mechanism | Current default or bound | Why it exists; relationship to Git |
|---|---|---|
| Actor attribution wait | Up to 3s per event; configurable |
Allows an audit fact to arrive; a match can release the event earlier |
| Attribution fact retention | 10m; configurable |
Keeps identity evidence available for late watches and restarts; adds no intentional publication delay |
| Configuration settling | 2s silence, capped at 10s |
Groups related target/rule edits before updating watches |
| Shared watch refresh | 30s |
Refreshes API catalogs and namespace scopes; unchanged watch plans do not fetch Git |
| Watch-owner retry | 2s, 5s, 10s, 30s, then 60s |
Retries failed planning or shared refreshes; separate from Git push retry |
| Watch reconnect | 2s backoff |
Reopens a failed source watch; a fresh snapshot can cause a Git resync |
| Type activation / removal | 5s stable / 60s grace |
Avoids acting on transient API discovery changes; observation cadence also affects visibility |
| Healthy controller reconcile | 5m |
Refreshes control-plane status and dependencies; does not imply a Git fetch |
| Unconverged target recheck | Normally 10s |
Includes repeated forced rechecks for refused Git paths; these can fetch |
| Lost status-write retry | 100ms |
Retries a Kubernetes status update after an optimistic-lock race |
| Watch planning / shared refresh deadlines | 30s / 2m |
Bounds watch-owner work; Git publication has separate execution |
| Scoped resync result wait | 5m |
Bounds the observer's wait for a worker result; does not cancel a queued Git operation |
Sources: attribution resolver, watch owner, watch sessions, type lifecycle, type removal, controller constants, and resync result handling.
Attribution runs before enqueueing and a watch stream handles events serially. Several missing facts can therefore accumulate delay on one stream. Lowering the Git commit window does not remove that upstream wait.
The cooldown limits successful publication cadence. It is not a retry backoff or a global request-rate limiter. A contention cycle attempts up to three pushes without a deliberate delay between them, fetching and rebuilding when the remote moved. Different branch workers can publish independently.
After a push or retained-write recovery fails, the worker keeps the work and schedules the next attempt itself: 10s after the first failure, doubling to at most 5m, whether or not anything else arrives. Commits made before that deadline accumulate locally rather than each trying the remote again, and a successful push resets the schedule. A failure caused by a missing parent branch is retried on parent recovery's probe schedule instead, and so is any failure while that recovery is open, including after the parent returns: new commits wait for its deadline too. The backoff does not rate-limit fetches needed by a new window or snapshot. A branch with nothing pending stays silent.
The byte threshold forces finalization but does not free retained writes during a remote outage.
It is not a hard memory ceiling. The separate branch queue has 1000 slots by default and refuses
new items when full; the producer offers a refused item again (git_queue_refusals_total), and only
a watch cursor that expires first costs that write's own commit. These throughput and recovery
limits do not justify fetching before every healthy publication.
The checked-in request ledger counts smart-HTTP requests served by a real Git backend. It measures neither TCP connections nor wall-clock latency. Authentication, transport setup, object size, and server behavior affect elapsed time.
| Operation in the harness | HTTP requests |
|---|---|
| Bootstrap helper against an empty remote | 1 |
| Bootstrap helper against a populated remote | 3 |
| Healthy publication after initialization | 2 |
| Several commits in one healthy publication | 2 |
| Live publication whose final plan is a no-op | 1 |
| Publication rejected once, then accepted | 6 |
| Publication rejected twice, then accepted | 10 |
| Forced recheck of an unchanged remote | 2 |
| Snapshot resync that produces a commit | 4 |
| Healthy idle worker | 0 |
The healthy publication drops from four requests before this PR to two. A successful push reads the receive-pack advertisement and then sends its packfile. A no-op reads the advertisement and sends no packfile. A fetch with no object transfer still costs two requests in this implementation; a fetch that transfers objects normally costs three.
For illustration, if each serial HTTP request costs roughly one second, removing two requests saves roughly two seconds of network wait per healthy publication. Actual elapsed time needs a benchmark. Local planning, rendering, encryption, and replay also take time; the ledger does not measure them.
The two bootstrap rows call EnsurePathBootstrapped directly. That helper has no production
caller at the reviewed revision, so these rows measure repository preparation through a test
entry point. They do not measure the complete operator startup sequence.
Snapshot resync keeps its fetch because a no-op snapshot can finish without a push advertisement. Avoiding that fetch safely would require a separate freshness check on that path.
A receiver delivery's savings need their own measurement. With today's primitives, an early notification followed by one object-transferring fetch and one successful push costs five HTTP requests. Its benefit in that scenario is avoiding the rejected advertisement, reducing six requests to five. Multiple remote moves depend on notification timing and coalescing.
The metrics guide documents the counters and queries. Read them by provider and branch so different workers do not hide one another's behavior.
| Signal | Interpretation |
|---|---|
gitopsreverser_git_fetches_total, reason publication |
Base-establishment fetches; should stop increasing during healthy steady publication |
gitopsreverser_git_fetches_total, reason refresh |
The periodic re-proof of an IDLE branch, counted only when the branch had moved and a fetch followed. A branch being published to never contributes: its pushes prove the remote already |
Same counter, reason contention |
Fetch/reset after confirmed remote movement |
Same counter, reason push_failure_probe |
Remote-state lookup after an unclassified push failure |
Same counter, reason recovery |
Attempts to rebuild unsafe retained work or clean a dirty checkout |
Same counter, reason forced_recheck |
Explicit refresh, and the snapshot a resync judges against |
gitopsreverser_git_pushes_total and gitopsreverser_git_push_retries_total |
Completed publication cycles and contention retries within them |
gitopsreverser_git_push_duration_seconds |
Time inside a push cycle, including its inline replays |
gitopsreverser_git_queue_depth and gitopsreverser_git_queue_refusals_total |
Outstanding work and queue overload |
A fetch counter counts calls. The ledger counts the HTTP requests each call produces. Push-cycle duration excludes the earlier attribution and commit-window waits, cooldown, and recovery performed before entering the cycle. It cannot by itself answer “how long from my API edit until Git accepted it?”
A flat recovery counter also cannot prove progress: the worker may be retaining work while it
waits out its backoff. Check queue depth, failure logs, and CommitRequest outcomes alongside rates.
A resync's fetch lands under forced_recheck whether or not the branch happened to hold retained
writes, so a growing publication series means live publication is fetching again and nothing
else. That was worth getting right: the two cases differ only in push timing, and while they
reported differently the central diagnostic on this page could not be read at face value.