# Execution: Split the provider blueprint into contract, appendix, template and index Plan: [docs/plans/2026-09-16-1108-provider-docs-split.md](../plans/2026-09-16-1108-provider-docs-split.md) - [x] Step 0 — Save and commit the plan - [x] Step 1 — Write `docs/provider-contract.md` - [x] Step 2 — Write `docs/provider-spec-template.md` - [x] Step 3 — Write `docs/providers-existing.md` - [x] Step 4 — Rewrite `docs/providers.md` as the index; link edits - [x] Step 5 — Verification sweep - [x] Step 6 — Final summary, CHANGELOG after confirmation ## Step 0 — Save and commit the plan Committed as `cf89ac4` on the existing `feat/provider-blueprint` branch, on its own, before any document work. The pre-existing local modification of `.claude/settings.json` was left out of the commit. ## Step 1 — Write `docs/provider-contract.md` 2026-09-16 11:17. Written from scratch rather than edited down from the old blueprint, so that nothing provider-specific could survive by accident. Every MUST names its caller by function (`reconcileManaged`, `replaceInstance`, `reconcileDelete`, `providerFailure`, `deletionFailure`, `Sweeper.sweep`, `Monitor.checkOnce`, `Monitor.run`, `registry.Build`) and states what breaks. **Axis codes frozen** (19, §4.1 to §4.19): `readiness`, `create-return`, `delete-return`, `ip-source`, `gc-mark`, `created-at`, `list-partial`, `quota`, `not-found`, `primary-key`, `provisioning-state`, `async-ops`, `secondary-resources`, `credentials`, `client-kind`, `timeouts`, `logging`, `child-spans`, `backend-test`. The appendix table and the template are keyed on these strings; the §4.20 summary table's last column gives the template entry number each axis maps to. **Two rules that were not in the old blueprint**, both found by reading the callers while verifying: - `Sweeper.sweep` loops providers sequentially in one goroutine, and no caller puts a deadline on the context (only `Monitor.checkOnce` uses `WithTimeout`). So a hung `ListByTag` stalls orphan GC for every provider. Recorded in §1.5 and the `timeouts` axis. - The Stopped branch of `reconcileManaged` calls `Delete` and requeues without clearing `ProviderID`, then deletes again on every poll until NotFound. A backend that reports a freshly created, never started instance as stopped would have it deleted right after creation. Recorded as a MUST in §2.2 and as the `provisioning-state` axis with three allowed profiles. Checks run on the draft (all clean): ```sh grep -niE "kubernetes|gcp|kvm|libvirt|squid|google|compute engine" docs/provider-contract.md grep -n "—" docs/provider-contract.md grep -nE '\.go:[0-9]+|:[0-9]+-[0-9]+' docs/provider-contract.md grep -ohE "(internal|cmd|config|docs|api|hack|test)/[A-Za-z0-9_./-]+" docs/provider-contract.md | sort -u | while read p; do test -e "$p" || echo "MISSING $p"; done ``` The path check reports three expected misses: `config/samples/proxy_.yaml` is a pattern, `docs/provider-spec-template.md` is written in Step 2, and `internal/provider/providertest` is a proposal named in §13. Worth noting: the contract refers to "the operator pod" and "the cluster's own API" rather than naming the platform, so that the abstractness grep stays meaningful. The `hack/providers-dev.yaml` rule is phrased as "only if the type needs no credentials" because that file exists for `make run-dev` without any Secret. ## Step 2 — Write `docs/provider-spec-template.md` 2026-09-16 11:28. Twenty-four entries in a fixed shape (Question, Allowed answers, Obligations, Answer, Evidence). Thirteen of them carry an axis code and their numbers are the ones the contract's §4.20 table points at; the other eleven (identity, placement, image, cloud-init, machine type, config block, Checker, concurrency, deployment, docs, accepted limits) are decisions the contract leaves open without being a profile axis. Entries 9, 13 and 15 carry an empty table to fill in (state mapping, error classification, config fields); entry 21 carries the contract §11 test list as checkboxes so a filled spec doubles as the test plan. Check that every axis code appears in the template: ```sh for c in $(grep -oE '^### 4\.[0-9]+ `[a-z-]+`' docs/provider-contract.md | grep -oE '`[a-z-]+`' | tr -d '`'); do grep -q "\`$c\`" docs/provider-spec-template.md || echo "template missing $c" done ``` Clean. Worth noting: the preamble allows "verify against the target version" only on an Evidence line, never as an Answer, because the whole point of the file is that the implementer does not guess. ## Step 3 — Write `docs/providers-existing.md` 2026-09-16 11:35. The profile table has exactly one row per contract axis code, checked by the same loop as Step 2 with a row count. Everything else comes from the old blueprint's §6, §7 and §9 plus a fresh read of the three packages, with every `file:line` replaced by a symbol name. Corrections to earlier notes applied while writing: kvm's Checker `Details` also carry `user` and `pool`; the kubernetes provider has no seam interface (`newWithClient` injects a `client.Client`, the fake client stands in); kvm's `classify` has an `isClassified` pass-through. Timeouts were checked per provider for the `timeouts` row: kubernetes sets no `rest.Config.Timeout`, gcp relies on SDK defaults, kvm bounds only the SSH handshake (`defaultHandshakeTimeout` in `dialSSH`), so all three are weaker than the contract's SHOULD and the table says so. Symbol check on the appendix (every backticked identifier grepped for in Go, Makefile and YAML sources): ```sh grep -ohE '`[A-Za-z_][A-Za-z0-9_.]*`' docs/providers-existing.md | tr -d '`' | sort -u | while read s; do grep -rqwF --include='*.go' --include='Makefile' --include='*.yaml' -- "$s" . || echo "$s" done ``` The residue is file names, dotted `Type.method` forms, the `timeouts` axis code, libvirt's `nostate`, and the API names `OwnerReference` and `rest.Config.Timeout`; nothing unexpected. Worth noting: matrix rows that the profile table now carries (Create non-blocking, GC marks, IP source, quota detection, Checker presence, credentials, logging, tracing, tests) were dropped from "Other differences" rather than kept twice. ## Step 4 — Rewrite `docs/providers.md` as the index; link edits 2026-09-16 11:40. `docs/providers.md` was rewritten in place (same path, so the inbound links from `README.md`, `CLAUDE.md`, `docs/architecture.md` and the historical plan files still resolve, and the pre-split blueprint stays reachable in the path's git history). The three inbound sentences were updated to name the new files: - `README.md` "### Adding a provider": the first sentence now names the contract, the template and the appendix, with `docs/providers.md` as the index; the "Start there" sentence is unchanged. - `CLAUDE.md` "## Architecture": the providers.md sentence now names all three files and the index. - `docs/architecture.md` components row "Provider contract": "rules in provider-contract.md, per-provider profile table in providers-existing.md". The historical files under `docs/plans/` and `docs/plans-executions/` keep their `providers.md` links on purpose. ## Step 5 — Verification sweep 2026-09-16 11:45. All ten checks from the plan, run against the working tree after Step 4; nothing needed fixing. Links (file targets and anchors) across the four new files plus `README.md`, `CLAUDE.md` and `docs/architecture.md`, with anchors compared against slugified headings of the target file: ```sh python3 - <<'PY' import re, pathlib files = ["docs/provider-contract.md","docs/provider-spec-template.md","docs/providers-existing.md","docs/providers.md","README.md","CLAUDE.md","docs/architecture.md"] def slug(h): h = re.sub(r'`','',h).strip().lower(); h = re.sub(r'[^\w\s-]','',h) return re.sub(r'\s+','-',h).strip('-') def headings(p): return {slug(m.group(2)) for m in (re.match(r'^(#{1,6})\s+(.*)$', l) for l in open(p)) if m} bad = 0 for f in files: for m in re.finditer(r'\]\(([^)\s]+)\)', open(f).read()): t = m.group(1) if t.startswith(('http://','https://','mailto:')): continue path, _, anchor = t.partition('#') target = pathlib.Path(f) if path == '' else pathlib.Path(f).parent/path if not target.exists(): print(f, "missing", t); bad += 1; continue if anchor and target.suffix == '.md' and anchor not in headings(target): print(f, "bad anchor", t); bad += 1 print("link problems:", bad) PY ``` Output: `link problems: 0`. Em-dashes, line references and the contract's abstractness: ```sh grep -n "—" docs/provider-contract.md docs/provider-spec-template.md docs/providers-existing.md docs/providers.md git diff 2d959af -- README.md CLAUDE.md docs/architecture.md | grep '^+' | grep "—" grep -nE '\.go:[0-9]+|:[0-9]+-[0-9]+' docs/provider-contract.md docs/provider-spec-template.md docs/providers-existing.md docs/providers.md grep -niE "kubernetes|gcp|kvm|libvirt|squid|google|compute engine" docs/provider-contract.md ``` The only em-dash hit is the pre-existing "Leader-elected" cell of the `architecture.md` components row, which shows as an added line because the row's last cell was edited; no new em-dash was written. The other three greps are empty. Nothing lost from the old blueprint's §9: the bold leads of every gap and proposal bullet in `git show 0bb699b:docs/providers.md` were looked up in the new files. The literal-prefix match reported 22 misses, all caused by rewording ("isn't" to "is not", bold removed), so each topic was re-checked by a distinctive phrase and all 22 are present in `providers-existing.md` or contract §13. Paths and symbols: every `path/...` token in the four files exists except the `config/samples/proxy_.yaml` pattern and the `internal/provider/providertest` proposal; every backticked identifier in the contract and template resolves in Go, Makefile or YAML sources except file names, dotted `Type.method` forms, profile names and the `NewWith...` placeholder. Axis alignment: the 19 axis codes from contract §4 each appear once as a row in the appendix table (six cells per row) and at least once in the template. `go build ./... && go test -short ./...`: all packages `ok`. ## Step 6 — Final summary The split is complete on `feat/provider-blueprint`: five commits after the plan commit, one per step. The CHANGELOG entry is deliberately not written yet; per `CLAUDE.md` it is added once the user confirms the documents read the way they want. Nothing has been pushed. Worth noting for whoever picks this up: - The contract's §4.20 table maps axis codes to template entry numbers; if entries are renumbered, that column must follow. - The `CHANGELOG.md` heading format in `CLAUDE.md` uses an em-dash; the "no em-dashes" rule applied to the four new documents only. - The stale `mock` comments in `internal/provider/provider.go`, `internal/provider/registry/registry.go` and `api/v1alpha1/proxy_types.go` are still listed as gaps, not fixed here. - The next planned use of these files is a Proxmox provider: copy the template to `docs/proxmox-provider-spec.md` and fill it in before any code.