Document · doc-0002

Wave operating model

This document carries only what is specific to this repository. The campaign model itself — run contract and run modes, the routing contract, authority and the thread pool, child lane briefs, external-contract freezing, the unattended blocker contract, the goal-file template and the pre-flight checklist — lives in the Agent fan-out protocol (canonical) document. Read that first; this one is the delta. Nothing here restates it, and nothing here that could be pasted into another project unchanged belongs here.

backlog doc list --plain shows both.

The one constraint that outranks everything

This repository is a portable public reference. It never touches a live Grafana Cloud stack, a live cluster, or any source environment. No lane may create, adopt, import, mutate or delete a real resource, and no lane may introduce a refused source-environment identifier. Permitted public shapes are stated below. Examples are inert by construction and stay that way.

The enforcement is scripts/public-release-scan.sh, and its blast radius is what makes this a standing constraint rather than a lint rule: it scans the working tree and every reachable Git revision. A banned literal that reaches a commit does not become clean when a later commit removes it — the scan still finds it in history, and the repository rules forbid rewriting published history. A single careless paste therefore permanently red-lights the gate.

The literals it rejects, verified in the scan source: the source customer identifier, the source API/domain identifier, the source account identifier, the proof-of-concept identifier, the source architecture acronym, Grafana Cloud and service-account token prefixes, private Tailscale hostnames, the local macOS home-directory path prefix, private-key PEM headers, AWS ARNs carrying an account ID, JWT-shaped values, kind: Secret documents, private-range IPv4 HTTP endpoints, the forbidden filenames (terraform.tfvars, .env, .envrc, config.json), and tracked archive, key container or database file extensions.

The frozen identifier boundary is deliberately narrower than a blanket slug prohibition. Stack slugs, regions and grafana.net hostnames are permitted. Bare numeric organisation, stack and account identifiers, private repository names outside the existing enumerated allowlist, and internal project or estate names are refused. The control for this class is unconditional and working-tree-only; it never iterates reachable history.

Three prior leak classes led to this boundary: GCV-0068 named a private repository outside that allowlist; GCV-0074 carried numeric stack and service-account identifiers; and GCV-0085 carried numeric organisation and stack identifiers, another private repository name and an internal project name. Those instances are already reachable in published history. History was not rewritten because rewriting a public repository to remove the prior instances was disproportionate; a history-reading pattern would otherwise leave the gate permanently red. The working tree was scrubbed, and this control rejects future instances while the existing history checks remain unchanged.

The trap that catches agents specifically: absolute local paths. Tooling instructions, hook tests, scratch notes and pasted command lines carry them by default, and the scan rejects the home path prefix case-sensitively. Anything committed here derives its paths — from git rev-parse --show-toplevel, from CLAUDE_PROJECT_DIR, or relatively. Never hard-code one. The reference hook test that this repository’s guard was copied from hard-coded them, and would have failed this repository’s own gate unchanged.

Live behavioural proof is out of scope, permanently

Every run’s report ends by listing live minting, adoption, migration and reconciliation as unproven. That wording is accurate and it is also misleading, because it reads like a backlog item. It is not one. The constraint above forbids the contact that would produce that proof, so no wave can close it and no wave should plan to. Treat it as settled, not as available work.

What stands in for it, and the limit of each, because a run that cannot tell these apart will over-claim:

The consequence for report writing: say “not exercised, and not exercisable here” rather than “unproven”, so the next run does not spend its judgement re-deciding this.

Every cluster tooling invocation runs against an explicitly empty or ephemeral kubeconfig. A bare kubectl inherits whatever context the machine happens to have current, and on a machine that administers real clusters that is a real cluster. Wave 9 crossed this: a lane ran kubectl apply --dry-run=client --validate=true with no context scoping, and --validate=true performs schema discovery, so read contact against the ambient cluster could not afterwards be ruled out. Nothing was mutated and nothing leaked, but the constraint was no longer provable, which is the whole value of having it. Scope the invocation (KUBECONFIG=/dev/null, or the envtest kubeconfig) or do not run it. A client-side dry run is not evidence of zero network contact.

Grafana Cloud only, deliberately

This vending machine supports Grafana Cloud stacks and will not support self-managed Grafana. Decided by the repository owner 2026-09-08. The consequence worth stating, because it removes a caveat that otherwise gets copied forward: preconditions that apply only to self-managed deployments are not constraints here. Grafana feature toggles are the live case — no provider resource sets a feature toggle on a Cloud stack, and several surveyed surfaces list two self-managed toggles as a precondition. Those rows are irrelevant to this repository and must not be recorded as blockers. Preview status is a separate question and remains a real caveat.

The gate

just check

