From d4ffaba9c85837f6e4bc180e6ce18a295ad20ab9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 15 Jul 2026 05:10:13 +0000 Subject: [PATCH 1/4] docs(support-boundary): design consent + the orchestrator reconcile trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused write is correct but delivered badly: it is decided late (at flush), invisible to the actor (the API server already returned 200), and — under the selfHeal:false config bi-directional editing requires — never reverted, since selfHeal is the only thing that reverts live drift. This design turns the refusal into a conversation, extending the tier-1/2/3 model in unreflectable-edits-and-write-gating.md in two directions its tier 3 does not cover: - CONSENT. Split refusals into correctness (the render oracle — never negotiable) and policy (fan-in>1 blast radius — the user may just not realize). Consent lifts only the policy kind, and it is modelled as DECLARED INTENT, not a bypass: acknowledging "this changes prod and staging" promotes the sibling object from the oracle's must-be-untouched set into its intended set, and the same oracle re-verifies the larger intent. No second code path; correctness is never waved through. The token is scoped to a computed consequence-hash, and it lifts "did you realize", never "are you allowed" (cross-RBAC base edits stay refused). This is what opens the door to a deliberate shared-base edit. - THE RECONCILE TRIGGER. One new outward action — ask Flux/Argo to reconcile now — used two ways. (1) Revert a refused edit promptly: with selfHeal off it is the ONLY thing that ever reverts it, and at refusal time the operator has the authorized-vs-unauthorized-drift signal Argo lacks, so it is the targeted substitute for the self-heal it had to disable. (2) An ordering barrier on origin drift: reconcile Git->cluster first, then process events, so the orchestrator's own apply is absorbed as a resync no-op instead of mirrored back (the reconcile echo). Grounded in the code: the oracle's WriteIntent set (render_verify.go), the CAS+rebase-by-replay that already handles a moved remote (git_atomic_push.go, branch_worker.go), and the existing replay barrier (target_watch.go). Honest about the prerequisites it does not have: the reconcile trigger is the FIRST write action on the orchestrator-ownership model, which is designed-but-unbuilt (orchestrator-knowledge-boundary.md) — the operator cannot name the Flux/Argo object for a path today. Direction-setting; ships no code. --- ...admission-consent-and-reconcile-trigger.md | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 docs/design/support-boundary/admission-consent-and-reconcile-trigger.md diff --git a/docs/design/support-boundary/admission-consent-and-reconcile-trigger.md b/docs/design/support-boundary/admission-consent-and-reconcile-trigger.md new file mode 100644 index 00000000..d8efe8f9 --- /dev/null +++ b/docs/design/support-boundary/admission-consent-and-reconcile-trigger.md @@ -0,0 +1,394 @@ +# Consent and the reconcile trigger: making a refused write a conversation, not a dead end + +> **design** — direction-setting; ships no code. Nothing it describes is supported today. +> Captured: 2026-07-15 +> Related: +> [README.md](README.md), +> [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) — **the tier-1/2/3 model this extends; tier 3 is the admission gate**, +> [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) — **the ownership model the reconcile trigger rides on**, +> [argocd-bi-directional.md](argocd-bi-directional.md) — why `selfHeal` must be off, and why that means nothing reverts a refused edit, +> [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) — the write boundary; fan-in = 1; base read-only by L1, +> [render-attribution.md](render-attribution.md) §5 — attribution may be heuristic, verification may not, +> [support-contract.md](support-contract.md) + +Today a write the operator cannot place is refused — correctly — but the refusal is a **dead +end**: it happens late (at flush, not at the edit), it is invisible to the person who made the +edit (the API server already returned `200 OK`), and — in the one orchestrator configuration this +operator can actually run under — the rejected live edit is **never undone**. + +This document turns that dead end into a conversation. It has two halves, and they compose: + +1. **Consent.** Move the "no" to admission time, tell the actor *why*, and — for the class of + refusal that is about blast radius rather than correctness — let them **acknowledge the + consequence and proceed**. That is what opens the door to a deliberate multi-resource edit (a + change to a shared base) that is refused today. +2. **The reconcile trigger.** Give the operator one new action — asking the GitOps orchestrator + (Flux/Argo) to reconcile now — used two ways: to **revert a refused edit promptly** instead of + leaving it as permanent drift, and to **order processing behind an incoming origin change** + (reconcile Git → cluster first, then process our events). + +Neither half is a correctness layer. The one correctness gate stays exactly where it is: the +flush-time render oracle ([`VerifyBatchRenders`](../../../internal/manifestanalyzer/render_verify.go)). +Everything here is about *feedback* and *loop responsiveness* — which is why every piece is allowed +to be optional, fail-open, and best-effort, and why the system underneath it must already be honest +without it (the fifth principle of +[unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md)). + +--- + +## 1. The problem, stated as three separate failures + +A user edits a live object through the Kubernetes API. The edit lands on the cluster. The operator +sees it, tries to place it in Git, and — because it edits a shared base, or a field a patch owns, +or would not converge — the flush-time oracle refuses. Nothing is committed; the GitTarget goes +`GitPathAccepted=False` / `WriteBoundaryRefused`. Correct. But: + +- **It is late.** The refusal is a property of the *batch*, decided in + [`writeBatch.flush`](../../../internal/git/plan_flush.go) after the commit window closes — seconds + after the edit, on a different goroutine. By then the actor is gone. +- **It is silent to the actor.** `kubectl apply` returned success. The only record that the edit + did not stick is a condition on a `GitTarget` the user may not even be watching. The person who + can fix it is the last to hear. +- **It is never undone — under the config we require.** [argocd-bi-directional.md](argocd-bi-directional.md) + proves that bi-directional editing only works with Argo `selfHeal: false` (with it on, Argo + reverts the live edit sub-second from *cached* Git and thrashes against us). But `selfHeal` is the + only thing that reverts live drift. With it off, a refused edit sits `OutOfSync` **forever**: the + push webhook never fires (there was no push), and the 120 s poll re-resolves an *unchanged* Git and + does nothing. Under Flux the story is milder but the same shape — drift is reverted only on the + next interval reconcile (often minutes), never promptly. + +So the refused edit is a silent, indefinite divergence between what the user did and what Git and +the orchestrator will enforce. The refusal was right; its *delivery* is the problem. + +### What already exists, and what this builds on + +This is not a greenfield. [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) +already frames three tiers of "no", and this document extends the top two: + +| Tier | Scope | Status | +|---|---|---| +| **1. Onboarding refusal** | whole folder, structural | shipped (acceptance gate) | +| **2. Per-edit accounting** | one object/field, runtime | designed — the `FullyReflected` unreflected set | +| **3. Admission preflight** | one API request, pre-persistence | designed — opt-in, fail-open validating webhook | + +Tier 3 as designed is **pure prevention**: it rejects an unsavable write with a named reason. This +document adds the two things tier 3 does not have — a way to *say yes anyway* (consent, §3–§4), and +a way to make the *outcome* immediate (the reconcile trigger, §5–§6). + +--- + +## 2. Two kinds of "no", and only one of them is negotiable + +Everything hinges on splitting the refusals into two piles, because consent is safe for exactly one +of them. + +| | **Correctness refusal** | **Policy refusal** | +|---|---|---| +| The operator is saying | "this write does not reproduce what you asked, or corrupts an object you did not touch" | "this write does what you asked, but the blast radius is bigger than you may realize" | +| Example | the field is owned by a patch/transformer; the render does not converge; an unpairable list | the file is a **base shared by prod and staging** (fan-in > 1); the edit changes *both* | +| Decided by | the render oracle (`VerifyBatchRenders`) | the write-boundary policy (L2 fan-in = 1) | +| Consent can lift it? | **Never.** Consent cannot make a non-converging write converge. | **Yes** — *"I know it changes both; do it."* | + +The correctness pile is the render oracle and it is absolute: a dyed render + a real re-render +either reproduce the live object and disturb nothing unintended, or they do not +([render-attribution.md §5](render-attribution.md)). No annotation changes physics. + +The policy pile is different. Fan-in = 1 (never write a file more than one render root reaches) +makes a shared base read-only *because the user probably did not mean to change every environment +at once* — [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md). +But **sometimes they do.** "Bump the base image for all environments" is a legitimate, ordinary +intent. Today it is refused with no way to express "yes, I mean the base." That is the door consent +opens. + +--- + +## 3. Consent is declared intent, not a bypass + +The temptation is to model consent as a `force: true` that skips a check. That is exactly wrong, +and the right model is already in the code. + +The oracle ([`render_verify.go`](../../../internal/manifestanalyzer/render_verify.go)) checks two +things against a set of **`WriteIntent`s**: every intended document renders to its live object, and +**every object the batch did *not* declare an intent for comes out byte-identical**. A fan-in +refusal is really the second clause firing: editing the shared base moves `Deployment/web` in +*staging* too, and staging was never an intent, so the write "moves something it never set out to +write" and is refused. + +**Consent adds the sibling object as an intent.** When the user acknowledges "this changes prod and +staging," the operator promotes staging's `Deployment/web` from the *must-be-untouched* set into the +*intended-to-change* set. Then the same oracle runs, unchanged, over the larger intent set — and it +still has to pass: the base edit must render to the acknowledged live state in **both** environments +and disturb nothing *else*. + +So consent does not remove a check. It **re-labels collateral as intent**, and the correctness gate +verifies the whole expanded intent exactly as before. There is no second code path, no bypass, and +no way for consent to wave through a write that does not actually converge. + +```mermaid +flowchart LR + E["edit a shared base"] --> O{oracle: does anything
unintended move?} + O -->|"staging/web moves,
not an intent"| R["REFUSE (fan-in)"] + R -->|"user acknowledges
the named consequence"| I["staging/web becomes
a declared intent"] + I --> O2{oracle: does the base edit
render to live in BOTH,
and disturb nothing else?} + O2 -->|yes| W["write the base"] + O2 -->|no| R2["REFUSE (correctness —
consent cannot lift this)"] + + classDef bad fill:#fdd,stroke:#c33,color:#111 + classDef good fill:#dfd,stroke:#3a3,color:#111 + class R,R2 bad + class W,I good +``` + +### The token is scoped to a specific consequence, not to "off" + +The acknowledgement must name *what* is being consented to, so it cannot become a standing "ignore +safety" flag: + +- The operator computes the consequence — the concrete set of `(object, environment)` pairs a write + would move — and reduces it to a **content hash**. +- Consent carries that hash. The operator honours it only when the *current* computed consequence + hashes to the same value. Change the base differently, or let the tree drift, and the old + acknowledgement no longer matches → the write is refused again, with the *new* consequence to + acknowledge. + +This is the same discipline the dye uses for attribution: consent to a **named, verified** fact, not +to a mood. + +### Where the token lives + +Two surfaces, both already "near the actor" per the write-gating doc's fourth principle, and both +already understood by the pipeline: + +- **An annotation on the edited object** (`configbutler.ai/acknowledge-consequence: `). It + travels with the `kubectl apply`, so consent is expressed in the same breath as the edit. It must + be **stripped before mirroring**, exactly as [`sanitize`](../../../internal/sanitize/types.go) + already strips orchestrator bookkeeping keys — a consent token is operator control data, never + content that belongs in Git. +- **A field on the `CommitRequest`** — the object a caller already polls for `Pushed` + `status.sha`. + This is the natural home for a *session-scoped* consent ("everything in this save window may touch + the base"), and it composes with the `FullyReflected` condition tier 2 puts there. + +### The edge consent must **not** cross: authorization + +Consent lifts *"did you realize,"* never *"are you allowed."* A base shared across environments that +live in **one** GitTarget's authorization scope is fine — the acking user already owns all of it. +But a base shared across **different** GitTargets (different RBAC, different tenants) is the case +[gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) +forecloses on purpose: a user who can edit prod must not change staging by editing the base, because +they may have no rights to staging. Consent from the prod editor cannot manufacture authority over +staging. So consent unlocks a shared-base edit **only within a single authorization scope**; across +scopes it stays refused, and the real route remains Option C (base-as-variant with its own GitTarget +and RBAC). This boundary is not negotiable by annotation either — it is the same class of "no" as +correctness. + +--- + +## 4. The admission surface (extending tier 3) + +Consent needs a synchronous surface, and admission is the only one: it is the sole point where the +user's intent can be accepted or rejected **whole, before persistence** — the exact property the +write-gating doc names as "the real argument for building [the gate]." The infrastructure exists: +the operator already runs admission webhooks ([`validate_operator_types_handler.go`](../../../internal/webhook/validate_operator_types_handler.go)), +and tier 3 already specifies the gate (opt-in per GitTarget, `failurePolicy: Ignore`, `--dry-run=server` +preflight). Consent adds one step to it: + +```mermaid +flowchart TD + A["kubectl apply (edit)"] --> W{admission: single-object
source-form + oracle preview} + W -->|"clean"| OK["allow — writes normally"] + W -->|"correctness refusal"| DENY["deny: name the field
(no token offered)"] + W -->|"policy refusal,
no matching ack"| ASK["deny: name the consequence
+ its ack token"] + W -->|"policy refusal,
ack matches"| OKC["allow — record the ack
as declared intent"] + ASK -.->|"user re-applies
with the token"| W +``` + +Two honest limits, both already answered by the tier-3 design: + +- **Admission sees one request; the oracle needs the batch.** The webhook can only run a + *single-object preview* of the oracle (project this one object to source form, re-render, read the + blast radius). It can be wrong — staleness, or cross-object batch effects it cannot see. That is + tolerable because the gate is **fail-open and advisory**: a wrong *deny* is a retry, and a wrong + *allow* is caught at flush by the real oracle. Admission is the UX; the flush is the authority. +- **Consent granted at admission does not bind the flush.** This is the load-bearing safety line. + The flush-time oracle re-verifies the *whole batch* against the declared intents — including the + consented ones — and still refuses if the consented set does not actually converge. So a stale or + mistaken admission-time "yes" cannot cause a bad write. Worst case it lands, the flush refuses it, + and §5's reconcile trigger reverts it — the same safety net that catches an edit made while the + gate was disabled entirely. + +--- + +## 5. The orchestrator reconcile trigger + +The operator gains exactly one new outward action: **ask the orchestrator to reconcile now.** It is +small, and it is the piece that closes the loop. + +### 5.1 Why it is necessary, not merely nice + +Restate the §1 finding sharply: with Argo `selfHeal: false` — *required* for bi-directional to work +at all — **nothing reverts a refused edit.** It is `OutOfSync` until a human intervenes. Under Flux +it is reverted only on the next interval. So triggering a reconcile is not a speed optimization; for +Argo it is the *only* thing that ever undoes a refused edit, and for Flux it turns "minutes" into +"seconds." + +And the operator is uniquely entitled to do it. [argocd-bi-directional.md](argocd-bi-directional.md) +identifies the missing ingredient precisely: a system that can *distinguish authorized drift from +unauthorized*, which Argo cannot (it "sees only the live object differs from cache"). **At refusal +time the operator has exactly that signal** — the refused edit is, by construction, the drift that +has no home in Git. So an operator-triggered reconcile of a refused edit is the **targeted, per-write +substitute for the blanket self-heal the operator had to switch off.** It realizes the one row that +doc leaves open: *"admission gate on Argo's writes → recovers revert-unauthorized."* + +### 5.2 What "trigger" means, per orchestrator + +The [orchestrator-knowledge-boundary](orchestrator-knowledge-boundary.md) rule is absolute here: +**never depend on the `argoproj` or `fluxcd` Go modules.** The trigger is a patch on an object, +matched by group+kind over `unstructured` — nothing more. But the two orchestrators differ, and the +difference matters: + +- **Flux — clean.** Patch `reconcile.fluxcd.io/requestedAt` (a timestamp) on the governing + `Kustomization`. Flux reconciles and server-side-applies desired state, which **reverts the drift** + as a side effect. One annotation, correct outcome. +- **Argo — delicate.** A plain `argocd.argoproj.io/refresh` only re-reads Git and re-compares; with + `selfHeal` off it will mark `OutOfSync` and **still not revert the drift.** Reverting requires an + actual **sync operation** — i.e. a *deliberate, one-shot self-heal* of a known-unauthorized edit. + That is a stronger action (it writes to the cluster), and it must be an explicitly granted + authority, not an ambient one. + +### 5.3 The prerequisite: this is the first *write* on the ownership model + +The operator today **cannot name the Flux/Argo object that deploys a GitTarget's path.** A GitTarget +knows only `(provider, branch, path)`; there is no field, no lookup, and no code that reaches an +orchestrator object (confirmed across `internal/`, `api/`). That capability is designed but unbuilt: +[orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) proposes per-orchestrator +*interpreters* (`internal/gitops/{flux,argocd}`) emitting **claims about paths** — e.g. +`RenderRootFor{path, by}` naming the object that renders a folder. + +The reconcile trigger is the **first write action** built on that model, which until now is purely a +read/claim vocabulary. It needs one new claim — *"object O reconciles path P"* — and the ability to +patch O. So this feature does not stand alone: **it is gated on the ownership interpreters landing +first.** Stated as a dependency, not smuggled as an assumption. + +### 5.4 The boundary: opt-in, and never on a mirror + +Patching another controller's object, and (on Argo) issuing a sync, are boundary crossings. They are +off by default and enabled per GitTarget, alongside the tier-3 write gate — e.g. a +`spec.reconcileTrigger: Off | OnRefusal | OnDrift` knob. Two hard rules: + +- **Never on a cluster the operator merely mirrors.** As with the write gate, we do not get to drive + someone's orchestrator because our mirror is lossy. Only where the cluster is an *editing surface* + whose changes are meant to flow to Git. +- **Absent orchestrator ⇒ no-op, not error.** If no interpreter claims the path, there is nothing to + trigger; the operator falls back to today's behavior (report and wait). The trigger is an + accelerator layered on top, exactly like the admission gate. + +--- + +## 6. The second use: reconcile-before-process, as an ordering barrier + +The same trigger answers a different, subtler problem: **origin moved under us.** + +### 6.1 What is and isn't already handled + +The commit direction is already safe against a moved remote. `PushAtomic` +([`git_atomic_push.go`](../../../internal/git/git_atomic_push.go)) is a compare-and-swap, never a +force-push; if the remote advanced, the push is rejected, and `pushPendingCommits` +([`branch_worker.go`](../../../internal/git/branch_worker.go)) **rebases by replay** — hard-reset the +local branch to the new tip, re-plan and re-commit the retained pending writes on top, re-push with +an updated CAS. So we never clobber someone else's push, and our own intent survives the rebase. + +What is **not** handled is the *cluster* side. When origin gains new desired state, the orchestrator +is about to apply it, producing a flood of watch events. Two problems follow: + +1. **The reconcile echo.** Those events came *from* Git via the orchestrator. If the operator + processes them as user intent and mirrors them back, it is round-tripping the orchestrator's own + apply into Git — noise at best, a fight at worst. +2. **The stale baseline.** The operator's model of "what this folder renders to" — the baseline its + source-form projection and oracle judge against — was computed against the *old* tree. Decisions + made for the transitional state (what to refuse, what to attribute) can be wrong until the cluster + reflects the new origin. + +### 6.2 The barrier + +The fix is an ordering rule: **on origin drift, trigger the orchestrator to reconcile Git → cluster, +wait for it, and only then resume processing live events.** After the reconcile, the operator's own +mark-and-sweep resync absorbs the orchestrator's apply as a **no-op against the new tree**, instead +of mirroring it as intent. + +This rides machinery that already exists. There is already a *reconcile-before-process* barrier: on +watch (re)establishment the operator enqueues a scoped mark-and-sweep ahead of live events, gated by +the `replaying` flag, all on one FIFO so order is preserved +([`target_watch.go`](../../../internal/watch/target_watch.go), +[`resync_flush.go`](../../../internal/git/resync_flush.go)). Two additions turn it into what we need: + +- **A new trigger.** Origin-drift detection (the operator already has a cached remote-drift check, + `SyncAndGetMetadata`) arms the same barrier. +- **A stronger wait.** The barrier's "reconcile" step must now include waiting for the *orchestrator* + to apply Git → cluster — not only the operator's own sweep. This is where the §5 trigger and its + ownership prerequisite are reused. + +> **Terminology, because "reconcile" is overloaded and this doc would mislead without saying so.** +> There are two: the **orchestrator reconcile** (Flux/Argo applies Git → cluster — what this section +> triggers and waits on) and the operator's internal **resync** (a mark-and-sweep that rebuilds the +> Git-side model from the cluster — cluster → Git). The barrier *triggers the first* and *runs the +> second after it*. Where this doc means the internal one, it says "resync." + +### 6.3 The hazard: pending intent vs. the reconcile that overwrites it + +There is a real ordering trap, and it must be designed for, not discovered. When origin drifts, the +operator may be holding **uncommitted** pending intent — live edits captured in the open commit +window but not yet pushed. Triggering the orchestrator reconcile *now* would apply the new origin +over those live edits on the cluster, erasing them before they reach Git. + +That is only safe because the intent is **durably captured** (the open window / `pendingWrites`) and +the commit side already rebases it onto a moved origin. So the barrier's ordering must be: **land +pending intent to Git first (rebased onto the new tip), *then* trigger the orchestrator reconcile, +*then* resume.** If capture were not durable, the reconcile would eat the user's edit — so the +barrier's safety rests on the durability the pipeline already has, and this dependency is a +precondition of the feature, stated as one. + +--- + +## 7. How the three pieces compose + +They are one escalation, each layer degrading safely to the one beneath it — the write-gating doc's +"correctness must not depend on optional layers," made concrete: + +1. **Admission consent (§3–§4)** — the actor learns the consequence *before* persistence, and can + consent to a *policy* consequence. Best case: the "no" never becomes a silent success. +2. **The flush oracle** — always runs, authoritative, unchanged. Consent only feeds it a larger + declared-intent set; it never bypasses it. This is the one correctness gate. +3. **Tier 2 accounting** — whatever still could not be placed is reported (`FullyReflected=False`), + never dropped silently. +4. **The reconcile trigger (§5)** — a refused edit that already landed is *reverted promptly* + instead of lingering as permanent `selfHeal`-off drift. +5. **The barrier (§6)** — independently, origin drift orders the orchestrator's incoming apply ahead + of our processing, so we never mirror a reconcile echo. + +Turn every optional layer off and the system is still honest: the flush oracle refuses what does not +converge, tier 2 reports the residue, and the orchestrator (eventually, on its own clock) reconciles. +The layers here make that fast, visible, and — for the blast-radius case — *consensual*, without ever +being the thing correctness depends on. + +--- + +## 8. Open questions + +- **Consent surface and granularity.** Object annotation (per-edit, stripped like the sanitize + deny-list) vs. `CommitRequest` field (per-window, already polled) vs. both. Per-consequence-hash + scoping is the recommendation; a per-GitTarget "base edits allowed" mode is the blunt alternative + and probably too blunt. +- **The Argo sync decision.** Reverting a refused edit needs a *sync operation*, not just a refresh — + a deliberate one-shot self-heal. Is issuing it an authority the operator should hold, and how is it + scoped so it can only ever revert the specific refused object, never sync the whole app? +- **Wait semantics for the barrier.** How long to wait for the orchestrator to finish reconciling; + timeout and fallback (resume anyway? stay blocked?); how to observe "done" without depending on the + orchestrator's Go types (its status conditions over `unstructured`). +- **The ownership prerequisite's shape.** The reconcile trigger needs one claim — + *"object O reconciles path P"* — from the [orchestrator interpreters](orchestrator-knowledge-boundary.md). + How confident must that claim be before the operator is willing to *write* (patch O) on the + strength of it? A wrong claim triggers the wrong controller. +- **Does consent-to-edit-a-base earn a place in the support contract?** It is a genuinely different + operation from per-environment patch authoring ("I mean the base" vs. "I mean this overlay"), and + [support-contract.md](support-contract.md) should say so explicitly rather than leave it implied by + the fan-in refusal being liftable. From 44e415d3e6662a988a89c6ca89cce801ef0853dd Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 15 Jul 2026 05:20:21 +0000 Subject: [PATCH 2/4] docs(support-boundary): split consent and the reconcile trigger into two docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They are two topics that shared a document. Split into: - admission-consent.md — turning a blast-radius refusal into a yes at the edit. The correctness-vs-policy split, consent as declared intent (promote the sibling into the oracle's WriteIntent set — never a bypass), the consequence-hash token, and the authorization edge consent must not cross. - orchestrator-reconcile-trigger.md — the operator's one new outward action, used two ways: revert a refused edit promptly (the only revert under selfHeal-off), and an ordering barrier on origin drift. Each carries its own mermaid diagrams. The trigger doc's are built from the diagrams in docs/bi-directional.md — the triggered-applier loop and sequence — and expanded to show where the trigger sits: the two arrows that guide does not draw (revert-on-refusal, and the origin-drift barrier), plus the refusal and barrier sequences. The two docs cross-reference each other as the two halves of one escalation. Direction-setting; ships no code. --- ...admission-consent-and-reconcile-trigger.md | 394 ------------------ .../support-boundary/admission-consent.md | 211 ++++++++++ .../orchestrator-reconcile-trigger.md | 250 +++++++++++ 3 files changed, 461 insertions(+), 394 deletions(-) delete mode 100644 docs/design/support-boundary/admission-consent-and-reconcile-trigger.md create mode 100644 docs/design/support-boundary/admission-consent.md create mode 100644 docs/design/support-boundary/orchestrator-reconcile-trigger.md diff --git a/docs/design/support-boundary/admission-consent-and-reconcile-trigger.md b/docs/design/support-boundary/admission-consent-and-reconcile-trigger.md deleted file mode 100644 index d8efe8f9..00000000 --- a/docs/design/support-boundary/admission-consent-and-reconcile-trigger.md +++ /dev/null @@ -1,394 +0,0 @@ -# Consent and the reconcile trigger: making a refused write a conversation, not a dead end - -> **design** — direction-setting; ships no code. Nothing it describes is supported today. -> Captured: 2026-07-15 -> Related: -> [README.md](README.md), -> [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) — **the tier-1/2/3 model this extends; tier 3 is the admission gate**, -> [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) — **the ownership model the reconcile trigger rides on**, -> [argocd-bi-directional.md](argocd-bi-directional.md) — why `selfHeal` must be off, and why that means nothing reverts a refused edit, -> [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) — the write boundary; fan-in = 1; base read-only by L1, -> [render-attribution.md](render-attribution.md) §5 — attribution may be heuristic, verification may not, -> [support-contract.md](support-contract.md) - -Today a write the operator cannot place is refused — correctly — but the refusal is a **dead -end**: it happens late (at flush, not at the edit), it is invisible to the person who made the -edit (the API server already returned `200 OK`), and — in the one orchestrator configuration this -operator can actually run under — the rejected live edit is **never undone**. - -This document turns that dead end into a conversation. It has two halves, and they compose: - -1. **Consent.** Move the "no" to admission time, tell the actor *why*, and — for the class of - refusal that is about blast radius rather than correctness — let them **acknowledge the - consequence and proceed**. That is what opens the door to a deliberate multi-resource edit (a - change to a shared base) that is refused today. -2. **The reconcile trigger.** Give the operator one new action — asking the GitOps orchestrator - (Flux/Argo) to reconcile now — used two ways: to **revert a refused edit promptly** instead of - leaving it as permanent drift, and to **order processing behind an incoming origin change** - (reconcile Git → cluster first, then process our events). - -Neither half is a correctness layer. The one correctness gate stays exactly where it is: the -flush-time render oracle ([`VerifyBatchRenders`](../../../internal/manifestanalyzer/render_verify.go)). -Everything here is about *feedback* and *loop responsiveness* — which is why every piece is allowed -to be optional, fail-open, and best-effort, and why the system underneath it must already be honest -without it (the fifth principle of -[unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md)). - ---- - -## 1. The problem, stated as three separate failures - -A user edits a live object through the Kubernetes API. The edit lands on the cluster. The operator -sees it, tries to place it in Git, and — because it edits a shared base, or a field a patch owns, -or would not converge — the flush-time oracle refuses. Nothing is committed; the GitTarget goes -`GitPathAccepted=False` / `WriteBoundaryRefused`. Correct. But: - -- **It is late.** The refusal is a property of the *batch*, decided in - [`writeBatch.flush`](../../../internal/git/plan_flush.go) after the commit window closes — seconds - after the edit, on a different goroutine. By then the actor is gone. -- **It is silent to the actor.** `kubectl apply` returned success. The only record that the edit - did not stick is a condition on a `GitTarget` the user may not even be watching. The person who - can fix it is the last to hear. -- **It is never undone — under the config we require.** [argocd-bi-directional.md](argocd-bi-directional.md) - proves that bi-directional editing only works with Argo `selfHeal: false` (with it on, Argo - reverts the live edit sub-second from *cached* Git and thrashes against us). But `selfHeal` is the - only thing that reverts live drift. With it off, a refused edit sits `OutOfSync` **forever**: the - push webhook never fires (there was no push), and the 120 s poll re-resolves an *unchanged* Git and - does nothing. Under Flux the story is milder but the same shape — drift is reverted only on the - next interval reconcile (often minutes), never promptly. - -So the refused edit is a silent, indefinite divergence between what the user did and what Git and -the orchestrator will enforce. The refusal was right; its *delivery* is the problem. - -### What already exists, and what this builds on - -This is not a greenfield. [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) -already frames three tiers of "no", and this document extends the top two: - -| Tier | Scope | Status | -|---|---|---| -| **1. Onboarding refusal** | whole folder, structural | shipped (acceptance gate) | -| **2. Per-edit accounting** | one object/field, runtime | designed — the `FullyReflected` unreflected set | -| **3. Admission preflight** | one API request, pre-persistence | designed — opt-in, fail-open validating webhook | - -Tier 3 as designed is **pure prevention**: it rejects an unsavable write with a named reason. This -document adds the two things tier 3 does not have — a way to *say yes anyway* (consent, §3–§4), and -a way to make the *outcome* immediate (the reconcile trigger, §5–§6). - ---- - -## 2. Two kinds of "no", and only one of them is negotiable - -Everything hinges on splitting the refusals into two piles, because consent is safe for exactly one -of them. - -| | **Correctness refusal** | **Policy refusal** | -|---|---|---| -| The operator is saying | "this write does not reproduce what you asked, or corrupts an object you did not touch" | "this write does what you asked, but the blast radius is bigger than you may realize" | -| Example | the field is owned by a patch/transformer; the render does not converge; an unpairable list | the file is a **base shared by prod and staging** (fan-in > 1); the edit changes *both* | -| Decided by | the render oracle (`VerifyBatchRenders`) | the write-boundary policy (L2 fan-in = 1) | -| Consent can lift it? | **Never.** Consent cannot make a non-converging write converge. | **Yes** — *"I know it changes both; do it."* | - -The correctness pile is the render oracle and it is absolute: a dyed render + a real re-render -either reproduce the live object and disturb nothing unintended, or they do not -([render-attribution.md §5](render-attribution.md)). No annotation changes physics. - -The policy pile is different. Fan-in = 1 (never write a file more than one render root reaches) -makes a shared base read-only *because the user probably did not mean to change every environment -at once* — [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md). -But **sometimes they do.** "Bump the base image for all environments" is a legitimate, ordinary -intent. Today it is refused with no way to express "yes, I mean the base." That is the door consent -opens. - ---- - -## 3. Consent is declared intent, not a bypass - -The temptation is to model consent as a `force: true` that skips a check. That is exactly wrong, -and the right model is already in the code. - -The oracle ([`render_verify.go`](../../../internal/manifestanalyzer/render_verify.go)) checks two -things against a set of **`WriteIntent`s**: every intended document renders to its live object, and -**every object the batch did *not* declare an intent for comes out byte-identical**. A fan-in -refusal is really the second clause firing: editing the shared base moves `Deployment/web` in -*staging* too, and staging was never an intent, so the write "moves something it never set out to -write" and is refused. - -**Consent adds the sibling object as an intent.** When the user acknowledges "this changes prod and -staging," the operator promotes staging's `Deployment/web` from the *must-be-untouched* set into the -*intended-to-change* set. Then the same oracle runs, unchanged, over the larger intent set — and it -still has to pass: the base edit must render to the acknowledged live state in **both** environments -and disturb nothing *else*. - -So consent does not remove a check. It **re-labels collateral as intent**, and the correctness gate -verifies the whole expanded intent exactly as before. There is no second code path, no bypass, and -no way for consent to wave through a write that does not actually converge. - -```mermaid -flowchart LR - E["edit a shared base"] --> O{oracle: does anything
unintended move?} - O -->|"staging/web moves,
not an intent"| R["REFUSE (fan-in)"] - R -->|"user acknowledges
the named consequence"| I["staging/web becomes
a declared intent"] - I --> O2{oracle: does the base edit
render to live in BOTH,
and disturb nothing else?} - O2 -->|yes| W["write the base"] - O2 -->|no| R2["REFUSE (correctness —
consent cannot lift this)"] - - classDef bad fill:#fdd,stroke:#c33,color:#111 - classDef good fill:#dfd,stroke:#3a3,color:#111 - class R,R2 bad - class W,I good -``` - -### The token is scoped to a specific consequence, not to "off" - -The acknowledgement must name *what* is being consented to, so it cannot become a standing "ignore -safety" flag: - -- The operator computes the consequence — the concrete set of `(object, environment)` pairs a write - would move — and reduces it to a **content hash**. -- Consent carries that hash. The operator honours it only when the *current* computed consequence - hashes to the same value. Change the base differently, or let the tree drift, and the old - acknowledgement no longer matches → the write is refused again, with the *new* consequence to - acknowledge. - -This is the same discipline the dye uses for attribution: consent to a **named, verified** fact, not -to a mood. - -### Where the token lives - -Two surfaces, both already "near the actor" per the write-gating doc's fourth principle, and both -already understood by the pipeline: - -- **An annotation on the edited object** (`configbutler.ai/acknowledge-consequence: `). It - travels with the `kubectl apply`, so consent is expressed in the same breath as the edit. It must - be **stripped before mirroring**, exactly as [`sanitize`](../../../internal/sanitize/types.go) - already strips orchestrator bookkeeping keys — a consent token is operator control data, never - content that belongs in Git. -- **A field on the `CommitRequest`** — the object a caller already polls for `Pushed` + `status.sha`. - This is the natural home for a *session-scoped* consent ("everything in this save window may touch - the base"), and it composes with the `FullyReflected` condition tier 2 puts there. - -### The edge consent must **not** cross: authorization - -Consent lifts *"did you realize,"* never *"are you allowed."* A base shared across environments that -live in **one** GitTarget's authorization scope is fine — the acking user already owns all of it. -But a base shared across **different** GitTargets (different RBAC, different tenants) is the case -[gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) -forecloses on purpose: a user who can edit prod must not change staging by editing the base, because -they may have no rights to staging. Consent from the prod editor cannot manufacture authority over -staging. So consent unlocks a shared-base edit **only within a single authorization scope**; across -scopes it stays refused, and the real route remains Option C (base-as-variant with its own GitTarget -and RBAC). This boundary is not negotiable by annotation either — it is the same class of "no" as -correctness. - ---- - -## 4. The admission surface (extending tier 3) - -Consent needs a synchronous surface, and admission is the only one: it is the sole point where the -user's intent can be accepted or rejected **whole, before persistence** — the exact property the -write-gating doc names as "the real argument for building [the gate]." The infrastructure exists: -the operator already runs admission webhooks ([`validate_operator_types_handler.go`](../../../internal/webhook/validate_operator_types_handler.go)), -and tier 3 already specifies the gate (opt-in per GitTarget, `failurePolicy: Ignore`, `--dry-run=server` -preflight). Consent adds one step to it: - -```mermaid -flowchart TD - A["kubectl apply (edit)"] --> W{admission: single-object
source-form + oracle preview} - W -->|"clean"| OK["allow — writes normally"] - W -->|"correctness refusal"| DENY["deny: name the field
(no token offered)"] - W -->|"policy refusal,
no matching ack"| ASK["deny: name the consequence
+ its ack token"] - W -->|"policy refusal,
ack matches"| OKC["allow — record the ack
as declared intent"] - ASK -.->|"user re-applies
with the token"| W -``` - -Two honest limits, both already answered by the tier-3 design: - -- **Admission sees one request; the oracle needs the batch.** The webhook can only run a - *single-object preview* of the oracle (project this one object to source form, re-render, read the - blast radius). It can be wrong — staleness, or cross-object batch effects it cannot see. That is - tolerable because the gate is **fail-open and advisory**: a wrong *deny* is a retry, and a wrong - *allow* is caught at flush by the real oracle. Admission is the UX; the flush is the authority. -- **Consent granted at admission does not bind the flush.** This is the load-bearing safety line. - The flush-time oracle re-verifies the *whole batch* against the declared intents — including the - consented ones — and still refuses if the consented set does not actually converge. So a stale or - mistaken admission-time "yes" cannot cause a bad write. Worst case it lands, the flush refuses it, - and §5's reconcile trigger reverts it — the same safety net that catches an edit made while the - gate was disabled entirely. - ---- - -## 5. The orchestrator reconcile trigger - -The operator gains exactly one new outward action: **ask the orchestrator to reconcile now.** It is -small, and it is the piece that closes the loop. - -### 5.1 Why it is necessary, not merely nice - -Restate the §1 finding sharply: with Argo `selfHeal: false` — *required* for bi-directional to work -at all — **nothing reverts a refused edit.** It is `OutOfSync` until a human intervenes. Under Flux -it is reverted only on the next interval. So triggering a reconcile is not a speed optimization; for -Argo it is the *only* thing that ever undoes a refused edit, and for Flux it turns "minutes" into -"seconds." - -And the operator is uniquely entitled to do it. [argocd-bi-directional.md](argocd-bi-directional.md) -identifies the missing ingredient precisely: a system that can *distinguish authorized drift from -unauthorized*, which Argo cannot (it "sees only the live object differs from cache"). **At refusal -time the operator has exactly that signal** — the refused edit is, by construction, the drift that -has no home in Git. So an operator-triggered reconcile of a refused edit is the **targeted, per-write -substitute for the blanket self-heal the operator had to switch off.** It realizes the one row that -doc leaves open: *"admission gate on Argo's writes → recovers revert-unauthorized."* - -### 5.2 What "trigger" means, per orchestrator - -The [orchestrator-knowledge-boundary](orchestrator-knowledge-boundary.md) rule is absolute here: -**never depend on the `argoproj` or `fluxcd` Go modules.** The trigger is a patch on an object, -matched by group+kind over `unstructured` — nothing more. But the two orchestrators differ, and the -difference matters: - -- **Flux — clean.** Patch `reconcile.fluxcd.io/requestedAt` (a timestamp) on the governing - `Kustomization`. Flux reconciles and server-side-applies desired state, which **reverts the drift** - as a side effect. One annotation, correct outcome. -- **Argo — delicate.** A plain `argocd.argoproj.io/refresh` only re-reads Git and re-compares; with - `selfHeal` off it will mark `OutOfSync` and **still not revert the drift.** Reverting requires an - actual **sync operation** — i.e. a *deliberate, one-shot self-heal* of a known-unauthorized edit. - That is a stronger action (it writes to the cluster), and it must be an explicitly granted - authority, not an ambient one. - -### 5.3 The prerequisite: this is the first *write* on the ownership model - -The operator today **cannot name the Flux/Argo object that deploys a GitTarget's path.** A GitTarget -knows only `(provider, branch, path)`; there is no field, no lookup, and no code that reaches an -orchestrator object (confirmed across `internal/`, `api/`). That capability is designed but unbuilt: -[orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) proposes per-orchestrator -*interpreters* (`internal/gitops/{flux,argocd}`) emitting **claims about paths** — e.g. -`RenderRootFor{path, by}` naming the object that renders a folder. - -The reconcile trigger is the **first write action** built on that model, which until now is purely a -read/claim vocabulary. It needs one new claim — *"object O reconciles path P"* — and the ability to -patch O. So this feature does not stand alone: **it is gated on the ownership interpreters landing -first.** Stated as a dependency, not smuggled as an assumption. - -### 5.4 The boundary: opt-in, and never on a mirror - -Patching another controller's object, and (on Argo) issuing a sync, are boundary crossings. They are -off by default and enabled per GitTarget, alongside the tier-3 write gate — e.g. a -`spec.reconcileTrigger: Off | OnRefusal | OnDrift` knob. Two hard rules: - -- **Never on a cluster the operator merely mirrors.** As with the write gate, we do not get to drive - someone's orchestrator because our mirror is lossy. Only where the cluster is an *editing surface* - whose changes are meant to flow to Git. -- **Absent orchestrator ⇒ no-op, not error.** If no interpreter claims the path, there is nothing to - trigger; the operator falls back to today's behavior (report and wait). The trigger is an - accelerator layered on top, exactly like the admission gate. - ---- - -## 6. The second use: reconcile-before-process, as an ordering barrier - -The same trigger answers a different, subtler problem: **origin moved under us.** - -### 6.1 What is and isn't already handled - -The commit direction is already safe against a moved remote. `PushAtomic` -([`git_atomic_push.go`](../../../internal/git/git_atomic_push.go)) is a compare-and-swap, never a -force-push; if the remote advanced, the push is rejected, and `pushPendingCommits` -([`branch_worker.go`](../../../internal/git/branch_worker.go)) **rebases by replay** — hard-reset the -local branch to the new tip, re-plan and re-commit the retained pending writes on top, re-push with -an updated CAS. So we never clobber someone else's push, and our own intent survives the rebase. - -What is **not** handled is the *cluster* side. When origin gains new desired state, the orchestrator -is about to apply it, producing a flood of watch events. Two problems follow: - -1. **The reconcile echo.** Those events came *from* Git via the orchestrator. If the operator - processes them as user intent and mirrors them back, it is round-tripping the orchestrator's own - apply into Git — noise at best, a fight at worst. -2. **The stale baseline.** The operator's model of "what this folder renders to" — the baseline its - source-form projection and oracle judge against — was computed against the *old* tree. Decisions - made for the transitional state (what to refuse, what to attribute) can be wrong until the cluster - reflects the new origin. - -### 6.2 The barrier - -The fix is an ordering rule: **on origin drift, trigger the orchestrator to reconcile Git → cluster, -wait for it, and only then resume processing live events.** After the reconcile, the operator's own -mark-and-sweep resync absorbs the orchestrator's apply as a **no-op against the new tree**, instead -of mirroring it as intent. - -This rides machinery that already exists. There is already a *reconcile-before-process* barrier: on -watch (re)establishment the operator enqueues a scoped mark-and-sweep ahead of live events, gated by -the `replaying` flag, all on one FIFO so order is preserved -([`target_watch.go`](../../../internal/watch/target_watch.go), -[`resync_flush.go`](../../../internal/git/resync_flush.go)). Two additions turn it into what we need: - -- **A new trigger.** Origin-drift detection (the operator already has a cached remote-drift check, - `SyncAndGetMetadata`) arms the same barrier. -- **A stronger wait.** The barrier's "reconcile" step must now include waiting for the *orchestrator* - to apply Git → cluster — not only the operator's own sweep. This is where the §5 trigger and its - ownership prerequisite are reused. - -> **Terminology, because "reconcile" is overloaded and this doc would mislead without saying so.** -> There are two: the **orchestrator reconcile** (Flux/Argo applies Git → cluster — what this section -> triggers and waits on) and the operator's internal **resync** (a mark-and-sweep that rebuilds the -> Git-side model from the cluster — cluster → Git). The barrier *triggers the first* and *runs the -> second after it*. Where this doc means the internal one, it says "resync." - -### 6.3 The hazard: pending intent vs. the reconcile that overwrites it - -There is a real ordering trap, and it must be designed for, not discovered. When origin drifts, the -operator may be holding **uncommitted** pending intent — live edits captured in the open commit -window but not yet pushed. Triggering the orchestrator reconcile *now* would apply the new origin -over those live edits on the cluster, erasing them before they reach Git. - -That is only safe because the intent is **durably captured** (the open window / `pendingWrites`) and -the commit side already rebases it onto a moved origin. So the barrier's ordering must be: **land -pending intent to Git first (rebased onto the new tip), *then* trigger the orchestrator reconcile, -*then* resume.** If capture were not durable, the reconcile would eat the user's edit — so the -barrier's safety rests on the durability the pipeline already has, and this dependency is a -precondition of the feature, stated as one. - ---- - -## 7. How the three pieces compose - -They are one escalation, each layer degrading safely to the one beneath it — the write-gating doc's -"correctness must not depend on optional layers," made concrete: - -1. **Admission consent (§3–§4)** — the actor learns the consequence *before* persistence, and can - consent to a *policy* consequence. Best case: the "no" never becomes a silent success. -2. **The flush oracle** — always runs, authoritative, unchanged. Consent only feeds it a larger - declared-intent set; it never bypasses it. This is the one correctness gate. -3. **Tier 2 accounting** — whatever still could not be placed is reported (`FullyReflected=False`), - never dropped silently. -4. **The reconcile trigger (§5)** — a refused edit that already landed is *reverted promptly* - instead of lingering as permanent `selfHeal`-off drift. -5. **The barrier (§6)** — independently, origin drift orders the orchestrator's incoming apply ahead - of our processing, so we never mirror a reconcile echo. - -Turn every optional layer off and the system is still honest: the flush oracle refuses what does not -converge, tier 2 reports the residue, and the orchestrator (eventually, on its own clock) reconciles. -The layers here make that fast, visible, and — for the blast-radius case — *consensual*, without ever -being the thing correctness depends on. - ---- - -## 8. Open questions - -- **Consent surface and granularity.** Object annotation (per-edit, stripped like the sanitize - deny-list) vs. `CommitRequest` field (per-window, already polled) vs. both. Per-consequence-hash - scoping is the recommendation; a per-GitTarget "base edits allowed" mode is the blunt alternative - and probably too blunt. -- **The Argo sync decision.** Reverting a refused edit needs a *sync operation*, not just a refresh — - a deliberate one-shot self-heal. Is issuing it an authority the operator should hold, and how is it - scoped so it can only ever revert the specific refused object, never sync the whole app? -- **Wait semantics for the barrier.** How long to wait for the orchestrator to finish reconciling; - timeout and fallback (resume anyway? stay blocked?); how to observe "done" without depending on the - orchestrator's Go types (its status conditions over `unstructured`). -- **The ownership prerequisite's shape.** The reconcile trigger needs one claim — - *"object O reconciles path P"* — from the [orchestrator interpreters](orchestrator-knowledge-boundary.md). - How confident must that claim be before the operator is willing to *write* (patch O) on the - strength of it? A wrong claim triggers the wrong controller. -- **Does consent-to-edit-a-base earn a place in the support contract?** It is a genuinely different - operation from per-environment patch authoring ("I mean the base" vs. "I mean this overlay"), and - [support-contract.md](support-contract.md) should say so explicitly rather than leave it implied by - the fan-in refusal being liftable. diff --git a/docs/design/support-boundary/admission-consent.md b/docs/design/support-boundary/admission-consent.md new file mode 100644 index 00000000..081f3c75 --- /dev/null +++ b/docs/design/support-boundary/admission-consent.md @@ -0,0 +1,211 @@ +# Admission consent: a blast-radius refusal you can say yes to + +> **design** — direction-setting; ships no code. Nothing it describes is supported today. +> Captured: 2026-07-15 +> Related: +> [README.md](README.md), +> [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) — **the tier-1/2/3 model this extends; tier 3 is the admission gate**, +> [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) — the write boundary; fan-in = 1; base read-only by L1, +> [render-attribution.md](render-attribution.md) §5 — attribution may be heuristic, verification may not, +> [orchestrator-reconcile-trigger.md](orchestrator-reconcile-trigger.md) — **the sibling half: what reverts a refusal that lands anyway**, +> [support-contract.md](support-contract.md) + +This is one half of a two-part design. This half is about turning a *refusal* into a *yes* at the +moment of the edit; the other half — [the reconcile trigger](orchestrator-reconcile-trigger.md) — is +about making the *outcome* of a "no" prompt and visible. They compose, but they are separate topics +and separate documents. + +Today a write the operator cannot place is refused, correctly, but the refusal is **binary and +mute**. The user who edits a base shared by prod and staging is told "no" — at flush time, on a +`GitTarget` condition they may not be watching — with no way to say *"yes, I know it changes both; +that is what I meant."* Some refusals should never be lift­able. But that one should, and this +document is about the line between them. + +--- + +## 1. The refusal is right, but there is no "yes" + +An edit to a shared base is refused because fan-in = 1 makes shared context read-only +([gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md)): +changing one file would change what more than one environment renders. That rule exists because the +user *usually* did not mean to change every environment at once. + +But sometimes they did. "Bump the base image for all environments" is an ordinary, legitimate +intent. Today it has no expression: the operator refuses it exactly as it refuses an accidental +cross-environment edit, because it cannot tell the two apart. The missing thing is not a weaker +rule — it is a way for the user to **declare which one this is.** + +That declaration has to happen where the user is, and synchronously, which is why this rides the +**tier-3 admission gate** already designed in +[unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) (opt-in per +GitTarget, `failurePolicy: Ignore`, `--dry-run=server` preflight). Tier 3 as written only *rejects* +an unsavable write. This adds the "yes." + +--- + +## 2. Two kinds of "no", and only one is negotiable + +Everything turns on splitting refusals into two piles, because consent is safe for exactly one. + +```mermaid +flowchart TD + N["the operator says NO"] --> Q{which kind of no?} + Q -->|"the render does not reproduce
what you asked, or moves an
object you never touched"| C["CORRECTNESS
consent cannot lift it —
no annotation changes physics"] + Q -->|"the edit does exactly what you asked,
but the file is a base shared by
more than one environment (fan-in > 1)"| P["POLICY / blast radius
consent CAN lift it —
'yes, change every environment'"] + classDef bad fill:#fdd,stroke:#c33,color:#111 + classDef good fill:#dfd,stroke:#3a3,color:#111 + class C bad + class P good +``` + +| | **Correctness refusal** | **Policy refusal** | +|---|---|---| +| The operator is saying | "this does not reproduce what you asked, or corrupts an object you did not touch" | "this does what you asked, but the blast radius is bigger than you may realize" | +| Example | the field is owned by a patch/transformer; the render does not converge; an unpairable list | the file is a **base shared by prod and staging**; the edit changes *both* | +| Decided by | the render oracle (`VerifyBatchRenders`) | the write-boundary policy (L2 fan-in = 1) | +| Consent lifts it? | **Never.** | **Yes.** | + +The correctness pile is the render oracle, and it is absolute: a dyed render plus a real re-render +either reproduce the live object and disturb nothing unintended, or they do not +([render-attribution.md §5](render-attribution.md)). No annotation makes a non-converging write +converge. + +The policy pile is different — it is refused for the user's protection, not for physics. That is the +pile consent unlocks. + +--- + +## 3. Consent is declared intent, not a bypass + +The tempting model is a `force: true` that skips a check. That is exactly wrong, and the correct +model is already in the code. + +The oracle ([`render_verify.go`](../../../internal/manifestanalyzer/render_verify.go)) checks two +things against a set of **`WriteIntent`s**: every intended document renders to its live object, and +**every object the batch did *not* declare an intent for comes out byte-identical**. A fan-in +refusal is really the second clause firing: editing the shared base also moves `Deployment/web` in +staging, staging was never an intent, so the write "moves something it never set out to write" and +is refused. + +**Consent adds the sibling object as an intent.** When the user acknowledges "this changes prod and +staging," the operator promotes staging's object from the *must-be-untouched* set into the +*intended-to-change* set, and the **same oracle** runs, unchanged, over the larger intent — and it +still has to pass: the base edit must render to the acknowledged live state in *both* environments +and disturb nothing else. + +```mermaid +flowchart LR + E["edit a shared base"] --> O{"oracle: does anything
UNINTENDED move?"} + O -->|"staging/web moves,
and it is not an intent"| R["REFUSE — fan-in"] + R -->|"user acknowledges
the named consequence"| I["staging/web becomes
a DECLARED intent"] + I --> O2{"oracle: does the base edit render
to live in BOTH, and disturb
nothing else?"} + O2 -->|yes| W["write the base"] + O2 -->|no| R2["REFUSE — correctness
(consent cannot lift this)"] + + classDef bad fill:#fdd,stroke:#c33,color:#111 + classDef good fill:#dfd,stroke:#3a3,color:#111 + class R,R2 bad + class W,I good +``` + +So consent does not remove a check. It **re-labels collateral as intent**, and the correctness gate +verifies the whole expanded intent exactly as before. There is no second code path, no bypass, and +no way for consent to wave through a write that does not actually converge. + +### The token is scoped to a consequence, not to "off" + +The acknowledgement must name *what* is being consented to, so it can never become a standing "ignore +safety" flag: + +- The operator computes the consequence — the concrete set of `(object, environment)` pairs the + write would move — and reduces it to a **content hash**. +- Consent carries that hash. The operator honours it only when the *current* computed consequence + hashes to the same value. Change the base differently, or let the tree drift, and the old + acknowledgement no longer matches → the write is refused again, naming the *new* consequence to + acknowledge. + +This is the same discipline the dye uses for attribution: consent to a **named, verified** fact, not +to a mood. + +### Where the token lives + +Two surfaces, both already "near the actor" (the write-gating doc's fourth principle) and both +already understood by the pipeline: + +- **An annotation on the edited object** (`configbutler.ai/acknowledge-consequence: `) travels + with the `kubectl apply`, so consent is expressed in the same breath as the edit. It must be + **stripped before mirroring**, exactly as [`sanitize`](../../../internal/sanitize/types.go) already + strips orchestrator bookkeeping keys — a consent token is operator control data, never content + that belongs in Git. +- **A field on the `CommitRequest`** — the object a caller already polls for `Pushed` + `status.sha` + — is the natural home for *session-scoped* consent ("everything in this save window may touch the + base"), and it composes with the `FullyReflected` condition tier 2 puts there. + +### The edge consent must not cross: authorization + +Consent lifts *"did you realize,"* never *"are you allowed."* A base shared across environments in +**one** GitTarget's authorization scope is fine — the acking user already owns all of it. But a base +shared across **different** GitTargets (different RBAC, different tenants) is the case +[gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) +forecloses on purpose: a user who can edit prod must not change staging by editing the base when they +may have no rights to staging. Consent from the prod editor cannot manufacture authority over +staging. So consent unlocks a shared-base edit **only within a single authorization scope**; across +scopes it stays refused, and the route remains Option C (base-as-variant with its own GitTarget and +RBAC). This boundary is the same class of "no" as correctness — not lift­able by annotation. + +--- + +## 4. The admission surface + +Consent needs a synchronous surface, and admission is the only one: it is the sole point where the +user's intent can be accepted or rejected **whole, before persistence** — the property the +write-gating doc names as the real argument for the gate. The infrastructure exists (the operator +already runs admission webhooks, +[`validate_operator_types_handler.go`](../../../internal/webhook/validate_operator_types_handler.go)), +and tier 3 already specifies it. Consent adds one branch: + +```mermaid +flowchart TD + A["kubectl apply (edit)"] --> W{"admission: single-object
source-form + oracle preview"} + W -->|"clean"| OK["allow — writes normally"] + W -->|"correctness refusal"| DENY["deny: name the field
(no token offered)"] + W -->|"policy refusal,
no matching ack"| ASK["deny: name the consequence
+ its ack token"] + W -->|"policy refusal,
ack matches"| OKC["allow — record the ack
as a declared intent"] + ASK -.->|"user re-applies
with the token"| W + + classDef bad fill:#fdd,stroke:#c33,color:#111 + classDef good fill:#dfd,stroke:#3a3,color:#111 + class DENY,ASK bad + class OK,OKC good +``` + +Two honest limits, both already answered by the tier-3 design: + +- **Admission sees one request; the oracle needs the batch.** The webhook can only run a + *single-object preview* of the oracle (project this one object to source form, re-render, read the + blast radius). It can be wrong — staleness, or cross-object batch effects it cannot see. That is + tolerable because the gate is **fail-open and advisory**: a wrong *deny* is a retry, and a wrong + *allow* is caught at flush. +- **Consent granted at admission does not bind the flush.** This is the load-bearing safety line. The + flush-time oracle re-verifies the *whole batch* against the declared intents — including the + consented ones — and still refuses if the consented set does not actually converge. So a stale or + mistaken admission-time "yes" cannot cause a bad write. Worst case it lands, the flush refuses it, + and the [reconcile trigger](orchestrator-reconcile-trigger.md) reverts it — the same safety net + that catches an edit made while the gate was disabled entirely. + +--- + +## 5. Open questions + +- **Surface and granularity.** Object annotation (per-edit, stripped like the sanitize deny-list) + vs. `CommitRequest` field (per-window, already polled) vs. both. Per-consequence-hash scoping is + the recommendation; a per-GitTarget "base edits allowed" mode is the blunt alternative, probably + too blunt. +- **Does consent-to-edit-a-base earn a place in the support contract?** It is a genuinely different + operation from per-environment patch authoring ("I mean the base" vs. "I mean this overlay"), and + [support-contract.md](support-contract.md) should say so explicitly rather than leave it implied by + the fan-in refusal being liftable. +- **What computes the consequence at admission time?** The single-object preview needs the same + source-form projection the writer uses, evaluated against a store snapshot. Its cost and staleness + are the tier-3 concerns; consent adds the requirement that the *hash* it produces is stable across + the preview and the eventual flush, or the token will spuriously fail to match. diff --git a/docs/design/support-boundary/orchestrator-reconcile-trigger.md b/docs/design/support-boundary/orchestrator-reconcile-trigger.md new file mode 100644 index 00000000..9f3f3da1 --- /dev/null +++ b/docs/design/support-boundary/orchestrator-reconcile-trigger.md @@ -0,0 +1,250 @@ +# The orchestrator reconcile trigger: revert a refusal, and order around origin drift + +> **design** — direction-setting; ships no code. Nothing it describes is supported today. +> Captured: 2026-07-15 +> Related: +> [README.md](README.md), +> [../../bi-directional.md](../../bi-directional.md) — **the user-facing model this expands: the reconciler as a *triggered applier***, +> [argocd-bi-directional.md](argocd-bi-directional.md) — why `selfHeal` must be off, and why that means nothing reverts a refused edit, +> [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) — **the ownership model this rides on; it is the first *write* action built on it**, +> [admission-consent.md](admission-consent.md) — the sibling half: deciding *whether* a write happens, +> [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) + +This is one half of a two-part design. [The other half](admission-consent.md) decides *whether* a +write happens, at admission time. This half is about the operator gaining **one new outward action — +asking the GitOps orchestrator (Flux/Argo) to reconcile now** — and the two places it is needed: +reverting a refused edit promptly, and ordering our processing behind an incoming origin change. + +It is not a correctness layer. The one correctness gate stays where it is — the flush-time render +oracle ([`VerifyBatchRenders`](../../../internal/manifestanalyzer/render_verify.go)). This is about +closing the loop *fast* and *visibly* once a "no" has been decided. + +--- + +## 1. The gap: a triggered applier that is never triggered on the two cases that need it + +[bi-directional.md](../../bi-directional.md) already establishes the model: the reconciler is not an +always-on loop, it is a **triggered applier**. After the operator commits, it triggers the reconciler +to apply *that exact commit* and waits for the SHA — and because the applied revision equals the +committed revision, the loop is closed. The guide draws it as a steady-state loop: + +```mermaid +flowchart LR + api(["API / operator edit"]) --> cluster[("Cluster")] + cluster -- watch --> gr["GitOps Reverser"] + gr -- "commit + push" --> git[("Git")] + git -- "push webhook" --> recon["Flux / Argo CD
(selfHeal OFF)"] + recon -- "apply fresh commit" --> cluster +``` + +That loop only closes on the **happy path**, where a commit is produced and a push fires the webhook. +Two cases fall straight through it, and they are exactly the ones where the operator must trigger the +reconciler *directly* rather than via a push that never happens: + +```mermaid +flowchart LR + api(["API / operator edit"]) --> cluster[("Cluster")] + cluster -- watch --> gr["GitOps Reverser"] + gr -- "commit + push (write placed)" --> git[("Git")] + git -- "push webhook" --> recon["Flux / Argo CD
(selfHeal OFF)"] + recon -- "apply fresh commit" --> cluster + + gr -. "REFUSED: no commit, no push, no webhook —
trigger reconcile to REVERT the live edit (§2)" .-> recon + other(["someone else pushes"]) --> git + git -. "ORIGIN DRIFT: trigger + wait, then process events (§3)" .-> recon + + classDef trig fill:#ffd,stroke:#cc3,color:#111 + class gr,git trig +``` + +The two dotted arrows are the whole of this document: the reconcile trigger, and where it sits. + +--- + +## 2. Use one: revert a refused edit + +### Why it is necessary, not merely nice + +With Argo `selfHeal: false` — *required* for bi-directional editing, because with it on Argo reverts +the live edit sub-second from cached Git and thrashes against us +([argocd-bi-directional.md](argocd-bi-directional.md)) — **nothing reverts a refused edit.** It is +`OutOfSync` until a human intervenes: the push webhook never fires (there was no commit, no push), and +the poll re-resolves an *unchanged* Git and does nothing. Under Flux it is milder but the same shape — +reverted only on the next interval reconcile. So triggering a reconcile is not a speed-up; for Argo it +is the *only* thing that ever reverts a refused edit. + +And the operator is uniquely entitled to do it. [argocd-bi-directional.md](argocd-bi-directional.md) +names the missing ingredient precisely: a system that can *distinguish authorized drift from +unauthorized*, which Argo cannot. **At refusal time the operator has exactly that signal** — the +refused edit is, by construction, the drift with no home in Git. So an operator-triggered reconcile of +a refused edit is the **targeted, per-write substitute for the blanket self-heal the operator had to +switch off.** + +```mermaid +sequenceDiagram + actor User + participant K8s as Cluster + participant Rev as GitOps Reverser + participant Git + participant Recon as Flux / Argo CD + + User->>K8s: edit a base-owned field + K8s-->>Rev: watch event + Note over Rev: flush oracle REFUSES —
no legal destination in Git + Rev-->>K8s: GitPathAccepted=False
(async — the User already got 200 OK) + Note over Git: nothing committed → no push → no webhook + Note over Recon: selfHeal off → it will NOT revert drift on its own + Rev->>Recon: TRIGGER reconcile of the governing object + Recon->>Git: fetch current revision (unchanged) + Recon->>K8s: re-apply desired state → the refused edit is reverted + Recon-->>Rev: reports revision applied + Note over Rev,K8s: loop closed in seconds, instead of never +``` + +Compare this to the guide's happy-path triggered-applier sequence: the shape is identical, but the +trigger fires on a **refusal** (nothing was committed) instead of on a commit, and its job is to +*revert* the drift rather than to *apply* a fresh commit. + +### What "trigger" means, per orchestrator + +The [orchestrator-knowledge-boundary](orchestrator-knowledge-boundary.md) rule is absolute: **never +depend on the `argoproj` or `fluxcd` Go modules.** The trigger is a patch on an object, matched by +group+kind over `unstructured`. But the two orchestrators differ, and the difference matters: + +| | **Flux** | **Argo CD** | +|---|---|---| +| Trigger | patch `reconcile.fluxcd.io/requestedAt` on the `Kustomization` | see below | +| Does it revert drift? | **Yes, cleanly** — Flux server-side-applies desired state, which reverts drift as a side effect | **Not with a plain refresh.** `argocd.argoproj.io/refresh` re-reads Git and re-compares, but with `selfHeal` off it only marks `OutOfSync` — it does **not** revert | +| To actually revert | (nothing more) | requires a **sync operation** — a deliberate, one-shot self-heal of the specific refused object | + +So Flux is a one-annotation, correct-outcome trigger. Argo needs the stronger action — a sync — which +writes to the cluster and must therefore be an explicitly granted authority, scoped so it can only +ever revert the specific refused object, never sync the whole app. + +--- + +## 3. Use two: reconcile-before-process, as an ordering barrier + +The same trigger answers a different, subtler problem — **origin moved under us** — and here it is a +*barrier*, not a revert. + +### What is, and isn't, already handled + +The commit direction is already safe against a moved remote. `PushAtomic` +([`git_atomic_push.go`](../../../internal/git/git_atomic_push.go)) is a compare-and-swap, never a +force-push; if the remote advanced, the push is rejected and `pushPendingCommits` +([`branch_worker.go`](../../../internal/git/branch_worker.go)) **rebases by replay** — hard-reset to +the new tip, re-plan and re-commit the retained pending writes on top, re-push with an updated CAS. So +we never clobber someone else's push, and our own intent survives. + +What is **not** handled is the *cluster* side. When origin gains new desired state, the orchestrator is +about to apply it, producing a flood of watch events. Two problems follow: + +1. **The reconcile echo.** Those events came *from* Git via the orchestrator. If the operator processes + them as user intent and mirrors them back, it is round-tripping the orchestrator's own apply into + Git — noise at best, a fight at worst. +2. **The stale baseline.** The operator's model of "what this folder renders to" was computed against + the *old* tree, so its refusal/attribution decisions for the transitional state can be wrong until + the cluster reflects the new origin. + +### The barrier + +The fix is an ordering rule: **on origin drift, trigger the orchestrator to reconcile Git → cluster, +wait for it, and only then resume processing live events.** After the reconcile, the operator's own +mark-and-sweep resync absorbs the orchestrator's apply as a **no-op against the new tree**, instead of +mirroring it as intent. + +```mermaid +sequenceDiagram + actor Other as Someone else + participant Git + participant Rev as GitOps Reverser + participant Recon as Flux / Argo CD + participant K8s as Cluster + + Other->>Git: push new desired state (origin drifts) + Note over Rev: detects the remote moved + Rev->>Git: land pending intent first
(rebase onto the new tip — §3, the hazard) + Rev->>Recon: TRIGGER reconcile Git → cluster, and WAIT + Recon->>Git: fetch the new revision + Recon->>K8s: apply the new desired state + Recon-->>Rev: reports the new revision applied + Note over Rev: only NOW resume live events —
the orchestrator's apply is absorbed
as a resync no-op, not mirrored back +``` + +This rides machinery that already exists. There is already a *reconcile-before-process* barrier: on +watch (re)establishment the operator enqueues a scoped mark-and-sweep ahead of live events, gated by +the `replaying` flag, all on one FIFO so order is preserved +([`target_watch.go`](../../../internal/watch/target_watch.go), +[`resync_flush.go`](../../../internal/git/resync_flush.go)). Two additions turn it into what we need: a +new **trigger** (origin-drift detection, from the existing cached remote-drift check `SyncAndGetMetadata`), +and a stronger **wait** (the barrier's "reconcile" step now waits for the *orchestrator* to apply +Git → cluster, not only the operator's own sweep). + +> **Terminology, because "reconcile" is overloaded and this doc would mislead without saying so.** +> There are two. The **orchestrator reconcile** (Flux/Argo applies Git → cluster) is what this +> document triggers and waits on. The operator's internal **resync** (a mark-and-sweep that rebuilds +> the Git-side model from the cluster, cluster → Git) is what runs *after* the barrier to absorb the +> apply. Where this doc means the internal one, it says "resync." + +### The hazard: pending intent vs. the reconcile that overwrites it + +There is a real ordering trap, drawn as the first `Rev->>Git` step above. When origin drifts, the +operator may hold **uncommitted** pending intent — live edits captured in the open commit window but +not yet pushed. Triggering the orchestrator reconcile *now* would apply the new origin over those live +edits on the cluster, erasing them before they reach Git. + +That is only safe because the intent is **durably captured** (the open window / `pendingWrites`) and +the commit side already rebases it onto a moved origin. So the ordering must be: **land pending intent +to Git first (rebased onto the new tip), *then* trigger the reconcile, *then* resume.** If capture +were not durable, the reconcile would eat the user's edit — so the barrier's safety rests on the +durability the pipeline already has, stated here as a precondition rather than discovered as a bug. + +--- + +## 4. The prerequisite: this is the first *write* on the ownership model + +The operator today **cannot name the Flux/Argo object that deploys a GitTarget's path.** A GitTarget +knows only `(provider, branch, path)`; there is no field, no lookup, and no code that reaches an +orchestrator object (confirmed across `internal/`, `api/`). That capability is designed but unbuilt: +[orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) proposes per-orchestrator +*interpreters* (`internal/gitops/{flux,argocd}`) emitting **claims about paths** — e.g. +`RenderRootFor{path, by}` naming the object that renders a folder. + +The reconcile trigger is the **first write action** built on that model, which until now is a purely +read/claim vocabulary. It needs one new claim — *"object O reconciles path P"* — and the ability to +patch O. So this feature does not stand alone: **it is gated on the ownership interpreters landing +first.** Stated as a dependency, not smuggled as an assumption. + +--- + +## 5. The boundary: opt-in, and never on a mirror + +Patching another controller's object, and (on Argo) issuing a sync, are boundary crossings. They are +off by default and enabled per GitTarget, alongside the tier-3 write gate — e.g. a +`spec.reconcileTrigger: Off | OnRefusal | OnDrift` knob. Two hard rules: + +- **Never on a cluster the operator merely mirrors.** As with the write gate, we do not get to drive + someone's orchestrator because our mirror is lossy. Only where the cluster is an *editing surface* + whose changes are meant to flow to Git. +- **Absent orchestrator ⇒ no-op, not error.** If no interpreter claims the path, there is nothing to + trigger; the operator falls back to today's behavior (report and wait). The trigger is an + accelerator layered on top. + +--- + +## 6. Open questions + +- **The Argo sync decision.** Reverting a refused edit needs a *sync operation*, not just a refresh — + a deliberate one-shot self-heal. Is issuing it an authority the operator should hold, and how is it + scoped so it can only ever revert the specific refused object, never sync the whole app? +- **Wait semantics for the barrier.** How long to wait for the orchestrator to finish; timeout and + fallback (resume anyway, or stay blocked?); how to observe "done" without depending on the + orchestrator's Go types (its status conditions over `unstructured` — e.g. + `Kustomization.status.lastAppliedRevision`, `Application.status.sync.revision`). +- **How confident must the ownership claim be before we *write* on it?** A wrong "object O reconciles + path P" claim triggers the wrong controller. This is a higher bar than a claim used only to *read*. +- **Interaction with the happy path.** When a commit *does* land, `bi-directional.md`'s webhook already + triggers the apply. Does the operator still issue an explicit trigger (and wait for the SHA, closing + the handshake the guide describes), or defer to the webhook? Likely: trigger only where the webhook + cannot help — refusal and drift — and let the push webhook cover the happy path. From c2fbbc83cc371670aa88164eede71ccc87733a2b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 15 Jul 2026 05:27:35 +0000 Subject: [PATCH 3/4] docs(support-boundary): kpt and KRM functions orientation note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An orientation note on where Kpt and KRM functions fit the reverse-GitOps model: packages as render roots, setters as an explicit inverse contract, function pipelines as a renderer extension point (not an inverse API), and a per-capability support ladder. It reuses the same oracle the Kustomize work established — a render result is not a source map, and causation is not governance. Indexed under "Renderers & provenance" in the folder README. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/support-boundary/README.md | 1 + .../support-boundary/kpt-and-krm-functions.md | 331 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 docs/design/support-boundary/kpt-and-krm-functions.md diff --git a/docs/design/support-boundary/README.md b/docs/design/support-boundary/README.md index 5c08547e..c8655a79 100644 --- a/docs/design/support-boundary/README.md +++ b/docs/design/support-boundary/README.md @@ -13,6 +13,7 @@ | Topic | Docs | |---|---| | **The boundary** | [support-contract.md](support-contract.md) — the single statement · [kustomize-support-boundary.md](kustomize-support-boundary.md) — field taxonomy + layout allowlist · [gittarget-granularity-and-cross-environment-edits.md](gittarget-granularity-and-cross-environment-edits.md) — **the write boundary; the one home of fan-in = 1** | +| **Renderers & provenance** | [render-attribution.md](render-attribution.md) — attribution and verification · [render-root-scoping.md](render-root-scoping.md) — render roots and the oracle · [kpt-and-krm-functions.md](kpt-and-krm-functions.md) — how Kpt packages, setters, and KRM functions may fit safely | | **Orchestrators & expansion** | [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) — renderability vs ownership; claims about paths · [expansion-boundary-and-corpus-organisation.md](expansion-boundary-and-corpus-organisation.md) — provenance; ApplicationSet vs ResourceSet; Helm · [`../../facts/expansion-provenance-markers.md`](../../facts/expansion-provenance-markers.md) — **the measured markers** · [argocd-bi-directional.md](argocd-bi-directional.md) — why `selfHeal` is incompatible | | **Documents & secrets** | [resource-capability-model.md](resource-capability-model.md) — what may I do to this document · [write-only-encrypted-secrets.md](write-only-encrypted-secrets.md) — SOPS · [sealed-secrets-and-external-secrets.md](sealed-secrets-and-external-secrets.md) | | **Edits with no home** | [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) | diff --git a/docs/design/support-boundary/kpt-and-krm-functions.md b/docs/design/support-boundary/kpt-and-krm-functions.md new file mode 100644 index 00000000..15a2a10e --- /dev/null +++ b/docs/design/support-boundary/kpt-and-krm-functions.md @@ -0,0 +1,331 @@ +# Kpt and KRM functions: a safe place in the reverse-GitOps model + +> **design** — an orientation note. Captured: 2026-07-14. +> +> Related: [render attribution](render-attribution.md), +> [render-root scoping](render-root-scoping.md), +> [the support contract](support-contract.md), and the +> `kustomize-tracer` plans (ConfigButler/kustomize-tracer, `plans/` — a local checkout under +> `external-sources/`, not tracked here). + +Kpt and KRM functions have a useful place in GitOps Reverser, but they do not make a +generic rendered object reversible. The product's central question remains: + +> Given a live field change, is there exactly one writable place in Git that can +> produce it again without changing anything else? + +Kustomize, Kpt, a CI hydration job, and an Argo or Flux controller are all possible +parts of the answer. They are not interchangeable. The renderer we use to attribute +and verify a write must be the renderer that produced the objects the delivery system +applies. + +## 1. The common model + +Every supported configuration system fits the same pipeline. The stages in green are +capabilities, not promises: an unfamiliar transform may stop at the red refusal. + +```mermaid +flowchart LR + G["Git intent\nfiles, Kustomization, Kptfile"] + R["actual renderer\nplain YAML / kustomize / kpt hydration"] + L["live KRM object\nuser changes a field"] + A["attribute governance\nsource field, override, setter, or unknown"] + P["propose the smallest\nsource edit"] + V["verify the whole\nrender scope"] + C["commit to Git"] + X["report refusal\nno write"] + + G --> R --> L --> A --> P --> V + V -->|target matches and\nother output is unchanged| C + A -->|no unique writable home| X + V -->|mismatch or blast radius| X + + classDef safe fill:#dfd,stroke:#3a3,color:#111 + classDef refuse fill:#fdd,stroke:#c33,color:#111 + class A,P,V,C safe + class X refuse +``` + +The current Kustomize work already implements the important safety half. A candidate +write is rendered against the exact post-write bytes; the intended object must match +the live object and every object outside the write batch must be unchanged. This is the +**oracle**. Attribution can improve over time, but it must never become the only proof +that a write is safe. + +This explains two rules that apply equally to Kustomize and Kpt: + +1. **Causation is not governance.** A transform that made no visible change can still + override a later source edit. An idempotent Kustomize `images:` entry and an + idempotent KRM function parameter are both "invisible and armed." +2. **A render result is not a source map.** It says what exists after rendering, not + which exact configuration field a user should edit to reproduce one value. + +## 2. Why Kustomize is the first renderer + +Kustomize is unusually tractable because its configuration language has named entries +such as `images[0]`, `replicas[0]`, and `patches[0]`. The tracer work observes the +before/after state around each transformer entry, so it can say both *which entry* and +*which field* changed. + +```yaml +# overlays/prod/kustomization.yaml +resources: + - ../../base + +images: + - name: ghcr.io/acme/web + newTag: "2.4.1" + +patches: + - path: production-tuning.yaml + target: + kind: Deployment + name: web +``` + +```yaml +# overlays/prod/production-tuning.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 3 +``` + +For `containers[name=web].image`, an `images[0]` event can route the edit to +`kustomization.yaml`. For `spec.replicas`, a patch may be the governing input. The +important result is not "always edit the patch"; Kustomize ordering can make a patch +dead text when a later `replicas:` entry wins. The renderer, rather than a hand-written +ordering model, decides that. + +The two complementary attribution techniques are: + +| Question | Technique | Result | +|---|---|---| +| What actually changed this field in this build? | Observer around each transformer entry | exact causal field events and lineage | +| Which configured input will win even when it agrees with its source? | Counterfactual dye | governance of safe, dyeable values | + +Neither is permission to write. Both feed the oracle, which checks the proposed edit over +the whole render root and its read scope. Fields changed by unobserved mechanisms—such +as generator hashes and reference rewrites—are **unattributable**, not source-owned by +default. + +## 3. What Kpt adds + +Kpt is package-centred rather than Kustomization-centred. A `Kptfile` can declare an +upstream package, package metadata, inventory, and a function pipeline. A function takes +KRM resources plus configuration, then emits KRM resources. This produces three useful +ideas for this project. + +### Packages are render roots + +A Kpt package can be treated as another candidate unit in repo discovery, much as a +Kustomize render root is today. Its read scope includes the package and any declared +inputs that its actual hydration process reads. Its write scope must still remain inside +the `GitTarget` path. + +This is only meaningful when Kpt hydration is in the delivery path. There are two +materially different repository shapes: + +```mermaid +flowchart TB + subgraph authoring["Kpt used only while authoring"] + D1["dry Kpt package"] --> CI["developer or CI: kpt fn render"] --> W["wet YAML committed for Flux/Argo"] + W --> F1["Flux or Argo applies YAML"] + end + + subgraph delivery["Kpt hydration is the delivery renderer"] + D2["dry Kpt package"] --> K["delivery job/controller: kpt fn render"] --> F2["apply rendered KRM"] + end + + classDef note fill:#fff4cc,stroke:#b8860b,color:#111 + class CI,K note +``` + +In the first shape, GitOps Reverser sees the wet YAML as its ordinary Git source; it +cannot safely reverse through an unobserved CI step into the dry package. In the second, +Kpt is part of the renderer and needs a dedicated renderer adapter, provenance rules, and +the same post-write verification as Kustomize. + +### Setters are an explicit inverse contract + +The most promising Kpt concept is the setter. It marks a resource value with the name of +the parameter that controls it. That is much better than inferring ownership from a final +rendered value. + +```yaml +# deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 2 # kpt-set: ${web-replicas} + template: + spec: + containers: + - name: web + image: ghcr.io/acme/web:2.4.1 # kpt-set: ghcr.io/acme/web:${web-tag} +``` + +```yaml +# setters.yaml -- function configuration, not an applied workload +apiVersion: v1 +kind: ConfigMap +metadata: + name: setters + annotations: + config.kubernetes.io/local-config: "true" +data: + web-replicas: "2" + web-tag: "2.4.1" +``` + +```yaml +# Kptfile +apiVersion: kpt.dev/v1 +kind: Kptfile +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/apply-setters:v0.2 + configPath: setters.yaml +``` + +An edit to the live Deployment's replicas can propose changing +`setters.yaml:data.web-replicas`, rather than overwriting the annotated Deployment +field. This is an **existing-setter** capability only. Creating comments, creating a +setter configuration, or guessing that equal values share a parameter is authoring and +must be a separate future feature. + +A setter is shared context. If `web-tag` controls ten resources, changing it for one +resource is valid only when the intended batch includes all ten resulting changes. The +whole-render oracle makes this check mechanical. + +### Function pipelines are a renderer extension point, not an inverse API + +Kpt's pipeline has useful structure: ordered mutators, validators, per-function +configuration, and selectors. It does **not** turn an arbitrary function into a reversible +operation. The function can be a container image or an executable, and selectors can fan +one configuration value out to many resources. + +```yaml +# This is renderable only under a deliberately approved runner policy. +apiVersion: kpt.dev/v1 +kind: Kptfile +pipeline: + mutators: + - image: ghcr.io/example/organisation-policy@sha256:REPLACE_WITH_PINNED_DIGEST + configMap: + team: payments + selectors: + - kind: Deployment + labels: + app.kubernetes.io/part-of: checkout +``` + +Even if the output has `metadata.labels.team: payments`, that does not identify a +source-field write location, prove that the function is deterministic, or prove it did +not change another object. Treat this form as **renderable read-only context** until a +specific function contract is supported. + +## 4. Support levels for Kpt and KRM functions + +The right boundary is additive and per capability, rather than "Kpt supported" or +"Kpt refused." + +```mermaid +flowchart TD + A["Kptfile or KRM function found"] --> B{"Does this process\nproduce deployed objects?"} + B -->|No / unknown| R1["Do not reverse through it\nedit deployed Git source only"] + B -->|Yes| C{"Runner is pinned, local/sandboxed,\nand reproducible?"} + C -->|No| R2["Read-only or refuse\narbitrary execution/network"] + C -->|Yes| D{"Existing explicit setter\nor supported function contract?"} + D -->|Existing setter| E["Propose setter-config edit\nthen verify whole render"] + D -->|Known function, proven sinks| F["Counterfactual attribution\nroute only proven fields"] + D -->|No| R3["Renderable, but field is\nunroutable: report/refuse edit"] + + classDef safe fill:#dfd,stroke:#3a3,color:#111 + classDef refuse fill:#fdd,stroke:#c33,color:#111 + class E,F safe + class R1,R2,R3 refuse +``` + +| Level | Example | Operator behaviour | +|---|---|---| +| 0 — metadata only | `Kptfile` without a pipeline in a raw-YAML delivery repo | Ignore it as a deployment transform; preserve it as package metadata. | +| 1 — observable but unroutable | A known pipeline renders the object, but no explicit ownership exists | Render for comparison; report the field as unreflectable; do not write. | +| 2 — explicit route | Existing `apply-setters` comment and config | Edit the named setter value; verify every render output. | +| 3 — narrow function contract | A pinned, allowlisted function with a tested sink-only configuration field | Dye or otherwise perturb the configuration, count fan-out, and write only after verification. | +| never generic | Arbitrary container/exec function, network access, unpinned tag, opaque generated output | No source routing. Refuse the edit or operate only on a separately committed rendered artifact. | + +The KRM Functions Catalog is therefore a source of **candidate contracts**, not a list to +enable. `apply-setters` is a strong candidate because it declares the inverse coordinate. +Bulk transforms such as `set-labels`, `set-namespace`, `set-image`, Starlark, Helm +rendering, or generic search-and-replace should begin at level 1. Each needs independent +evidence about determinism, selector fan-out, safe-to-dye values, and a comment-preserving +write location before it can move higher. + +## 5. Extending the render-plan idea + +The Kustomize tracer's proposed render plan is a useful *shape* that can eventually cover +more than Kustomize. It is an inverse-build artifact: a lookup hint for routing, never +permission to write. + +```yaml +apiVersion: gitopsreverser.io/v1alpha1 +kind: RenderPlan +renderer: + type: kpt + packageRoot: apps/web + pipelineFingerprint: sha256:REPLACE_WITH_REAL_CONTENT_HASH +inputs: + Kptfile: sha256:REPLACE_WITH_REAL_CONTENT_HASH + deployment.yaml: sha256:REPLACE_WITH_REAL_CONTENT_HASH + setters.yaml: sha256:REPLACE_WITH_REAL_CONTENT_HASH +objects: + - id: apps/v1/Deployment/default/web + origin: deployment.yaml + fields: + spec.replicas: + source: + kind: setter + file: setters.yaml + path: data.web-replicas + function: apply-setters +unattributable: + - object: apps/v1/Deployment/default/web + field: metadata.labels.team + reason: function-contract-not-supported +``` + +The fingerprint prevents a plan made for old inputs from posing as current knowledge. A +matching plan can propose the setter edit; a missing, stale, or incomplete plan causes a +fallback to live counterfactual attribution where that is safe, otherwise a refusal. In +all cases the post-write render remains decisive. + +This artifact could eventually serve three consumers without creating three analyses: + +- **writer:** field-to-source proposals and clear refusals; +- **repository map:** render roots/packages, inputs, and ownership edges; and +- **metrics:** counts of `unattributable` fields and the transforms that cause them. + +## 6. Practical sequencing + +Do not let Kpt exploration interrupt the current Kustomize support-boundary work. +`patches:` is the immediate, measured opportunity: make folders renderable first, move +refusals to individual unroutable fields, then add tightly bounded attribution and routing. + +After that, the smallest useful Kpt slice is: + +1. Detect `Kptfile` during repo discovery and report whether a pipeline is present. +2. Record whether the deployment path actually executes Kpt. Do not assume that it does. +3. Support **existing setters only** for a locally reproducible, pinned `apply-setters` + pipeline, with a fixture that proves single-field and fan-out behaviour. +4. Reuse the existing full-batch oracle over the Kpt render scope. +5. Add one function contract at a time, only when its safe inputs and writeback coordinate + are explicit and covered by counterfactual tests. + +The desired outcome is not universal transformation support. It is a growing set of +honest answers: *this live field belongs to this source coordinate and we can prove the +round-trip*, or *this renderer produced the field but we cannot safely write it back.* From 5a360968f3087a4f9abee3adbd84bb0de723042e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Wed, 15 Jul 2026 05:27:52 +0000 Subject: [PATCH 4/4] docs(support-boundary): index admission-consent and the reconcile-trigger docs Link the two docs split out earlier from the folder README, under 'Edits with no home' beside the tier-1/2/3 write-gating doc they extend. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/design/support-boundary/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/support-boundary/README.md b/docs/design/support-boundary/README.md index c8655a79..76989dfa 100644 --- a/docs/design/support-boundary/README.md +++ b/docs/design/support-boundary/README.md @@ -16,7 +16,7 @@ | **Renderers & provenance** | [render-attribution.md](render-attribution.md) — attribution and verification · [render-root-scoping.md](render-root-scoping.md) — render roots and the oracle · [kpt-and-krm-functions.md](kpt-and-krm-functions.md) — how Kpt packages, setters, and KRM functions may fit safely | | **Orchestrators & expansion** | [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md) — renderability vs ownership; claims about paths · [expansion-boundary-and-corpus-organisation.md](expansion-boundary-and-corpus-organisation.md) — provenance; ApplicationSet vs ResourceSet; Helm · [`../../facts/expansion-provenance-markers.md`](../../facts/expansion-provenance-markers.md) — **the measured markers** · [argocd-bi-directional.md](argocd-bi-directional.md) — why `selfHeal` is incompatible | | **Documents & secrets** | [resource-capability-model.md](resource-capability-model.md) — what may I do to this document · [write-only-encrypted-secrets.md](write-only-encrypted-secrets.md) — SOPS · [sealed-secrets-and-external-secrets.md](sealed-secrets-and-external-secrets.md) | -| **Edits with no home** | [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) | +| **Edits with no home** | [unreflectable-edits-and-write-gating.md](unreflectable-edits-and-write-gating.md) — tier-1/2/3 accounting · [admission-consent.md](admission-consent.md) — say yes to a blast-radius refusal · [orchestrator-reconcile-trigger.md](orchestrator-reconcile-trigger.md) — revert a refusal / order around origin drift | | **Discovery (read-only)** | [repo-discovery-and-onboarding-scan.md](repo-discovery-and-onboarding-scan.md) | | **Shipped** | [finished/images-and-replicas-edit-through.md](finished/images-and-replicas-edit-through.md) · [finished/higher-level-krm-documents.md](finished/higher-level-krm-documents.md) | | **Evidence** | [`test/fixtures/gitops-layouts/`](../../../test/fixtures/gitops-layouts/) — the corpus of real-world repo shapes, and the generated behavioural baseline beside it |