From 9b8cf069486bc091c6b3e305cb7413e684e9bf7c Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 28 Aug 2026 15:51:07 +0000 Subject: [PATCH] docs: collect the layout work under docs/layout/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question of where a live object's document goes in Git, and what else has to change so that file is reachable, had grown to five documents split across docs/spec/ and docs/design/. Following the argument meant knowing which of the two a given piece lived in, which is a filing decision leaking into the reading. They move to docs/layout/, organized by topic. That is a deliberate exception to INDEX.md's rule that folders are picked by lifecycle, so the folder's README labels each page with the class it would have had — two are spec-class and cited by path from Go source, the rest are design-class intent. The names lose the prefixes the old folders needed to disambiguate them: gittarget-layout-model.md becomes model.md, contextual-namespace-and-kustomize-folder-editing.md becomes contextual-namespace.md. No content changes. Every reference moves with the files, including the ones in Go comments that task lint-docs checks, so doccheck proves the move rather than a reader having to. Co-Authored-By: Claude Opus 5 --- api/v1alpha3/gittarget_types.go | 2 +- docs/INDEX.md | 38 +++++++++++++---- docs/TODO.md | 2 +- docs/architecture.md | 2 +- docs/design/open-asks-priority.md | 18 ++++---- .../images-and-replicas-edit-through.md | 2 +- .../helm-light-support-boundary.md | 4 +- .../repo-discovery-and-onboarding-scan.md | 4 +- .../direction-and-configuration-surface.md | 4 +- ...intainer-review-status-and-config-model.md | 4 +- docs/layout/README.md | 41 +++++++++++++++++++ .../api-wave.md} | 8 ++-- .../contextual-namespace.md} | 10 ++--- .../model.md} | 4 +- .../new-file-placement-rules.md} | 18 ++++---- ...cement-visibility-and-declared-defaults.md | 8 ++-- docs/spec/README.md | 4 +- docs/spec/manifest-system.md | 4 +- docs/spec/sops-single-file-no-multidoc.md | 6 +-- .../gittarget_placement_validation.go | 2 +- internal/git/manifestedit/kustomization.go | 2 +- internal/git/plan_flush.go | 4 +- .../contextual_namespace_corpus_test.go | 2 +- internal/manifestanalyzer/placement.go | 4 +- internal/manifestanalyzer/store.go | 4 +- internal/types/identifier.go | 2 +- test/e2e/new_file_placement_e2e_test.go | 2 +- 27 files changed, 135 insertions(+), 70 deletions(-) create mode 100644 docs/layout/README.md rename docs/{design/gittarget-api-wave.md => layout/api-wave.md} (97%) rename docs/{spec/contextual-namespace-and-kustomize-folder-editing.md => layout/contextual-namespace.md} (98%) rename docs/{design/gittarget-layout-model.md => layout/model.md} (99%) rename docs/{spec/gittarget-new-file-placement-rules.md => layout/new-file-placement-rules.md} (99%) rename docs/{design => layout}/placement-visibility-and-declared-defaults.md (98%) diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index 65ba8d68..dfc6125a 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -149,7 +149,7 @@ type GitTargetSpec struct { // GitTargetPlacementSpec declares where NEW resources are written when no document // for their identity exists yet in Git — one exact-type map plus a fallback // default template (Option B2 of -// docs/spec/gittarget-new-file-placement-rules.md). There is +// docs/layout/new-file-placement-rules.md). There is // deliberately no separate "sensitive" placement block: sensitivity is a // write-safety classification the controller owns (encrypt the content, keep the // path identity-complete, never append or co-mingle), not a second placement diff --git a/docs/INDEX.md b/docs/INDEX.md index 3bef7830..d5192a7a 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -4,7 +4,7 @@ This page names the documents that bind the current implementation. If a documen listed here, it is either a user guide (see [`README.md`](README.md)) or historical context you can safely skip. -## The four folders, and what each one means +## The folders, and what each one means | Folder | Means | Binds? | |---|---|---| @@ -12,6 +12,7 @@ you can safely skip. | [`design/`](design/) | **We are still deciding.** Open questions, proposals, unbuilt work. | yes — as intent, not as shipped behaviour | | [`facts/`](facts/) | Durable reference: how Kubernetes behaves, and what we discovered about it. | yes, as reference | | [`finished/`](finished/) | **This happened.** Shipped plans, closed investigations. Kept for context. | **no** | +| [`layout/`](layout/README.md) | **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 @@ -25,6 +26,13 @@ declares itself non-binding. It is labelled **built.** in the table below instea 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/`](layout/README.md) +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](layout/README.md) 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`](../README.md) — what the operator does. @@ -58,8 +66,6 @@ misled. Full list in [`spec/README.md`](spec/README.md); the ones that carry a | [`current-manifest-support-review.md`](spec/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`](spec/manifestedit-field-ownership-spike.md) | the API wins — full-object ownership, never field-subset | | [`reconcile-via-watchlist-mark-and-sweep.md`](spec/reconcile-via-watchlist-mark-and-sweep.md) | **no bookmark, no sweep** | -| [`contextual-namespace-and-kustomize-folder-editing.md`](spec/contextual-namespace-and-kustomize-folder-editing.md) | kustomize namespace inference; the supported subset | -| [`gittarget-new-file-placement-rules.md`](spec/gittarget-new-file-placement-rules.md) | where a new resource's file goes: declared, the folder's one kustomize root, canonical. Sibling inference is removed, and kept as history | | [`sops-single-file-no-multidoc.md`](spec/sops-single-file-no-multidoc.md) | one encrypted file is one document | | [`scale-subresource-audit-rehydration.md`](spec/scale-subresource-audit-rehydration.md) | `/scale` only; every other subresource ignored | | [`commit-window-refactor.md`](spec/commit-window-refactor.md) | one grouped commit = one (author, GitTarget) | @@ -97,9 +103,6 @@ Eighteen other open items: | Doc | Open question | |---|---| | [`open-asks-priority.md`](design/open-asks-priority.md) | **the work queue.** Swept 2026-07-30 against the 0.41.0 release, which carries the attribution model and the placement break and deliberately **not** the GitTarget wave: every Tier 2 entry that changes a `GitTarget` field is part of postponed [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) and is not independently schedulable, while the Tier 1 entries are not and must not wait for it. One entry moved up in the sweep, to Tier 1: a declared path into a kustomize subdirectory produces a file that is in Git and rendered by nothing, which is the product being silently wrong. Reconciled 2026-07-29 to what the attribution branch shipped: the fact stream, consumer ask #23, the name tier and metrics Phase 1 are struck from it and recorded in "already shipped", the residue they leave (the removal-wait decision, the head-of-line block on the shard, the aggregated create) is ranked, and #5 loses one of its two arguments because #23's fix retired it. Three backlogs are open at once — the gitops-api consumer asks, the maintainer review's unbuilt block (F6, F9, F10), and the config-surface proposal (B1–B6) — and they overlap. Merges them into one ordered queue under four stated tests, and makes one design call against what was asked: **delete Option C sibling inference** rather than ship an off-switch for it, because it lets a human's edit to the repository change the operator's behaviour with nothing in status recording the move, its central guard has already failed once by cascading, and the explainability its own spec made mandatory was never built. That answers the namespace-leak ask by removal, and means `spec.placement.mode` is never built. **The deletion has shipped**, together with the placement metrics the argument had said to lead *away* from — an objection to their labels, which naming the GitTarget and the type retires — and "what the deletion taught" records the two things building it found: namespace inheritance was a second implementation of a rule that belonged to the governing kustomization, and it was missing the check that the transformer names the resource's own namespace. Open: whether a fall-back to canonical also raises an Event on the GitTarget, and `status.layout` | -| [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | the three questions the inference deletion left, **decided and then not built** — PR #291 shipped the deletion, the counters and the namespace-transformer fix, and none of the eight items this page had queued behind them, which the page now says. The residue is filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) (correctness: a declared path into a kustomize subdirectory is never rendered, and the identity gate rejects the versionless canonical path) and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296) (visibility: `status.layout`, the ambiguous render root, the `declared` metric split, `{kindLower}`). Its Question 2 is superseded outright by the layout model. What still stands: **Keep `canonical`** as the name for the built-in path and split `declared` into `byType`/`default`, because reusing "default" for both a declaration and the absence of one makes the metric unreadable. **No CRD default for `placement.default`**, and the reason is structural rather than the two that look obvious: a defaulted default is never empty, so it shadows the kustomize-root step and every new file in an overlay would take the canonical path, in Git and rendered by nothing. The validation failure is real but the rule doing the rejecting is itself wrong, and the persistence objection is a trade we could take; defaulting the Secret route to work around the first is a floor that vanishes when a user writes any `byType` entry, because map defaults never merge. **`status.layout` instead**, with five worked examples (greenfield, kustomize overlay, brownfield missing one rule, two ambiguous roots, a refusal from an operator-configured sensitive type) over the `MarkTargetRetention` seam, which already enqueues on change and so retires the "the data plane cannot notify the GitTarget" objection. **`{kindLower}`, not a `toLower` function.** Carries three findings that changed a decision: `IdentityCompletePlacementTemplate` demanding `{version}` contradicts the versionless-path decision; two supported kustomizations still produce a file nothing renders and nothing counts; and **a declared path into a subdirectory of a kustomize folder is registered only when render-root scoping happens to be in force**, so one `byType` line reproduces the unrendered-file bug today. Fixing that last one (walk up to the nearest kustomization) also weakens the case against the CRD default from a correctness wall to a legibility trade, which the page says rather than leaving the stronger argument standing | -| [`gittarget-layout-model.md`](design/gittarget-layout-model.md) | **postponed to a later deployment, filed as [#293](https://github.com/ConfigButler/gitops-reverser/issues/293)** — the proposal the placement questions were circling around: a path template is the wrong primitive, so declare what the folder IS. `spec.layout.kind` with the values `Auto`, `Kustomize`, `Tree`, `Flat` and `Template`, plus `byType` overrides valid under every kind, with two rules that carry the value: whatever chose the path, the file is registered with the kustomization that governs it (so F10 becomes unstatable rather than fixed), and a structural kind excludes a blanket `default` (so a declared template can no longer silently disable the render root). `kind: Auto` is a safe CRD default because it NAMES the structural rule instead of standing in front of it, which is why defaulting a mode works where defaulting a path did not, and it is declared inference rather than the undeclared kind that was deleted. `kind: Kustomize` with `create: true` bootstraps an empty repository into a folder `kubectl apply -k` can build. Seven worked examples, a status shape with `declaredKind` beside the resolved `kind`, a mechanical migration for every current configuration, and an argument that the layout should NOT be its own CRD: a shared object changing where N folders write, with nothing on the GitTarget recording it, is the same defect as sibling inference with a different actor, the shared thing is four lines, and generators already solve reuse. Also carries the namespace half: `scope: SingleNamespace` is a STRUCTURAL claim that must agree with the authorization bound `allowedSourceNamespaces`, and it cannot be derived because that matcher may be absent and because the namespaces that arrive come from WatchRule objects that do not own the folder; `writeNamespace` with the values `FromContext`, `Always` and `Never` replaces the inference that decides whether `metadata.namespace` is written, which is the one inference an empty folder cannot perform, and `create: true` lets the operator ESTABLISH the convention by writing `namespace:` into the kustomization it creates. The layout is **immutable** except a widening transition, because GitTarget has no finalizer so recreating one re-adopts every document by identity, and `Auto` resolves once and pins the result so a deleted `kustomization.yaml` cannot silently re-lay-out the folder. Open: whether `scope` should be derived and materialized at creation instead of declared | -| [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **postponed, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). Not in 0.41.0**, which already carries the attribution model and the placement break. One breaking wave on GitTarget, sequencing the layout model with the maintainer review's still-open API block (F6, F10, F12's reference nit, §3's pushbacks) and the queue's Tier 2 items (B4, B1, #5, #6). The batching argument is the weaker half; the stronger one is that four of them are the same decision seen from different angles: **the folder is described on the GitTarget and the connection describes only the connection**, which is why `commitWindow` and `commit.message` move off `GitProvider`. Two findings change the layout design rather than accompanying it: `spec.mode: Observe` becomes how a layout is adopted safely (a dry run over `status.layout` instead of declare-and-hope), and `spec.interval` plus an observation pass is what keeps the scan-derived half of that status fresh for a target that writes nothing. `spec.suspend` is a precondition rather than a rider, because a layout that creates a `kustomization.yaml` needs a stop button. Records that F7 already shipped the EventRecorder the placement Event was said to be too expensive for, that layout is mutable like `prune`, that F9 stays OUTSIDE the wave because its answer constrains the enum work, and that the version stays `v1alpha3` with loud rejections rather than paying for a conversion path | | [`target-watch-plan.md`](design/target-watch-plan.md) | **built.** The companion to [`watch-manager-ownership.md`](design/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`](design/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`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.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 | @@ -115,6 +118,24 @@ Eighteen other open items: | [`e2e-git-server-choice.md`](design/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`](design/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](https://github.com/ConfigButler/gitops-reverser/pull/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 → **1** → **9** 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 (`Handshake` → `GetRemoteRefs`/`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 | +## The layout topic — [`layout/`](layout/README.md) + +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](layout/README.md) labels each one. Two of these are `spec/`-class and cited +by path from Go source. + +| Document | Class | What it holds | +|---|---|---| +| [`contextual-namespace.md`](layout/contextual-namespace.md) | **spec** | kustomize namespace inference; the supported subset | +| [`new-file-placement-rules.md`](layout/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 | +| [`placement-visibility-and-declared-defaults.md`](layout/placement-visibility-and-declared-defaults.md) | **design** | the three questions the inference deletion left, **decided and then not built** — PR #291 shipped the deletion, the counters and the namespace-transformer fix, and none of the eight items this page had queued behind them, which the page now says. The residue is filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) (correctness: a declared path into a kustomize subdirectory is never rendered, and the identity gate rejects the versionless canonical path) and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296) (visibility: `status.layout`, the ambiguous render root, the `declared` metric split, `{kindLower}`). Its Question 2 is superseded outright by the layout model. What still stands: **Keep `canonical`** as the name for the built-in path and split `declared` into `byType`/`default`, because reusing "default" for both a declaration and the absence of one makes the metric unreadable. **No CRD default for `placement.default`**, and the reason is structural rather than the two that look obvious: a defaulted default is never empty, so it shadows the kustomize-root step and every new file in an overlay would take the canonical path, in Git and rendered by nothing. The validation failure is real but the rule doing the rejecting is itself wrong, and the persistence objection is a trade we could take; defaulting the Secret route to work around the first is a floor that vanishes when a user writes any `byType` entry, because map defaults never merge. **`status.layout` instead**, with five worked examples (greenfield, kustomize overlay, brownfield missing one rule, two ambiguous roots, a refusal from an operator-configured sensitive type) over the `MarkTargetRetention` seam, which already enqueues on change and so retires the "the data plane cannot notify the GitTarget" objection. **`{kindLower}`, not a `toLower` function.** Carries three findings that changed a decision: `IdentityCompletePlacementTemplate` demanding `{version}` contradicts the versionless-path decision; two supported kustomizations still produce a file nothing renders and nothing counts; and **a declared path into a subdirectory of a kustomize folder is registered only when render-root scoping happens to be in force**, so one `byType` line reproduces the unrendered-file bug today. Fixing that last one (walk up to the nearest kustomization) also weakens the case against the CRD default from a correctness wall to a legibility trade, which the page says rather than leaving the stronger argument standing | +| [`model.md`](layout/model.md) | **design** | **postponed to a later deployment, filed as [#293](https://github.com/ConfigButler/gitops-reverser/issues/293)** — the proposal the placement questions were circling around: a path template is the wrong primitive, so declare what the folder IS. `spec.layout.kind` with the values `Auto`, `Kustomize`, `Tree`, `Flat` and `Template`, plus `byType` overrides valid under every kind, with two rules that carry the value: whatever chose the path, the file is registered with the kustomization that governs it (so F10 becomes unstatable rather than fixed), and a structural kind excludes a blanket `default` (so a declared template can no longer silently disable the render root). `kind: Auto` is a safe CRD default because it NAMES the structural rule instead of standing in front of it, which is why defaulting a mode works where defaulting a path did not, and it is declared inference rather than the undeclared kind that was deleted. `kind: Kustomize` with `create: true` bootstraps an empty repository into a folder `kubectl apply -k` can build. Seven worked examples, a status shape with `declaredKind` beside the resolved `kind`, a mechanical migration for every current configuration, and an argument that the layout should NOT be its own CRD: a shared object changing where N folders write, with nothing on the GitTarget recording it, is the same defect as sibling inference with a different actor, the shared thing is four lines, and generators already solve reuse. Also carries the namespace half: `scope: SingleNamespace` is a STRUCTURAL claim that must agree with the authorization bound `allowedSourceNamespaces`, and it cannot be derived because that matcher may be absent and because the namespaces that arrive come from WatchRule objects that do not own the folder; `writeNamespace` with the values `FromContext`, `Always` and `Never` replaces the inference that decides whether `metadata.namespace` is written, which is the one inference an empty folder cannot perform, and `create: true` lets the operator ESTABLISH the convention by writing `namespace:` into the kustomization it creates. The layout is **immutable** except a widening transition, because GitTarget has no finalizer so recreating one re-adopts every document by identity, and `Auto` resolves once and pins the result so a deleted `kustomization.yaml` cannot silently re-lay-out the folder. Open: whether `scope` should be derived and materialized at creation instead of declared | +| [`api-wave.md`](layout/api-wave.md) | **design** | **postponed, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). Not in 0.41.0**, which already carries the attribution model and the placement break. One breaking wave on GitTarget, sequencing the layout model with the maintainer review's still-open API block (F6, F10, F12's reference nit, §3's pushbacks) and the queue's Tier 2 items (B4, B1, #5, #6). The batching argument is the weaker half; the stronger one is that four of them are the same decision seen from different angles: **the folder is described on the GitTarget and the connection describes only the connection**, which is why `commitWindow` and `commit.message` move off `GitProvider`. Two findings change the layout design rather than accompanying it: `spec.mode: Observe` becomes how a layout is adopted safely (a dry run over `status.layout` instead of declare-and-hope), and `spec.interval` plus an observation pass is what keeps the scan-derived half of that status fresh for a target that writes nothing. `spec.suspend` is a precondition rather than a rider, because a layout that creates a `kustomization.yaml` needs a stop button. Records that F7 already shipped the EventRecorder the placement Event was said to be too expensive for, that layout is mutable like `prune`, that F9 stays OUTSIDE the wave because its answer constrains the enum work, and that the version stays `v1alpha3` with loud rejections rather than paying for a conversion path | + +The proposal is not built. What it would take, the worked examples that make it concrete, and the +external review of both are separate documents that land beside these as they are written. + ## Deferred, but still wanted — [`future/`](future/) [`idea-application-editing.md`](future/idea-application-editing.md) is where the @@ -133,7 +154,10 @@ is the strategy review on top of it: the config-as-data direction as the headlin 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. -Five more ideas sit beside them. +[`flux-maintainer-review-status-and-config-model.md`](future/flux-maintainer-review-status-and-config-model.md) +is the first external review, `F1`-`F12` over status and the configuration model; most of it +shipped on `feat/flux-status-contract`, and `F6`, `F9` and `F10` are still open. +Four more ideas sit beside them. ## History — [`finished/`](finished/) diff --git a/docs/TODO.md b/docs/TODO.md index 62cd455e..bf9fe6fd 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -66,7 +66,7 @@ This file is meant to track the smaller current backlog, not historical notes. - [ ] Revisit output layout. **Now designed and postponed, not open-ended.** The answer is to declare what the folder *is* rather than a path template: `GitTarget.spec.layout`, designed in - [gittarget-layout-model.md](design/gittarget-layout-model.md) and filed as + [gittarget-layout-model.md](layout/model.md) and filed as [#293](https://github.com/ConfigButler/gitops-reverser/issues/293), sequenced with the rest of the breaking `GitTarget` work as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). Deliberately **not** in 0.41.0, which already carries the new attribution model and the diff --git a/docs/architecture.md b/docs/architecture.md index 77f225be..695d665d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1139,7 +1139,7 @@ Placement runs **only for a resource with no existing document** in the target. manifest identity rather than by path, instead of recomputing placement. So a change to how new files are placed never moves a file already in Git. A new resource is placed by the first of these that applies ([internal/manifestanalyzer/placement.go](../internal/manifestanalyzer/placement.go), -[design](spec/gittarget-new-file-placement-rules.md)): +[design](layout/new-file-placement-rules.md)): 1. **Declared policy (`spec.placement`).** A `GitTarget` can declare a `byType` map (exact `[group/]version/resource` → path template) plus a `default` template, rendered from a small diff --git a/docs/design/open-asks-priority.md b/docs/design/open-asks-priority.md index ae319604..e1ea44ab 100644 --- a/docs/design/open-asks-priority.md +++ b/docs/design/open-asks-priority.md @@ -8,8 +8,8 @@ > sticky removal pointer, the metric relabel, the name tier, and the analyzer/encoder corrections, > plus PR #291's sibling-inference deletion and placement counters. That is a large breaking release > on its own, and **the GitTarget work is deliberately not in it**. The layout model and the API wave -> ([`gittarget-layout-model.md`](gittarget-layout-model.md), -> [`gittarget-api-wave.md`](gittarget-api-wave.md)) are postponed to a later deployment and tracked as +> ([`gittarget-layout-model.md`](../layout/model.md), +> [`gittarget-api-wave.md`](../layout/api-wave.md)) are postponed to a later deployment and tracked as > GitHub issues, so the queue below is read with one standing caveat: **every Tier 2 entry that > changes a `GitTarget` field is now part of that postponed wave, not independently schedulable.** > The Tier 1 entries are not, and should not wait for it. @@ -68,7 +68,7 @@ do not own. > **Built.** `resolveInferred` through `allSameDir` are gone, the kustomize-root fallback stayed, and no > enum was added. What building it added to the argument below is recorded in > [what the deletion taught](#what-the-deletion-taught). The spec's Option C sections are retained as -> history in [`gittarget-new-file-placement-rules.md`](../spec/gittarget-new-file-placement-rules.md), +> history in [`gittarget-new-file-placement-rules.md`](../layout/new-file-placement-rules.md), > and the behaviour change has a [`docs/UPGRADING.md`](../UPGRADING.md) entry. The config-surface proposal's **B3** offers `spec.placement.mode: Infer|Declared|Strict`: an @@ -77,7 +77,7 @@ cohort ladder entirely**, keep the kustomize-root fallback, and ship no enum at ### What inference is, precisely -[`gittarget-new-file-placement-rules.md`](../spec/gittarget-new-file-placement-rules.md) Option C. +[`gittarget-new-file-placement-rules.md`](../layout/new-file-placement-rules.md) Option C. It fires **only** for a resource that has no document in Git yet; everything already written is match-first and never moves. For that narrow case it finds the largest cohort of similar existing documents (step 1: same type + namespace; step 2: same type, any namespace) and puts the new @@ -236,8 +236,8 @@ and is not independently schedulable. |---|---|---|---|---| | 15 | A declared `auditRoute` with zero facts must say so, and a route losing them with it | gitops-api | **1** | — | | n/a | Stop paying a full grace for a delete fact that will never arrive (F, then C) | [`attribution-removal-wait-options.md`](attribution-removal-wait-options.md) | **1** | — | -| n/a | A declared path in a kustomize subdirectory is never rendered; the identity gate rejects the versionless canonical path | [`placement-visibility-and-declared-defaults.md`](placement-visibility-and-declared-defaults.md) | **1** | [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) | -| n/a | `spec.layout`: declare what the folder is | [`gittarget-layout-model.md`](gittarget-layout-model.md) | **2** | [#293](https://github.com/ConfigButler/gitops-reverser/issues/293), wave | +| n/a | A declared path in a kustomize subdirectory is never rendered; the identity gate rejects the versionless canonical path | [`placement-visibility-and-declared-defaults.md`](../layout/placement-visibility-and-declared-defaults.md) | **1** | [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) | +| n/a | `spec.layout`: declare what the folder is | [`gittarget-layout-model.md`](../layout/model.md) | **2** | [#293](https://github.com/ConfigButler/gitops-reverser/issues/293), wave | | F6 | `spec.suspend`, `spec.interval`, `requestedAt` | maintainer review | **2** | wave | | 5 | `CommitRequest.spec.author`, SAR-guarded | gitops-api (#220) | **2** | wave | | B4 | `commitWindow` / `commit.message` move to GitTarget | config surface | **2** | wave | @@ -246,7 +246,7 @@ and is not independently schedulable. | F10 | CommitRequest TTL / ownerRef + the `delete` verb | maintainer review | **2** | wave | | n/a | The blocking resolve is head-of-line on the shard goroutine | [`../spec/attribution.md`](../spec/attribution.md#the-wait) | **2** | — | | B2 | `GitTarget.status.layout` | config surface | **3** | [#296](https://github.com/ConfigButler/gitops-reverser/issues/296) | -| n/a | The ambiguous render root, the `declared` metric split, `{kindLower}`, canonical-as-template | [`placement-visibility-and-declared-defaults.md`](placement-visibility-and-declared-defaults.md) | **3** | [#296](https://github.com/ConfigButler/gitops-reverser/issues/296) | +| n/a | The ambiguous render root, the `declared` metric split, `{kindLower}`, canonical-as-template | [`placement-visibility-and-declared-defaults.md`](../layout/placement-visibility-and-declared-defaults.md) | **3** | [#296](https://github.com/ConfigButler/gitops-reverser/issues/296) | | F9 | The `scope: Namespaced` status-write envtest | maintainer review | **3** | outside the wave, deliberately | | B6 | The `default` ClusterProvider not-found message | config surface | **3** | — | | n/a | An aggregated create carries no name and no body: accept it, or stop waiting for it | [`../spec/attribution.md`](../spec/attribution.md#what-the-shape-driven-rules-reach-and-what-they-do-not) | **3** | — | @@ -581,7 +581,7 @@ defects. fall-back-to-canonical Event.~~ **Done for the removal and the entry**; the Event is still undecided, and the metric now carries the actionable part in the meantime. 3. ~~Rewriting Option C's sections in - [`gittarget-new-file-placement-rules.md`](../spec/gittarget-new-file-placement-rules.md).~~ + [`gittarget-new-file-placement-rules.md`](../layout/new-file-placement-rules.md).~~ **Done**: the ladder is documented as three steps, the kustomize-root fallback keeps its section and gained the namespace-match rule, and P1–P10 are annotated one by one with which are retired by the deletion and which (P7, P9, P10) are facts about the code that remains. @@ -608,5 +608,5 @@ defects. 9. Not letting a decided-but-unbuilt list read as imminent. The placement work is filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) (correctness) and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296) (visibility); - [`placement-visibility-and-declared-defaults.md`](placement-visibility-and-declared-defaults.md) + [`placement-visibility-and-declared-defaults.md`](../layout/placement-visibility-and-declared-defaults.md) now says which of its eight items shipped, which is **none of them**. diff --git a/docs/design/support-boundary/finished/images-and-replicas-edit-through.md b/docs/design/support-boundary/finished/images-and-replicas-edit-through.md index 31c2a66c..01dfbe6a 100644 --- a/docs/design/support-boundary/finished/images-and-replicas-edit-through.md +++ b/docs/design/support-boundary/finished/images-and-replicas-edit-through.md @@ -10,7 +10,7 @@ > Captured: 2026-07-06 > Related: > [../README.md](../README.md), -> [contextual-namespace-and-kustomize-folder-editing.md](../../../spec/contextual-namespace-and-kustomize-folder-editing.md), +> [contextual-namespace-and-kustomize-folder-editing.md](../../../layout/contextual-namespace.md), > [manifestedit/DECISION.md](../../../../internal/git/manifestedit/DECISION.md) ## Problem diff --git a/docs/design/support-boundary/helm-light-support-boundary.md b/docs/design/support-boundary/helm-light-support-boundary.md index 7bd85e5e..10518203 100644 --- a/docs/design/support-boundary/helm-light-support-boundary.md +++ b/docs/design/support-boundary/helm-light-support-boundary.md @@ -130,7 +130,7 @@ and refused **by name**, the way `patchesJson6902` refuses under its own name to `patternProperties` and an unresolved reference mean for closure before any of this is built. The schema is the chart author *declaring* the input surface, a closed leaf set, which is the same declared-over-inferred principle the layout model rests on - ([gittarget-layout-model.md](../gittarget-layout-model.md)). A values key the schema + ([gittarget-layout-model.md](../../layout/model.md)). A values key the schema does not declare refuses the folder. 3. No `dependencies:` in `Chart.yaml`, no `charts/` directory, no `Chart.lock`. The chart is one program, not a graph of them. (Vendored dependencies are a conceivable later @@ -302,7 +302,7 @@ locally, no plugins, no network, in-memory filesystem as the jail. How helm-light surfaces on the API (`layout.kind`, or a renderer property of the structural kind) belongs to the GitTarget API wave -([gittarget-api-wave.md](../gittarget-api-wave.md)), not to this boundary. +([gittarget-api-wave.md](../../layout/api-wave.md)), not to this boundary. ## Where the arguments live diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index caf5a96b..077f72bd 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -14,7 +14,7 @@ > [kustomize-support-boundary.md](kustomize-support-boundary.md), > [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), -> [../manifest/file-agnostic-placement.md](../../spec/gittarget-new-file-placement-rules.md), +> [../manifest/file-agnostic-placement.md](../../layout/new-file-placement-rules.md), > [../../finished/current-manifest-support-review.md](../../spec/current-manifest-support-review.md) ## Why this exists @@ -49,7 +49,7 @@ Repo-wide target discovery is therefore a **new axis**, not a new operator feature. Putting it in the operator would fight both the simplicity goal and the one-owner invariant. It belongs in the CLI/library, and anything built on top of the operator consumes its report — exactly the division already recorded in -[file-agnostic-placement.md](../../spec/gittarget-new-file-placement-rules.md) and the +[file-agnostic-placement.md](../../layout/new-file-placement-rules.md) and the [README](README.md) responsibilities table: | Layer | Owns | Gains repo-wide discovery? | diff --git a/docs/future/direction-and-configuration-surface.md b/docs/future/direction-and-configuration-surface.md index 327f97f8..a175dc29 100644 --- a/docs/future/direction-and-configuration-surface.md +++ b/docs/future/direction-and-configuration-surface.md @@ -5,8 +5,8 @@ > Date: 2026-08-27. > Companions: [`config-surface-for-a-structured-repository.md`](config-surface-for-a-structured-repository.md) > (the field-level review this extends), -> [`../design/gittarget-layout-model.md`](../design/gittarget-layout-model.md) and -> [`../design/gittarget-api-wave.md`](../design/gittarget-api-wave.md) (the API work this +> [`../design/gittarget-layout-model.md`](../layout/model.md) and +> [`../design/gittarget-api-wave.md`](../layout/api-wave.md) (the API work this > sequences), and > [`../design/support-boundary/helm-light-support-boundary.md`](../design/support-boundary/helm-light-support-boundary.md) > (the Helm option this deliberately parks). diff --git a/docs/future/flux-maintainer-review-status-and-config-model.md b/docs/future/flux-maintainer-review-status-and-config-model.md index 066b2ecb..a2e33996 100644 --- a/docs/future/flux-maintainer-review-status-and-config-model.md +++ b/docs/future/flux-maintainer-review-status-and-config-model.md @@ -17,7 +17,7 @@ > > **That change now has a shape.** F6, F10, F12's reference-type nit and §3's pushbacks are sequenced > together with the new-file-placement redesign in -> [`gittarget-api-wave.md`](../design/gittarget-api-wave.md), on the principle that the folder is +> [`gittarget-api-wave.md`](../layout/api-wave.md), on the principle that the folder is > described on the GitTarget and the connection describes only the connection. Two of this review's > findings change that design rather than accompanying it: `spec.mode: Observe` (config-surface B1) > becomes the way a layout is adopted safely, and `spec.interval` is what keeps the layout's @@ -640,7 +640,7 @@ criticism. **Then** (API surface — do the breaking ones while still `v1alpha3`). These are the wave, and they are now sequenced with the layout model in -[`gittarget-api-wave.md`](../design/gittarget-api-wave.md) rather than scheduled from here: +[`gittarget-api-wave.md`](../layout/api-wave.md) rather than scheduled from here: 1. **F6** — `spec.suspend` on GitTarget/WatchRule/ClusterWatchRule/GitProvider; `spec.interval` on GitProvider at minimum; jitter the requeue; `reconcile.configbutler.ai/requestedAt` + diff --git a/docs/layout/README.md b/docs/layout/README.md new file mode 100644 index 00000000..7affa080 --- /dev/null +++ b/docs/layout/README.md @@ -0,0 +1,41 @@ +# `docs/layout/` — where a document goes in Git, collected in one place + +This folder is organized by **topic**, which is a deliberate exception to the rule in +[`../INDEX.md`](../INDEX.md) that the other folders are picked by lifecycle. The layout question +(given a live object, which file in which folder receives it, and what else has to change so that +file is reachable) had grown to five documents spread across `spec/` and `design/`, and following +the argument meant knowing which of the two a given piece lived in. + +Lifecycle is still the thing that decides whether a page binds, so each entry below is labelled +with the class it would have had in the old layout. Read the label before you read the page. + +## Current behavior, and the code depends on it + +These are `spec/`-class. Go source cites them by path, and `task lint-docs` checks those citations. +If you change one of these behaviors, change the document in the same commit. + +| Document | What it pins | +|---|---| +| [`new-file-placement-rules.md`](new-file-placement-rules.md) | where a brand-new resource's file goes: declared, the folder's one kustomize root, canonical. Sibling inference is removed, and kept as history | +| [`contextual-namespace.md`](contextual-namespace.md) | kustomize graph-aware namespace inference, and the supported subset | + +## Being decided + +These are `design/`-class: intent, not shipped behavior. + +| Document | What it proposes | +|---|---| +| [`model.md`](model.md) | the proposal: a path template is the wrong primitive, so declare what the folder **is**. `spec.layout` as a discriminated union, with registration as an invariant rather than a rung | +| [`api-wave.md`](api-wave.md) | how the layout break sequences with the other `feat(api)!` work on `GitTarget`, so the consumer pays one bump | +| [`placement-visibility-and-declared-defaults.md`](placement-visibility-and-declared-defaults.md) | the three questions the sibling-inference deletion left. Decided, mostly unbuilt | + +## What is deliberately not here + +- [`../design/support-boundary/`](../design/support-boundary/README.md) owns what the operator may + edit and what it refuses. Layout decides **where** a document goes; the support boundary decides + **whether** it may be written at all. +- [`../design/open-asks-priority.md`](../design/open-asks-priority.md) is the cross-cutting queue. + It references the layout work rather than containing it. +- [`../future/config-surface-for-a-structured-repository.md`](../future/config-surface-for-a-structured-repository.md) + is the broader configuration-surface review that the layout thesis came out of. It covers + `spec.path`, modes and `commitWindow` as well, so it stayed with its companion review. diff --git a/docs/design/gittarget-api-wave.md b/docs/layout/api-wave.md similarity index 97% rename from docs/design/gittarget-api-wave.md rename to docs/layout/api-wave.md index b8a331df..2aa986cc 100644 --- a/docs/design/gittarget-api-wave.md +++ b/docs/layout/api-wave.md @@ -7,11 +7,11 @@ > Combines three pieces of work that are all `feat(api)!` on `GitTarget` and are cheaper together > than apart: > -> - the layout model, [`gittarget-layout-model.md`](gittarget-layout-model.md); +> - the layout model, [`gittarget-layout-model.md`](model.md); > - the still-open API-surface block of the maintainer review, > [`flux-maintainer-review-status-and-config-model.md`](../future/flux-maintainer-review-status-and-config-model.md) > §4 "Then": **F6**, **F9**, **F10**, plus F12's reference-type nit and §3's pushbacks; -> - the Tier 2 breaking items in [`open-asks-priority.md`](open-asks-priority.md): **B4**, **B1**, +> - the Tier 2 breaking items in [`open-asks-priority.md`](../design/open-asks-priority.md): **B4**, **B1**, > **#5**, **#6**. > > It does not touch Tier 1 (the removal-wait decision, #15's condition). Those are not breaking and @@ -80,7 +80,7 @@ resource writes, which is a detail worth stating before either is built. ### 4. The Events question is already answered, and the layout is what to say -[`open-asks-priority.md`](open-asks-priority.md) left one thing open about the inference deletion: +[`open-asks-priority.md`](../design/open-asks-priority.md) left one thing open about the inference deletion: whether a fall-back to canonical should raise an Event on the GitTarget, and it reasoned that this was expensive because placement runs on the branch worker with no recorder. @@ -107,7 +107,7 @@ which file came from which. So: immutable, with a CEL exception for a **widening** transition (`Flat` to `Tree`) that cannot lose the identity-completeness the folder already had. And `Auto` resolves once and is pinned in status, because immutability of a field that says "look at the folder" pins nothing. The reasoning is in -[`gittarget-layout-model.md`](gittarget-layout-model.md). +[`gittarget-layout-model.md`](model.md). This settles a question **#6** would otherwise have to reopen: if `path` ever becomes movable (`status.observedDestination`), the layout moves with it, because a new folder may have a different diff --git a/docs/spec/contextual-namespace-and-kustomize-folder-editing.md b/docs/layout/contextual-namespace.md similarity index 98% rename from docs/spec/contextual-namespace-and-kustomize-folder-editing.md rename to docs/layout/contextual-namespace.md index c9ecd5fa..b25ca207 100644 --- a/docs/spec/contextual-namespace-and-kustomize-folder-editing.md +++ b/docs/layout/contextual-namespace.md @@ -8,11 +8,11 @@ > Captured: 2026-06-08 > Updated: 2026-06-08 > Related: -> [file-agnostic-placement.md](gittarget-new-file-placement-rules.md), -> [manifest-inventory-file-agnostic-placement.md](manifest-system.md), -> [current-manifest-support-review.md](current-manifest-support-review.md), -> [version2/gittarget-repository-validity-and-placement.md](gittarget-new-file-placement-rules.md), -> [version2/gittarget-new-file-placement-rules.md](gittarget-new-file-placement-rules.md) +> [file-agnostic-placement.md](new-file-placement-rules.md), +> [manifest-inventory-file-agnostic-placement.md](../spec/manifest-system.md), +> [current-manifest-support-review.md](../spec/current-manifest-support-review.md), +> [version2/gittarget-repository-validity-and-placement.md](new-file-placement-rules.md), +> [version2/gittarget-new-file-placement-rules.md](new-file-placement-rules.md) ## Summary diff --git a/docs/design/gittarget-layout-model.md b/docs/layout/model.md similarity index 99% rename from docs/design/gittarget-layout-model.md rename to docs/layout/model.md index 7b475eed..757851ea 100644 --- a/docs/design/gittarget-layout-model.md +++ b/docs/layout/model.md @@ -10,7 +10,7 @@ > This is a `feat(api)!` change to `GitTarget`. It does not fit in PR #291 and is not proposed for it. > How it sequences with the other breaking work on the same object (`spec.suspend`, `spec.mode`, the > `commitWindow` move, CommitRequest lifecycle) is in -> [`gittarget-api-wave.md`](gittarget-api-wave.md), which also records the two places where those +> [`gittarget-api-wave.md`](api-wave.md), which also records the two places where those > items change this design rather than merely accompanying it. ## Why the current shape keeps producing dead ends @@ -478,7 +478,7 @@ and starts registering its files instead. `spec.placement` becomes a loud rejection for one release rather than a silent alias, following the pattern `ClusterWatchRule.spec.rules[].scope` set: refusing a stored field the user can see beats translating it behind their back. It rides the Tier 2 breaking wave in -[`open-asks-priority.md`](open-asks-priority.md), so the consumer pays one coordinated bump. +[`open-asks-priority.md`](../design/open-asks-priority.md), so the consumer pays one coordinated bump. ## What this changes about the work already queued diff --git a/docs/spec/gittarget-new-file-placement-rules.md b/docs/layout/new-file-placement-rules.md similarity index 99% rename from docs/spec/gittarget-new-file-placement-rules.md rename to docs/layout/new-file-placement-rules.md index d7186793..7d0d61ab 100644 --- a/docs/spec/gittarget-new-file-placement-rules.md +++ b/docs/layout/new-file-placement-rules.md @@ -15,12 +15,12 @@ > Captured: 2026-06-05. Option C removed: 2026-07-29. > Related: > [open-asks-priority.md](../design/open-asks-priority.md) — **the argument for deleting Option C**, -> [contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace-and-kustomize-folder-editing.md), -> [gittarget-repository-validity-and-placement.md](gittarget-new-file-placement-rules.md), -> [current-manifest-support-review.md](current-manifest-support-review.md), -> [manifestedit-new-file-placement-spike.md](gittarget-new-file-placement-rules.md), -> [reconcile-via-watchlist-mark-and-sweep.md](reconcile-via-watchlist-mark-and-sweep.md), -> [gitpath-foreign-content-stringency.md](gitpath-foreign-content-stringency.md) +> [contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace.md), +> [gittarget-repository-validity-and-placement.md](new-file-placement-rules.md), +> [current-manifest-support-review.md](../spec/current-manifest-support-review.md), +> [manifestedit-new-file-placement-spike.md](new-file-placement-rules.md), +> [reconcile-via-watchlist-mark-and-sweep.md](../spec/reconcile-via-watchlist-mark-and-sweep.md), +> [gitpath-foreign-content-stringency.md](../spec/gitpath-foreign-content-stringency.md) ## Summary @@ -123,7 +123,7 @@ The current `GitTargetSpec` has `providerRef`, `branch`, `path`, and optional ([api/v1alpha3/gittarget_types.go](../../api/v1alpha3/gittarget_types.go)). The writer already uses the materialized-model direction described in -[current-manifest-support-review.md](current-manifest-support-review.md): +[current-manifest-support-review.md](../spec/current-manifest-support-review.md): - steady-state writes scan the GitTarget subtree into a content-derived store, then apply a commit-scoped plan @@ -649,7 +649,7 @@ in force, which is not something the user can see. A document in a directory whose kustomization sets a `namespace:` transformer does not carry `metadata.namespace` — the build context supplies it (see -[contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace-and-kustomize-folder-editing.md)). +[contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace.md)). A new document placed there must follow that convention, or it breaks the folder's own style, and this applies to **every** resolved path: a declared template pointing into a governed directory is under the same obligation as the kustomize-root fallback. @@ -811,7 +811,7 @@ because the answer does not depend on the folder's history.* **P7 — A resolved path is still subject to the write-time ignore invariant.** Any resolved path — declared, kustomize-root, or canonical — can collide with a `.gittargetignore` pattern and trip the §4.3 `IgnoreShadowsManagedPath` precondition -([gitpath-foreign-content-stringency.md](gitpath-foreign-content-stringency.md)), aborting +([gitpath-foreign-content-stringency.md](../spec/gitpath-foreign-content-stringency.md)), aborting the flush. *Still live, and unrelated to inference: placement inherits this failure mode rather than creating it.* diff --git a/docs/design/placement-visibility-and-declared-defaults.md b/docs/layout/placement-visibility-and-declared-defaults.md similarity index 98% rename from docs/design/placement-visibility-and-declared-defaults.md rename to docs/layout/placement-visibility-and-declared-defaults.md index 7ef490e3..b24a84a7 100644 --- a/docs/design/placement-visibility-and-declared-defaults.md +++ b/docs/layout/placement-visibility-and-declared-defaults.md @@ -13,7 +13,7 @@ > Reading it in order matters, because the two halves have different standing. The **findings** are > checkable facts about the tree and they are why the calls are what they are. The **calls** are still > the calls. Question 2 (a CRD default for `placement.default`) has since been superseded outright by -> [`gittarget-layout-model.md`](gittarget-layout-model.md), which argues that a path template is the +> [`gittarget-layout-model.md`](model.md), which argues that a path template is the > wrong primitive to be defaulting at all. Three questions came out of reviewing #291, and one of them (a CRD default for @@ -103,7 +103,7 @@ fixing F4. `MarkTargetRetention` records a fact from the write path into an epoch-scoped per-target roll-up and calls `enqueueGitPathChange` **on a change only**, and the controller projects it in `gitTargetRetentionStatus`. This retires the objection recorded in -[`open-asks-priority.md`](open-asks-priority.md) that placement facts cannot reach the GitTarget +[`open-asks-priority.md`](../design/open-asks-priority.md) that placement facts cannot reach the GitTarget promptly because "a refusal recorded on the data plane does not enqueue the GitTarget". One does already. `status.retention` is proof. @@ -503,7 +503,7 @@ because they are worth different urgency: | `{kindLower}` | **filed, legibility** | Small, self-contained | | Canonical path as a template constant | **filed, cleanup** | Removes the hand-written duplication; what a future default would reuse | | `renderRootReason: Ambiguous` | **filed** | Belongs with `status.layout`, and the layout model decides the refuse-or-write policy | -| A CRD default for `placement.default` | **superseded** | [`gittarget-layout-model.md`](gittarget-layout-model.md): the primitive is wrong. `layout.kind` is the defaultable thing, because it names the structural rule instead of standing in front of it | +| A CRD default for `placement.default` | **superseded** | [`gittarget-layout-model.md`](model.md): the primitive is wrong. `layout.kind` is the defaultable thing, because it names the structural rule instead of standing in front of it | The original build list follows, because each entry says *how* to build the thing and that is the part an issue should not have to restate. @@ -539,7 +539,7 @@ Ordered by risk, smallest first. **Not now, with the trigger written down:** a CRD default for `placement.default`. This was already "not now" when the page was written, and it has since become "not this shape at all", because -[`gittarget-layout-model.md`](gittarget-layout-model.md) replaces the template with a declared +[`gittarget-layout-model.md`](model.md) replaces the template with a declared `layout.kind` and `Auto` is a safe default where a path never was. The freezing question (F5) is a trade we could take; the shadowing question is not, and the layout model dissolves it rather than answering it. `spec.expect.layout` stays out too, on the config-surface doc's own rule: publish the diff --git a/docs/spec/README.md b/docs/spec/README.md index 4933e1f9..07dc6ccf 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -27,9 +27,9 @@ If you change one of these behaviours, change the document in the same commit. |---|---| | [`attribution.md`](attribution.md) | **how a commit gets its author**: deletion-at-intent, the publish and join halves, the tier ladder, the audit-route partition, and what the metrics can and cannot answer | | [`current-manifest-support-review.md`](current-manifest-support-review.md) | the manifest store, plan/apply/flush, and the all-or-nothing folder claim | -| [`contextual-namespace-and-kustomize-folder-editing.md`](contextual-namespace-and-kustomize-folder-editing.md) | kustomize graph-aware namespace inference; the supported subset | +| [`contextual-namespace-and-kustomize-folder-editing.md`](../layout/contextual-namespace.md) | kustomize graph-aware namespace inference; the supported subset | | [`reconcile-via-watchlist-mark-and-sweep.md`](reconcile-via-watchlist-mark-and-sweep.md) | initial reconcile; **no bookmark, no sweep** | -| [`gittarget-new-file-placement-rules.md`](gittarget-new-file-placement-rules.md) | where a brand-new resource's file goes | +| [`gittarget-new-file-placement-rules.md`](../layout/new-file-placement-rules.md) | where a brand-new resource's file goes | | [`manifestedit-field-ownership-spike.md`](manifestedit-field-ownership-spike.md) | "the API wins" — full-object ownership, and the do-not-build list | | [`type-followability.md`](type-followability.md) | is a type followable, and if not, the single reason | | [`type-lifecycle-events-and-wobble-settling.md`](type-lifecycle-events-and-wobble-settling.md) | removal grace and flap coalescing | diff --git a/docs/spec/manifest-system.md b/docs/spec/manifest-system.md index 227c6a93..742227c9 100644 --- a/docs/spec/manifest-system.md +++ b/docs/spec/manifest-system.md @@ -47,7 +47,7 @@ identity (group, kind, namespace, name). The store indexes by that identity and writes an edit back to wherever the document already lives — *match-first placement*. Being strict about which file a resource "should" live in was explicitly rejected. Only a genuinely new resource needs a placement decision, and -that is [`gittarget-new-file-placement-rules.md`](gittarget-new-file-placement-rules.md). +that is [`gittarget-new-file-placement-rules.md`](../layout/new-file-placement-rules.md). **A GitTarget makes an all-or-nothing claim on its folder.** It either manages everything in the subtree or it refuses the folder. There is no partial ownership, @@ -111,7 +111,7 @@ beside it. Raw identity (what the bytes say) and effective identity (what kustom would produce) are tracked separately, and an inherited namespace is kept *out* of the file bytes on write. The supported subset, and why everything outside it is refused rather than unimplemented, is -[`contextual-namespace-and-kustomize-folder-editing.md`](contextual-namespace-and-kustomize-folder-editing.md). +[`contextual-namespace-and-kustomize-folder-editing.md`](../layout/contextual-namespace.md). The governing constraint is invertibility: an edit must round-trip in both directions. Generators, `patches*`, `namePrefix`/`nameSuffix`, `components`, remote diff --git a/docs/spec/sops-single-file-no-multidoc.md b/docs/spec/sops-single-file-no-multidoc.md index bd6de697..6e5bef9d 100644 --- a/docs/spec/sops-single-file-no-multidoc.md +++ b/docs/spec/sops-single-file-no-multidoc.md @@ -5,8 +5,8 @@ > Status: decided > Captured: 2026-06-08 > Related: -> [file-agnostic-placement.md](gittarget-new-file-placement-rules.md), -> [contextual-namespace-and-kustomize-folder-editing.md](contextual-namespace-and-kustomize-folder-editing.md), +> [file-agnostic-placement.md](../layout/new-file-placement-rules.md), +> [contextual-namespace-and-kustomize-folder-editing.md](../layout/contextual-namespace.md), > [../sops-repo-bootstrap-and-key-management-architecture.md](../finished/sops-repo-bootstrap-and-key-management-architecture.md), > [../sops-repo-bootstrap-out-of-scope.md](../finished/sops-repo-bootstrap-out-of-scope.md) @@ -17,7 +17,7 @@ SOPS file**. We do **not** write SOPS-encrypted multi-document YAML (no `\n---\n`-separated documents inside an encrypted file). Plaintext manifests may still be multi-document where that is convenient (see -[file-agnostic-placement.md](gittarget-new-file-placement-rules.md)); this decision is +[file-agnostic-placement.md](../layout/new-file-placement-rules.md)); this decision is scoped to files we encrypt with SOPS. ## Why this came up diff --git a/internal/controller/gittarget_placement_validation.go b/internal/controller/gittarget_placement_validation.go index dd73f15b..839a79ff 100644 --- a/internal/controller/gittarget_placement_validation.go +++ b/internal/controller/gittarget_placement_validation.go @@ -19,7 +19,7 @@ const coreSecretsTypeKey = "v1/secrets" // validatePlacementPolicy statically validates a GitTarget's declared placement // policy (Option B2: -// docs/spec/gittarget-new-file-placement-rules.md) against the +// docs/layout/new-file-placement-rules.md) against the // spec alone — no repository scan is needed, so this runs as part of the Validated // gate, the same spec-well-formedness check that already covers provider/branch // resolution and path-overlap conflicts. A nil spec (no declared policy) is always diff --git a/internal/git/manifestedit/kustomization.go b/internal/git/manifestedit/kustomization.go index c60826d3..86877ed1 100644 --- a/internal/git/manifestedit/kustomization.go +++ b/internal/git/manifestedit/kustomization.go @@ -128,7 +128,7 @@ func applyKustomizationEdit(root *yaml.Node, e KustomizationEdit) error { // AppendKustomizationResource adds one entry to an existing kustomization.yaml's // resources: sequence — the mechanism half of the "add to the right kustomize -// file" (docs/spec/gittarget-new-file-placement-rules.md): a +// file" (docs/layout/new-file-placement-rules.md): a // new sibling file placed inside a kustomize-governed directory must also be named // in that directory's resources: list, or kustomize never renders it. // diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go index bb68231f..eb9e80f8 100644 --- a/internal/git/plan_flush.go +++ b/internal/git/plan_flush.go @@ -154,7 +154,7 @@ type writeBatch struct { // and therefore cannot see coming — form one deterministic, resource-identity- // sorted multi-document file instead of each writeWholeFile call silently // discarding the one before it. See - // docs/spec/gittarget-new-file-placement-rules.md, + // docs/layout/new-file-placement-rules.md, // "Collision and append behavior": "if several new plaintext resources in one // plan render to the same path, write a multi-document file in deterministic // resource-identity order." @@ -339,7 +339,7 @@ func wroteBytes(o upsertOutcome) bool { // createNew resolves the placement of a resource with no existing document — // declared policy (Option B), the folder's one kustomize root, or the canonical -// fallback — per docs/spec/gittarget-new-file-placement-rules.md, +// fallback — per docs/layout/new-file-placement-rules.md, // adds the kustomize resources: entry the placement may require, and writes the new // document: a brand-new file, or an additional document appended to an existing // accepted plaintext bundle. A placement LocateNew cannot honour safely (today, only diff --git a/internal/manifestanalyzer/contextual_namespace_corpus_test.go b/internal/manifestanalyzer/contextual_namespace_corpus_test.go index 9f761735..8f658498 100644 --- a/internal/manifestanalyzer/contextual_namespace_corpus_test.go +++ b/internal/manifestanalyzer/contextual_namespace_corpus_test.go @@ -25,7 +25,7 @@ type wantDoc struct { // testdata/contextual-namespace. Each folder is built as a GitTarget subtree and the // per-document namespace provenance is asserted, so the supported boundary is pinned by // real layouts rather than prose. See -// docs/spec/contextual-namespace-and-kustomize-folder-editing.md. +// docs/layout/contextual-namespace.md. func TestContextualNamespaceCorpus(t *testing.T) { cases := []struct { dir string diff --git a/internal/manifestanalyzer/placement.go b/internal/manifestanalyzer/placement.go index 8919f6b6..f4e9d97a 100644 --- a/internal/manifestanalyzer/placement.go +++ b/internal/manifestanalyzer/placement.go @@ -13,7 +13,7 @@ import ( ) // PlacementPolicy is a resolved GitTarget placement declaration (Option B2 of -// docs/spec/gittarget-new-file-placement-rules.md): a single +// docs/layout/new-file-placement-rules.md): a single // exact-type map plus a fallback default template, consulted for every resource // regardless of sensitivity. It mirrors api/v1alpha3.GitTargetPlacementSpec // field-for-field but is defined locally so this analyzer package stays free of any @@ -161,7 +161,7 @@ func (e *PlacementRefusedError) Error() string { return e.detail } func (e *PlacementRefusedError) Unwrap() error { return e.cause } // LocateNew resolves the placement of a resource with no existing document, per -// docs/spec/gittarget-new-file-placement-rules.md: a declared template (Option B) +// docs/layout/new-file-placement-rules.md: a declared template (Option B) // wins when present; otherwise the folder's one supported kustomize root, if it has // exactly one; otherwise the canonical path. // diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index 55bfaf7c..43189fb8 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -263,7 +263,7 @@ type DocumentModel struct { // It replaces an earlier "namespace came from kustomize" boolean so the store can also // explain the no-context and ambiguous cases to status, duplicate diagnostics, and // future placement — see -// docs/spec/contextual-namespace-and-kustomize-folder-editing.md. +// docs/layout/contextual-namespace.md. type NamespaceSourceKind string const ( @@ -796,7 +796,7 @@ func kustomizationInfos(kusts map[string]*kustomizationDoc) map[string]*Kustomiz // namespace transformer, its resources/bases graph entries, and whether it uses any // feature outside the supported contextual-namespace subset (which disqualifies it as // a namespace source). See the "Kustomize subset proposal" in -// docs/spec/contextual-namespace-and-kustomize-folder-editing.md. +// docs/layout/contextual-namespace.md. type kustomizationDoc struct { path string // kustomization file path (slash) namespace string // the namespace: transformer value diff --git a/internal/types/identifier.go b/internal/types/identifier.go index 03293c5e..90712392 100644 --- a/internal/types/identifier.go +++ b/internal/types/identifier.go @@ -87,7 +87,7 @@ func (r ResourceIdentifier) Key() string { // once any layout exists in the target, sibling inference follows it, and an // existing document is always edited in place at its current location (match-first), // so changing this shape never moves a file that is already in Git. See -// docs/spec/gittarget-new-file-placement-rules.md. +// docs/layout/new-file-placement-rules.md. // // That omitted version is the other half of the decision recorded at // [ResourceIdentifier.Key]: this versionless identity is the durable one — the object stays diff --git a/test/e2e/new_file_placement_e2e_test.go b/test/e2e/new_file_placement_e2e_test.go index a2683fa1..5d2f2e70 100644 --- a/test/e2e/new_file_placement_e2e_test.go +++ b/test/e2e/new_file_placement_e2e_test.go @@ -14,7 +14,7 @@ import ( // Validates new-file placement end-to-end: a brand-new resource with no // existing document in Git — the "install something extra in test" launch use -// case (docs/spec/gittarget-new-file-placement-rules.md, +// case (docs/layout/new-file-placement-rules.md, // docs/design/support-boundary/README.md) — lands inside the kustomize-managed overlay // directory it belongs to, not the canonical GVR-tree path, and the overlay's // kustomization.yaml gains the resources: entry so kustomize actually renders it.