One command, and it is the whole local gate: it runs scripts/validate.sh, byte for byte the same script the hosted Validate public reference workflow runs, covering the public-release scan, gofmt, go mod tidy with a git diff --exit-code on go.mod/go.sum, race-enabled tests with coverage, go vet, a YAML parse of every tracked YAML document, platform and environment Kustomize renders plus every discovered catalog base, recursive platform-manifest coverage, XRD/renderer registry agreement, emitted-kind activation coverage against the pinned provider CRD map, discovered signed-package/verifier digest agreement, and the exact ApplicationSet watch-path assertion. The admission harness installs every XRD at the pinned real API server, admits catalog examples, and proves fail-closed rules with create/update and weaken/admit/restore controls. Explicit SCIM null is admitted and persisted with its key present, then refused by the renderer with zero children; that reconcile-time boundary was accepted by the owner on 2026-09-08. definition_of_done in backlog/config.yml carries it plus the hosted run, so every task inherits both.

Discover the task surface rather than guessing it: just --list, just --dump --dump-format json, just --show <recipe>. Prefer just <recipe> over the underlying tool.

A green local gate is not sufficient for Done. The hosted Validate public reference workflow must pass on the completing commit, and its run ID belongs in the task’s final summary alongside the SHA — that is the convention the closed issue history already established and it is worth keeping.

Recurring defects in this codebase

These have each cost a real debugging cycle. Check them before writing composition code, not after.

Deterministic children need an explicit external name. Every resource whose import identity is derivable from the request renders crossplane.io/external-name — stacks by slug, folders as <uid>-folder, dashboards by uid, roles and whole-role assignments by role uid, provider options by provider name. Omit it and a non-destructive orphan-and-adopt transition tries to create a resource that already exists, which is precisely the failure mode the non-destructive management policies exist to prevent.

Provider-assigned IDs cannot be derived, only observed or inventoried. StackServiceAccount, AccessPolicy and Team identities are assigned by the provider. Guessing one is always wrong. renderTeamAccess reads status.atProvider.teamId from the observed resource and emits nothing at all until it is present (platform/function/roles.go:99, gated at :125 and :146) — that deliberate wait is the pattern to copy, not a missing case to fill in.

Role assignment items are three segments, not four. The external name is roleUID + ":team:" + teamID using the bare observed team ID. Passing the Team reference through instead yields an org-qualified value, and the provider then builds an invalid four-segment ID. Both call sites are in platform/function/roles.go; the contract is pinned by tests.

The pinned provider’s Role initializer errors when autoIncrementVersion is omitted, so Roles render it explicitly as false while continuing to omit the deprecated server-managed version field. This is a workaround against a specific pinned provider version — if the pin moves, re-check it rather than assuming it is still needed.

Every catalog directory must be a renderable Kustomize base with a README. A consumer applying Kustomize patches forces Kustomize rendering for every selected catalog path, so a directory without a kustomization.yaml fails before deployment even though it validates in isolation. This shipped once: examples/catalog/minimal lacked one and broke a consumer render. The gate now checks README and Kustomization coverage, discovers every catalog directory, checks all sibling manifest entries, and renders every directory. Adding a catalog directory requires no hand-kept render-list entry. A missing directory manifest or resource entry must fail the gate by path.

