diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 04bb5d0b..3210f44b 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -178,6 +178,13 @@ cat ~/.config/git/allowed_signers git config --show-origin --get-regexp '^(commit\.gpgsign|gpg\.)' ``` +## Background Claude Code sessions + +The container keeps running when VS Code closes (`"shutdownAction": "none"`), so a `claude --bg` +session can finish a long task on its own. Setup, the commands to check on it, and why it cannot +commit or push while VS Code is closed: +[`docs/background-claude-sessions.md`](../docs/background-claude-sessions.md). + ## Optional `.env` for `gh` A repo-root `.env` file is optional. If present, login shells inside the devcontainer automatically diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9e1cd6e4..2914cc31 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -93,6 +93,9 @@ "postCreateCommand": "bash .devcontainer/post-create.sh '${containerWorkspaceFolder}'", "postStartCommand": "bash .devcontainer/post-start.sh '${containerWorkspaceFolder}'", "remoteUser": "vscode", + // Keep the container running when the VS Code window closes, so a background + // `claude --bg` session (or a long e2e run) survives. Stop it with `docker stop`. + "shutdownAction": "none", "mounts": [ "source=gomodcache,target=/go/pkg/mod,type=volume", "source=gobuildcache,target=/home/vscode/.cache/go-build,type=volume", diff --git a/docs/background-claude-sessions.md b/docs/background-claude-sessions.md new file mode 100644 index 00000000..6c2154d1 --- /dev/null +++ b/docs/background-claude-sessions.md @@ -0,0 +1,104 @@ +# Running Claude Code in the background of the devcontainer + +How to start a long Claude Code task inside the devcontainer, close VS Code, and come back to it +later. The usual case is a prompt file under `docs/` (`*.ignore.md`, git-ignored) that takes hours +because it runs the model sweep and the e2e legs. + +## Why it needs setup + +Two things stop a session that VS Code started from outliving VS Code: + +- **The container stops with the window.** By default VS Code stops a Dockerfile-based + devcontainer when its last window closes, which kills every process inside it. + [`devcontainer.json`](../.devcontainer/devcontainer.json) sets `"shutdownAction": "none"` so the + container keeps running. Stop it yourself with `docker stop` on the host when you are done; until + then it holds its memory (capped at 12 GB by `runArgs`). +- **A chat in the VS Code extension belongs to the extension.** Closing VS Code ends it. A session + started with `claude --bg` runs as its own process in the container instead. + +The k3d e2e clusters are sibling containers on the host daemon (docker-outside-of-docker), so they +are unaffected either way. + +## One-time setup + +1. **Pick up `shutdownAction`.** After the line is in `devcontainer.json`, run + **Developer: Reload Window**. If the container still stops when you close VS Code, run + **Dev Containers: Rebuild Container**, which is the sure way to apply it. +2. **Put the CLI on `PATH`.** The VS Code extension ships a `claude` binary but does not put it on + `PATH`. Link the newest one: + + ```bash + mkdir -p ~/.local/bin + ln -sf "$(ls -d ~/.vscode-server/extensions/anthropic.claude-code-*/resources/native-binary/claude | sort -V | tail -1)" ~/.local/bin/claude + ``` + + Redo this after an extension update (the path contains the version) and after a rebuild + (`~/.local/bin` is not on a volume). +3. **Trust the workspace once.** `claude --bg` refuses to start in an untrusted folder. Run + `claude` in `/workspaces/gitops-reverser2`, accept the trust prompt, and `/exit`. The answer is + stored in `~/.claude`, which is a volume, so it survives a rebuild. + +## Start a session + +```bash +cd /workspaces/gitops-reverser2 +claude --bg --permission-mode auto -n save-asks \ + "Read docs/-prompt.ignore.md and carry it out end to end, following AGENTS.md. \ +The user is away: do not wait for answers. When a decision is theirs, take the conservative \ +option, note it in the PR body, and carry on. If you are truly blocked, stop and write down where." +``` + +`-n` names the session, so the commands below take the name instead of the id. `--bg` prints the +id and returns at once. + +## Check on it + +| Command | Does | +|---|---| +| `claude agents` | Lists sessions and whether each is working. Add `--json` outside a terminal. | +| `claude logs save-asks` | Prints the session's recent output. | +| `claude attach save-asks` | Opens the session in this terminal, so you can answer or redirect it. | +| `claude stop save-asks` | Stops it. | + +If the container stopped anyway, the work on disk is kept (the workspace is a bind mount). Reopen +the container, find the session id with `claude agents`, and continue it with +`claude --bg --resume `. + +## What does not work while VS Code is closed: signing and push + +Both go through VS Code, so a session can edit, build, test and run e2e with VS Code closed, but it +cannot commit or push until VS Code is back: + +- **Commit signing** uses the SSH agent that VS Code forwards + (`SSH_AUTH_SOCK=/tmp/vscode-ssh-auth-.sock`, see + [`sync-signing-key.sh`](../.devcontainer/sync-signing-key.sh)). Every commit must be signed, so + `git commit` fails without it. Never work around this with `--no-gpg-sign`. +- **`git push`** uses VS Code's credential helper, which `/etc/gitconfig` and `~/.gitconfig` point + at a script that talks to the VS Code window. + +Reopening VS Code restores both, but under a new socket path. A session that was already running +still has the old `SSH_AUTH_SOCK`; tell it (through `claude attach`) to use the newest socket: + +```bash +export SSH_AUTH_SOCK="$(ls -t /tmp/vscode-ssh-auth-*.sock | head -1)" +``` + +To let a session commit and push with VS Code closed, give the container credentials of its own: + +1. **A signing key file.** Git signs with a key file directly, without an agent (checked: a + commit made with `SSH_AUTH_SOCK` unset carries a good signature). Store the key on the + `persisted-home` volume so it survives a rebuild, and add the `.pub` file to GitHub as a + **signing** key: + + ```bash + mkdir -p ~/persisted-home/.ssh + ssh-keygen -t ed25519 -N '' -C 'devcontainer signing' -f ~/persisted-home/.ssh/devcontainer-signing + git config --global user.signingkey ~/persisted-home/.ssh/devcontainer-signing + ``` + + The trade-off: the key has no passphrase and lives in the container, so anything running in the + container can sign as you. Remove it from GitHub if the container is ever compromised. + `sync-signing-key.sh` keeps a `user.signingkey` you configured. `~/.gitconfig` is not on a + volume, so set it again after a rebuild. +2. **`gh` for push.** `gh` is already logged in (`~/.config/gh` is a volume). + `gh auth setup-git` makes Git use it for `github.com` instead of the VS Code helper. diff --git a/docs/future/gitops-api-open-asks.md b/docs/future/gitops-api-open-asks.md index 2a03b48a..b486c010 100644 --- a/docs/future/gitops-api-open-asks.md +++ b/docs/future/gitops-api-open-asks.md @@ -1,37 +1,38 @@ # gitops-reverser in production: how we use it, and what we'd still like -_From the [gitops-api](https://github.com/ConfigButler/gitops-api) team. **Revision 17, 2026-10-26** +_From the [gitops-api](https://github.com/ConfigButler/gitops-api) team. **Revision 19, 2026-10-28** (first sent 2026-07-09). Written to be readable from the gitops-reverser repo — no gitops-api knowledge assumed. Every ask is traced to something we measured on a running system. **We run -`v0.54.0`.**_ +`v0.55.0`.**_ ## Where this stands -**`0.54.0` closed every ask we had open but three, and we adopted it the day it was tagged.** It -reshaped every connection object with no in-place migration, so we rebuilt our cluster rather than -migrate it; loop conformance 21/21 and our editor click-through pass on it. #30 shipped as -`GitReceiver` and deleted more of our code than any release since #1: one receiver for the whole -platform, so our relay now forwards GitHub's delivery verbatim, with no per-repository token, Role -or receiver. Adoption write-up: [reverser-0.54-adoption.md](https://github.com/ConfigButler/gitops-api/blob/main/docs/design/reverser-0.54-adoption.md). +**`0.55.0` closed both our open asks and the `kube-root-ca.crt` vote, and we adopted it the day it +was tagged.** The source-cluster `CommitRequest` deleted more of our code than #30 did: our editor now +saves inside the customer's own kcp workspace, with their own token, so our save endpoint, our +per-repository commit lock, the one grant a customer held on our management cluster, and your +admission webhook with its certificate are all gone. On a rebuilt cluster: loop conformance 21/21, +our editor click-through 77/77, a save's `ConfigPublication` published 33 ms after its `Applied`, and +the first snapshot after the rebuild made no commit at all. Adoption write-up: +[reverser-0.55-adoption.md](https://github.com/ConfigButler/gitops-api/blob/main/docs/design/reverser-0.55-adoption.md). -**One new ask, #35, and unlike the last few it costs us something today.** `0.54.0` stopped -applying the forward leg after the reverser's own pushes, which is right for the cluster and leaves -the applier's own state — its applied revision, its source artifact — behind the branch until -somebody else pushes. We read that state to publish. Details and a proposal below. - -**We accept your reasoning for deferring #28**: a namespace split leaves the CRDs and the -ClusterRole shared, and the cost that reaches users (a restart dropping an open window) is better -fixed inside one instance — which is the second vote below. +**Six asks are new since then.** Three are small and came from the adoption. The other three came +from tuning the editor's save the day after. **#39 is the one that matters**: with your defaults, a +save's message can land on somebody else's change. | Open | What it is | |---|---| -| **35** 🟡 new | Converge the applier on the reverser's own pushes, at a bounded rate | -| **34** 🟡 | An e2e for an in-cluster applier with a remote source cluster — our production topology | +| **39** 🟠 new | `CurrentOrNext` gives a save's message to an earlier, unclaimed write of the same author, and your defaults do it too | +| **40** 🟡 new | A request's progress is reported on a fixed 2 s poll, so a save's latency moves in 2 s steps | +| **41** 🟡 new | A push that resolves a `CommitRequest` waits out the 5 s `PushCooldown` | +| **38** 🟡 new | A source-cluster request naming a `GitTarget` that does not mirror that cluster gets no status at all | +| **37** ⚪ new | Default `gitTargetRef.namespace` on a source cluster to the serving `KubeProvider`'s namespace | +| **36** ⚪ new | `AuthorUnresolved` as a failure (`Ready=False`), not a success | | **10** ⚪ | Sibling inference can place an object in another namespace's directory — cosmetic | | **28** ⏸ | Two instances on one config-plane cluster — deferred, by agreement | -Plus two **votes for items already on your TODO**, with our evidence: filtering `kube-root-ca.crt`, -and the durable worker queue. +Plus two **votes for items already on your list**: the durable worker queue, and +`concurrent-author-windows.md`. ## Who we are @@ -76,58 +77,97 @@ when they part. ## Open asks -### 35. Converge the applier on the reverser's own pushes, at a bounded rate - -**Ask.** A periodic converge: when an applier's last applied commit is behind the branch tip, and -nothing else is pending for it, apply the tip — **at most once per interval**. For example -`FluxClaim.spec.convergeInterval` / `ArgoCDClaim.spec.convergeInterval`, defaulting to something like -`1m`, with `0` keeping `0.54.0`'s behaviour. The decision stays in the branch worker, like every -other one; the claim's own periodic reconcile may be the natural clock. Batched by construction: a -burst of saves inside one interval costs one apply, not one per push. - -It is a timer on a reason you already have. A `Converge` already applies the tip "because nothing -says the applier runs it", and already asks nothing when the applier's last succeeded operation -applied that commit. Today it fires once at worker start and once per new `GitTarget`; this asks for -it on a clock as well. - -**Why.** `applier-echoes.md` S2 is right about the cluster: after the flush, an apply following the -worker's own push changes no object, so it protects nothing there. But the applier's **own state** -is read by other things, and on `0.54.0` that state stops moving: - -- the `Kustomization`'s `status.lastAppliedRevision`; -- the `GitRepository`'s artifact. It fetches only when the claim asks it to apply, so it stays at - the last foreign commit as well; -- everything downstream of those two: Flux's commit-status notifications, `flux get kustomizations`, - any promotion or packaging step that consumes "what was applied". - -Measured on `0.54.0`, after one conformance run and one editor click-through: branch tip -`be3b08d2`, artifact **and** applied revision `c81500d0`, the last commit somebody else pushed — -**15 commits behind, and it moves only when somebody pushes in GitHub.** On `0.53.0` the quiet-branch -`CatchUp` kept them level. - -It costs us one feature. We publish each repository's folder as an OCI image from what the forward -leg **applied** (applied revision == artifact revision), downloaded from source-controller. On -`0.54.0` an edit made in our editor is mirrored, committed and attributed perfectly, and then never -published until an unrelated push happens. We cannot work around it from our side: the only lever -we have is to fetch or reconcile the Flux objects ourselves, and reconcile ownership tells us — rightly -— that a fetch of ours between your check and your resume applies a commit nobody checked. - -**Why not after every push.** That was `0.53.0`'s `CatchUp` minus the quiet period, and it would make -every save cost an apply. A bounded interval keeps the applier eventually level for whoever reads its -state, and the cluster pays at most one no-op apply per interval per applier. - -### 34. An e2e for an in-cluster applier with a remote source cluster - -**Ask.** One e2e where the `GitTarget`'s `KubeProvider` names a remote cluster (`secretRef`) and the -`FluxClaim`'s names the operator's own (`inCluster`), with an API edit landing during an apply. - -**Why.** It is our production topology: Flux runs in the management cluster and applies into a remote -workspace through `spec.kubeConfig`; the reverser reads that workspace remotely and holds the -`Kustomization` locally. We have proven the hold, the apply cycle, the receiver and `ForeignCommit` -in it (21/21 in our conformance probe, on `0.53.0` and on `0.54.0`). We have **not** seen the apply -window withhold anything in it — `apply_window_collisions_total` stayed at zero across every run — -so the part of #29 that reads `managedFields` on the remote object is untested in this topology by -anyone. An e2e that provokes one collision would settle it. +### 39. `CurrentOrNext` gives a save's message to somebody else's writes + +**Ask.** If a request attaches to a window that was already open when it registered, keep that +window's own timers until a write arrives after the registration. Alternatively make `Next` the +default. At the least, say in the CRD docs that `CurrentOrNext` can label writes the request never +saw. + +**Why.** An attached request replaces the window's timers with its own, and they are measured from +the window's past. Suppose a write of the same author was claimed by no request (kubectl, or the +tail of an earlier save). It leaves a window open on the `GitTarget`'s 5 s idle timer. A save that +registers inside that window attaches to it, and the window then closes on the save's timers +(`maxDuration: 2s`, or an `idleTimeout` already past), before the save's own writes reach the worker. +The result, measured with a write made just before the request was filed: + +- the request's message is committed on the earlier write; +- the save's own writes commit under the generated message; +- the request reports `Committed`. + +**Your defaults do it too**, not just our tuning. Our click-through caught it as a shift by one: +three saves in a row, each message on the previous save's change. We now file `attach: Next`, which +fixes it. The cost is one extra push and then #41's cooldown, so the fix costs us about 4 s whenever +it is needed. + +**Answer (gitops-reverser, PR #465): fixed by #40, and the window rule stays.** The request's timers +do not reach into the window's past: its `maxDuration` and its first idle interval start at the +attach. The shift by one came from #40's poll. Your client waits for `WaitingForWindow` before it +writes, and that status took up to 2 s to appear, as long as the default `maxDuration`. So the +window had closed under the save's message before the save's writes arrived, and those writes +opened the window the next save attached to. With #40 the registration reaches the client in about +56 ms. `TestSaveFolder_ThreeSavesInARowKeepEachMessageOnItsOwnChange` pins three saves in a row, +each message on its own change, and fails with a 2.1 s client delay. + +We tried your first option, holding a save's timers until its first write after the attach, and +took it out again. It made the `GitTarget`'s timers decide when an attached save commits: a +`GitTarget` with a short `idleTimeout` brought the bug back, and a folder applied together with its +`CommitRequest` waited 5 s instead of 2 s. `CurrentOrNext` stays the default, and you can drop +`attach: Next`, so you no longer pay that extra push either. What remains: a save's writes must reach +GitOps Reverser within `maxDuration` of the attach. On a slow audit pipeline, raise `maxDuration`. + +### 40. Report a request's progress without the 2 s poll + +**Ask.** Wake the serving goroutine when the worker registers, resolves or applies a request, +instead of polling `commitRequestPollInterval = 2s` (`commitrequest_controller.go:63`, +`recordPhase` in `commitrequest_core.go`). Failing that, poll at ~100 ms while the phase is still +`""`. + +**Why.** A save's visible latency moves in 2 s steps. Measured create → registered: ~200 ms to the +claim (author found), then ~2 s until the next poll reports `WaitingForWindow`. The same 2 s comes +again between push and `Ready`, and on the `Applied` follow. Waiting for registration before writing +is what makes `SaveInProgress` safe, so a client has no way around this poll. About 3 s of our ~6.8 s +save is this poll. + +### 41. Skip `PushCooldown` for a push a `CommitRequest` waits on + +**Ask.** Push at once when the commit resolves an attached request, as an apply flush already does +(`branch_worker.go`, `PushCooldown = 5s`). + +**Why.** A person is watching that push. With `attach: Next` (#39), every save that finds an open +window pushes twice, and the second push waits out the cooldown: ~10.8 s instead of ~6.8 s. Two +saves in a row hit it the same way. + +### 38. Refuse a source-cluster request whose `GitTarget` does not mirror that cluster + +**Ask.** A `CommitRequest` on a source cluster that names a `GitTarget` mirroring some _other_ +cluster should end `NotAllowed`, the way `0.55.0` already refuses the opposite direction (a request +on the control cluster for a `GitTarget` that mirrors another cluster). + +**Why.** Measured: a request in one tenant's workspace naming a `GitTarget` that mirrors our +management cluster stayed with **no status at all**, and nothing was logged, until we deleted it by +hand. The serving path is not its owner, so it rechecks every 5 minutes +(`commitRequestUnownedRecheck`). The boundary holds, which is the important half: it is never served +and attaches to nothing. But its creator waits for ever, and because the request is never finished +it is also never collected; our tenants hold no `delete` on the type, so it outlives every TTL. + +### 37. Default `gitTargetRef.namespace` on a source cluster + +**Ask.** On a source cluster, let an omitted `spec.gitTargetRef.namespace` mean the namespace of the +`KubeProvider` the request is served through. + +**Why.** Every one of our `GitTarget`s lives beside its `KubeProvider`, and that namespace is an +infrastructure compartment our customers are not meant to know about. Today it must appear in an +object they can read. It is a name, not a credential, so this is polish. + +### 36. `AuthorUnresolved` as a failure + +**Ask.** End a request no audit event names with `Ready=False` / `Stalled=True`, not `Ready=True`. + +**Why.** It is the one outcome that means an audit event was dropped (kcp drops the event of a +YAML-bodied write, and we have been bitten), and the save's typed message is lost with it. Ready=True +reads as success to every client that waits for `Ready`. Your new +`commit_requests_total{outcome="author_unresolved"}` counter covers our alarm in the meantime. ### 10. Sibling inference can place an object in another namespace's directory — ⚪ cosmetic @@ -151,22 +191,18 @@ everyone. Our analysis: [reverser-instance-topology.md](https://github.com/Confi ## Votes for your own TODO -Both are already on your list. These are the measurements behind our vote, not new asks. +These are the measurements behind our vote, not new asks. -**Filter `kube-root-ca.crt`** (`docs/TODO.md`, "Filter more cluster-generated noise"). Our tenants -mirror ConfigMaps, so every tenant repository carries the workspace's cluster CA certificate — a -file nobody wrote, that means nothing outside that one cluster. Every rebuild of the cluster gives -it a new CA, and the first snapshot commits the change as a `message_source="reconcile"` commit by -the committer. That is the only commit-without-a-human our alarm -(`git_commits_total{author_kind="committer"}`) sees, and it fires once per rebuild on every -tenant. A default filter, with an opt-back-in for whoever really wants it, would remove both. +**The durable worker queue** (`docs/TODO.md`, "Make the worker queue durable"). The one cost of +sharing an instance that our users can see. `RenderMatchesLive` starts `Unknown` after a restart, an +open commit window is dropped, and a save made in that window loses the message its author typed. +It is the last item standing between us and "a deploy of the reverser is invisible to the people +editing". -**The durable worker queue** (`docs/TODO.md`, "Make the worker queue durable"). This is the one cost -of sharing an instance that our users can see. `RenderMatchesLive` starts `Unknown` after a restart, -an open commit window is dropped, and a save made in that window loses the message its author typed -and its attribution. `0.54.0`'s #443 now holds live edits while fidelity is pending, which fixes the -edit; as far as we can tell the window's message is still dropped. It is the last item standing -between us and "a deploy of the reverser is invisible to the people editing". +**`concurrent-author-windows.md`**, for when our tenants get several editors. Today each has one +human, so `SaveInProgress` covers us. Our answer to its open question 4: report a window that a +`path_conflict` closed early, because a save that commits less than its author meant to save, +silently, is the bug we built our own lock for. ## Process notes @@ -176,6 +212,12 @@ between us and "a deploy of the reverser is invisible to the people editing". behaviour. When a field is simply removed, the API server prunes it and the object stays `Ready=True`, so we write tests asserting the _absence_ of old spellings. Making rejection-before-removal the default would make those tests unnecessary. +- **`SaveInProgress` is decided at registration, seconds after `Saving`.** `UPGRADING.md` tells a + client to wait for `Ready=Unknown` before its edit. That is right for `idleTimeout: "0s"`, but a + client relying on `SaveInProgress` to serialise saves must wait for `WaitingForWindow`: measured, a + second save sat at `Saving` with its author set for ~3 s and was then refused. Our first build + applied at `Saving` and wrote into the other save's window. One sentence in the guide would save + the next adopter that run. - **`0.54.0`'s "recreate the connection objects" was the right call** for a reshape this size, and `UPGRADING.md`'s eight steps were complete. We rebuilt rather than followed them, which a disposable cluster allows, and found nothing missing from the guide. @@ -191,7 +233,7 @@ One line each: what happened, and whether we are happy. | 1 | Separate the config plane from the watched cluster | `v0.38.0` (#249/#251), #259 | **Happy** — one reverser now serves our whole platform; it deleted a tier of our architecture | | 3 | A Redis key prefix | #221 | **Happy** — defaulted to the historic root, no migration | | 4 | Export `pkg/manifestanalyzer` | #221 | **Happy** — the foundation of our wizard's folder step | -| 5 | A `CommitRequest` that can assert an author | the admission webhook records the submitter (#220 itself closed unmerged) | **Happy** — a save's author comes from who filed it | +| 5 | A `CommitRequest` that can assert an author | the admission webhook (#220 closed unmerged), then `0.55.0`: the author comes from the request's own audit event | **Very happy** — no webhook, and it works on a source cluster | | 6 | Movable `GitTarget` destination, or fail loudly | immutability that fails at admission with "delete and recreate" | **Happy** — we delete and recreate, now for a `GitBranch` too | | 7 | Degrade without `apiregistration.k8s.io` | #223, `v0.34.0` | **Happier than asked** — un-started rather than blacklisted | | 8 | Never sweep the mirror to empty | `v0.39.0` (#260), `prune.mode: OnEvent` | **Very happy** — an API default, not a guard we maintain | @@ -210,6 +252,9 @@ One line each: what happened, and whether we are happy. | 31 | Count "resumed elsewhere" in a metric | `0.54.0`, `apply_claimed_resource_reconciled_total` | **Happy** — we alert on it | | 32 | A troubleshooting row for a full-spec writer | `0.54.0`, the Flux guide | **Happy** | | 33 | Say why `prefetch` is false | `0.54.0` (#425), prefetch removed | **Happy** | +| 34 | An e2e for an in-cluster applier with a remote source cluster | `0.55.0` (#456), scenario 10 | **Very happy** — it found a real apply-window bug in our topology | +| 35 | Converge the applier on the reverser's own pushes | `0.55.0` (#456), `FluxClaim.spec.catchUpAfter` + `CommitRequest.spec.catchUp: AfterPush` | **Happier than asked** — a save is published 33 ms after its apply | +| — | Filter `kube-root-ca.crt` (a vote) | `0.55.0` | **Happy** — a rebuild makes no commit at all | | — | `WatchRule` readiness lag (never filed) | `0.44.0` (#343) | **Happy** — rule readiness is usable as an alert | **#2 — ignore writes by an identity or field manager. ⚫ WITHDRAWN 2026-07-14, our mistake.** The @@ -217,8 +262,8 @@ forward leg's echo is self-terminating: the applier applies the commit the rever content matches, the echo commits nothing. We built the filter ourselves and **it caused a two-and-a-half-minute flip-flop**, every commit unattributed. **Please never add `excludeUsers`/`excludeFieldManagers` on our account** — -[why](https://github.com/ConfigButler/gitops-api/blob/main/docs/design/forward-leg-loop.md). The right -shape was the one you already had: sanitize the content, do not filter the writer. +[why](https://github.com/ConfigButler/gitops-api/blob/main/docs/design/forward-leg-loop.md). The right shape +was the one you already had: sanitize the content, do not filter the writer. ## What is _not_ upstream's problem @@ -233,8 +278,8 @@ So a wishlist does not become a blame list: ## Suggested order -1. **#35** — the only open ask with a cost to us today, and a timer on a reason you already have. -2. **#34** whenever the e2e budget allows — our production topology, and the one part of #29 nobody - has seen work in it. -3. The two TODO items above, in whichever order your plan already has them. -4. **#10** — cosmetic, whenever. +1. **#39** — the one that commits a person's sentence on the wrong change, with defaults. +2. **#38** — small, and the only one where a client can wait for ever. +3. **#40** and **#41** — together they are about half of a save's latency. +4. The two votes, in whichever order your plan already has them. +5. **#36**, **#37** and **#10** — polish, whenever.