plan: route records — record vocabulary & reconcile convergence
Implements spec §3, §4 and §5 (rollout steps 1-2) in seven TDD tasks: record shape fields, routeFor as the single mint builder, Channel/ Environment convergence (#130), adopt-on-match, a dry-run pass, and the carlos route refusal that must ship in the same roll as the convergence. Plans 2 (console endpoint + CLI API mode) and 3 (carlos add record mode + fleet backfill) follow separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+1504
−0
docs/superpowers/plans/2026-08-10-route-records-vocabulary.md+1504 −0
diff --git a/docs/superpowers/plans/2026-08-10-route-records-vocabulary.md b/docs/superpowers/plans/2026-08-10-route-records-vocabulary.md| new file mode 100644 |
| index 000000000..e5234dbc7 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-10-route-records-vocabulary.md |
| @@ -0,0 +1,1504 @@ |
| +# Route Records: Record Vocabulary & Reconcile Convergence — Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Give `provision.Record` enough vocabulary to describe every route the platform actually runs, make `provision.Reconcile` converge and adopt against it, and stop `carlos route` writing the columns the reconciler now owns. |
| + |
| +**Architecture:** Six additive fields on the bucket record whose zero values reproduce today's mint byte-for-byte; a single `routeFor` builder shared by the mint path and the adoption match test; narrow-UPDATE convergence of `Channel` and `Environment` on records the pass owns; and a `recordOwnedRow` refusal in the CLI modelled exactly on the existing `platformCanaryOwnedRow` one. |
| + |
| +**Tech Stack:** Go, SQLite (`internal/registry`), S3/DirStore (`internal/release`), stdlib `testing`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-10-route-records-design.md` — this plan implements §3, §4 and §5 (the spec's rollout steps 1 and 2, which it calls one coherent PR). |
| + |
| +**Not in this plan** — each gets its own: |
| +- §6 (console `PUT` endpoint, rail moved console-side, §6.2's rail fix) and `carlos route` API mode → plan 2. |
| +- §7 (`carlos add` record mode, `-local` break-glass) and §10 step 5 (fleet backfill) → plan 3. |
| + |
| +## Global Constraints |
| + |
| +- `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean, every package, before any commit lands. |
| +- `gofmt -l` clean for every file touched. Three pre-existing unformatted files elsewhere are left alone. |
| +- Sandbox note: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. A "read-only file system" error from the toolchain is the tell that the sandbox needs disabling. |
| +- **Wire-shape changes are additive.** Live records exist in the flagship and tito production buckets. Anything written before this change must still parse and must still materialize the identical route. Task 1 pins this; do not weaken that test. |
| +- **The consumer list is everything that PARSES the shape**, not everything that calls the API. |
| +- Production channel names are `canary/*` and are pinned in `internal/release/laddercheck_test.go`. Nothing here narrows `release.ValidChannel`. |
| +- Any test that shells out to the carlos binary must build its env through `carlosTestEnv` and set `cmd.Dir` to a scratch dir. The `routeTestFixture` in `e2e_test.go` already does both — use it, do not hand-roll. |
| +- `GOOS=darwin` must still compile: `internal/host` links into the CLI that people run on Macs. CI runs a cross-compile; run one locally if you touch anything platform-shaped. |
| + |
| +--- |
| + |
| +### Task 1: Record grows the shape vocabulary |
| + |
| +**Files:** |
| +- Modify: `internal/provision/provision.go` (the `Record` struct at :29-42, and new helpers/validators beside `ValidHost` at :115) |
| +- Test: `internal/provision/provision_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from earlier tasks. |
| +- Produces, all in package `provision`: |
| + - `Record.Kind string`, `Record.Addr string`, `Record.Unit string`, `Record.Hibernate *bool`, `Record.Environment string`, `Record.StatusNamed bool` |
| + - `func (r Record) KindOrDefault() string` |
| + - `func (r Record) HibernateOrDefault() bool` |
| + - `func (r Record) DerivesPaths() bool` |
| + - `func ValidKind(kind string) bool` |
| + - `func ValidateShape(rec Record) error` |
| + - `func ValidRecordHost(rec Record, domain string) bool` |
| + |
| +- [ ] **Step 1: Write the failing wire-shape test** |
| + |
| +This is the test that protects live production records. Add to `internal/provision/provision_test.go`: |
| + |
| +```go |
| +// A schema-1 record exactly as the flagship and tito buckets hold it today: |
| +// none of the shape fields exist. Every one must default to what reconcile |
| +// hardcoded before they were introduced — a non-nil Hibernate here, or a |
| +// Kind that does not fall back to "instance", rewrites the fleet on the |
| +// first pass after deploy. This is the additive wire-shape rule stated as a |
| +// test; do not relax it. |
| +func TestRecordWireShapeIsAdditive(t *testing.T) { |
| + const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` + |
| + `"account":"bab","pool":"tito","region":"ie","channel":"stable",` + |
| + `"created_at":"2026-08-04T09:00:00Z"}` |
| + var rec Record |
| + if err := json.Unmarshal([]byte(legacy), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Kind != "" { |
| + t.Errorf("Kind = %q, want empty", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + t.Errorf("Addr = %q, want empty", rec.Addr) |
| + } |
| + if rec.Unit != "" { |
| + t.Errorf("Unit = %q, want empty", rec.Unit) |
| + } |
| + if rec.Environment != "" { |
| + t.Errorf("Environment = %q, want empty", rec.Environment) |
| + } |
| + if rec.StatusNamed { |
| + t.Error("StatusNamed = true, want false") |
| + } |
| + if rec.Hibernate != nil { |
| + t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate) |
| + } |
| + if got := rec.KindOrDefault(); got != "instance" { |
| + t.Errorf("KindOrDefault() = %q, want instance", got) |
| + } |
| + if !rec.DerivesPaths() { |
| + t.Error("DerivesPaths() = false, want true — a legacy record is a socket-backed instance") |
| + } |
| + if !rec.HibernateOrDefault() { |
| + t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today") |
| + } |
| +} |
| + |
| +// The console rewrites records (plan 2's PUT). A rewrite must not sprout |
| +// keys the reader before it did not know, so every added field is |
| +// omitempty and a record carrying none of them marshals to exactly what it |
| +// marshalled to before. |
| +func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) { |
| + body, err := json.Marshal(Record{ |
| + Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Pool: "tito", Region: "ie", Channel: "stable", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named"} { |
| + if strings.Contains(string(body), `"`+key+`"`) { |
| + t.Errorf("marshalled record carries %q when it was never set: %s", key, body) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add `encoding/json` and `strings` to that file's imports if absent. |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run 'TestRecordWireShape|TestRecordMarshal' -v` |
| + |
| +Expected: FAIL to compile — `rec.Kind undefined`, `rec.KindOrDefault undefined`. |
| + |
| +- [ ] **Step 3: Add the fields and helpers** |
| + |
| +In `internal/provision/provision.go`, extend the `Record` struct. Keep the existing fields exactly where they are and append: |
| + |
| +```go |
| + // --- shape fields (2026-08-10 route-records design §3) -------------- |
| + // Every one is omitempty with a zero value that reproduces the mint |
| + // reconcile hardcoded before they existed: a record written by an older |
| + // console still materializes a byte-identical route. |
| + |
| + // Kind is the route class: "instance" | "service" | "static". Empty |
| + // means "instance", which is what reconcile minted unconditionally |
| + // before this field existed. |
| + Kind string `json:"kind,omitempty"` |
| + // Addr is a TCP upstream as host:port, for whole-app tenants whose |
| + // process the platform does not spawn (kass, keymail, titogo, the |
| + // consoles). Set means the route proxies to it and derives no socket. |
| + Addr string `json:"addr,omitempty"` |
| + // Unit is the systemd unit that owns this route's process, so adoption |
| + // can cycle it (console.service — issue #110). See registry.Route.Unit |
| + // for why a typo here is a route whose adoption silently restarts |
| + // nothing; ValidateShape is the door that refuses one. |
| + Unit string `json:"unit,omitempty"` |
| + // Hibernate is a POINTER because absent and false are different facts: |
| + // every record already in a production bucket has no such key, and a |
| + // plain bool would read those as false and un-hibernate the fleet on |
| + // the first pass after deploy. nil = HibernateOrDefault's derivation. |
| + Hibernate *bool `json:"hibernate,omitempty"` |
| + // Environment is the named config bundle this route binds to ("" = the |
| + // app's default bundle alone). The second member-facing axis of |
| + // `carlos route`, and owned by this record for the same reason Channel |
| + // is (design §5). |
| + Environment string `json:"environment,omitempty"` |
| + // StatusNamed opts this app into being named on the public status page. |
| + StatusNamed bool `json:"status_named,omitempty"` |
| +``` |
| + |
| +Then, beside `ValidHost`: |
| + |
| +```go |
| +// KindOrDefault resolves the record's route class. "" means "instance" — |
| +// the class reconcile minted unconditionally before Kind existed, and so |
| +// the only value that keeps a legacy record materializing unchanged. |
| +func (r Record) KindOrDefault() string { |
| + if r.Kind == "" { |
| + return "instance" |
| + } |
| + return r.Kind |
| +} |
| + |
| +// DerivesPaths reports whether this record's route builds filesystem paths |
| +// out of its host — the <host>.sock and <host>.db segments. It is the hinge |
| +// two separate rules turn on: which host validator applies (ValidRecordHost: |
| +// a host that becomes a path segment stays suffix-pinned, one that does not |
| +// needs only a valid shape), and what hibernation defaults to (only a route |
| +// with a socket has a process for the activator to wake). |
| +func (r Record) DerivesPaths() bool { |
| + return r.KindOrDefault() == "instance" && r.Addr == "" |
| +} |
| + |
| +// HibernateOrDefault resolves the tri-state. An explicit value wins. Absent |
| +// derives: a socket-backed instance hibernates (the activator model, and |
| +// what reconcile minted unconditionally before this field existed, so every |
| +// legacy record is unchanged), and everything else does not — a static |
| +// route has no process to wake, and an addr-backed tenant is always on. |
| +func (r Record) HibernateOrDefault() bool { |
| + if r.Hibernate != nil { |
| + return *r.Hibernate |
| + } |
| + return r.DerivesPaths() |
| +} |
| + |
| +// ValidKind pins the record's route-class vocabulary to the set `carlos |
| +// add` documents. "status" is deliberately absent: the fleet status page's |
| +// route is the platform's own, not an app's, and nothing should be able to |
| +// mint one from an app-scoped record. |
| +func ValidKind(kind string) bool { |
| + switch kind { |
| + case "", "instance", "service", "static": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// ValidateShape checks the §3 fields at the door, before a record is written |
| +// or materialized. The rails live at the door, exactly as they do for |
| +// registry.SetCaps and SetChannel — Upsert validates no column at all, so a |
| +// value that reaches the row unvalidated is discovered months later as an |
| +// adopt pass that restarted nothing (the `-backing unti` incident, cli.go). |
| +func ValidateShape(rec Record) error { |
| + if !ValidKind(rec.Kind) { |
| + return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + h, p, err := net.SplitHostPort(rec.Addr) |
| + if err != nil || h == "" || p == "" { |
| + return fmt.Errorf("addr %q: want a TCP upstream as host:port (e.g. 127.0.0.1:8090)", rec.Addr) |
| + } |
| + } |
| + if rec.Unit != "" && !registry.ValidUnitName(rec.Unit) { |
| + return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service)", rec.Unit) |
| + } |
| + if rec.KindOrDefault() == "static" { |
| + if rec.Addr != "" { |
| + return errors.New("kind static with addr: a static route serves a file tree and proxies to nothing — drop one of the two") |
| + } |
| + if rec.Unit != "" { |
| + return errors.New("kind static with unit: a static route has no process for a unit to own — drop one of the two") |
| + } |
| + if rec.Hibernate != nil && *rec.Hibernate { |
| + return errors.New("kind static with hibernate: a static route has no process to wake, so arming the activator for it would wedge the host — drop hibernate") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ValidRecordHost applies the host rail this record actually needs (design |
| +// §3.2). ValidHost is path-traversal defence: it is what keeps a hostile |
| +// host from reaching the <host>.sock and <host>.db path segments as anything |
| +// but an opaque, safe component, and it stays exactly where that danger is. |
| +// A record that derives no filesystem path has no such exposure — its host |
| +// reaches a bucket key and the edge's routing table only — so it needs the |
| +// shape check alone, which is what lets an apex (carloku.com) or a console's |
| +// own hostname be expressed at all. |
| +func ValidRecordHost(rec Record, domain string) bool { |
| + if rec.DerivesPaths() { |
| + return ValidHost(rec.Host, domain) |
| + } |
| + return ValidHostShape(rec.Host) |
| +} |
| +``` |
| + |
| +Add `errors`, `net` and `github.com/carlosframework/platform/internal/registry` to the file's imports. `internal/registry` does not import `internal/provision`, so there is no cycle; `reconcile.go` in this same package already imports it. |
| + |
| +- [ ] **Step 4: Run the test to verify it passes** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run 'TestRecordWireShape|TestRecordMarshal' -v` |
| + |
| +Expected: PASS, both tests. |
| + |
| +- [ ] **Step 5: Write the validator tests** |
| + |
| +```go |
| +func TestValidKind(t *testing.T) { |
| + for _, k := range []string{"", "instance", "service", "static"} { |
| + if !ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = false, want true", k) |
| + } |
| + } |
| + // "status" is the platform's own status-page class, not an app's. |
| + for _, k := range []string{"status", "Instance", "unti", "instance "} { |
| + if ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = true, want false", k) |
| + } |
| + } |
| +} |
| + |
| +func TestValidateShape(t *testing.T) { |
| + yes := true |
| + base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Channel: "stable"} |
| + with := func(f func(*Record)) Record { |
| + r := base |
| + f(&r) |
| + return r |
| + } |
| + ok := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"legacy instance", base}, |
| + {"addr tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090" })}, |
| + {"unit-owned tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Unit = "console.service" })}, |
| + {"static", with(func(r *Record) { r.Kind = "static" })}, |
| + {"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })}, |
| + } |
| + for _, c := range ok { |
| + if err := ValidateShape(c.rec); err != nil { |
| + t.Errorf("%s: ValidateShape = %v, want nil", c.name, err) |
| + } |
| + } |
| + bad := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"bogus kind", with(func(r *Record) { r.Kind = "unti" })}, |
| + {"status kind", with(func(r *Record) { r.Kind = "status" })}, |
| + {"addr without port", with(func(r *Record) { r.Addr = "127.0.0.1" })}, |
| + {"unit typo", with(func(r *Record) { r.Unit = "console" })}, |
| + {"static with addr", with(func(r *Record) { r.Kind = "static"; r.Addr = "127.0.0.1:8090" })}, |
| + {"static with unit", with(func(r *Record) { r.Kind = "static"; r.Unit = "console.service" })}, |
| + {"static hibernating", with(func(r *Record) { r.Kind = "static"; r.Hibernate = &yes })}, |
| + } |
| + for _, c := range bad { |
| + if err := ValidateShape(c.rec); err == nil { |
| + t.Errorf("%s: ValidateShape = nil, want a refusal", c.name) |
| + } |
| + } |
| +} |
| + |
| +// Design §3.2: the suffix pin stays exactly where the traversal danger is, |
| +// and nowhere else. |
| +func TestValidRecordHostFollowsPathDerivation(t *testing.T) { |
| + socketBacked := Record{Host: "acme.go.tito.io"} |
| + if !ValidRecordHost(socketBacked, "go.tito.io") { |
| + t.Error("a socket-backed instance under its own domain was refused") |
| + } |
| + offDomain := Record{Host: "carloku.com"} |
| + if ValidRecordHost(offDomain, "go.tito.io") { |
| + t.Error("a socket-backed instance escaped its domain suffix — that is the traversal rail") |
| + } |
| + static := Record{Host: "carloku.com", Kind: "static"} |
| + if !ValidRecordHost(static, "go.tito.io") { |
| + t.Error("a static apex was refused; it derives no path and needs shape validation only") |
| + } |
| + addrBacked := Record{Host: "console.platform.tito.io", Addr: "127.0.0.1:8090"} |
| + if !ValidRecordHost(addrBacked, "go.tito.io") { |
| + t.Error("an addr-backed tenant off-domain was refused; it derives no path") |
| + } |
| + hostile := Record{Host: "../../etc/passwd", Kind: "static"} |
| + if ValidRecordHost(hostile, "go.tito.io") { |
| + t.Error("a hostile host passed the shape check") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run them** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1` |
| + |
| +Expected: PASS, including every pre-existing test in the package unchanged. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/provision.go internal/provision/provision_test.go |
| +git commit -m "provision: record grows a route-shape vocabulary (additive) |
| + |
| +Kind/Addr/Unit/Hibernate/Environment/StatusNamed, every one omitempty with |
| +a zero value that reproduces today's mint. Hibernate is a pointer because |
| +absent and false are different facts: a plain bool would read every record |
| +already in a production bucket as false and un-hibernate the fleet. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §3" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Reconcile mints every shape |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (the `!ok` mint branch at :224-241) |
| +- Test: `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Record.KindOrDefault`, `Record.HibernateOrDefault`, `Record.DerivesPaths` (Task 1). |
| +- Produces: `func routeFor(rec Record, dataDir, seed string) registry.Route` — the single builder for "what route does this record describe". Task 4's match test compares against it, so it must stay the only place that answer is computed. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Add to `internal/provision/reconcile_test.go`: |
| + |
| +```go |
| +// Every shape the record can now describe, minted in one pass. The three |
| +// cases are the production routes the old vocabulary could not express: |
| +// a console (addr-backed, unit-owned, never hibernating), a whole-app TCP |
| +// tenant, and a static site. |
| +func TestReconcileMintsEveryShape(t *testing.T) { |
| + s, sqid := setup(t) |
| + off := false |
| + recs := []Record{ |
| + {Schema: 1, Host: "console.go.tito.io", Slug: "console", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Kind: "instance", |
| + Addr: "127.0.0.1:8090", Unit: "console.service", CreatedAt: time.Now()}, |
| + {Schema: 1, Host: "whole.go.tito.io", Slug: "whole", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Addr: "127.0.0.1:9443", |
| + Hibernate: &off, CreatedAt: time.Now()}, |
| + {Schema: 1, Host: "site.go.tito.io", Slug: "site", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Kind: "static", |
| + StatusNamed: true, CreatedAt: time.Now()}, |
| + } |
| + for _, rec := range recs { |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + console := *mustGet(t, reg, "console.go.tito.io") |
| + console.CreatedAt = 0 |
| + wantConsole := registry.Route{ |
| + Host: "console.go.tito.io", Slug: "console", Kind: "instance", |
| + Addr: "127.0.0.1:8090", Unit: "console.service", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", |
| + Provisioned: true, Seed: `["seed"]`, Status: "ready", |
| + } |
| + if console != wantConsole { |
| + t.Errorf("console route = %+v, want %+v", console, wantConsole) |
| + } |
| + |
| + whole := *mustGet(t, reg, "whole.go.tito.io") |
| + if whole.Socket != "" || whole.DBPath != "" { |
| + t.Errorf("an addr-backed tenant derived filesystem paths: socket %q db %q", whole.Socket, whole.DBPath) |
| + } |
| + if whole.Hibernate { |
| + t.Error("an explicitly non-hibernating record minted a hibernating route") |
| + } |
| + |
| + site := *mustGet(t, reg, "site.go.tito.io") |
| + if site.Kind != "static" { |
| + t.Errorf("static route Kind = %q", site.Kind) |
| + } |
| + if site.Socket != "" || site.DBPath != "" { |
| + t.Errorf("a static route derived filesystem paths: socket %q db %q", site.Socket, site.DBPath) |
| + } |
| + if site.Hibernate { |
| + t.Error("a static route was armed for hibernation — it has no process to wake") |
| + } |
| + if !site.StatusNamed { |
| + t.Error("StatusNamed did not reach the row") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run TestReconcileMintsEveryShape -v` |
| + |
| +Expected: FAIL — the minted routes carry `Socket`/`DBPath` for every case and ignore `Addr`, `Unit`, `Kind`, `StatusNamed`. |
| + |
| +- [ ] **Step 3: Extract `routeFor` and use it in the mint branch** |
| + |
| +Add to `internal/provision/reconcile.go`, above `Reconcile`: |
| + |
| +```go |
| +// routeFor is the one answer to "what route does this record describe". The |
| +// mint path builds from it and the adoption match test (design §4.2) |
| +// compares against it — one function, so a shape the reconciler would mint |
| +// and a shape it will adopt can never drift apart. |
| +// |
| +// Socket and DBPath are DERIVED, never declared: DefaultSocket and |
| +// DefaultDBPath exist so a hand-added route and a provisioned one land on |
| +// identical path shapes, and their doc comment records what two names for |
| +// one database cost. |
| +func routeFor(rec Record, dataDir, seed string) registry.Route { |
| + rt := registry.Route{ |
| + Host: rec.Host, Slug: rec.Slug, Kind: rec.KindOrDefault(), |
| + Addr: rec.Addr, Unit: rec.Unit, |
| + App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| + Channel: rec.Channel, Environment: rec.Environment, |
| + Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamed, |
| + Provisioned: true, Seed: seed, Status: "ready", |
| + } |
| + if rec.DerivesPaths() { |
| + rt.Socket = DefaultSocket(rec.Host) |
| + rt.DBPath = DefaultDBPath(dataDir, rec.Host) |
| + } |
| + return rt |
| +} |
| +``` |
| + |
| +Replace the `case !ok:` body's route literal with it. The branch becomes: |
| + |
| +```go |
| + case !ok: |
| + // Hibernate and the path derivation now come from the record |
| + // (routeFor); everything else about this branch is unchanged. |
| + nr := routeFor(rec, cfg.DataDir, seed) |
| + if err := reg.Upsert(nr); err != nil { |
| + log.Printf("provision: mint %s: %v", rec.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: minted %s route %s (app %q acct %q)", nr.Kind, rec.Host, rec.App, rec.Account) |
| +``` |
| + |
| +- [ ] **Step 4: Run the whole package** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1 -v` |
| + |
| +Expected: PASS, and **`TestReconcileMints` must pass unchanged** — that existing test pins the legacy mint field-by-field and is the second half of the wire-shape guarantee. If it fails, `routeFor` has drifted; fix `routeFor`, never the test. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: reconcile mints every shape the record describes |
| + |
| +routeFor is now the single builder for what a record materializes; the |
| +adoption match test compares against it, so a shape the pass would mint and |
| +one it will adopt cannot drift apart. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §4" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Reconcile converges Channel and Environment (#130) |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (the `existing.Provisioned && …` converge branch at :245-249) |
| +- Test: `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `routeFor` (Task 2). |
| +- Produces: no new exported names. Uses `registry.SetChannel(host, channel)`, `registry.SetEnvironment(host, env)` and `registry.SetHibernate(host, on)`, all of which exist. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// Issue #130, as it happened in production on 2026-08-09: a record created |
| +// on stable, deleted and recreated on edge, and the route row left on |
| +// stable — a route pointing at a channel with no pointer, so every wake |
| +// failed "no adoptable head" while carlos channels cheerfully showed edge |
| +// promoted. Reconcile created from a record and never converged to one. |
| +func TestReconcileConvergesChannel(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + |
| + rec.Channel = "edge" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — the record moved and the row did not", after.Channel) |
| + } |
| + after.Channel = before.Channel |
| + if after != before { |
| + t.Errorf("converge touched more than Channel: before %+v after %+v", before, after) |
| + } |
| + |
| + // The fleet runs on canary/* channels today (pinned in |
| + // internal/release/laddercheck_test.go); a converge that could not carry |
| + // one would stop deploys. |
| + rec.Channel = "canary/rehearsal" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Channel; got != "canary/rehearsal" { |
| + t.Errorf("channel = %q, want canary/rehearsal", got) |
| + } |
| +} |
| + |
| +func TestReconcileConvergesEnvironment(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rec.Environment = "staging" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Environment; got != "staging" { |
| + t.Errorf("environment = %q, want staging", got) |
| + } |
| + |
| + // Clearing must reach the row too. Upsert deliberately PRESERVES a |
| + // non-empty environment (it cannot tell "untouched" from "cleared"), so |
| + // the clear has to go through the narrow setter — which is exactly why |
| + // this converge does not re-Upsert. |
| + rec.Environment = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Environment; got != "" { |
| + t.Errorf("environment = %q, want it cleared", got) |
| + } |
| +} |
| + |
| +// An explicit Hibernate converges; an absent one never does. Absent means |
| +// "the record does not speak to this", and a pass that stamped its |
| +// derivation over an operator's deliberate SetHibernate would be the silent |
| +// stomp this design exists to end. |
| +func TestReconcileConvergesHibernateOnlyWhenExplicit(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetHibernate("acme.go.tito.io", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mustGet(t, reg, "acme.go.tito.io").Hibernate { |
| + t.Error("an absent Hibernate re-asserted its derivation over an operator's setting") |
| + } |
| + |
| + on := true |
| + rec.Hibernate = &on |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !mustGet(t, reg, "acme.go.tito.io").Hibernate { |
| + t.Error("an explicit Hibernate did not converge") |
| + } |
| +} |
| +``` |
| + |
| +Add the helper the three tests share, beside `putReplace`: |
| + |
| +```go |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run TestReconcileConverges -v` |
| + |
| +Expected: FAIL — `channel = "stable", want edge`, and the environment and hibernate equivalents. |
| + |
| +- [ ] **Step 3: Converge in the owned branch** |
| + |
| +Replace the `case existing.Provisioned && …` body. Keep the existing seed converge exactly as it is and add the three columns before it: |
| + |
| +```go |
| + case existing.Provisioned && existing.App == rec.App && existing.Account == rec.Account: |
| + // The columns this record owns (design §4.1, issue #130). Narrow |
| + // UPDATEs, never a re-Upsert: a read-modify-write racing a |
| + // deploy's SetVersions or the activator's SetNextDue would |
| + // clobber those, and Upsert cannot express "clear Environment" |
| + // at all (it preserves a non-empty value by design). |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } |
| + } |
| + if existing.Environment != rec.Environment { |
| + if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil { |
| + log.Printf("provision: converge environment on %s: %v", rec.Host, err) |
| + } |
| + } |
| + // Absent means "this record does not speak to hibernation" — |
| + // never a derivation stamped over an operator's own setting. |
| + if rec.Hibernate != nil && existing.Hibernate != *rec.Hibernate { |
| + if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil { |
| + log.Printf("provision: converge hibernate on %s: %v", rec.Host, err) |
| + } |
| + } |
| + // seedIsKnown guards against stamping a transient manifest-read |
| + // failure's "" fallback over a working seed — see seedFor. |
| + if seedIsKnown && existing.Seed != seed { |
| + if err := reg.SetSeed(rec.Host, seed); err != nil { |
| + log.Printf("provision: converge seed on %s: %v", rec.Host, err) |
| + } |
| + } |
| +``` |
| + |
| +Note the `rec.Channel != ""` guard: an empty channel would pin the route to nothing, and the create door already refuses one. A malformed record that lost its channel must leave the row alone, not blank it. |
| + |
| +**Deliberately not converged here:** `Kind`, `Addr`, `Unit`, `Slug`, `Pool`, `Region`. A record that changes those describes a structurally different route, and converging them in place would mean re-deriving socket and DB paths under a running process. Log it and leave it — add, immediately after the seed converge: |
| + |
| +```go |
| + if want := routeFor(rec, cfg.DataDir, seed); want.Kind != existing.Kind || |
| + want.Addr != existing.Addr || want.Unit != existing.Unit || |
| + want.Socket != existing.Socket || want.DBPath != existing.DBPath { |
| + log.Printf("provision: %s: record describes a different route SHAPE than the row "+ |
| + "(kind %q/%q addr %q/%q unit %q/%q) — not converged in place; `carlos remove %s` and let the next pass re-mint", |
| + rec.Host, want.Kind, existing.Kind, want.Addr, existing.Addr, want.Unit, existing.Unit, rec.Host) |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1` |
| + |
| +Expected: PASS, whole package. `TestReconcileConvergesSeed` must still pass — it asserts the converge touches *only* Seed, and it uses a record whose channel and environment never move, so it still holds. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: reconcile converges Channel, Environment and explicit Hibernate |
| + |
| +Closes the divergence half of #130: a record whose channel moved now moves |
| +the route row, so the member-visible state and the box state cannot |
| +disagree about a column the bucket owns. |
| + |
| +Shape changes (kind/addr/unit) are logged, not converged in place — that |
| +would re-derive socket and DB paths under a running process. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §4.1" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Reconcile adopts a matching route |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` (new `SetProvisioned`, beside `SetSeed` at :1062) |
| +- Modify: `internal/provision/reconcile.go` (the `default:` branch at :250-253) |
| +- Test: `internal/registry/registry_test.go`, `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `routeFor` (Task 2), the converge branch (Task 3). |
| +- Produces: |
| + - `func (r *Registry) SetProvisioned(host string, on bool) error` |
| + - `func shapeMatches(existing registry.Route, want registry.Route) bool` (package-private, `internal/provision`) |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `internal/provision/reconcile_test.go`: |
| + |
| +```go |
| +// Design §4.2 — the payoff. Backfilling the fleet is a pure bucket-side |
| +// write per route: describe what is already there, and the next serial bump |
| +// adopts it. No carlos add, no SSM session, no on-box verb. |
| +func TestReconcileAdoptsMatchingHandAddedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + rec.Channel = "edge" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The route as an operator hand-added it before records existed: the |
| + // same shape, a different channel, and no Provisioned flag. |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := *mustGet(t, reg, "acme.go.tito.io") |
| + if !got.Provisioned { |
| + t.Fatal("a matching hand-added route was not adopted") |
| + } |
| + if got.Channel != "edge" { |
| + t.Errorf("channel = %q, want edge — adoption should converge in the same pass", got.Channel) |
| + } |
| +} |
| + |
| +// The match test EXCLUDES the three columns the record is taking ownership |
| +// of. Requiring them to agree first would make adoption impossible in |
| +// exactly the case it exists for — a hand-added route on the wrong channel |
| +// is the whole reason to adopt it. |
| +func TestReconcileAdoptionIgnoresOwnedColumns(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + rec.Channel = "edge" |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "canary/rehearsal", Environment: "staging", Hibernate: false, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !mustGet(t, reg, "acme.go.tito.io").Provisioned { |
| + t.Error("adoption refused over a Channel/Environment/Hibernate difference") |
| + } |
| +} |
| + |
| +// A shape that differs is left exactly as alone as it is today. This is the |
| +// guard that stands between "adopt on match" and reconcile's removal phase |
| +// acquiring authority over a route it does not describe. |
| +func TestReconcileRefusesToAdoptMismatchedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + mismatched := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "other", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, mismatched) |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after != before { |
| + t.Errorf("a mismatched route was touched: before %+v after %+v", before, after) |
| + } |
| + if after.Provisioned { |
| + t.Error("a mismatched route was adopted — removal authority granted over a route the record does not describe") |
| + } |
| +} |
| + |
| +// An adopted route is removable by the pass that adopted it, on the same |
| +// terms as one it minted: the record disappearing is the bucket's own |
| +// decision to retire the instance. |
| +func TestReconcileRemovesAdoptedRouteWhenRecordGoes(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := DeleteRecord(ctx, s, sqid, "tito", "acme.go.tito.io"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := reg.Get("acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt != nil { |
| + t.Errorf("adopted route survived its record's deletion: %+v", rt) |
| + } |
| +} |
| + |
| +// The other side of that coin, and the one that matters more: a route no |
| +// record ever described is never removed, no matter how many passes run. |
| +// Adoption is the ONLY way this pass acquires removal authority, and this |
| +// test is what stands between a backfill and a retired production route. |
| +func TestReconcileNeverRemovesUnadoptedRoute(t *testing.T) { |
| + s, sqid := setup(t) |
| + // A record for some OTHER host, so the pass runs a full removal phase |
| + // with a non-empty record set rather than bailing early. |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + handAdded := registry.Route{ |
| + Host: "console.go.tito.io", Kind: "instance", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Channel: "stable", Addr: "127.0.0.1:8090", |
| + Unit: "console.service", Status: "ready", |
| + } |
| + reg := regWith(t, handAdded) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + for i := 0; i < 3; i++ { |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + got, err := reg.Get("console.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatal("a hand-added route with no record was removed") |
| + } |
| + if got.Provisioned { |
| + t.Error("a hand-added route with no record was adopted") |
| + } |
| + if *got != handAdded { |
| + t.Errorf("a hand-added route with no record was modified: %+v", got) |
| + } |
| +} |
| +``` |
| + |
| +The final `*got != handAdded` comparison will need `handAdded.CreatedAt` filled from the row before comparing (Upsert stamps it) — read it back once after `regWith` and assign, exactly as `TestReconcileMints` zeroes `CreatedAt` for the same reason. |
| + |
| +In `internal/registry/registry_test.go`: |
| + |
| +```go |
| +func TestSetProvisioned(t *testing.T) { |
| + r := openTestRegistry(t) |
| + if err := r.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "x", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SetProvisioned("a.example.com", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := r.Get("a.example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !rt.Provisioned { |
| + t.Error("SetProvisioned(true) did not stamp the column") |
| + } |
| + if err := r.SetProvisioned("missing.example.com", true); err == nil { |
| + t.Error("SetProvisioned on an unknown host returned nil; want an error (the SetChannel contract)") |
| + } |
| +} |
| +``` |
| + |
| +`openTestRegistry` is the helper that file already defines at `internal/registry/registry_test.go:263`; use it, do not add another. |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ ./internal/registry/ -run 'Adopt|SetProvisioned|RemovesAdopted' -v` |
| + |
| +Expected: FAIL — `SetProvisioned undefined`, and the adoption tests report the route untouched. |
| + |
| +- [ ] **Step 3: Add `SetProvisioned`** |
| + |
| +In `internal/registry/registry.go`, beside `SetSeed`: |
| + |
| +```go |
| +// SetProvisioned stamps (or clears) the flag that marks a route as the |
| +// instance-provisioning reconcile pass's own — a narrow UPDATE, same |
| +// rationale as SetSeed. It errors when no row matched, the SetChannel |
| +// contract: the pass must notice a row that vanished under it rather than |
| +// silently doing nothing. |
| +// |
| +// This flag is an authority transfer, not a label: it is the single |
| +// condition the reconcile pass's removal phase keys on. Only that pass's |
| +// adoption step (provision.Reconcile, design §4.2) should ever set it, and |
| +// it does so only after matching the row's shape against the record. |
| +func (r *Registry) SetProvisioned(host string, on bool) error { |
| + v := 0 |
| + if on { |
| + v = 1 |
| + } |
| + res, err := r.sql.Exec(`UPDATE routes SET provisioned = ? WHERE host = ?`, v, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Add `shapeMatches` and the adoption branch** |
| + |
| +In `internal/provision/reconcile.go`, beside `routeFor`: |
| + |
| +```go |
| +// shapeMatches reports whether an existing route is the same ROUTE the |
| +// record describes — the gate on adoption (design §4.2), and so the gate on |
| +// this pass acquiring removal authority over a row it did not mint. |
| +// |
| +// Channel, Environment and Hibernate are deliberately absent: those are the |
| +// columns the record is taking ownership OF, and a hand-added route on the |
| +// wrong channel is the whole reason to adopt one. Requiring them to agree |
| +// would make adoption impossible in exactly the case it exists for. |
| +// |
| +// Everything else that identifies the route is compared, including the |
| +// derived Socket and DBPath — two rows agreeing on kind and app but not on |
| +// where the database lives are not the same route, and adopting across that |
| +// difference would point a running process at a second copy of its own data. |
| +func shapeMatches(existing, want registry.Route) bool { |
| + return existing.App == want.App && |
| + existing.Account == want.Account && |
| + existing.Kind == want.Kind && |
| + existing.Addr == want.Addr && |
| + existing.Unit == want.Unit && |
| + existing.Slug == want.Slug && |
| + existing.Pool == want.Pool && |
| + existing.Region == want.Region && |
| + existing.Socket == want.Socket && |
| + existing.DBPath == want.DBPath |
| +} |
| +``` |
| + |
| +Replace the `default:` branch: |
| + |
| +```go |
| + default: |
| + // A route exists that this pass did not mint. If it is the same |
| + // route the record describes, adopt it — that is what makes |
| + // backfilling the fleet a bucket-side write rather than a box |
| + // session (design §4.2). If it is not, leave it exactly as alone |
| + // as before: adoption grants this pass's removal phase authority |
| + // over the row, so the match test is the only thing standing |
| + // between a backfill and a retired production route. |
| + want := routeFor(rec, cfg.DataDir, seed) |
| + if !shapeMatches(existing, want) { |
| + log.Printf("provision: %s exists but is not the route this record describes "+ |
| + "(app %q/%q kind %q/%q addr %q/%q) — leaving it alone", |
| + rec.Host, existing.App, want.App, existing.Kind, want.Kind, existing.Addr, want.Addr) |
| + continue |
| + } |
| + if err := reg.SetProvisioned(rec.Host, true); err != nil { |
| + log.Printf("provision: adopt %s: %v", rec.Host, err) |
| + continue |
| + } |
| + log.Printf("provision: adopted existing route %s (app %q acct %q) — this record now owns its channel and environment", |
| + rec.Host, rec.App, rec.Account) |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on newly adopted %s: %v", rec.Host, err) |
| + } |
| + } |
| + if existing.Environment != rec.Environment { |
| + if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil { |
| + log.Printf("provision: converge environment on newly adopted %s: %v", rec.Host, err) |
| + } |
| + } |
| +``` |
| + |
| +Hibernate is deliberately NOT converged at adoption even when explicit: the first pass over a hand-added route should change what it serves and who owns the column, not whether a live process is allowed to sleep. It converges on the next pass through the owned branch (Task 3), by which point the adoption is visible in the log. |
| + |
| +- [ ] **Step 5: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ ./internal/registry/ -count=1` |
| + |
| +Expected: PASS. `TestReconcileLeavesConflictAlone` must still pass — its hand-added route has `App: "other"` and `Addr: "127.0.0.1:9000"`, so `shapeMatches` is false and the route stays untouched. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ internal/registry/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/registry/registry.go internal/registry/registry_test.go internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: adopt an existing route whose shape matches an appearing record |
| + |
| +Backfilling the fleet becomes a pure bucket-side write per route — describe |
| +what is already there and the next serial bump adopts it, with no box |
| +commands. The match test excludes Channel/Environment/Hibernate, which are |
| +the columns the record is taking ownership of. |
| + |
| +Adoption grants the removal phase authority over the row, so shapeMatches |
| +compares the derived Socket and DBPath too. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §4.2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Reconcile dry-run |
| + |
| +**Files:** |
| +- Modify: `internal/provision/reconcile.go` (`ReconcileConfig`, and every write site) |
| +- Test: `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: everything from Tasks 2-4. |
| +- Produces: `ReconcileConfig.DryRun bool`. |
| + |
| +**Scope note:** §4.3 also asks for a tool that prints the record it *would write* for each existing route. That is record generation from routes — a backfill authoring aid, not a reconcile concern — and it belongs with plan 3's backfill along with the operator-facing verb that invokes it. What this task delivers is the half that lives here: a pass that reports what it would do and writes nothing. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// A dry-run pass reports and writes nothing — the mitigation that lets a |
| +// backfill be checked before adoption grants the removal phase authority |
| +// over a production route (design §4.3). |
| +func TestReconcileDryRunWritesNothing(t *testing.T) { |
| + s, sqid := setup(t) |
| + if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // One route to adopt, one record to mint, one route to remove. |
| + adoptable := registry.Route{ |
| + Host: "acme.go.tito.io", Slug: "acme", Kind: "instance", |
| + Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db", |
| + App: "tito", Account: sqid, Pool: "tito", Region: "ie", |
| + Channel: "stable", Hibernate: true, Status: "ready", |
| + } |
| + retired := registry.Route{ |
| + Host: "gone.go.tito.io", Kind: "instance", App: "tito", Account: sqid, |
| + Pool: "tito", Region: "ie", Status: "ready", Provisioned: true, |
| + } |
| + reg := regWith(t, adoptable, retired) |
| + before, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data", DryRun: true} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(before, after) { |
| + t.Errorf("dry-run wrote to the registry:\nbefore %+v\nafter %+v", before, after) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -run TestReconcileDryRun -v` |
| + |
| +Expected: FAIL to compile — `unknown field DryRun`. |
| + |
| +- [ ] **Step 3: Add the field and guard every write** |
| + |
| +In `ReconcileConfig`: |
| + |
| +```go |
| + // DryRun reports what the pass would do and writes nothing — not to the |
| + // registry, not anywhere. The backfill mitigation (design §4.3): a |
| + // production route is adopted only after someone has read what adopting |
| + // it would mean, because adoption is what grants the removal phase |
| + // authority over that row. |
| + DryRun bool |
| +``` |
| + |
| +Guard all five write sites. Each keeps its log line, prefixed, and skips its write: |
| + |
| +```go |
| + case !ok: |
| + nr := routeFor(rec, cfg.DataDir, seed) |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would mint %s route %s (app %q acct %q channel %q)", |
| + nr.Kind, rec.Host, rec.App, rec.Account, rec.Channel) |
| + continue |
| + } |
| +``` |
| + |
| +Converge (Task 3), each of the three: |
| + |
| +```go |
| + if rec.Channel != "" && existing.Channel != rec.Channel { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } else if err := reg.SetChannel(rec.Host, rec.Channel); err != nil { |
| + log.Printf("provision: converge channel on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel) |
| + } |
| + } |
| +``` |
| + |
| +…and the same shape for Environment, Hibernate and Seed. |
| + |
| +Adoption (Task 4): |
| + |
| +```go |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would adopt existing route %s (app %q acct %q), converging channel %s -> %s", |
| + rec.Host, rec.App, rec.Account, existing.Channel, rec.Channel) |
| + continue |
| + } |
| +``` |
| + |
| +Removal: |
| + |
| +```go |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would remove retired instance route %s (record gone)", rt.Host) |
| + continue |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/provision/ -count=1` |
| + |
| +Expected: PASS, whole package. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +gofmt -l internal/provision/ |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +git add internal/provision/reconcile.go internal/provision/reconcile_test.go |
| +git commit -m "provision: ReconcileConfig.DryRun reports the pass and writes nothing |
| + |
| +The backfill mitigation from design §4.3: a production route is adopted |
| +only after someone has read what adopting it would mean, because adoption |
| +is what grants the removal phase authority over that row." |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: `carlos route` refuses the columns the record now owns |
| + |
| +**Files:** |
| +- Modify: `cli.go` (`platformCanaryOwnedRow` at :1006-1020; the `verdictKind` const block at :690-698; the channel switch at :715-728; the `envVerdictKind` const block at :774-782; the environment switch at :805-818; the refusal print loops at :855-880) |
| +- Test: `e2e_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.Route.Provisioned` (already exists). |
| +- Produces: `func recordOwnedRow(rt registry.Route) bool` in `package main`. |
| + |
| +**Why this ships in the same commit range as Tasks 3-4:** a `Provisioned` route whose channel the CLI will still happily write is precisely the window where the silent stomp is possible — the operator's write lands, and the next serial bump reverts it with no message anywhere. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `e2e_test.go`, beside `TestRoutePlatformCanaryNeverLeavesCanary`: |
| + |
| +```go |
| +// Design §5: once a route is Provisioned, its Channel and Environment are |
| +// the instance record's. A write here lands and is reverted within seconds |
| +// of the next serial bump, silently — the same class of bug as #130, |
| +// arriving from the other side. The refusal names the record as the door, |
| +// and no flag overrides it: -allow-version-change answers "what would this |
| +// change", not "who owns this column". |
| +func TestRouteChannelRefusedOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "site", "v1") |
| + f.promote(release.OpsAccount, "site", "stable", "v1") |
| + f.promote(release.OpsAccount, "site", "canary/preview", "v1") |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "canary/preview", Status: "ready", Provisioned: true}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-channel", "stable", "-allow-version-change") |
| + if err == nil { |
| + t.Fatalf("route -channel on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if !strings.Contains(out, "record") { |
| + t.Errorf("refusal does not name the instance record as the door:\n%s", out) |
| + } |
| + if got := f.channelOf("acme.go.tito.io"); got != "canary/preview" { |
| + t.Fatalf("channel = %q, want it unwritten after the refusal", got) |
| + } |
| +} |
| + |
| +func TestRouteEnvironmentRefusedOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.ship(release.OpsAccount, "site", "v1") |
| + f.promote(release.OpsAccount, "site", "stable", "v1") |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true}) |
| + f.saveEnvironment(release.OpsAccount, "site", "demo") |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-environment", "demo") |
| + if err == nil { |
| + t.Fatalf("route -environment on a Provisioned route was accepted:\n%s", out) |
| + } |
| + if got := f.environmentOf("acme.go.tito.io"); got != "" { |
| + t.Fatalf("environment = %q, want it unwritten after the refusal", got) |
| + } |
| + // Clearing is equally not this verb's to do — the refusal is on the ROW, |
| + // not on one direction of travel, exactly as the Platform+Canary one is. |
| + f.addRoute(registry.Route{Host: "bound.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true, Environment: "demo"}) |
| + out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "bound.go.tito.io", "-environment", "-") |
| + if err == nil { |
| + t.Fatalf("clearing an environment on a Provisioned route was accepted:\n%s", out) |
| + } |
| +} |
| + |
| +// The box-scoped axes are untouched: they name resources that exist on one |
| +// machine, the record does not own them, and an operator must still be able |
| +// to grant a port or stamp a unit on a provisioned route. |
| +func TestRouteBoxScopedAxesStillWorkOnProvisionedRoute(t *testing.T) { |
| + f := newRouteTestFixture(t) |
| + f.addRoute(registry.Route{Host: "acme.go.tito.io", Kind: "instance", App: "site", |
| + Channel: "stable", Status: "ready", Provisioned: true, Addr: "127.0.0.1:8090"}) |
| + |
| + out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, |
| + "-host", "acme.go.tito.io", "-unit", "console.service") |
| + if err != nil { |
| + t.Fatalf("route -unit on a Provisioned route was refused:\n%s", out) |
| + } |
| + if got := f.routeOf("acme.go.tito.io").Unit; got != "console.service" { |
| + t.Errorf("unit = %q, want console.service", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify they fail** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestRouteChannelRefusedOnProvisioned|TestRouteEnvironmentRefusedOnProvisioned|TestRouteBoxScopedAxes' -count=1 -v` |
| + |
| +Expected: the two refusal tests FAIL (the writes are accepted); the box-scoped test PASSES already and is there to stay passing. |
| + |
| +- [ ] **Step 3: Add `recordOwnedRow` and the two verdict kinds** |
| + |
| +In `cli.go`, beside `platformCanaryOwnedRow`: |
| + |
| +```go |
| +// recordOwnedRow reports whether the instance-provisioning reconciler, not |
| +// this verb, owns this route's Channel and Environment columns. |
| +// |
| +// Provisioned is set only by that pass — on mint, and on adoption after it |
| +// matched the row's shape against the bucket record (internal/provision). |
| +// The pass converges both columns from the record on every tick, so a write |
| +// here lands and is reverted within seconds of the next serial bump, with |
| +// nothing said anywhere. Same structural refusal as platformCanaryOwnedRow, |
| +// one more owner: the door is the record. |
| +func recordOwnedRow(rt registry.Route) bool { return rt.Provisioned } |
| +``` |
| + |
| +Add to the `verdictKind` const block: |
| + |
| +```go |
| + vRecordOwned // Provisioned: the instance record owns this column, never overridable |
| +``` |
| + |
| +Add to the `envVerdictKind` const block: |
| + |
| +```go |
| + eRecordOwned // Provisioned: the instance record owns this column |
| +``` |
| + |
| +- [ ] **Step 4: Add the switch cases** |
| + |
| +In the channel switch, immediately after `case platformCanaryOwnedRow(rt):`: |
| + |
| +```go |
| + case recordOwnedRow(rt): |
| + chanVerdicts[i] = verdict{rt: rt, from: from, kind: vRecordOwned, note: fmt.Sprintf( |
| + "provisioned route on %s — its channel is owned by the instance record at\n"+ |
| + "apps/<account>/%s/instances/%s.json, which the provisioning reconciler converges onto this row\n"+ |
| + "on every pass; a change here is reverted within seconds of the next serial bump.\n"+ |
| + "Repoint the RECORD instead: `carlos route -host %s -channel <channel>` against the console", |
| + from, rt.App, rt.Host, rt.Host)} |
| +``` |
| + |
| +In the environment switch, immediately after `case platformCanaryOwnedRow(rt):` and **before** `case envClear:` — so clearing is refused too, for the reason the platform case already documents (a half-owned column is the worst of both): |
| + |
| +```go |
| + case recordOwnedRow(rt): |
| + envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eRecordOwned, note: "provisioned route — its environment is owned by the\n" + |
| + "instance record, which the provisioning reconciler converges onto this row on every pass. A binding\n" + |
| + "made here (or cleared here) is reverted within seconds of the next serial bump.\n" + |
| + "Bind the RECORD instead: `carlos route -host " + rt.Host + " -environment <name>` against the console"} |
| +``` |
| + |
| +- [ ] **Step 5: Add the refusal print cases** |
| + |
| +In the channel refusal loop, beside `case vPlatformOwned:`: |
| + |
| +```go |
| + case vRecordOwned: |
| + refusals = append(refusals, fmt.Sprintf( |
| + "%s: %s — refusing regardless of -allow-version-change: no flag overrides a column this verb does not own", |
| + v.rt.Host, v.note)) |
| +``` |
| + |
| +In the environment refusal loop (`cli.go:886`), beside `case ePlatformOwned:` — which reads `refusals = append(refusals, fmt.Sprintf("%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note))`. Mirror it exactly, so the two refusals read alike at the terminal: |
| + |
| +```go |
| + case eRecordOwned: |
| + refusals = append(refusals, fmt.Sprintf( |
| + "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note)) |
| +``` |
| + |
| +Leave the `-tunnel` axis alone. It carries the same `platformCanaryOwnedRow` refusal a few lines below, but a tunnel name is box-scoped (design §5) and the record does not own it. |
| + |
| +- [ ] **Step 6: Run the tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestRoute -count=1` |
| + |
| +Expected: PASS, every `TestRoute*` test in the package. The `-app` bulk form is covered by the same code path (the verdict loop runs over every candidate), so a provisioned route inside an `-app` sweep is refused too — and because any refusal aborts the whole batch, one provisioned route in a sweep now blocks it. That is intended: a partially-applied bulk repoint is the failure mode pass 1 exists to prevent. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +gofmt -l cli.go e2e_test.go |
| +go build ./... && go vet ./... && go test ./... -count=1 |
| +GOOS=darwin go build ./... |
| +git add cli.go e2e_test.go |
| +git commit -m "route: refuse -channel and -environment on a provisioned route |
| + |
| +The reconciler converges both columns from the instance record on every |
| +pass, so a write here lands and is reverted within seconds of the next |
| +serial bump, silently. Same structural refusal as platformCanaryOwnedRow, |
| +one more owner; the message names the record as the door. |
| + |
| +The box-scoped axes (-grant/-revoke/-backing/-unit/-tunnel) are untouched. |
| + |
| +Design: docs/superpowers/specs/2026-08-10-route-records-design.md §5" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Documentation and the deploy note |
| + |
| +**Files:** |
| +- Modify: `CLAUDE.md` (the "Rules this repo has paid for" section) |
| +- Modify: `README.md:339` area (the `carlos add` description) |
| + |
| +- [ ] **Step 1: Add the ownership rule to CLAUDE.md** |
| + |
| +Under "Rules this repo has paid for", after the "Adoption swaps a symlink" bullet: |
| + |
| +```markdown |
| +- **A route's Channel and Environment belong to its instance record** once |
| + the route is `Provisioned` (main, 2026-08-10). The provisioning reconciler |
| + converges both from `apps/<sqid>/<app>/instances/<host>.json` on every |
| + pass, so a `carlos route -channel` on the box lands and is reverted within |
| + seconds of the next serial bump. The CLI refuses it and names the record. |
| + Reconcile also ADOPTS an existing route whose shape matches an appearing |
| + record — which is how the fleet gets backfilled with no box commands, and |
| + also how a sloppy record acquires removal authority over a live route. |
| +``` |
| + |
| +- [ ] **Step 2: Note the record path in README** |
| + |
| +In the `carlos add` description around `README.md:339`, append exactly: |
| + |
| +```markdown |
| +A route added this way is described by no bucket record, so nothing |
| +converges it: its channel and environment are box-local until an instance |
| +record adopts it. A provisioned route's channel is repointed through the |
| +record, not here. |
| +``` |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +git add CLAUDE.md README.md |
| +git commit -m "docs: record ownership of a route's channel and environment" |
| +``` |
| + |
| +--- |
| + |
| +## Verification before handing back |
| + |
| +- [ ] `go build ./... && go vet ./... && go test ./... -count=1` — clean, every package. |
| +- [ ] `GOOS=darwin go build ./...` — clean. |
| +- [ ] `gofmt -l` clean for: `internal/provision/provision.go`, `internal/provision/provision_test.go`, `internal/provision/reconcile.go`, `internal/provision/reconcile_test.go`, `internal/registry/registry.go`, `internal/registry/registry_test.go`, `cli.go`, `e2e_test.go`. |
| +- [ ] `TestReconcileMints` passes **unchanged** — it is the legacy-mint pin. |
| +- [ ] `TestRecordWireShapeIsAdditive` passes — it is the live-record pin. |
| +- [ ] Nothing in this plan writes a record. The console does not yet emit the new fields, no production record carries one, and reconcile's behaviour against every record that exists today is byte-identical. That is what makes this deployable ahead of plan 2. |
| + |
| +## Deviations from the spec, for the reviewer |
| + |
| +Three places where this plan decided something the spec left open. Each is |
| +called out in a code comment at the site. |
| + |
| +1. **Hibernate's default derives from the route shape** (`DerivesPaths`), |
| + not a flat `true`. The spec says nil means `true`; a flat `true` would arm |
| + the activator for static and addr-backed routes, which have no process to |
| + wake. Legacy records are unaffected — they all derive paths — so the |
| + wire-shape guarantee holds either way. |
| +2. **Shape fields are not converged in place** on an already-provisioned |
| + route (Task 3). A record that changes `Kind`/`Addr`/`Unit` describes a |
| + structurally different route, and converging it would re-derive socket and |
| + DB paths under a running process. It is logged with the repair. |
| +3. **§4.3's record-generation tool is not in this plan.** Generating a record |
| + from an existing route is a backfill authoring aid; it ships with plan 3's |
| + backfill and the operator-facing verb that runs it. The half that belongs |
| + here — a pass that reports and writes nothing — is Task 5. |