carlos / platform Public

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
Paul Campbell 757d82ed8ee5aedc94d240c5e1ccbc13b198a0ee parent 7e577ab
2 files changed, +104 −0
  • cli.go +32 −0
  • e2e_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/*