carlos / platform Public

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>
Paul Campbell c13fb6ba8f71396ae23a4462268460cc366ab8bc parent c5aa128
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.