carlos / platform Public

Merge pull request #137 from carlosframework/worktree-route-records

route records: a route's desired state lives in the bucket (#123, #130)
Paul Campbell d754d04eb31939acb34cc4fc9c700340b5f528c0 parent c16f557parent 4dea961
12 files changed, +3672 −16
  • CLAUDE.md +35 −0
  • README.md +4 −0
  • cli.go +27 −2
  • docs/superpowers/plans/2026-08-10-route-records-vocabulary.md +1504 −0
  • docs/superpowers/specs/2026-08-10-route-records-design.md +413 −0
  • e2e_test.go +90 −0
  • internal/provision/provision.go +170 −0
  • internal/provision/provision_test.go +160 −0
  • internal/provision/reconcile.go +311 −14
  • internal/provision/reconcile_test.go +853 −0
  • internal/registry/registry.go +55 −0
  • internal/registry/registry_test.go +50 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 55fc89614..fc66ddc9d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,6 +47,26 @@ commands.** The command alone must complete the job — no follow-up SSM
session, AWS console visit, or on-box verb may be required for it to
deliver or verify its result.
+## Every change lands through a PR (Paul's rule, 2026-08-10)
+
+**Nothing merges to `main` without a pull request.** No local merge to
+`main`, no direct commit to `main`, no fast-forward "it's only a docs
+change" — every change, however small, goes up as a branch and lands
+through a PR. That includes doc-only edits, one-line fixes, and revert
+commits.
+
+Agents: this rule is on you as much as on people. When work is complete,
+push the branch and open the PR; never offer merging to `main` as an
+option, and never take it if offered. Merging the PR is a human's action.
+
+The reason is timing, not ceremony. `.github/workflows/ci.yml` triggers on
+`pull_request` *and* on `push` to `main`, so a locally merged change does
+still meet the gate — but only **after** it is on `main`, where a red run
+is an incident to clean up rather than a merge that simply doesn't happen.
+The PR is the one place the gate runs against the merge result while the
+change can still be turned away, and the one place the branch's diff is
+read as a single piece rather than commit by commit.
+
## Build & test
- `go build ./...`, `go vet ./...`, `go test ./... -count=1` — all clean,
@@ -75,6 +95,21 @@ deliver or verify its result.
- **Adoption swaps a symlink; it does not restart processes.** Order is:
promote → adopt → restart. Long-running units (console, keymail relay) need
the restart; hibernating tenants pick it up on wake.
+- **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 — `carlos route` refuses both axes on such a row 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. Adoption stamps `Provisioned`, and `Provisioned` is the one
+ condition that GRANTS the removal phase authority over a row (the loop also
+ gates on region/pool scope and on the record being gone) — so adoption is an
+ authority transfer,
+ and the shape match is what stands between a backfill and a retired
+ production route. It refuses any row carrying a column the record cannot
+ express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`,
+ `Canary`).
- **Console security gates are ordered**: session → CSRF → owner (byte-identical
404) → `requireFreshAuth` LAST. A non-member must never get a redirect that
confirms an account exists.
diff --git a/README.md b/README.md
index 6407b8c61..2de3a42bc 100644
--- a/README.md
+++ b/README.md
@@ -340,6 +340,10 @@ never silently dropped. App names are opt-in only, via `carlos add
-status-named`, and everything else folds into one honest `apps_unlisted`
count, so the aggregate never lies by omission.
+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.
+
**Config surface** — every env var this feature reads, gathered here since
no single reader owns the whole contract:
diff --git a/cli.go b/cli.go
index d8e809046..62653d8c7 100644
--- a/cli.go
+++ b/cli.go
@@ -695,6 +695,7 @@ func runRoute(args []string) {
vVersionChange // resolved, differs: overridable
vUnproven // resolved, but nothing proves what would change: never overridable
vPlatformOwned // Platform+Canary: reconciler owns this column, never overridable
+ vRecordOwned // Provisioned: the instance record owns this column, never overridable
)
type verdict struct {
rt registry.Route
@@ -723,6 +724,12 @@ func runRoute(args []string) {
"reconciler's canary-minting pass (internal/domains/reconcile.go), which is idempotent via a\n"+
"check on THIS route's own Channel column; any change here is silently reverted on the next\n"+
"reconcile pass. This verb does not own this column", from)}
+ 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/%s/%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.",
+ from, release.RouteAccount(rt), rt.App, rt.Host)}
default:
chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck}
anyNeedsCheck = true
@@ -777,6 +784,7 @@ func runRoute(args []string) {
eWillWrite // clearing, or the bundle exists — proceed
eMissing // named but absent in the bucket — refuse, no override
ePlatformOwned // Platform+Canary: the reconciler owns this row, never overridable
+ eRecordOwned // Provisioned: the instance record owns this column
)
type envVerdict struct {
rt registry.Route
@@ -816,6 +824,12 @@ func runRoute(args []string) {
"check goes false, from values it derives itself. This verb does not own this row.\n" +
"A binding already on such a route (made before this rail existed) survives re-mints and cannot be\n" +
"cleared here either: `carlos remove " + rt.Host + "` and let the next reconcile pass re-mint it clean"}
+ case recordOwnedRow(rt):
+ envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eRecordOwned, note: fmt.Sprintf(
+ "provisioned route — its environment is owned by the instance record at\n"+
+ "apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row on every\n"+
+ "pass. A binding made here (or cleared here) is reverted within seconds of the next serial bump.",
+ release.RouteAccount(rt), rt.App, rt.Host)}
case envClear:
envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite}
default:
@@ -868,7 +882,7 @@ func runRoute(args []string) {
case vUnproven:
refusals = append(refusals, fmt.Sprintf(
"%s: %s — refusing regardless of -allow-version-change: the comparison itself is unproven", v.rt.Host, v.note))
- case vPlatformOwned:
+ case vPlatformOwned, 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))
@@ -883,7 +897,7 @@ func runRoute(args []string) {
envToWrite = append(envToWrite, v.rt.Host)
case eMissing:
refusals = append(refusals, fmt.Sprintf("%s: %s", v.rt.Host, v.note))
- case ePlatformOwned:
+ case ePlatformOwned, eRecordOwned:
refusals = append(refusals, fmt.Sprintf(
"%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note))
}
@@ -1019,6 +1033,17 @@ func platformCanaryOwnedRow(rt registry.Route) bool {
return rt.Platform && rt.Canary
}
+// 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 }
+
// runRouteGrant is the operator's door to a transfer of box authority: one
// token, onto or off one route, on this box. Everything it can refuse it
// refuses before writing, and the write itself is registry.SetCaps — the
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.
diff --git a/docs/superpowers/specs/2026-08-10-route-records-design.md b/docs/superpowers/specs/2026-08-10-route-records-design.md
new file mode 100644
index 000000000..85cc6bdee
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-route-records-design.md
@@ -0,0 +1,413 @@
+# Route records — a route's desired state lives in the bucket
+
+**Date:** 2026-08-10 · **Status:** draft (design agreed in conversation
+2026-08-10; approach 1 of two chosen — extend the existing record additively
+rather than mint a new primitive) · **Requested by:** Paul
+
+**Closes:** #123 (`carlos route` is box-local only), #130 (instance-record
+channel change never reaches the route row, and the repair is refused),
+and the write-converge half of #64 (`carlos routes`: a registry-mode
+listing verb).
+
+**Extends:** the instance provisioning design (2026-08-04 — the record and
+reconcile pass this generalises), the CLI console API design (2026-08-03 —
+the verb family the new endpoint joins), and `carlos deploy` (2026-08-08 —
+the serial bump that makes convergence seconds rather than an hour).
+
+**Follows on:** #82 (console-driven custom domain attach) builds on this
+primitive rather than beside it — see §9.
+
+## 1. Why
+
+`carlos route` has exactly one source of truth: `-registry`, defaulting to
+`/data/registry.db` (cli.go's `runRoute`). It has no `-console` flag, unlike
+`channels`, `ship`, `promote`, `deploy` and `instances list`. Changing which
+channel a route follows therefore requires a shell on the box, which breaks
+the rule recorded in CLAUDE.md:
+
+> every `carlos` command is designed for a user with no access to the
+> underlying infra and no box commands. The command alone must complete the
+> job.
+
+The obvious reading of #123 is "give `carlos route` an API mode". That
+reading is wrong in a way worth stating, because it decides the whole shape:
+an API mode implies the console reaching into a registry, and the console
+today can only read registries it is colocated with (`view.go`'s
+`openRegistry` opens the DB path directly). Generalising that to more than
+one box means inventing a fleet RPC, which is #73, and which is unbuilt.
+
+The platform already has the answer, and it is the platform's whole premise:
+**the bucket is the control plane**. Desired state goes in the bucket, boxes
+converge. Instance records at `apps/<sqid>/<app>/instances/<host>.json`
+already carry a `Channel` and `provision.Reconcile` already materializes
+them into route rows. The gap is not a missing API. It is that the record
+vocabulary describes exactly one kind of route, and that reconcile creates
+from a record but never converges to it.
+
+### 1.1 Why "there shouldn't be hand-added routes" is the destination, not the state
+
+There are four doors that mint a route: `carlos add` (cli.go), the two
+`domains.Reconcile` alias mints, and `provision.Reconcile`. The record door
+mints one shape, hardcoded:
+
+```go
+Host: rec.Host, Slug: rec.Slug, Kind: "instance",
+Socket: DefaultSocket(rec.Host), DBPath: DefaultDBPath(cfg.DataDir, rec.Host),
+App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region,
+Channel: rec.Channel, Hibernate: true, Provisioned: true,
+Seed: seed, Status: "ready",
+```
+
+Single-label subdomain of the app's declared `Domain`, exec-child socket,
+hibernating, `kind=instance`. The `Record` struct has no field for kind,
+addr, unit or hibernate — reconcile supplies all of them by convention.
+
+Compare what `carlos add` accepts: `-kind static`, `-addr`, `-unit`, `-slug`,
+`-db`, `-status-named`, `-hibernate`. Against the deployments named in
+CLAUDE.md, the record shape cannot express **either console** (unit-backed
+`console.service`), **keymail, kass or titogo** (`-addr` whole-app TCP
+tenants), or **the static sites** (carloku.com, platform-site). Those are not
+routes somebody lazily hand-added. They are routes the control plane has no
+words for.
+
+So the work is not "stop hand-adding routes". It is: give the record enough
+vocabulary to describe every route the platform actually runs, then make
+the bucket the normal door and the registry write the documented exception.
+
+## 2. The model
+
+Three changes, each additive, each following an existing pattern:
+
+1. **`provision.Record` grows the missing shape fields** (§3), every one
+ `omitempty` with a zero value that reproduces today's mint exactly.
+2. **`provision.Reconcile` converges and adopts** (§4): it converges
+ `Channel` and `Environment` on records it owns, and it adopts an existing
+ route whose shape already matches an appearing record.
+3. **A member-facing record edit endpoint** (§6), with the version safety
+ rail moved console-side where the pubkey and the store actually are.
+
+`carlos add` becomes a record writer in bucket mode, with its registry write
+surviving behind an explicit break-glass flag (§7).
+
+## 3. The record grows a shape vocabulary (all additive)
+
+`provision.Record` gains six optional fields. The rule every one obeys: the
+zero value is what reconcile does today, so a schema-1 record written before
+this change parses and materializes a byte-identical route.
+
+| Field | JSON | Zero value means | Covers |
+|---|---|---|---|
+| `Kind` | `kind` | `instance` | static sites |
+| `Addr` | `addr` | derive socket via `DefaultSocket` | whole-app TCP tenants |
+| `Unit` | `unit` | no owning unit | `console.service` |
+| `Hibernate` | `hibernate` | `true` | long-running units that must not sleep |
+| `Environment` | `environment` | the app's default bundle | the second member-facing axis of `carlos route` |
+| `StatusNamed` (`*bool`) | `status_named` | unstated — mints false, never converges | `carlos add -status-named` |
+
+### 3.1 Field notes
+
+**`Hibernate` is `*bool`, not `bool`.** Reconcile mints `Hibernate: true`
+today, and every record already in the flagship and tito buckets has no such
+field. A plain bool would read those as `false` and un-hibernate the fleet on
+the first pass after deploy. `nil` must mean `true`. This is the same
+tri-state honesty `runRoute` already buys with `fs.Visit`, for the same
+reason: absent and false are different facts.
+
+**`StatusNamed` is `*bool` for the same reason** (added during implementation,
+2026-08-10). It is converged like `Channel` and `Environment`, and a plain
+bool cannot be: a record omitting the field would read as `false` and
+silently opt a route out of the status page that an operator opted in with
+`carlos add -status-named`. So `nil` means "this record does not speak to the
+status page" — it mints `false`, matching every record already in a
+production bucket, and it never converges. A backfill record that wants a
+route named on the status page must say `status_named: true` explicitly;
+omitting it leaves the row's opt-in box-local and undescribed.
+
+**`Socket` and `DBPath` stay derived, never declared.** `DefaultSocket` and
+`DefaultDBPath` exist precisely so a hand-added route and a provisioned one
+land on identical path shapes, and the `filepath.Join` note in their doc
+comment records what two names for one database cost. Declaring them would
+reintroduce that. The upstream rule is therefore positional:
+
+- `Addr` set → TCP upstream, no socket, no DB path.
+- `Addr` empty, `Kind` instance → derived socket and DB path (today).
+- `Kind` static → neither.
+
+**`Kind` is validated against the registry's own vocabulary**
+(`instance | service | static`), not accepted verbatim. The `-backing unti`
+incident recorded in cli.go — an unvalidated value written straight to a
+column, leaving a route neither unit-backed nor visibly wrong — is the
+failure mode this avoids at the other door.
+
+**`Unit` is validated with `registry.ValidUnitName`** at the console, before
+the record is written, for the reason both existing doors already state: a
+route created with a typo'd unit is a route whose adoption silently restarts
+nothing.
+
+**`Unit` and `Kind: instance` + unit-backing remain mutually exclusive**, the
+same refusal `runAdd` and `unitRail` already carry, at this third door.
+
+### 3.2 Host validation
+
+`provision.ValidHost` requires exactly one DNS label plus the app manifest's
+`Domain`, and its doc comment states why: the host becomes a bucket key
+segment and, on the box, the `<host>.sock` and `<host>.db` filesystem path
+segments. That is path-traversal defence, and it is not loosened casually.
+
+It also cannot express an apex (`carloku.com`) or a console's own hostname,
+which is most of what needs backfilling.
+
+The resolution follows the derivation rule in §3.1: **a record validates with
+`ValidHost` when it derives filesystem paths, and with the looser
+`ValidHostShape` when it does not.** Concretely:
+
+- `Kind: instance` with no `Addr` — derives `.sock` and `.db` paths →
+ `ValidHost`, suffix-pinned, unchanged.
+- `Kind: static`, or any record with `Addr` set — derives no filesystem path
+ at all → `ValidHostShape`.
+
+The traversal safety comes from not building a path out of the host, rather
+than from the suffix. The suffix pin remains exactly where the danger is.
+
+The sibling-apex check in `handleAPIInstanceCreate` (one app quietly serving
+another's public URL) is unaffected: it is scoped to
+`m.Domain == acct.Sqid+"."+s.cfg.AppsDomain`, which a custom-domain or apex
+record never enters.
+
+## 4. Reconcile converges and adopts
+
+Two changes to `provision.Reconcile`.
+
+### 4.1 Converge (closes #130)
+
+The `existing.Provisioned && existing.App == rec.App && existing.Account ==
+rec.Account` branch converges `Seed` and nothing else today. It should also
+converge `Channel` and `Environment`, and the §3 shape fields.
+
+This is #130 closed structurally rather than patched. That incident — a
+record created on `stable`, deleted and recreated on `edge`, with the route
+row left on `stable` and every wake failing `no adoptable head` while
+`carlos channels` cheerfully showed `edge` promoted — becomes
+unrepresentable. Member-visible state and box state cannot disagree about a
+column the bucket owns.
+
+`Seed`'s existing `seedIsKnown` guard is the pattern to copy: a transient
+manifest-read failure must never stamp a `""` fallback over a working value.
+Channel has no manifest dependency, so it converges directly from the record.
+
+### 4.2 Adopt on match
+
+The `default:` branch today logs "exists but was not minted by this pass —
+leaving it alone" and moves on. It becomes:
+
+- If the existing route's shape **matches** the record's declared shape,
+ stamp `Provisioned` and take ownership. Log the adoption.
+- If it **differs**, leave it alone exactly as today, and log loudly enough
+ that the mismatch is diagnosable without a box session.
+
+This is what makes the migration cheap, and it is the design's main payoff.
+Backfilling the fleet becomes a pure bucket-side write per route — no
+`carlos add`, no SSM session, no on-box verb. Write the record that describes
+what is already there; the next serial bump adopts it. Get the shape wrong
+and nothing happens, with the log saying so.
+
+"Matches" is: `App`, `Account`, `Kind`, `Addr`, `Unit`, `Slug`, `Pool`,
+`Region` all equal, and the derived socket/DB paths equal what the record
+would derive. `Channel`, `Environment` and `Hibernate` are deliberately
+**excluded** from the match test — those are the columns the record is taking
+ownership of, and requiring them to agree first would make adoption
+impossible in exactly the case it is for.
+
+### 4.3 The risk this creates, stated plainly
+
+Adoption grants reconcile's removal phase authority over the route, because
+that phase keys on `Provisioned`. A sloppy shape-match check could retire a
+live route.
+
+The existing guards are real and stay: `readFailed` disables removal
+pass-wide, `protect[]` covers a record that exists but could not be read
+cleanly, and scope is region/pool-bound. On top of them:
+
+- A `-dry-run` mode that prints the record it would write for every route on
+ a box, and for each, whether it would adopt, mint or leave alone.
+- Backfill applied one route at a time, verified through the serial, with
+ the two consoles **last** — a console that retires its own route cannot be
+ used to put it back.
+
+## 5. Reconcile owns `Channel` and `Environment`, so the CLI must stop writing them
+
+Once a route is `Provisioned`, `carlos route -channel` or `-environment` on
+the box is a write that gets stomped within seconds of the next serial bump.
+Silently. That is the same class of bug as #130, arriving from the other
+side.
+
+Both are refused, with a message naming the record as the door.
+
+The precedent to copy is exact: `platformCanaryOwnedRow` already refuses
+`-environment` on platform and canary routes, and the `Environment` doc
+comment in registry.go states the reason — "the reconciler owns those rows
+and re-Upserts them whenever its `followed` check goes false … a column the
+reconciler rebuilds is not one this verb should be handing an operator."
+One more owner, same rail, same voice.
+
+`-grant`, `-revoke`, `-backing`, `-unit` and `-tunnel` are untouched by this:
+they are box-scoped columns the record does not own.
+
+## 6. The API
+
+```
+PUT /api/cli/apps/{account}/{app}/instances/{host}
+```
+
+Joining `registerCLIRoutes` beside the existing `POST`/`GET`/`DELETE` on the
+same path family, with that family's gate ordering and byte-identical 404
+refusal.
+
+**Body** carries the member-mutable subset only:
+
+```json
+{ "channel": "edge", "environment": "-", "allow_version_change": false }
+```
+
+`environment: "-"` is the clear sentinel, the same one `carlos route` and
+`carlos env` already use, for the same reason (a bare hyphen fits
+`[a-z0-9-]{1,32}` and would otherwise name a real bundle).
+
+**Not member-mutable:** `kind`, `addr`, `unit`, `hibernate`, `slug`, `pool`,
+`region`. The record can *express* them so a route can be declared
+declaratively; editing them is operator domain. A body naming one is refused
+rather than ignored — a flag that is silently dropped is how an operator
+comes to believe a change landed.
+
+**Concurrency:** read-modify-write CAS'd on the record's etag via a new
+`provision.PutRecordIfMatch`, mirroring the `PutManifestIfMatch` that already
+exists. A lost race is a 409, never a silent overwrite — the same discipline
+`CreateRecord`'s create-only write carries.
+
+**Serial:** the handler calls `s.bumpSerial(ctx)` after the write, exactly as
+`handleAPIInstanceCreate` does, because this is a box-consumed write. The
+repoint is live in seconds.
+
+**Audit:** `s.audit(acct, app, principal, "instance-repoint", …)`.
+
+### 6.1 The version rail moves console-side
+
+`routeVersionCheck` (cli.go) resolves whether a repoint changes what gets
+served. It needs `CARLOS_RELEASE_PUBKEY` and bucket access — an API-mode
+caller on a laptop has neither, by design. The console has both
+(`s.cfg.Releases`), so the check runs in the handler, refusing 409 with the
+same note text the CLI prints today. `allow_version_change` is the request
+field standing in for the flag.
+
+The three outcomes are unchanged: resolved-and-agreeing proceeds; resolved-
+and-disagreeing is the override's intended door; unprovable is never
+overridable.
+
+### 6.2 One targeted fix inside the rail (closes #130's second ask)
+
+Today the branch where the route has no recorded `Version` **and** its
+current channel does not resolve returns `overridable: false` — refused
+regardless of the flag. #130 hit exactly this trying to repair the route it
+describes, and had to fall back to `carlos remove` and a re-mint.
+
+That branch is only reached **after the target channel has already resolved
+and verified**. The route has never served anything, and the stale channel is
+unresolvable because it was always wrong. There is no comparison to protect,
+only a stuck route. It becomes overridable.
+
+The genuinely unprovable cases stay unoverridable, unchanged: no pubkey, an
+unresolvable target, a bad signature. The distinction is whether the
+*target* is proven, not whether the comparison is complete.
+
+## 7. `carlos add` becomes a record writer
+
+In bucket mode (`CARLOS_DEPLOYMENT_BUCKET` / `CARLOS_DEPLOYMENT_DIR` set —
+`resolveMode`'s direct mode, which is what pinfra scripts and box-side
+bootstrap already run with), `carlos add` writes a record instead of a
+registry row. Its existing flags map onto §3's fields one for one, which is
+the point of having chosen that vocabulary.
+
+The registry write survives behind an explicit `-local` flag, documented as
+break-glass: standing up a box before it has a control plane, or repairing
+one that cannot reach the bucket.
+
+That is the operational form of "no hand-added routes" — not removing the
+door, but making the normal path go through the bucket and making the
+exception announce itself. The alternative, deleting the local path outright,
+fails the first time a box is partitioned and someone needs a route back.
+
+## 8. Testing
+
+**Wire shape (the rule this repo has paid for three times).** A fixture of a
+schema-1 record carrying none of the §3 fields, asserted to mint a route
+byte-identical to today's — field by field, not `reflect.DeepEqual` against a
+freshly built expectation, so the assertion still means something if the mint
+changes. This is the test that protects the live records in the flagship and
+tito buckets.
+
+**Reconcile.**
+- Converge: a record whose `Channel` changed moves the route's column;
+ `Environment` likewise.
+- Adopt on match: an existing hand-added route plus an appearing record whose
+ shape matches → `Provisioned` stamped, columns converged.
+- Refuse on mismatch: shape differs → route untouched, mismatch logged.
+- Removal authority unchanged: a hand-added route with no record is never
+ removed; a `readFailed` pass removes nothing.
+- Adoption does not depend on `Channel`/`Environment`/`Hibernate` agreeing
+ (§4.2), pinned directly — it is the property most likely to be "tidied"
+ into a full-shape comparison by a later reader.
+
+**Console.**
+- The member gate and byte-identical 404 for a non-member (the ordering rule
+ in CLAUDE.md: session → CSRF → owner → `requireFreshAuth` last).
+- CAS conflict → 409.
+- A body naming a non-mutable field → refused, not ignored.
+- The version rail's three outcomes, including §6.2's newly-overridable case.
+- Serial bumped after a successful write.
+
+**CLI.**
+- API mode: `-channel` and `-environment` reach the endpoint.
+- API mode: `-grant`/`-revoke`/`-backing`/`-unit`/`-tunnel` refused as
+ box-scoped, naming why.
+- Direct mode on a `Provisioned` route: `-channel` refused, naming the record
+ (§5).
+- Direct mode on a hand-added route: unchanged.
+- Every test shelling out to the binary builds its env through
+ `carlosTestEnv` and sets `cmd.Dir` to a scratch dir — non-negotiable, and
+ the reason is recorded in CLAUDE.md.
+
+**Channel names.** Production runs on `canary/*`; `laddercheck_test.go` pins
+them. Nothing here narrows `release.ValidChannel`, and a test asserts a
+`canary/*` repoint is accepted end to end.
+
+## 9. Out of scope
+
+- **#82, custom domain attach.** This design makes it buildable — a record
+ may already name a host outside the app's declared domain when it derives
+ no filesystem path (§3.2). What #82 still owes is **ownership
+ verification**: proving the member controls the domain before a record
+ naming it is honoured. That is a genuinely separate concern and does not
+ belong in this primitive.
+- **#73, the fleet API.** Nothing here needs box RPC; that is the point.
+- **`-grant`/`-revoke`/`-backing`/`-tunnel` as record fields.** Capabilities
+ are operator grants scoped to one box, and registry.go's `Caps` comment
+ records why the column lives where no tenant can write. They stay there.
+- **Migrating the alias routes** minted by `domains.Reconcile`. Those already
+ have a declarative owner; they are simply a different one.
+
+## 10. Rollout
+
+1. Record fields + validation, with the §8 wire-shape test. No behaviour
+ change: nothing writes the new fields yet.
+2. Reconcile converge + adopt, with `-dry-run`, **plus §5's refusal in the
+ same roll**. The two must land together: a `Provisioned` route whose
+ channel the CLI will still happily write is precisely the window where the
+ silent stomp is possible. Still no behaviour change in production at this
+ point, because no record carries a new field and no new record exists.
+3. Console endpoint + rail move + §6.2's fix. `carlos route` API mode.
+4. `carlos add` record mode, `-local` break-glass.
+5. Backfill, one route at a time, consoles last (§4.3).
+
+Steps 1–2 are one coherent PR and step 3 is another. Step 5 is an operational
+task, not a code change, and wants its own plan with per-route verification
+through `X-Carlos-Version` on the URL — never a cached answer.
diff --git a/e2e_test.go b/e2e_test.go
index 97539e2c4..27a7fa66e 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -2822,6 +2822,96 @@ func TestRoutePlatformCanaryNeverLeavesCanary(t *testing.T) {
}
}
+// 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)
+ }
+ if got := f.environmentOf("bound.go.tito.io"); got != "demo" {
+ t.Fatalf("environment = %q, want it still bound after the refusal", got)
+ }
+}
+
+// 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)
+ }
+
+ // -unit short-circuits before the verdict machinery this change touched
+ // (the grantAxis/hibernateSet/backingSet/unitSet block returns early, well
+ // above chanVerdicts/envVerdicts). -tunnel does not: it runs through the
+ // same candidates slice and sits downstream of the refusal-collection
+ // loops recordOwnedRow feeds, so it is the axis a future recordOwnedRow
+ // case could plausibly catch by mistake. Exercise it directly.
+ out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath,
+ "-host", "acme.go.tito.io", "-tunnel", "relay1")
+ if err != nil {
+ t.Fatalf("route -tunnel on a Provisioned route was refused:\n%s", out)
+ }
+ if got := f.routeOf("acme.go.tito.io").Tunnel; got != "relay1" {
+ t.Errorf("tunnel = %q, want relay1", got)
+ }
+}
+
// re-review NEW 1, the reachable gap: moving a Platform+Canary route BETWEEN
// two canary channels (not off canary/* at all) was previously accepted,
// because the round-1 guard only fired when the target left the canary/*
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 87a4a0256..b2ed5342d 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -11,13 +11,17 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net"
"os"
"path/filepath"
"regexp"
"sort"
+ "strconv"
"strings"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -37,6 +41,43 @@ type Record struct {
Channel string `json:"channel"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
+
+ // --- 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.
+ // A POINTER for the same reason Hibernate is: this column is CONVERGED,
+ // so absent must mean "this record does not speak to the status page" —
+ // a plain bool would read every legacy record (and every record whose
+ // writer left the field out) as an explicit false and silently opt out a
+ // route an operator opted in with `carlos add -status-named`. nil =
+ // StatusNamedOrDefault's answer.
+ StatusNamed *bool `json:"status_named,omitempty"`
}
// Manifest is the wire shape at apps/<sqid>/<app>/instances.json — the app's
@@ -140,6 +181,135 @@ func ValidHostShape(host string) bool {
return true
}
+// 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()
+}
+
+// StatusNamedOrDefault resolves the tri-state. An explicit value wins.
+// Absent is false — the status page's naming is an opt-in, and false is what
+// reconcile minted unconditionally before this field existed, so every legacy
+// record is unchanged. Absent and false are still different facts, and the
+// difference is load-bearing at the converge site: absent leaves the row's
+// column alone (an operator's `carlos add -status-named` survives), while an
+// explicit false clears it.
+func (r Record) StatusNamedOrDefault() bool {
+ if r.StatusNamed != nil {
+ return *r.StatusNamed
+ }
+ return false
+}
+
+// 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)
+ }
+ // Parse port as integer and validate range 1..65535. net.SplitHostPort
+ // accepts garbage ports (e.g. "127.0.0.1:abc") — we need to refuse them
+ // here at the door, not discover them months later as a route that never
+ // connects (the -backing unti class of incident).
+ portNum, err := strconv.ParseUint(p, 10, 16)
+ if err != nil || portNum == 0 {
+ return fmt.Errorf("addr %q: port must be an integer in the range 1..65535", 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.Environment != "" && !appconfig.ValidEnvironmentName(rec.Environment) {
+ return fmt.Errorf("environment %q: want [a-z0-9-]{1,32}", rec.Environment)
+ }
+ 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")
+ }
+ }
+ if rec.KindOrDefault() == "service" && rec.Addr == "" {
+ return errors.New("kind service with no addr: the platform only proxies to a service, so the record must name the upstream it proxies to")
+ }
+ // Same wedge as static+hibernate, arriving from the other side. Addr does
+ // NOT exclude a route from activator management: internal/edge's gate is
+ // `Kind == "instance" && Hibernate && !viaTunnel`, so an addr-backed
+ // instance flagged for hibernation is armed for an exec-child wake it has
+ // no socket for and whose process the platform never spawned.
+ // HibernateOrDefault's own derivation already states the invariant ("an
+ // addr-backed tenant is always on"); this is the door that enforces it.
+ if rec.Addr != "" && rec.Hibernate != nil && *rec.Hibernate {
+ return errors.New("addr with hibernate: an addr-backed route proxies to a process the platform does not spawn and has no socket 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)
+}
+
// regionPoolRe is the shared charset for a region or pool code: lowercase
// a-z, 0-9, hyphen, up to 16 characters. Empty is deliberately valid — the
// unconstrained region and the shared (default) pool are both "".
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 21c00a6ae..94f6fc1b4 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -12,6 +12,71 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// 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 != nil {
+ t.Errorf("StatusNamed = %v, want nil — absent and false are different facts here too", rec.StatusNamed)
+ }
+ if rec.StatusNamedOrDefault() {
+ t.Error("StatusNamedOrDefault() = true, want false — reconcile minted StatusNamed:false for these")
+ }
+ 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)
+ }
+ }
+}
+
func TestValidHost(t *testing.T) {
cases := []struct {
host, domain string
@@ -423,3 +488,98 @@ func TestPutManifestIfMatch(t *testing.T) {
t.Fatalf("manifest after stale write = %+v %v, want the guarded update's beta", final, err)
}
}
+
+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" })},
+ {"service with addr", with(func(r *Record) { r.Kind = "service"; r.Addr = "127.0.0.1:8090" })},
+ {"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" })},
+ {"addr with garbage port", with(func(r *Record) { r.Addr = "127.0.0.1:abc" })},
+ {"unit typo", with(func(r *Record) { r.Unit = "console" })},
+ {"bad environment name", with(func(r *Record) { r.Environment = "Demo" })},
+ {"service with no addr", with(func(r *Record) { r.Kind = "service" })},
+ {"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 })},
+ // The same wedge from the other side: edge's activator gate is
+ // `Kind == "instance" && Hibernate`, which Addr does not exclude, so
+ // this route would be armed for a wake it has no socket for.
+ {"addr hibernating", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Hibernate = &yes })},
+ {"addr service hibernating", with(func(r *Record) {
+ r.Kind = "service"
+ r.Addr = "127.0.0.1:8090"
+ 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")
+ }
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 7928ca80d..df3d0f866 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -38,6 +38,12 @@ type ReconcileConfig struct {
DataDir string
// Now is the clock seam; nil = time.Now.
Now func() time.Time
+ // 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
}
// acctApp identifies one app within one account — the unit both the record
@@ -45,6 +51,186 @@ type ReconcileConfig struct {
// only within an account, never globally.
type acctApp struct{ acct, app string }
+// 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. Deriving DBPath EXPLICITLY here — rather than leaving it
+// empty on a route that has one — is also what keeps ops.FleetDBs'
+// hibernating-exclusion correct: internal/ops/litestream.go builds that
+// exclusion set from `r.Hibernate && r.DBPath != ""`, so a hibernating route
+// whose row carried no path would be swept up by the dataDir glob and
+// replicated box-wide as if it never slept.
+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.StatusNamedOrDefault(),
+ Provisioned: true, Seed: seed, Status: "ready",
+ }
+ if rec.DerivesPaths() {
+ rt.Socket = DefaultSocket(rec.Host)
+ rt.DBPath = DefaultDBPath(dataDir, rec.Host)
+ }
+ return rt
+}
+
+// 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.
+//
+// Backing, Tunnel and Caps are deliberately required to be empty (unset) on
+// the existing row — registry.Route's own doc comments name these three as
+// the box-scoped operator columns, and a record has a field for none of
+// them, so a record can never fully describe a row that carries one:
+// - Backing "unit" names a carlos-app@<host> systemd tenant; adopting
+// across it would claim a process shape the record says nothing about.
+// - Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can
+// state: internal/edge picks the tunnel proxy before it ever looks at
+// Socket, so a tunnel-backed route can agree with a record on every
+// other compared column while every byte it actually serves goes down
+// a channel the record cannot see or reclaim.
+// - Caps is the operator-granted, box-local capability grant column
+// (port:25, host-role, ...). registry.Delete's purge of a route's
+// grants is already treated as a human-gated, last-resort action
+// elsewhere in this file (the shape-drift log below); adoption must not
+// hand that purge to an unattended bucket-record deletion.
+//
+// A row this pass cannot state the shape of is a row this pass must not
+// claim to describe, no matter how well everything else lines up.
+//
+// These three refusals guard the adoption INSTANT, not the row forever after:
+// `carlos route -backing/-unit/-tunnel/-grant` still work on a Provisioned
+// row, so an already-owned route can acquire one of them later. See the
+// removal phase's comment in Reconcile for what that costs.
+//
+// Platform and Canary are likewise required false: those flags mark a row
+// domains.Reconcile minted and re-mints/removes on its own authority
+// (mintedRoute leaves Slug/Pool/Region/DBPath at their zero values, so a
+// platform apex on an unconstrained-region box can otherwise agree with a
+// record on every other compared column). Adoption must not hand this
+// pass's removal authority to a row another pass already owns and
+// periodically rebuilds — nor let it write Channel/Environment onto a row
+// the CLI itself refuses (platformCanaryOwnedRow, cli.go) for the same
+// reason.
+//
+// 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 shapeMismatch(existing, want) == ""
+}
+
+// shapeMismatch names the first column on which existing disagrees with
+// want — including the box-scoped and pass-ownership guards shapeMatches
+// documents — or "" when every compared column agrees. It backs both
+// shapeMatches and the adoption refusal log: printing only a couple of
+// columns there (the original message did app/kind/addr) leaves every
+// refusal over any OTHER column looking identical and undiagnosable to the
+// operator the refusal message exists for.
+func shapeMismatch(existing, want registry.Route) string {
+ switch {
+ case existing.Backing != "":
+ return fmt.Sprintf("backing %q (box-scoped, not expressible in a record)", existing.Backing)
+ case existing.Tunnel != "":
+ return fmt.Sprintf("tunnel %q (box-scoped, not expressible in a record)", existing.Tunnel)
+ case existing.Caps != "":
+ return fmt.Sprintf("caps %q (box-scoped, not expressible in a record)", existing.Caps)
+ case existing.Platform:
+ return "platform (owned by the domains reconcile pass)"
+ case existing.Canary:
+ return "canary (owned by the domains reconcile pass)"
+ case existing.App != want.App:
+ return fmt.Sprintf("app %q/%q", existing.App, want.App)
+ case existing.Account != want.Account:
+ return fmt.Sprintf("account %q/%q", existing.Account, want.Account)
+ case existing.Kind != want.Kind:
+ return fmt.Sprintf("kind %q/%q", existing.Kind, want.Kind)
+ case existing.Addr != want.Addr:
+ return fmt.Sprintf("addr %q/%q", existing.Addr, want.Addr)
+ case existing.Unit != want.Unit:
+ return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit)
+ case existing.Slug != want.Slug:
+ return fmt.Sprintf("slug %q/%q", existing.Slug, want.Slug)
+ case existing.Pool != want.Pool:
+ return fmt.Sprintf("pool %q/%q", existing.Pool, want.Pool)
+ case existing.Region != want.Region:
+ return fmt.Sprintf("region %q/%q", existing.Region, want.Region)
+ case existing.Socket != want.Socket:
+ return fmt.Sprintf("socket %q/%q", existing.Socket, want.Socket)
+ case existing.DBPath != want.DBPath:
+ return fmt.Sprintf("db %q/%q", existing.DBPath, want.DBPath)
+ default:
+ return ""
+ }
+}
+
+// convergeOwned applies the record's Channel, Environment and StatusNamed onto
+// a route it owns — the columns the record is authoritative for once it owns
+// the row, whether ownership was just granted by adoption or was already held
+// by an earlier pass. Both the owned-route converge branch and the
+// adoption branch call this, so there is exactly one place these columns get
+// written from a record, never two copies drifting apart.
+//
+// StatusNamed converges only when the record STATES it (design §3, the same
+// tri-state Hibernate carries): absent means "this record does not speak to
+// the status page", and stamping a derived false over an operator's `carlos
+// add -status-named` would be the silent stomp this design exists to end.
+// Unlike Hibernate it is converged at the adoption instant too — it decides
+// what a public page prints, not whether a live process is allowed to sleep.
+//
+// Every write logs on success as well as on failure. These are the writes
+// that REVERT an operator's own `carlos route -environment` or `-hibernate`;
+// a row that moved with nothing saying so is the exact failure class of the
+// production incident behind #130, seen from the outside.
+//
+// dryRun is a parameter here, not left to callers, deliberately: this
+// function is reached from two branches, and a dry-run guard living in only
+// one of the two callers is exactly the single-unguarded-write failure mode
+// the pass exists to prevent (design §4.3). Guarding here means both call
+// sites are safe by construction, not by each one remembering to check.
+func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, dryRun bool) {
+ if rec.Channel != "" && existing.Channel != rec.Channel {
+ if 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)
+ }
+ }
+ if existing.Environment != rec.Environment {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment)
+ } else if err := reg.SetEnvironment(rec.Host, rec.Environment); err != nil {
+ log.Printf("provision: converge environment on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s environment %q -> %q", rec.Host, existing.Environment, rec.Environment)
+ }
+ }
+ if rec.StatusNamed != nil && existing.StatusNamed != *rec.StatusNamed {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s status-named %v -> %v", rec.Host, existing.StatusNamed, *rec.StatusNamed)
+ } else if err := reg.SetStatusNamed(rec.Host, *rec.StatusNamed); err != nil {
+ log.Printf("provision: converge status-named on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s status-named %v -> %v", rec.Host, existing.StatusNamed, *rec.StatusNamed)
+ }
+ }
+}
+
// Reconcile materializes bucket instance records into registry routes for
// THIS agent's region and pools: mint on appearance, converge seed, remove
// on record deletion — removal only for routes this pass (or an earlier one
@@ -122,6 +308,29 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
protect[host] = true
continue
}
+ // ValidateShape is the console create door's policy check — but a
+ // door is not a guarantee about what is already in the bucket (the
+ // same reasoning as the host-shape check just above). This pass
+ // materializes whatever it finds, so a record whose Kind/Addr/Unit/
+ // Environment combination is malformed must be caught here too, or
+ // a bad value becomes a live route. Protected like a corrupted
+ // body: the record is there, it just cannot be used.
+ if err := ValidateShape(rec); err != nil {
+ log.Printf("provision: skipping record %s: invalid shape: %v", key, err)
+ protect[host] = true
+ continue
+ }
+ // ValidateShape deliberately does not inspect Channel (it validates
+ // shape fields, and Channel is not one), but a channel-less record
+ // must not become — or converge — a route pinned to no channel:
+ // that is issue #130's failure mode arriving through a different
+ // door than the one Task 3 closed. Protected like a corrupted body:
+ // the record is there, it just cannot be used.
+ if rec.Channel == "" {
+ log.Printf("provision: skipping record %s: empty channel", key)
+ protect[host] = true
+ continue
+ }
if rec.Region != cfg.Region || !poolServed(cfg.Pools, rec.Pool) {
continue // another agent's jurisdiction, not this pass's business
}
@@ -224,32 +433,107 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
existing, ok := byHost[rec.Host]
switch {
case !ok:
- // Hibernate true is the activator model — and what hands the DB
- // to the lease replicator from birth; DBPath explicit is what
- // keeps ops.FleetDBs' hibernating-exclusion correct.
- nr := registry.Route{
- Host: rec.Host, Slug: rec.Slug, Kind: "instance",
- Socket: DefaultSocket(rec.Host), DBPath: DefaultDBPath(cfg.DataDir, rec.Host),
- App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region,
- Channel: rec.Channel, Hibernate: true, Provisioned: true,
- Seed: seed, Status: "ready",
+ // The route's whole shape now comes from the record (routeFor):
+ // Kind, Addr, Unit, Environment, StatusNamed, Hibernate and the
+ // path derivation. A record carrying none of them still mints the
+ // exact route this branch hardcoded before they existed — that is
+ // the additivity rule, pinned by TestRecordWireShapeIsAdditive and
+ // TestReconcileMints.
+ 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
}
if err := reg.Upsert(nr); err != nil {
log.Printf("provision: mint %s: %v", rec.Host, err)
continue
}
- log.Printf("provision: minted instance route %s (app %q acct %q)", rec.Host, rec.App, rec.Account)
+ log.Printf("provision: minted %s route %s (app %q acct %q)", nr.Kind, rec.Host, rec.App, rec.Account)
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).
+ convergeOwned(reg, existing, rec, cfg.DryRun)
+ // 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 cfg.DryRun {
+ log.Printf("provision: dry-run: would converge %s hibernate %v -> %v", rec.Host, existing.Hibernate, *rec.Hibernate)
+ } else if err := reg.SetHibernate(rec.Host, *rec.Hibernate); err != nil {
+ log.Printf("provision: converge hibernate on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s hibernate %v -> %v", rec.Host, existing.Hibernate, *rec.Hibernate)
+ }
+ }
// 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 {
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would converge %s seed", rec.Host)
+ } else if err := reg.SetSeed(rec.Host, seed); err != nil {
log.Printf("provision: converge seed on %s: %v", rec.Host, err)
}
}
+ // Unit is compared only when the RECORD states one: `carlos
+ // route -unit` (issue #110) is the supported way to stamp the
+ // unit owning a route's process out of band, and a record that
+ // never spoke to Unit must not treat that stamp as drift — or
+ // this would fire on a healthy, correctly-stamped route every
+ // pass, forever.
+ if want := routeFor(rec, cfg.DataDir, seed); want.Kind != existing.Kind ||
+ want.Addr != existing.Addr || (rec.Unit != "" && 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 socket %q/%q db %q/%q) — not converged in place; "+
+ "`carlos remove %s` purges this route's capability grants (registry.Delete's contract, "+
+ "not a bug) before the next pass re-mints it, so treat that as a last resort, not a routine fix",
+ rec.Host, want.Kind, existing.Kind, want.Addr, existing.Addr, want.Unit, existing.Unit,
+ want.Socket, existing.Socket, want.DBPath, existing.DBPath, rec.Host)
+ }
default:
- log.Printf("provision: %s exists but was not minted by this pass (app %q acct %q) — leaving it alone",
+ // 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 reason := shapeMismatch(existing, want); reason != "" {
+ log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone",
+ rec.Host, reason)
+ continue
+ }
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would adopt existing route %s (app %q acct %q) — this record would own its channel and environment",
+ rec.Host, rec.App, rec.Account)
+ // convergeOwned reports channel and environment together,
+ // and only the columns that actually differ (design §4.1) —
+ // a fixed "converging channel X -> Y" line here would both
+ // omit environment (the defect this fixes) and claim a
+ // convergence that would not happen when the route is
+ // already on the record's channel. Every write inside
+ // convergeOwned is dry-run guarded there, so this call
+ // writes nothing.
+ convergeOwned(reg, existing, rec, true)
+ 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)
+ // Hibernate is deliberately NOT converged here even when
+ // explicit: this 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 above, by which point the
+ // adoption is already visible in the log.
+ convergeOwned(reg, existing, rec, cfg.DryRun)
}
}
@@ -260,6 +544,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// foreign box's provisioned route is not this pass's business), and
// absence from the record set this pass just read (the bucket's own
// decision to retire the instance, not a guess this pass made).
+ //
+ // What reaches reg.Delete here may carry Backing, Tunnel or Caps even
+ // though shapeMismatch refuses to ADOPT a row carrying any of them: those
+ // refusals cover the adoption instant only. `carlos route -backing/-unit/
+ // -tunnel/-grant` all still work on a Provisioned row, so a route can
+ // acquire an operator column after it was minted or adopted and remain
+ // one unattended record-deletion away from registry.Delete's grant purge.
+ // That asymmetry predates this pass's adoption step; it is stated here so
+ // the adoption guard is not mistaken for a standing guarantee.
if !readFailed {
for _, rt := range routes {
if !rt.Provisioned || rt.Region != cfg.Region || !poolServed(cfg.Pools, rt.Pool) {
@@ -272,11 +565,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
log.Printf("provision: not removing %s: its record exists but could not be read cleanly this pass", rt.Host)
continue
}
+ if cfg.DryRun {
+ log.Printf("provision: dry-run: would remove retired %s route %s (record gone)", rt.Kind, rt.Host)
+ continue
+ }
if err := reg.Delete(rt.Host); err != nil {
- log.Printf("provision: remove retired instance route %s: %v", rt.Host, err)
+ log.Printf("provision: remove retired %s route %s: %v", rt.Kind, rt.Host, err)
continue
}
- log.Printf("provision: removed retired instance route %s (record gone)", rt.Host)
+ log.Printf("provision: removed retired %s route %s (record gone)", rt.Kind, rt.Host)
}
}
return nil
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 5fe3bfb1d..4153d849d 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -85,6 +85,15 @@ func putReplace(t *testing.T, s release.Store, key string, body []byte) {
}
}
+func mustJSON(t *testing.T, v any) []byte {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
// setup writes a valid account manifest (id acctID) and that account's
// "tito" app instances manifest (domain go.tito.io, pool tito, seed
// ["seed"]) — the fixture every test case below builds records on top of.
@@ -129,6 +138,90 @@ func TestReconcileMints(t *testing.T) {
}
}
+// Every shape the record can now describe, minted in one pass. The four
+// cases are the production routes the old vocabulary could not express:
+// a console (addr-backed, unit-owned, never hibernating), a whole-app TCP
+// tenant, a static site, and a sibling service.
+func TestReconcileMintsEveryShape(t *testing.T) {
+ s, sqid := setup(t)
+ off := false
+ on := true
+ 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: &on, CreatedAt: time.Now()},
+ {Schema: 1, Host: "svc.go.tito.io", Slug: "svc", App: "tito", Account: sqid,
+ Pool: "tito", Region: "ie", Channel: "stable", Kind: "service",
+ Addr: "127.0.0.1:7000", Environment: "staging", 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")
+ }
+
+ // A sibling service, and the INSERT path for Environment. On the conflict
+ // path Upsert deliberately preserves a non-empty environment (`CASE WHEN
+ // excluded.environment = ''`), so the insert is the only place a mint's
+ // Environment can land at all — and every other assertion in this file
+ // would pass just the same if it never did.
+ svc := *mustGet(t, reg, "svc.go.tito.io")
+ svc.CreatedAt = 0
+ wantSvc := registry.Route{
+ Host: "svc.go.tito.io", Slug: "svc", Kind: "service", Addr: "127.0.0.1:7000",
+ App: "tito", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable",
+ Environment: "staging", Provisioned: true, Seed: `["seed"]`, Status: "ready",
+ }
+ if svc != wantSvc {
+ t.Errorf("service route = %+v, want %+v", svc, wantSvc)
+ }
+}
+
// 2. Scope.
func TestReconcileScope(t *testing.T) {
s, sqid := setup(t)
@@ -266,6 +359,219 @@ func TestReconcileConvergesSeed(t *testing.T) {
}
}
+// 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)
+ }
+}
+
+// The converge guard is load-bearing: an empty Channel means a malformed
+// record, not "clear the channel" — an empty channel would pin the route to
+// nothing, exactly the #130 failure mode. A route already provisioned on a
+// real channel must keep it when its record goes channel-less, not have it
+// blanked.
+func TestReconcileConvergeGuardEmptyChannelLeavesRouteAlone(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")
+ if before.Channel != "stable" {
+ t.Fatalf("fixture's route didn't channel as expected: %+v", before)
+ }
+
+ rec.Channel = ""
+ 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 != before {
+ t.Errorf("channel-less record changed the row: before %+v after %+v", before, after)
+ }
+}
+
+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")
+ }
+}
+
+// StatusNamed is converged, so it carries Hibernate's tri-state for
+// Hibernate's reason: explicit true reaches the row, explicit false CLEARS
+// it, and absent is not an answer at all.
+func TestReconcileConvergesStatusNamedWhenExplicit(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 mustGet(t, reg, "acme.go.tito.io").StatusNamed {
+ t.Fatal("a record with no status_named minted an opted-in route")
+ }
+
+ on := true
+ rec.StatusNamed = &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").StatusNamed {
+ t.Error("an explicit status_named:true did not converge onto the row")
+ }
+
+ off := false
+ rec.StatusNamed = &off
+ 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").StatusNamed {
+ t.Error("an explicit status_named:false did not clear the row's opt-in")
+ }
+}
+
+// The whole reason StatusNamed is a pointer: an operator's `carlos add
+// -status-named` opt-in survives every pass of a record that never mentions
+// the field. A plain bool would read absent as an explicit false and quietly
+// pull the app's name off the public status page.
+func TestReconcileAbsentStatusNamedDoesNotStompOptIn(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.SetStatusNamed("acme.go.tito.io", true); 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").StatusNamed {
+ t.Error("an absent StatusNamed stamped its default over an operator's opt-in")
+ }
+}
+
// 5. Conflict.
func TestReconcileLeavesConflictAlone(t *testing.T) {
s, sqid := setup(t)
@@ -477,6 +783,100 @@ func TestReconcileSkipsRecordWithInvalidHostShape(t *testing.T) {
mustGet(t, reg, bad)
}
+// ValidateShape is a safety net, not just the console door's policy check:
+// a record can reach the bucket by paths other than the console (an older
+// or future writer, or direct bucket access), and this pass must not
+// materialize one whose Kind/Addr/Unit/Environment combination is
+// malformed. Skipped loudly, and protected from removal for the same
+// reason a corrupted body or an invalid host shape is — an unusable record
+// is not a retired one.
+func TestReconcileSkipsRecordWithInvalidShape(t *testing.T) {
+ s, sqid := setup(t)
+ // kind "service" with no addr: ValidateShape refuses this combination
+ // (the platform only proxies to a service, so it must name its
+ // upstream), but it agrees with its own key path and its host shape is
+ // fine — the only thing wrong is the Kind/Addr combination itself.
+ bad := Record{Schema: 1, Host: "bad-service.go.tito.io", Slug: "bad-service", App: "tito",
+ Account: sqid, Pool: "tito", Region: "ie", Channel: "stable", Kind: "service",
+ CreatedAt: time.Now()}
+ body, err := json.Marshal(bad)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, RecordKey(sqid, "tito", bad.Host), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := CreateRecord(ctx, s, acmeRecord(sqid)); 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 rt, err := reg.Get(bad.Host); err != nil || rt != nil {
+ t.Errorf("minted a route from a shape-invalid record: %+v %v", rt, err)
+ }
+ // The good neighbour in the same pass is untouched by the skip.
+ mustGet(t, reg, "acme.go.tito.io")
+
+ // And a route that somehow already carries that host is not treated as
+ // retired: the record is there, it just cannot be used.
+ if err := reg.Upsert(registry.Route{Host: bad.Host, Kind: "instance", App: "tito",
+ Status: "ready", Provisioned: true, Region: "ie", Pool: "tito", Account: sqid}); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, bad.Host)
+}
+
+// ValidateShape deliberately does not inspect Channel (it validates shape
+// fields, Channel is not one), but a channel-less record must not become a
+// route pinned to no channel — that is issue #130's failure mode (a route
+// whose channel resolves to nothing, waking with "no adoptable head")
+// arriving through the MINT door instead of the converge door Task 3
+// closed. Skipped loudly at the read-path gate, and protected from removal
+// for the same reason a corrupted body is — an unusable record is not a
+// retired one.
+func TestReconcileSkipsRecordWithEmptyChannel(t *testing.T) {
+ s, sqid := setup(t)
+ bad := acmeRecord(sqid)
+ bad.Host, bad.Slug, bad.Channel = "no-channel.go.tito.io", "no-channel", ""
+ body, err := json.Marshal(bad)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, RecordKey(sqid, "tito", bad.Host), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := CreateRecord(ctx, s, acmeRecord(sqid)); 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 rt, err := reg.Get(bad.Host); err != nil || rt != nil {
+ t.Errorf("minted a route from a channel-less record: %+v %v", rt, err)
+ }
+ // The good neighbour in the same pass is untouched by the skip.
+ mustGet(t, reg, "acme.go.tito.io")
+
+ // And a route that somehow already carries that host is not treated as
+ // retired: the record is there, it just cannot be used.
+ if err := reg.Upsert(registry.Route{Host: bad.Host, Kind: "instance", App: "tito",
+ Status: "ready", Provisioned: true, Region: "ie", Pool: "tito", Account: sqid}); err != nil {
+ t.Fatal(err)
+ }
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ mustGet(t, reg, bad.Host)
+}
+
// CRITICAL regression (code review finding): an existing route's record
// going unparseable — not gone, just unreadable this pass — must never read
// as "the bucket retired it". Before the fix, json.Unmarshal failure and
@@ -643,3 +1043,456 @@ func TestReconcileDeclinesUnreadableTrashMarker(t *testing.T) {
t.Fatalf("an unreadable trash marker still minted a route: %+v %v", rt, err)
}
}
+
+// 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")
+ }
+}
+
+// A record has no field for Backing — registry.Route.Backing's own doc
+// comment calls it a box-scoped operator column, alongside capability
+// grants and tunnels — so a record can never describe a carlos-app@<host>
+// unit tenant at all. Even a row that otherwise matches in every column
+// must not be adopted: adoption would hand this pass's removal phase
+// authority over a row it cannot even state the shape of.
+func TestReconcileRefusesToAdoptBackedRoute(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",
+ Backing: registry.BackingUnit,
+ }
+ reg := regWith(t, handAdded)
+ 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 Backing-owned route was touched: before %+v after %+v", before, after)
+ }
+ if after.Provisioned {
+ t.Error("a carlos-app@ unit tenant was adopted — a record cannot state its Backing")
+ }
+}
+
+// Tunnel is routing-decisive and OUTRANKS the Socket/Addr a record can
+// state: internal/edge picks the tunnel proxy before it ever looks at
+// Socket (edge.go's route-selection switch), so a tunnel-backed route can
+// otherwise agree with a record on every other compared column while every
+// byte it actually serves goes down a channel the record cannot see or
+// reclaim (2026-08-10 review, Critical).
+func TestReconcileRefusesToAdoptTunnelBoundRoute(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",
+ Tunnel: "pi-fleet",
+ }
+ reg := regWith(t, handAdded)
+ 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 tunnel-bound route was touched: before %+v after %+v", before, after)
+ }
+ if after.Provisioned {
+ t.Error("a tunnel-bound route was adopted — a record cannot state or reclaim its Tunnel binding")
+ }
+}
+
+// Caps is the operator-granted, box-local capability-grant column, and
+// registry.Delete's purge of a route's grants is already a human-gated,
+// last-resort action elsewhere in this file (the shape-drift log). Adoption
+// must not hand that purge to an unattended bucket-record deletion
+// (2026-08-10 review, Critical).
+func TestReconcileRefusesToAdoptCapsBearingRoute(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",
+ Caps: "port:25",
+ }
+ reg := regWith(t, handAdded)
+ 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 caps-bearing route was touched: before %+v after %+v", before, after)
+ }
+ if after.Provisioned {
+ t.Error("a caps-bearing route was adopted — its grants would now be one record deletion from purged")
+ }
+}
+
+// domains.mintedRoute builds platform routes with empty Slug/Pool/Region/
+// DBPath, copying Kind/Socket/Addr from the source route — so a record
+// whose Slug/Pool/Region are also empty (an unconstrained-region/pool box)
+// can otherwise agree with a platform-minted row on every other compared
+// column. Adoption must not hand this pass's removal authority to a row
+// domains.Reconcile already owns and periodically re-Upserts/removes on its
+// own authority (2026-08-10 review, Important 1).
+func TestReconcileRefusesToAdoptPlatformRoute(t *testing.T) {
+ s, sqid := setup(t)
+ rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: sqid,
+ Channel: "stable", CreatedAt: time.Now()}
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ handAdded := registry.Route{
+ Host: "acme.go.tito.io", Kind: "instance",
+ Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db",
+ App: "tito", Account: sqid, Status: "ready", Platform: true,
+ }
+ reg := regWith(t, handAdded)
+ before := *mustGet(t, reg, "acme.go.tito.io")
+ cfg := ReconcileConfig{DataDir: "/data"} // unconstrained region, default pool only
+ 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 platform-owned route was touched: before %+v after %+v", before, after)
+ }
+ if after.Provisioned {
+ t.Error("a platform-owned route was adopted — removal authority granted to a second pass")
+ }
+}
+
+// Same guard, the other flag domains.Reconcile also uses for its own
+// removal authority (2026-08-10 review, Important 1).
+func TestReconcileRefusesToAdoptCanaryRoute(t *testing.T) {
+ s, sqid := setup(t)
+ rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: sqid,
+ Channel: "stable", CreatedAt: time.Now()}
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ handAdded := registry.Route{
+ Host: "acme.go.tito.io", Kind: "instance",
+ Socket: "/run/carlos/acme.go.tito.io.sock", DBPath: "/data/acme.go.tito.io.db",
+ App: "tito", Account: sqid, Status: "ready", Canary: true,
+ }
+ reg := regWith(t, handAdded)
+ before := *mustGet(t, reg, "acme.go.tito.io")
+ cfg := ReconcileConfig{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 canary-owned route was touched: before %+v after %+v", before, after)
+ }
+ if after.Provisioned {
+ t.Error("a canary-owned route was adopted — removal authority granted to a second pass")
+ }
+}
+
+// Nothing currently locked the derived Socket/DBPath comparison specifically
+// — the only prior mismatch test differed on App, so a regression that
+// dropped the DBPath comparison from shapeMismatch would leave the suite
+// green (2026-08-10 review, test gap called out alongside the Important
+// findings).
+func TestReconcileRefusesToAdoptOnDBPathMismatch(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/other.db",
+ App: "tito", Account: sqid, Pool: "tito", Region: "ie",
+ Channel: "stable", Hibernate: true, Status: "ready",
+ }
+ reg := regWith(t, handAdded)
+ 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 DBPath-mismatched route was touched: before %+v after %+v", before, after)
+ }
+ if after.Provisioned {
+ t.Error("a route was adopted despite a DBPath mismatch")
+ }
+}
+
+// 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)
+ }
+}
+
+// A dry-run pass reports what it would do and writes nothing — 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. This exercises all four write
+// shapes in the same pass — mint (acme.go.tito.io has no existing route),
+// adopt (a hand-added route matching a record's shape but on the wrong
+// channel), converge (an already-provisioned route whose channel,
+// environment, hibernate and seed all disagree with its record), and remove
+// (a provisioned route whose record is gone) — because a test that only
+// covers minting would pass while a removal wrote through.
+func TestReconcileDryRunWritesNothing(t *testing.T) {
+ s, sqid := setup(t)
+
+ // Mint: no existing route for this host.
+ if err := CreateRecord(ctx, s, acmeRecord(sqid)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Adopt: a hand-added route matching the record's shape, on a
+ // different channel AND environment — adoption would converge both in
+ // the same pass. Two differing columns, deliberately: a dry-run report
+ // that only ever mentioned channel here would leave environment's
+ // change invisible to the operator reading it (design §4.3 review).
+ adoptRec := Record{Schema: 1, Host: "adopt.go.tito.io", Slug: "adopt", App: "tito",
+ Account: sqid, Pool: "tito", Region: "ie", Channel: "edge", Environment: "staging", CreatedAt: time.Now()}
+ if err := CreateRecord(ctx, s, adoptRec); err != nil {
+ t.Fatal(err)
+ }
+ adoptable := registry.Route{
+ Host: "adopt.go.tito.io", Slug: "adopt", Kind: "instance",
+ Socket: "/run/carlos/adopt.go.tito.io.sock", DBPath: "/data/adopt.go.tito.io.db",
+ App: "tito", Account: sqid, Pool: "tito", Region: "ie",
+ Channel: "stable", Hibernate: true, Status: "ready",
+ }
+
+ // Converge: already provisioned (this pass or an earlier one owns it),
+ // but channel, environment, hibernate and seed all disagree.
+ onHibernate := true
+ ownedRec := Record{Schema: 1, Host: "owned.go.tito.io", Slug: "owned", App: "tito",
+ Account: sqid, Pool: "tito", Region: "ie", Channel: "edge", Environment: "staging",
+ Hibernate: &onHibernate, CreatedAt: time.Now()}
+ if err := CreateRecord(ctx, s, ownedRec); err != nil {
+ t.Fatal(err)
+ }
+ owned := registry.Route{
+ Host: "owned.go.tito.io", Slug: "owned", Kind: "instance",
+ Socket: "/run/carlos/owned.go.tito.io.sock", DBPath: "/data/owned.go.tito.io.db",
+ App: "tito", Account: sqid, Pool: "tito", Region: "ie",
+ Channel: "stable", Provisioned: true, Status: "ready",
+ }
+
+ // Remove: provisioned, in scope, and its record is gone.
+ 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, owned, 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)
+ }
+ if rt, err := reg.Get("acme.go.tito.io"); err != nil || rt != nil {
+ t.Errorf("dry-run minted a route: %+v %v", rt, err)
+ }
+
+ // Positive control (reviewer-flagged): the assertion above — total
+ // state unchanged — would also pass if this fixture simply never
+ // exercised the mint/adopt/converge/remove branches at all, e.g. if a
+ // future validation change caused ownedRec to be skipped and
+ // protect-ed, silently dropping the converge branch from coverage while
+ // the test still reported green. Re-running the identical fixture for
+ // real proves the fixture DOES drive every branch: if state came out
+ // unchanged here too, the dry-run assertion above would have been
+ // vacuous, not a proof of anything.
+ cfg.DryRun = false
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ live, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reflect.DeepEqual(before, live) {
+ t.Fatal("a real pass over the identical fixture left the registry unchanged — " +
+ "the dry-run assertion above proves nothing if nothing was ever going to write")
+ }
+}
+
+// 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)
+ // Upsert stamps CreatedAt; read the row back so the final comparison
+ // isn't comparing against a zero value the row never actually had.
+ handAdded = *mustGet(t, reg, "console.go.tito.io")
+ 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)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 250ca1721..1ac4ede6e 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -1176,6 +1176,61 @@ func (r *Registry) SetSeed(host, seed string) error {
return nil
}
+// 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 condition that
+// GRANTS the reconcile pass's removal phase authority over a row (that loop
+// also gates on region/pool scope and on the record being gone). 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
+}
+
+// SetStatusNamed flips a route's public-status-page naming opt-in as a narrow
+// UPDATE, same rationale as SetSeed (a read-modify-write Upsert racing a
+// deploy's SetVersions or the activator's SetNextDue would clobber those). It
+// errors when no row matched, the SetChannel contract: the provisioning
+// reconcile pass converges this column from the instance record and must
+// notice a row that vanished under it rather than silently doing nothing.
+func (r *Registry) SetStatusNamed(host string, on bool) error {
+ v := 0
+ if on {
+ v = 1
+ }
+ res, err := r.sql.Exec(`UPDATE routes SET status_named = ? 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
+}
+
// DueInstances returns hibernatable instance routes whose next_due has
// arrived (0 means no schedule and never matches).
func (r *Registry) DueInstances(now int64) ([]Route, error) {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index 0e0ab87ec..1721cdef4 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -1640,6 +1640,56 @@ func TestValidTunnelNameLengthBoundary(t *testing.T) {
}
}
+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)")
+ }
+}
+
+func TestSetStatusNamed(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.SetStatusNamed("a.example.com", true); err != nil {
+ t.Fatal(err)
+ }
+ rt, err := r.Get("a.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !rt.StatusNamed {
+ t.Error("SetStatusNamed(true) did not stamp the column")
+ }
+ if err := r.SetStatusNamed("a.example.com", false); err != nil {
+ t.Fatal(err)
+ }
+ rt, err = r.Get("a.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rt.StatusNamed {
+ t.Error("SetStatusNamed(false) did not clear the column")
+ }
+ if err := r.SetStatusNamed("missing.example.com", true); err == nil {
+ t.Error("SetStatusNamed on an unknown host returned nil; want an error (the SetChannel contract)")
+ }
+}
+
// TestSetFleetBoxRoundTrips mirrors TestSetTunnel's shape: SetFleetBox is a
// narrow UPDATE on the new fleet_box column, which records which fleet box
// is CURRENTLY serving a placed route (distinct from Region/placement