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
2 files changed,
+104
−0
cli.go+32 −0e2e_test.go+72 −0
diff --git a/cli.go b/cli.go| index aeca97f16..c26bec15b 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,13 @@ 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/<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)} |
| default: |
| chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck} |
| anyNeedsCheck = true |
| @@ -777,6 +785,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 +825,11 @@ 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: "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"} |
| case envClear: |
| envVerdicts[i] = envVerdict{rt: rt, from: from, kind: eWillWrite} |
| default: |
| @@ -872,6 +886,10 @@ func runRoute(args []string) { |
| 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)) |
| + 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)) |
| } |
| } |
| var envToWrite []string |
| @@ -886,6 +904,9 @@ func runRoute(args []string) { |
| case ePlatformOwned: |
| refusals = append(refusals, fmt.Sprintf( |
| "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note)) |
| + case eRecordOwned: |
| + refusals = append(refusals, fmt.Sprintf( |
| + "%s: %s — no flag overrides a row this verb does not own", v.rt.Host, v.note)) |
| } |
| } |
| // The tunnel rail: -host only (the -app refusal above guarantees exactly |
| @@ -1019,6 +1040,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/e2e_test.go b/e2e_test.go| index 97539e2c4..13615561a 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -2822,6 +2822,78 @@ 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) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| // 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/* |