carlos / platform Public

cli: -backing meets the same rails as the other single-route axes

runRouteBacking's doc comment claimed the value was checked "at the flag
before the registry is even opened" and nothing performed the check.
registry.SetBacking writes the column verbatim and release.adoptable
compares against BackingUnit exactly, so `-backing unti` was accepted,
stored "unti", and left the route neither unit-backed nor visibly wrong —
the migration's one write, silently a no-op.

-backing was also outside the axis exclusions the other two live behind.
Every combination was a silent partial: runRoute's switch runs grantAxis,
then hibernateSet, then -backing, so `-hibernate=false -backing unit` wrote
the hibernate column and dropped the stamp, and `-backing unit -channel X`
stamped the row and never repointed it. `-app x -backing unit` fell through
to a lookup of the empty host and answered `no route for host ""`.

The help text gains -backing beside -grant/-revoke/-hibernate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ce804363053eb71b0fd2589250d6529cade429f4 parent a64a34c
3 files changed, +184 −4
  • cli.go +29 −4
  • e2e_test.go +151 −0
  • main.go +4 −0
diff --git a/cli.go b/cli.go
index 985db217d..cf9f6c5f5 100644
--- a/cli.go
+++ b/cli.go
@@ -350,14 +350,27 @@ func runRoute(args []string) {
log.Fatal("route: -grant and -revoke are mutually exclusive — a grant and a revoke of the same route are two operations, and typing both leaves it ambiguous which one you meant to happen")
}
grantAxis := *grant != "" || *revoke != ""
+ // -backing is a single-route axis exactly like the other two, and it is
+ // held to the same exclusions (2026-08-03 review, I2). It was not, and
+ // each gap was a SILENT PARTIAL: the switch below runs grantAxis first,
+ // then hibernateSet, then -backing, so `-hibernate=false -backing unit`
+ // wrote the hibernate column and dropped the backing stamp on the floor
+ // without a word, and `-backing unit -channel canary/x` stamped the row
+ // and never repointed it. An operator who typed both wanted both; a
+ // command that does one of them and reports success is worse than one
+ // that refuses.
if grantAxis && hibernateSet {
log.Fatal("route: -grant/-revoke and -hibernate are mutually exclusive — run them as two commands, so each meets its own rail alone")
}
- if (grantAxis || hibernateSet) && (*channel != "" || *environment != "") {
- log.Fatal("route: -grant/-revoke/-hibernate cannot be combined with -channel or -environment — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together")
+ if backingSet && (grantAxis || hibernateSet) {
+ log.Fatal("route: -backing is mutually exclusive with -grant/-revoke and -hibernate — run them as two commands, so each meets its own rail alone. " +
+ "The order is not arbitrary either: -backing unit comes FIRST, because a grant onto a route that is not unit-backed is refused (it would materialize nothing)")
}
- if (grantAxis || hibernateSet) && *host == "" {
- log.Fatal("route: -grant/-revoke/-hibernate require -host, never -app: grants are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage)
+ if (grantAxis || hibernateSet || backingSet) && (*channel != "" || *environment != "") {
+ log.Fatal("route: -grant/-revoke/-hibernate/-backing cannot be combined with -channel or -environment — a capability grant and a repoint are different operations with different rails, and one command that did both would commit them together")
+ }
+ if (grantAxis || hibernateSet || backingSet) && *host == "" {
+ log.Fatal("route: -grant/-revoke/-hibernate/-backing require -host, never -app: grants and process backing are per route per box by design, and a bulk grant is precisely the operation nobody should be able to type by accident\n\n" + usage)
}
// The token is checked BEFORE the registry is opened for writing: a typo
// should cost nothing and reach nothing.
@@ -370,6 +383,18 @@ func runRoute(args []string) {
log.Fatalf("route: %v", err)
}
}
+ // And so is -backing's value, for the same reason and at the same moment
+ // (2026-08-03 review, I1): runRouteBacking's doc comment already claimed
+ // this check ("the value is \"\" or BackingUnit, checked at the flag
+ // before the registry is even opened") while nothing performed it, and
+ // registry.SetBacking writes the column verbatim. `-backing unti` was
+ // therefore accepted, wrote "unti", and left the route neither unit-backed
+ // (release.adoptable compares against BackingUnit exactly) nor visibly
+ // wrong — the migration's one write, silently a no-op. Same wording as
+ // runAdd's check, which is the other door onto this column.
+ if backingSet && *backing != "" && *backing != registry.BackingUnit {
+ log.Fatalf("route: backing %q: want %q (a carlos-app@<host> systemd unit) or \"\" (an exec child of the agent)", *backing, registry.BackingUnit)
+ }
if *channel == "" && *environment == "" && !grantAxis && !hibernateSet && !backingSet {
log.Fatal(`route: at least one of -channel, -environment, -grant, -revoke, -hibernate or -backing is required. "" only means stable-by-default at route CREATION (carlos add) — ` +
"this verb sets an explicit binding, and needs an explicit target to set it to.")
diff --git a/e2e_test.go b/e2e_test.go
index ec7c817f9..e1ad4f71b 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -3034,3 +3034,154 @@ func TestAccountsMigrateLegacyKeepsNewerQualifiedPointer(t *testing.T) {
t.Errorf("v1's manifest did not migrate: %v", err)
}
}
+
+// routeOf returns a whole registry row, for the assertions that need more
+// than one column of it.
+func (f *routeTestFixture) routeOf(host string) registry.Route {
+ f.t.Helper()
+ reg, err := registry.Open(f.regPath)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ defer reg.Close()
+ rt, err := reg.Get(host)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ if rt == nil {
+ f.t.Fatalf("no route for host %q", host)
+ }
+ return *rt
+}
+
+// TestRouteBackingRefusesAnUnknownValue is the 2026-08-03 review's I1:
+// runRouteBacking's doc comment already claimed the value was checked "at the
+// flag before the registry is even opened", and nothing performed the check.
+// registry.SetBacking writes the column verbatim, and release.adoptable
+// compares against BackingUnit EXACTLY — so `-backing unti` was accepted,
+// stored "unti", and left the route neither unit-backed nor visibly wrong: the
+// migration's one write, silently a no-op that only shows up as an app that
+// never gets its capabilities.
+//
+// The refusal has to NAME both legal values, because the empty one is a real
+// value of this column and an operator cannot guess that from a rejection.
+func TestRouteBackingRefusesAnUnknownValue(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail",
+ Channel: "stable", Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unti")
+ if err == nil {
+ t.Fatalf("a misspelled -backing value was accepted:\n%s", out)
+ }
+ for _, want := range []string{"unti", "unit", `""`} {
+ if !strings.Contains(out, want) {
+ t.Errorf("refusal does not name %q:\n%s", want, out)
+ }
+ }
+ if got := f.routeOf("keymail.dev").Backing; got != "" {
+ t.Fatalf("backing column = %q after a refusal, want it untouched", got)
+ }
+}
+
+// TestRouteBackingIsExclusiveWithTheOtherAxes is I2: -backing is a
+// single-route axis exactly like -grant/-revoke and -hibernate, and it was not
+// held to their exclusions. Every combination below was a SILENT PARTIAL —
+// runRoute's switch runs grantAxis, then hibernateSet, then -backing, and the
+// channel/environment branch is unreachable once any of them is set — so the
+// command did SOME of what was typed and reported success for all of it. And
+// `-app x -backing unit` fell through to a registry lookup of the empty host,
+// answering `no route for host ""` instead of the require-host message.
+func TestRouteBackingIsExclusiveWithTheOtherAxes(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.ship(release.OpsAccount, "keymail", "v1")
+ f.promote(release.OpsAccount, "keymail", "stable", "v1")
+ f.promote(release.OpsAccount, "keymail", "canary/x", "v1")
+ f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail",
+ Channel: "stable", Version: "v1", Status: "ready"})
+
+ cases := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {
+ // The repoint would never have happened: the axis branch returns
+ // before the channel machinery is reached.
+ name: "-backing with -channel",
+ args: []string{"-host", "keymail.dev", "-backing", "unit", "-channel", "canary/x"},
+ want: "-channel",
+ },
+ {
+ name: "-backing with -environment",
+ args: []string{"-host", "keymail.dev", "-backing", "unit", "-environment", "eu"},
+ want: "-environment",
+ },
+ {
+ // The dangerous ordering: -hibernate is evaluated FIRST, so this
+ // used to write the hibernate column and drop the backing stamp.
+ name: "-hibernate=false with -backing",
+ args: []string{"-host", "keymail.dev", "-hibernate=false", "-backing", "unit"},
+ want: "mutually exclusive",
+ },
+ {
+ name: "-grant with -backing",
+ args: []string{"-host", "keymail.dev", "-grant", "port:25", "-backing", "unit"},
+ want: "mutually exclusive",
+ },
+ {
+ name: "-app with -backing gets the require-host message",
+ args: []string{"-app", "keymail", "-backing", "unit"},
+ want: "require -host",
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ args := append([]string{"route", "-registry", f.regPath}, c.args...)
+ out, err := f.run(f.fullEnv(), args...)
+ if err == nil {
+ t.Fatalf("%s was accepted:\n%s", c.name, out)
+ }
+ if !strings.Contains(out, c.want) {
+ t.Errorf("refusal does not contain %q:\n%s", c.want, out)
+ }
+ if strings.Contains(out, `no route for host ""`) {
+ t.Errorf("refusal is the empty-host lookup, not an axis rail:\n%s", out)
+ }
+ // Nothing written, on any axis, by any of them.
+ rt := f.routeOf("keymail.dev")
+ if rt.Backing != "" || rt.Hibernate || rt.Caps != "" || rt.Channel != "stable" || rt.Environment != "" {
+ t.Fatalf("a refused command still wrote: %+v", rt)
+ }
+ })
+ }
+}
+
+// The other half of the same rail: -backing ALONE, on its own route, still
+// works — and prints what the next adopt pass will do with the new value.
+// Without this the exclusions above could be satisfied by a verb that refuses
+// everything.
+func TestRouteBackingStampsAndSaysWhatHappensNext(t *testing.T) {
+ f := newRouteTestFixture(t)
+ f.addRoute(registry.Route{Host: "keymail.dev", Kind: "instance", App: "keymail",
+ Channel: "stable", Status: "ready"})
+
+ out, err := f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "unit")
+ if err != nil {
+ t.Fatalf("route -backing unit: %v\n%s", err, out)
+ }
+ if got := f.routeOf("keymail.dev").Backing; got != registry.BackingUnit {
+ t.Fatalf("backing column = %q, want %q", got, registry.BackingUnit)
+ }
+ if !strings.Contains(out, "capability drop-ins") {
+ t.Errorf("the stamp does not say what the next adopt pass will do:\n%s", out)
+ }
+ // And back again — the migration's reversibility depends on it.
+ out, err = f.run(f.fullEnv(), "route", "-registry", f.regPath, "-host", "keymail.dev", "-backing", "")
+ if err != nil {
+ t.Fatalf(`route -backing "": %v\n%s`, err, out)
+ }
+ if got := f.routeOf("keymail.dev").Backing; got != "" {
+ t.Fatalf("backing column = %q after clearing, want empty", got)
+ }
+}
diff --git a/main.go b/main.go
index 5c76151c6..ffd334ea8 100644
--- a/main.go
+++ b/main.go
@@ -98,6 +98,10 @@ commands:
-host <h> -hibernate[=false]: hibernate when idle
(refused for a route whose release declares the
daemon capability)
+ -host <h> -backing <unit|"">: who owns this
+ route's process — a carlos-app@<host> systemd
+ unit, or an exec child of the agent (the
+ migration's one write; grants need unit backing)
auth login | whoami | logout — who this terminal is
(headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive