Skip to content

Latest commit

 

History

History
200 lines (170 loc) · 31.8 KB

File metadata and controls

200 lines (170 loc) · 31.8 KB

Index: what to read, and what is history

This page names the documents that bind the current implementation. If a document is not listed here, it is either a user guide (see README.md) or historical context you can safely skip.

The folders, and what each one means

Folder Means Binds?
spec/ This is true now, and the code depends on it. Most are cited by path from Go source. Change the behaviour, change the doc. yes
design/ We are still deciding. Open questions, proposals, unbuilt work. yes — as intent, not as shipped behaviour
facts/ Durable reference: how Kubernetes behaves, and what we discovered about it. yes, as reference
finished/ This happened. Shipped plans, closed investigations. Kept for context. no
layout/ One topic, all of it. Where a document goes in Git: two current-behaviour contracts, the proposal, its plan, its review, and the worked examples. per document, and each is labelled

The rule that was missing before: design/ used to hold shipped work and finished/ used to hold live contracts. If you are adding a document, pick the folder by lifecycle, not by topic.

One exception, and it is about binding rather than about lifecycle. A design/ page that has shipped stays in design/ when Go source cites it by path as the rationale for what the code does. finished/ means "binds? no", and such a page still binds — moving it would leave a dozen file comments pointing at a folder that declares itself non-binding. It is labelled built. in the table below instead, so the lifecycle is still readable. spec/ is for a contract stated as a contract; this is a plan whose reasoning the code kept.

A second exception, and this one is about topic rather than lifecycle. layout/ collects the whole layout question, which had grown to eight documents across three folders, so following the argument meant knowing which folder each step lived in. It mixes lifecycle classes on purpose and labels every entry with the class it would have had, which its README does. It is the only topic folder, and adding a second one should take the same amount of argument this one did.

If you are new: read these five

  1. ../README.md — what the operator does.
  2. architecture.md — how the operator is put together.
  3. spec/manifest-system.mdhow a live object becomes a line in a Git file. The single best explanation of the core.
  4. spec/current-manifest-support-review.md — the manifest store's contract, and the rules that must never break.
  5. design/support-boundary/support-contract.mdwhat the operator edits, what it refuses, and why.

Beside architecture.md: components.md is the component map. It names every package by plane, and answers which components observe a Kubernetes API server (five do, and only two of them observe types).

Provider-specific setup that needed writing down: azure-devops-getting-started.md — Azure DevOps end to end, why its credential Secret is shaped differently from every other provider's, and the three test layers that cover a provider CI cannot reach.

Before you change any CRD field: facts/crd-upgrade-strategies.md — the two honest strategies for removing or renaming one, a decision matrix keyed on whether pruning fails open or closed, and the API-server behaviour that decides it. Measured rather than assumed: a status update does not re-validate spec; a removed field stops being served immediately, not after the next write (so a migration inventory must be taken BEFORE the upgrade); and a previously-defaulted field cannot be removed by kubectl apply, which makes refusing one an upgrade nobody can complete.

The contracts — spec/

The code cites these. Breaking one without updating it is how the next person gets misled. Full list in spec/README.md; the ones that carry a rule rather than a description:

Spec The rule
manifest-system.md the whole live → Git pipeline, and every invariant below in summary
attribution.md how a commit gets its author. Deletion is attributed at intent time; the publish half files a fact under the strongest key it has, the join half walks the tiers strongest-first, and neither branches on the type. The single reference, folded from six design records
current-manifest-support-review.md all-or-nothing folder claim; never half-write a multi-doc file; refuse rather than prune
manifestedit-field-ownership-spike.md the API wins — full-object ownership, never field-subset
reconcile-via-watchlist-mark-and-sweep.md no bookmark, no sweep
sops-single-file-no-multidoc.md one encrypted file is one document
scale-subresource-audit-rehydration.md /scale only; every other subresource ignored
commit-window-refactor.md one grouped commit = one (author, GitTarget)
gittarget-isolation-on-rule-change.md a rule change on target A never touches target B
audit-readiness-probe-plan.md liveness must never depend on Redis
type-followability.md is a type followable, and if not, the one reason
gitpath-foreign-content-stringency.md refusing a path that shadows foreign content
unsupported-folder-refusal-plan.md GitPathAccepted, and refusing what we cannot own
commitrequest-design.md the CommitRequest window and its conditions
commitrequest-admission-authorship.md how command submitters are captured and matched to commit windows
where-validation-lives.md schema → CEL → the reconciler; a webhook only for what exists solely at admission
e2e-serial-registry.md which e2e specs must run Serial, and why

What is being decided now — design/

gittarget-configuration-freshness.md is deferred, kept as a decision record rather than an active proposal. It works out what a target-level desired/applied configuration identity would have to be, and why it is not worth building yet: as sequenced, both markers move together inside one pass, so the only mismatch they can show is a failing pass that existing status already reports, and everything the page is for lives in the window where nothing has been published. The one part that stood on its own has shipped — the e2e rule barrier now requires observedGeneration == generation before it accepts StreamsRunning=True. The page carries the trigger conditions for picking the rest up.

The live workstream is design/support-boundary/ — editing existing GitOps folders through the Kubernetes API. Start at support-contract.mdthe single page that says what we support and refuse — and then its README, which maps the rest of the folder: the kustomize field taxonomy, the write boundary, the orchestrator/expansion line, and how secrets are handled.

Eighteen other open items:

Doc Open question
open-asks-priority.md the work queue. Merges three overlapping backlogs — the gitops-api consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal (B1–B6) — into one ordered queue under four stated tests, and says where we deliberately do not do what was asked. The standing caveat narrowed once the layout model reversed: a Tier 2 entry belongs to postponed #294 only if it breaks a GitTarget field, and everything else is independently schedulable. Makes one design call against what was asked: delete Option C sibling inference rather than ship an off-switch for it, because it let a human's edit to the repository change operator behavior with nothing in status recording the move. That deletion has shipped, and "what the deletion taught" records what building it found. F9 is Tier 1: the only item whose answer is unknown rather than whose work is unscheduled, and it gates planning the enum work
placement-visibility-and-declared-defaults.md design. The three questions the inference deletion left, decided and then not built: PR #291 shipped the deletion and none of the eight items queued behind it. The residue was filed as #295which shipped in 0.42.1 via #319 and is what reversed the layout model — and #296. Its Question 2 is superseded outright by layout/model.md. What still stands: keep canonical as the name for the built-in path and split declared into byType/default; no CRD default for placement.default, on the structural argument that a defaulted default is never empty and so shadows the kustomize-root rung; status.layout instead, over the MarkTargetRetention seam that already enqueues on change; and {kindLower}, not a toLower function
created-root-namespace.md design, decided. One question with five answers: what namespace a kustomization.yaml the operator CREATES should carry. Decided B, never write onespec.serializeNamespace: false means the artifact does not encode its deployment namespace, and adding a root must not quietly change that contract; the namespace comes from the documents when the field is unset or true, and from the installer (Flux targetNamespace, Argo destination.namespace) when it is false. Records the three facts an earlier draft got wrong (a namespace-less root is ordinary, both installers supply one, and what refused it was our own fidelity gate rather than kustomize), and carries the scoped fidelity rule that follows: the namespace is ignored in the render comparison ONLY when the governing root sets none, so a root that declares namespace: shop still rejects a live billing object. Also the sibling call: under useKustomize a placement no resources: list would name is refused rather than committed unrendered
build-order.md design, and the one page that is only about sequencing. Five in-flight changes resolve to three tracks that do not block each other — additive placement, the breaking source-scope wave, and patch authoring — with the two real couplings named (* is defined in terms of the field the wave deletes; useKustomize's created root depends on the one-source-namespace rule) and three couplings people keep assuming that do not exist. Holds no design: every item is specified elsewhere and the specification wins
gittarget-api-wave.md design, filed as #294. What is left of one breaking wave on GitTarget after the layout model reversed and left it: B4's commitWindow/commit.message move off the connection, the source-scope deletion (the only member that makes the API smaller), and the riders. Organizing principle: the folder is described on the GitTarget, the connection describes only the connection — and this is where that becomes a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. spec.mode and GitTarget.spec.interval are both dropped, with re-open triggers. Records that F9's envtest stays OUTSIDE the wave and gates it, and that staying v1alpha3 on loud rejections is a one-consumer countdown, not a constant
target-watch-plan.md built. The companion to watch-manager-ownership.md: the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into keep/start/restart/stop and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a restart is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. stop never touches files — removal is a Git-side sweep under the target's existing spec.prune.mode, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the stop classification wants a settled TypeRemoved from typeset (see TODO), and removal on INTENT is undecided.
watch-manager-ownership.md built. A rule edit used to be applied inline by the controller worker that observed it, and it re-planned EVERY GitTarget rather than the one the rule names: 1256 plan reconciles across 28 targets in one e2e run, peaking at 78 in a second, behind two network calls, on a shared worker pool. The watch manager had no owner, so eleven mutexes stood in for one. Now controllers post a trigger naming a GitTarget and return, one loop owns the plan and paces itself, and repeated triggers for one target collapse into a single pass. The debounce is framed around how the config is actually edited: a GitTarget and its rules are one piece of configuration applied together, so a per-target ROLLING SILENCE window of 2s (max wait ~10s) turns a five-object kubectl apply into one pass, the same mechanism DefaultCommitWindow already uses one layer down on the write path. That reverses an earlier revision's "never debounce the first declaration": declaring a GitTarget the instant it lands means declaring it with no rules yet, which manufactures a transient EMPTY plan on every cold start, and an empty plan is what vacuously cleared a write divergence in the fidelity gate. States the contract as "one settled configuration adjustment, not one function invocation": the window is a heuristic and never a correctness boundary (Kubernetes has no apply-complete event), a per-target DIRTY SEQUENCE means a change arriving mid-pass is never lost, and the pass reads a coherent rule-store snapshot rather than the rule that triggered it. Carries the deletion inventory, because the point is that the system got smaller: four trigger mechanisms collapsed to one (signalCatalogRefresh and catalogRefreshCh are gone), refreshRunningTargetWatches and its running-set filter are gone (that filter is why a target whose first declare never completed was never picked up again), and six mutexes went for stated reasons, with RenderFidelityGate.mu and the two event-channel locks kept and justified. The four steps shipped: the local-cluster discovery call is bounded (a real defect — the legacy non-context ServerGroupsAndResources() ran with no deadline at all), the owner loop carries the debounce, dirty sequence, per-target deadline and 2s/5s/10s/30s/1m backoff, the plan now carries NO lock while the projection is a published snapshot, and catalog invalidation is scoped by diffing each target's rendered plan across the re-projection. "What shipped" records where the implementation departed from the page, including the one bug only e2e caught: streams were parented to the PASS context, which a deadline cancels the moment the pass returns, so every stream died the instant its plan was applied — and it reads like health, because the plan logs start:1, every later pass reports keep:1 and never restarts it, and nothing logs an error while readiness sits at Replaying and every WatchRule sits Ready=False. A stream's parent is the manager's lifetime; the pass deadline bounds the pass. A second e2e catch is a BEHAVIORAL consequence worth knowing: toggling a rule off and on inside the settle window is no longer a replay — it used to tear the stream down and re-establish it because each apply replanned synchronously, and it is now one pass over a plan that never changed. Correct (a net-zero change is no change; widening prune.mode remains the supported force) but a real difference in what an operator gesture does. Both specs that broke were also gating on the wrong thing: asking a GitTarget "are all your streams running" about a change to ONE rule, which a target that is already mirroring answers True to before that rule has been planned, and which a different controller publishes than the one that compiled the rule — so the rule's OWN StreamsRunning is the gate, and waitForWatchRuleStreamsRunning existed unused for exactly this. Departures: reports became a published snapshot rather than a second channel; ISOLATION came from taking the I/O off the loop rather than from the deadline (a pass never dials, the shared refresh runs on its own goroutine, and the deadline is the backstop it should have been — a first cut that kept two network calls on the loop had one unreachable cluster holding every healthy target, which is the same availability failure relocated); DELETION names an incarnation resolved when it is queued, because both production callers react to a NotFound and carry no UID, so a UID-less delete matched everything and could tear down the successor of a same-name recreate; and persistent failure surfaces as WatchPlanFailing, where pending means "no pass has ever landed", not "dirty right now". Step 3's type-to-target index did not earn its staleness. Still open: whether the settle window ever needs to be configurable
docs-linting.md how to mechanize style-guide.md with markdownlint-cli2 and Vale. Both are wired into task lint, gated on the files .docs-lint-scope lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the MD013 limit, and whether AGENTS.md and the chart READMEs are in scope
attribution-removal-wait-options.md a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite
watch-and-catalog-architecture.md the target three-layer watch model — needs a human call before building
metrics-observability-plan.md the canonical metrics plan, reconciled to the code after the fact-stream switchover and now carrying the attribution surface that shipped (documented in spec/attribution.md). Reads the product as one pipeline — watch events arrive, and are processed into commits — and maps a metric to each stage. The attribution join is built and correctly labelled; watch ingestion, shard queue delay, and the relevance filter are still dark. Phase 1 — the attribution relabel plus the loss-path counters — has shipped; Phase 2 is the watch stage, Phase 3 the filter and push health, Phase 4 the dashboard and alerts. Open: Phases 2-4, and the dashboard JSON is deliberately not written until the watch families exist
reconcile-triggering.md which controllers still fail to wake up
multi-source-audit-ingress-hardening.md how independent sources authenticate to a named audit route, when annotation routing is trustworthy, and how multi-provider ingestion remains fair
release-image-reuse-plan.md PRs 2–5 unstarted
e2e-coverage-gaps-and-improvements-plan.md tests A/B/C still proposals
e2e-finish-plan.md remaining e2e harness work
sensitive-resource-diagnostics-follow-up.md deferred diagnostics
e2e-git-server-choice.md stay on Gitea or move to Forgejo — the _csrf pin is fixable in place on both, so the migration is now a preference call, not a fix; also why we adopt no SDK either way
azure-devops-multi-ack.md decided and built: go-git v6 — why Azure DevOps rejects our fetches, and what to do instead of PR #292's bundled git binary. The capability filter fails in two independent halves: advertising multi_ack is a four-line change, but v5 then cannot parse the multi-ACK response, which only a fetch with have lines provokes. That is why Flux ships ADO support on v5 with no git binary — it never fetches, only CloneContext, so it never enters the path v5 cannot serve; our persistent-clone-plus-incremental-fetch design is the opposite, which makes the trim alone insufficient for us. go-git v6 already implements multi_ack (PR #1204, in every v6 tag; upstream then deleted their ADO example saying it "works out of the box"), and its churn in the packages we import runs 96 → 39 → 19 removals per alpha, so it is one settled breaking wave rather than a moving target; the migration is four known API removals over two rewritten files, transport.AuthMethod being the invasive one. Prices PR #292 as measured rather than argued: the image goes 217 MB → 940 MB, of which 723 MB is a cp -rL that dereferences 165 hardlinks to one binary (a one-character fix), arm64 is unaffected and native, but Trivy reports zero findings on both images while the new one carries git 2.54.0, OpenSSH 10.3p1 and OpenSSL 3.5.7 as loose files no package database describes — so the CRITICAL gate is blind to a third of the runtime. Also catches an unflagged non-ADO regression (Depth: 1 dropped, so every provider full-fetches) and 10% patch coverage on an untestable path. The unlock is that canonical git upload-pack advertises multi_ack (verified), so the Gitea already in the e2e lab plus a 400-injecting proxy is a faithful ADO simulator — no tenant needed, and the only way any option becomes CI-testable. Four options priced, and Option A (v6) is the one shipped. Carries a measured capability matrix over our three network calls with two diagrams, which narrows the blast radius to one call, repo.Fetch: receive-pack never advertises multi_ack (measured), so the atomic push is out of scope for every option — its safety rests on the same-session advertisement plus the server-side Old/New compare-and-swap in packp.Command, neither of which touches upload-pack, and we already push from a shallow store today. v6 keeps that pattern 1:1 (HandshakeGetRemoteRefs/Push, same []*packp.Command), which is an argument for migrating. Records what the migration actually cost, including the four v6 behaviour changes it surfaced — two of them settings v6 reads from the environment and fails closed on, invisible to unit tests
source-scope-simplification.md SHIPPED, except the additive SelfSubjectAccessReview pass it explicitly leaves for later. Declines Flux-style impersonation, deletes GitTarget.spec.allowedSourceNamespaces and its selector machinery (4,569 lines, and the only cross-cluster read in the authorization path), renames two ClusterProvider fields, and redefines sourceNamespace: "*" as one cluster-wide list and watch. The argument is an API reading, not a security one: the chain from a Git folder back to the object that fills it never leaves one namespace, so ordinary RBAC on watchrules already answers it. Keeps allowedNamespaces (renamed accessFrom), reversing an earlier draft — source RBAC bounds what a credential may READ, never which tenant may WIELD it. Prices what is lost: source-side label selectors, which have no replacement. Archaeology in facts/kubernetes-impersonation-and-flux-identity.md

The layout topic — layout/

Where a live object's document goes in Git, and what else has to change so that file is reachable. Collected by topic rather than by lifecycle, so the folder mixes binding contracts with an unbuilt proposal; its README labels each one. Two of these are spec/-class and cited by path from Go source.

Document Class What it holds
contextual-namespace.md spec kustomize namespace inference; the supported subset
new-file-placement-rules.md spec where a new resource's file goes: declared, the folder's one kustomize root, canonical. Sibling inference is removed, and kept as history
model.md design reversed, and much smaller than it was. The earlier thesis wanted spec.placement replaced by a spec.layout discriminated union; three of its five arguments were retired by #319, which made registration an invariant. So the template stays and gains two optional booleans: spec.placement.useKustomize (create and maintain the folder's root; registering into a root that already exists is an invariant, not a setting) and spec.serializeNamespace (a *bool, because unset must keep meaning "infer" — no plain default preserves today's behavior), which sits one level up because it governs the bytes of every write and the identity a managed document is found by, not just new files. Carries four kustomize facts measured against v5.8.1, three of which contradict the earlier model; the status.placement stanza and the post-scan pass; and the build order. The headline is what it deletes — spec.layout, kind, scope, kustomize.create, the LayoutProfile question, the migration, the post-scan supplier guard (the supplier of a namespace-free folder lives in another cluster and may not even be single, so the check fires on the correct configuration), and the dry-run framing of spec.suspend (a scratch branch is a better preview and needs nothing built, so suspend is a panic knob and status.placement explains writes rather than previewing them) — so the largest breaking change in the queue stops being breaking at all

shapes/ is the specification by example: the cross-product of folder shapes, one live object written into all of them, so the only difference between two folders is the configuration that produced it. specific-examples/ is the remainder — an Argo CD app-of-apps, a Flux two-layer repository, and the shared prerequisites. Both are design material, not install manifests, and model.md turns them into an executable corpus in its first PR; build-order.md says when.

Deferred, but still wanted — future/

idea-application-editing.md is where the whole edit-through-the-API workstream started, and still holds the branch/session grouping strategies nothing else covers. ha-gittarget-distribution-plan.md is the HA plan architecture.md cites three times (and the reason Redis is required). least-privilege-remaining-work.md has three open RBAC items. config-surface-for-a-structured-repository.md reviews the configuration docs and argues the API never caught up with what the folder analysis learned — a look-before-you-write mode, a status.layout projection, an inference switch, and moving commitWindow onto the GitTarget. direction-and-configuration-surface.md is the strategy review on top of it: the config-as-data direction as the headline with brownfield mirroring as the on-ramp, a decided Helm standpoint (declaration editing plus the values projection; helm-light inversion parked behind entry criteria), and worked examples of where the configuration surface should go. Four more ideas sit beside them.

History — finished/

Twenty-two shipped plans and closed investigations. Nothing here binds. Read one only when you want to know why something is the way it is; the answer to what it is always lives in spec/.

The newest is attribution-fact-stream.md: why attribution facts stopped being a keyspace the watch side polls and became a per-type log it follows into a bounded in-memory index. It deleted the SET/GET fact keys, the 150ms poll loop, and the deletecollection expander, replacing the expander with one collection fact that every removal in its scope joins by uid membership or by scope. That last part is a capability gain rather than a like-for-like swap: a collection delete the API server sent no response body for used to lose its author entirely. exact_deletecollection_item is replaced by deletecollection_body_uid and deletecollection_scope, and --author-attribution-transport=memory runs attribution with no Redis on a single replica. Shipped as #283, #284, #286 and #287.

Before it, analyzer-consumer-contract-asks.md: why a refusal carries whether it can be solved and by whom, why the analyzer's report is a KRM document that names the build that produced it, and why ResourceIdentifier.Key() is a cross-product contract with a golden test. Shipped as #273 and #275.

Note that most of the pre-2026-07 audit-pipeline archaeology has been deleted outright: the watch-first rewrite removed internal/gate, the audit joiner, and the audit-as-state pipeline, so ~30 documents describing them were prose about code that no longer exists. git log has them.