The ApplicationSet must watch only top-level enabled/*. scripts/validate.sh asserts the generator’s directories equals exactly [{path: enabled/*}]. enabled/ starts empty and inert; examples/ is never watched. Widening that glob is how inert examples become live requests.

Whole-set resources need exactly one declarative owner. Folder and dashboard ACLs and role assignments replace the entire set. Two owners silently fight. Any change that adds a second writer to one of these is a design error, not a merge conflict to resolve.

A fail-closed tightening on an ALREADY-RELEASED API is a breaking change, however safe it looks. Wave 11 added stackRef.name is immutable and repository.uid is immutable to GrafanaProvisioningRepository, which shipped mutable in v1.0.0 and v1.0.1, and graded it in its root-judgement record as “authorization-boundary hardening without changing intended valid inputs”. It does change them: an update that v1.0.1 accepted is now rejected. The same commit also moved required url, branch and path out of spec.repository into per-provider blocks, invalidating every existing request. Both landed under a plain feat: with no ! and no BREAKING CHANGE: footer, so release-please computed a minor bump for a major-shaped change and the changelog said only “Features”. The reviewer caught it while PR #34 was still unmerged, which is the only reason it was cheap.

Two rules follow. Before adding any CEL rule, required field or immutability constraint, check whether the kind is already releasedgit show <latest tag>:platform/apis/<file> is the whole check. If it is, the commit carries ! or a BREAKING CHANGE: footer and the change gets a migration section, and the delegated-authority prohibition on amending a seam frozen in an earlier wave applies to it: a released API is such a seam whether or not the goal names it in the standing list.

Never rename a Kubernetes child whose external identity is recovered from observed state. The on-call renderer names its integration child <name>-inbound-email and sets crossplane.io/external-name only from the observed value (platform/function/oncall.go:141, onCallExternalAnnotations at :180). Renaming the child drops the observed link, so the provider creates a second external resource and orphans the first. When a hard-coded literal becomes selectable, the child’s Kubernetes name and its forProvider display name stay exactly as they are for the pre-existing default; only newly selectable values get new identities.

The renderer config map is aliased, and copying it at a new seam loses writes silently. expiry.go:53 writes the expiry status into whatever config map the renderer was handed, and fn.go reads it back out of its own config reference to build the composite status. Those are the same map object, and serviceBootstrapConfig preserves that identity for every kind it does not rewrite. A new wrapper at that seam that rebuilds the map - the natural thing to write - drops the expiry status with no error anywhere: the render succeeds, the status is just missing. Four expiry tests are the only thing that catches it. Add config keys in place and return the same map; copy only to strip a key, as serviceBootstrapConfig does for its two kinds.

Lane conventions

Natural boundaries, each with a single owner per wave:

The escape hatch: a lane that needs a change inside another lane’s file stops and returns the exact edit as a blocker rather than making it. The wiring pass applies it. A lane that finds the gate already red on main before it starts also stops — it is not that lane’s failure to fix.

Lane-local validation, because the registries are wiring-pass owned. A lane cannot run just check meaningfully before its kustomization and activation-policy entries exist, so a lane’s own acceptance check is its package tests plus a YAML parse of the documents it wrote. One named gate owner runs just check after the wiring pass, against the integrated tree.

A shared prerequisite blocks its consumers, never unrelated work

A wave that builds one shared thing before fanning out — a test fixture, an extracted external contract, a frozen schema — must say which lanes consume it. A failure blocks those consumers while unrelated lanes continue. Before making that block a terminal park, the root applies the canonical resolution rule: investigate or delegate the unresolved prerequisite, repair within authority, and preserve required review and shared attempt limits. Park the affected consumers only when authorised feasible resolution is exhausted, required authority or evidence is unavailable, or an explicit stop limit is reached.

Wave 10 is the worked example and it cost a whole run. Its fixture pre-pass fed two of four lanes; the goal classified a pre-pass failure as a whole-run stop; the control failed; and the root correctly obeyed the goal and stopped, killing an independent five-line lane whose own dependency column said nothing. Zero entries landed. The root even noted the contradiction in its report and still stopped, which is the right behaviour — a lane brief that contradicts the goal is the root’s to repair, but an explicit whole-run stop rule is not something to reinterpret under pressure.

So the rule is the goal writer’s to get right, not the root’s to work around:

The provenance question it was reaching for has a real answer, and it is the shape to copy: all 42 pre-existing fixture CRDs are present in the cached package at the pinned digest and every one agrees exactly modulo that empty stanza. Agreement across 42 complete schemas is the evidence. One neighbour is not.

A name in a goal is frozen, not verified. Wave 10’s goal froze a provider kind as SecureValueV1Beta1 at group version .../v1beta1. The artefact says SecurevalueV1Beta1 — lowercase v — served at enterprise.grafana.m.crossplane.io/v1alpha1, because the V1Beta1 in the kind and plural is the Grafana app-platform resource version, not the CRD’s. Read every frozen GVK out of the pinned artefact before a lane codes against it; the registry test catches a wrong one, but only after a lane has burned a cycle on it.

The exclusive resource: function package publishing

Publishing the composition function is serialized, single-owner, and cannot run in parallel with anything that depends on it. The sequence is fixed: land the code change, let the publish workflow build and sign a multi-platform OCI index, verify the signature against the exact main-branch publish workflow identity, then pin the resulting immutable digest in platform/function/install.yaml in a follow-up commit. The digest appears twice in that file — the Cosign verification Job’s args and the Function package reference. Both move together. The verification Job uses a stable PreSync-hook name with BeforeHookCreation; it has no digest suffix to update.

Consequences worth stating because they have bitten: the digest cannot be known before the workflow runs, so a wave cannot pre-write it; the intermediate commit legitimately carries the previous digest; and a function behaviour change is not actually delivered until the pin moves, even though the gate is green.

Run-end against this tracker

Task state is the record, so the run’s closing terminal message is a covering note only — what this run learned that no single task captures. Nothing durable may live only there.

Writing the report is the last unit of work, not a reply to a request.

View the source file on GitHub