carlos / platform Public

appconfig cli: -environment on env/secrets set/unset/list, plus listing

carlos env/secrets set/unset/list now take -environment <name>, operating
on the named apps/<acct>/<app>/environments/<name>.json bundle via Task
1's LoadEnvironment/SaveEnvironment/ListEnvironments instead of the
default config.json bundle; `list` prints which bundle it is showing.
`carlos env environments -app <a>` lists an app's bundle names. A bundle
is created implicitly on its first `set`, matching config.json today.

"default" is refused as an -environment value, with a message explaining
it already names the base bundle rather than a bare "invalid name" —
closing the forward reference Task 2's route existence-rail message and
LoadMerged's missing-environment error already made to this flag.

e2e tests: named-bundle round trip, default untouched by named writes and
vice versa, `environments` listing (including an app with none), reserved
-name refusal (default, invalid chars), and a merge-visible check that
sets default+override through the CLI and confirms via the library's
LoadMerged what a bound route would actually receive.
Paul Campbell 7ca93b54d7ec701064e9afed7f27a2b245c11ca6 parent 18b5aad
2 files changed, +339 −23
  • appconfig_cli.go +152 −23
  • e2e_test.go +187 −0
diff --git a/appconfig_cli.go b/appconfig_cli.go
index a971d9baf..35cdf81e8 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -15,14 +15,21 @@ import (
"text/tabwriter"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/release"
)
const envUsage = `usage: carlos env <command>
commands:
- set -app <a> KEY=value [KEY2=value2 ...] set one or more plain config vars
- unset -app <a> KEY [KEY2 ...] remove config vars
- list -app <a> list an app's config vars
+ set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] set one or more plain config vars
+ unset -app <a> [-environment <name>] KEY [KEY2 ...] remove config vars
+ list -app <a> [-environment <name>] list an app's config vars
+ environments -app <a> list an app's named environment bundles
+
+-environment names a bundle at apps/<acct>/<app>/environments/<name>.json,
+layered on top of the default bundle (config.json) at materialization time —
+see LoadMerged. Omit -environment for the default bundle itself. The bundle
+is created implicitly on its first "set", same as config.json today.
`
// RunEnv is the `carlos env` verb.
@@ -38,12 +45,98 @@ func RunEnv(args []string) {
runEnvUnset(args[1:])
case "list":
runEnvList(args[1:])
+ case "environments":
+ runEnvEnvironments(args[1:])
default:
fmt.Fprint(os.Stderr, envUsage)
os.Exit(1)
}
}
+// bundleLabel names the bundle a command is reading or writing, for a
+// `list` header or a confirmation line: "default" reads better to a human
+// than "", and matches ValidEnvironmentName's own vocabulary for the base
+// bundle every route already gets when no -environment is bound.
+func bundleLabel(environment string) string {
+ if environment == "" {
+ return "default"
+ }
+ return environment
+}
+
+// checkEnvironmentFlag refuses a bad -environment value before it reaches
+// appconfig, with a reason a bare "invalid" wouldn't give. "" is not
+// reserved — it is the flag's own way of saying "no -environment", i.e. the
+// default bundle — but "default" IS reserved: it already names that same
+// bundle (config.json), so naming it as an -environment value would either
+// error confusingly deep in appconfig or, worse, look like a second bundle
+// that happens to share a name. Every env/secrets subcommand that accepts
+// -environment calls this right after fs.Parse.
+func checkEnvironmentFlag(environment string) {
+ if environment == "" {
+ return
+ }
+ if environment == "default" {
+ log.Fatal(`-environment default is refused: "default" already names the base bundle ` +
+ "(config.json) that every route gets when no -environment is bound — omit -environment " +
+ "to set/unset/list it directly, don't name it")
+ }
+ if !appconfig.ValidEnvironmentName(environment) {
+ log.Fatalf("-environment %q is not a valid environment name — want [a-z0-9-]{1,32}", environment)
+ }
+}
+
+// loadBundle fetches the manifest a `list` command shows: the default
+// bundle when environment=="", or the named environment's own bundle
+// otherwise — never merged. LoadMerged is what a bound route actually gets
+// at materialization time; `list` shows exactly one bundle, matching what
+// `set`/`unset` write to for the same -environment value.
+func loadBundle(ctx context.Context, s release.Store, acct, app, environment string) (*appconfig.Manifest, error) {
+ if environment == "" {
+ return appconfig.Get(ctx, s, acct, app)
+ }
+ return appconfig.LoadEnvironment(ctx, s, acct, app, environment)
+}
+
+// saveEnvVars writes plain config vars to the default bundle when
+// environment=="", or to the named environment's own bundle otherwise —
+// the same CAS discipline either way (appconfig.SetEnv / SaveEnvironment).
+func saveEnvVars(ctx context.Context, s release.Store, acct, app, environment string, kv map[string]string) error {
+ if environment == "" {
+ return appconfig.SetEnv(ctx, s, acct, app, kv)
+ }
+ sets := make(map[string]appconfig.Var, len(kv))
+ for k, v := range kv {
+ sets[k] = appconfig.Var{Kind: "env", Value: v}
+ }
+ return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil)
+}
+
+// saveSecrets writes already-sealed secrets to the default bundle when
+// environment=="", or to the named environment's own bundle otherwise —
+// mirrors saveEnvVars; see its comment.
+func saveSecrets(ctx context.Context, s release.Store, acct, app, environment string, sealed map[string]string) error {
+ if environment == "" {
+ return appconfig.SetSecret(ctx, s, acct, app, sealed)
+ }
+ sets := make(map[string]appconfig.Var, len(sealed))
+ for k, v := range sealed {
+ sets[k] = appconfig.Var{Kind: "secret", Envelope: v}
+ }
+ return appconfig.SaveEnvironment(ctx, s, acct, app, environment, sets, nil)
+}
+
+// unsetVars removes vars (env or secret) by key from the default bundle
+// when environment=="", or from the named environment's own bundle
+// otherwise — shared by `env unset` and `secrets unset`, same as
+// appconfig.Unset is shared by their non-environment paths today.
+func unsetVars(ctx context.Context, s release.Store, acct, app, environment string, keys []string) error {
+ if environment == "" {
+ return appconfig.Unset(ctx, s, acct, app, keys)
+ }
+ return appconfig.SaveEnvironment(ctx, s, acct, app, environment, nil, keys)
+}
+
// parseKV turns ["KEY=value", "KEY2=value2"] into a map, refusing anything
// without an "=" or with an empty key. Shared by `env set` and `secrets set`.
func parseKV(args []string) (map[string]string, error) {
@@ -67,47 +160,54 @@ func parseKV(args []string) (map[string]string, error) {
func runEnvSet(args []string) {
fs := flag.NewFlagSet("env set", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env set -app <a> KEY=value [KEY2=value2 ...]")
+ log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
}
+ checkEnvironmentFlag(*environment)
kv, err := parseKV(fs.Args())
if err != nil {
log.Fatalf("env set: %v", err)
}
- if err := appconfig.SetEnv(context.Background(), openStore(), acctOf(), *app, kv); err != nil {
+ if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d env var(s) for %s\n", len(kv), *app)
+ fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
}
func runEnvUnset(args []string) {
fs := flag.NewFlagSet("env unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos env unset -app <a> KEY [KEY2 ...]")
+ log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]")
}
- if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil {
+ checkEnvironmentFlag(*environment)
+ if err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args()); err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d var(s) for %s\n", fs.NArg(), *app)
+ fmt.Printf("unset %d var(s) for %s (%s)\n", fs.NArg(), *app, bundleLabel(*environment))
}
func runEnvList(args []string) {
fs := flag.NewFlagSet("env list", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env list -app <a>")
+ log.Fatal("usage: carlos env list -app <a> [-environment <name>]")
}
- m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app)
+ checkEnvironmentFlag(*environment)
+ m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
if err != nil {
log.Fatal(err)
}
+ fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment))
var keys []string
for k, v := range m.Vars {
if v.Kind == "env" {
@@ -123,13 +223,35 @@ func runEnvList(args []string) {
w.Flush()
}
+func runEnvEnvironments(args []string) {
+ fs := flag.NewFlagSet("env environments", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctOf := accountFlags(fs)
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal("usage: carlos env environments -app <a>")
+ }
+ names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, n := range names {
+ fmt.Println(n)
+ }
+}
+
const secretsUsage = `usage: carlos secrets <command>
commands:
- set -app <a> KEY=value [KEY2=value2 ...] seal and set one or more secrets
- unset -app <a> KEY [KEY2 ...] remove secrets
- list -app <a> list a secret's key names only — never values
- genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair
+ set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...] seal and set one or more secrets
+ unset -app <a> [-environment <name>] KEY [KEY2 ...] remove secrets
+ list -app <a> [-environment <name>] list a secret's key names only — never values
+ genkey mint a CARLOS_SECRETS_KEY/PUBKEY pair
+
+-environment names a bundle at apps/<acct>/<app>/environments/<name>.json —
+see "carlos env" for the full explanation; the same bundle backs both env
+vars and secrets, sealed or not. "carlos env environments -app <a>" lists
+an app's bundle names.
`
// RunSecrets is the `carlos secrets` verb.
@@ -166,11 +288,13 @@ func secretsPubKey() *ecdh.PublicKey {
func runSecretsSet(args []string) {
fs := flag.NewFlagSet("secrets set", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos secrets set -app <a> KEY=value [KEY2=value2 ...]")
+ log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
}
+ checkEnvironmentFlag(*environment)
kv, err := parseKV(fs.Args())
if err != nil {
log.Fatalf("secrets set: %v", err)
@@ -192,38 +316,43 @@ func runSecretsSet(args []string) {
}
sealed[k] = env
}
- if err := appconfig.SetSecret(context.Background(), openStore(), acctOf(), *app, sealed); err != nil {
+ if err := saveSecrets(context.Background(), openStore(), acctOf(), *app, *environment, sealed); err != nil {
log.Fatal(err)
}
- fmt.Printf("set %d secret(s) for %s\n", len(kv), *app)
+ fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
}
func runSecretsUnset(args []string) {
fs := flag.NewFlagSet("secrets unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos secrets unset -app <a> KEY [KEY2 ...]")
+ log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]")
}
- if err := appconfig.Unset(context.Background(), openStore(), acctOf(), *app, fs.Args()); err != nil {
+ checkEnvironmentFlag(*environment)
+ if err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args()); err != nil {
log.Fatal(err)
}
- fmt.Printf("unset %d secret(s) for %s\n", fs.NArg(), *app)
+ fmt.Printf("unset %d secret(s) for %s (%s)\n", fs.NArg(), *app, bundleLabel(*environment))
}
func runSecretsList(args []string) {
fs := flag.NewFlagSet("secrets list", flag.ExitOnError)
app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos secrets list -app <a>")
+ log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]")
}
- m, err := appconfig.Get(context.Background(), openStore(), acctOf(), *app)
+ checkEnvironmentFlag(*environment)
+ m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
if err != nil {
log.Fatal(err)
}
+ fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment))
var keys []string
for k, v := range m.Vars {
if v.Kind == "secret" {
diff --git a/e2e_test.go b/e2e_test.go
index b28ab5116..7e5e15f72 100644
--- a/e2e_test.go
+++ b/e2e_test.go
@@ -1432,6 +1432,193 @@ func TestSecretsCLI(t *testing.T) {
}
}
+// TestEnvironmentBundlesCLI exercises `carlos env`/`carlos secrets -environment`
+// (2026-08-02-pipelines-design.md §4a, plan Task 4): named environment
+// bundles are the same manifest shape as config.json, at a sibling bucket
+// key, created implicitly on first `set` exactly like the default bundle —
+// and never touched by a write to the other.
+func TestEnvironmentBundlesCLI(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ carlosBin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", carlosBin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ storeDir := filepath.Join(tmp, "store")
+ env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir)
+ run := func(args ...string) string {
+ t.Helper()
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+ }
+ runFails := func(args ...string) string {
+ t.Helper()
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = env
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ t.Fatalf("carlos %s: succeeded, want a refusal\n%s", strings.Join(args, " "), out)
+ }
+ return string(out)
+ }
+
+ // Named-bundle round trip: set on a named environment, see it in that
+ // environment's own `list`, unset removes it.
+ run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL=trace")
+ out := run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo")
+ if !strings.Contains(out, "demo") {
+ t.Fatalf("env list -environment demo = %q, want the bundle name in the header", out)
+ }
+ if !regexp.MustCompile(`(?m)^LOG_LEVEL\s+trace$`).MatchString(out) {
+ t.Fatalf("env list -environment demo = %q, want LOG_LEVEL=trace", out)
+ }
+ run("env", "unset", "-account", "bab", "-app", "keymail", "-environment", "demo", "LOG_LEVEL")
+ out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo")
+ if strings.Contains(out, "LOG_LEVEL") {
+ t.Fatalf("env list -environment demo = %q, LOG_LEVEL survived unset", out)
+ }
+
+ // Default untouched by a named-bundle write: set something on demo, and
+ // on a second, never-before-seen environment; the default bundle (no
+ // -environment) still shows nothing.
+ run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "demo", "STRIPE_MODE=sandbox")
+ run("env", "set", "-account", "bab", "-app", "keymail", "-environment", "staging", "STRIPE_MODE=staging-sandbox")
+ out = run("env", "list", "-account", "bab", "-app", "keymail")
+ if !strings.Contains(out, "default") {
+ t.Fatalf("env list (no -environment) = %q, want the bundle header to say default", out)
+ }
+ if strings.Contains(out, "STRIPE_MODE") {
+ t.Fatalf("env list (no -environment) = %q, a named-bundle write leaked into the default bundle", out)
+ }
+ // And the reverse: write the default bundle, and it does not appear
+ // under the named ones.
+ run("env", "set", "-account", "bab", "-app", "keymail", "BASE_ONLY=1")
+ out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "demo")
+ if strings.Contains(out, "BASE_ONLY") {
+ t.Fatalf("env list -environment demo = %q, a default-bundle write leaked into a named bundle", out)
+ }
+
+ // `carlos env environments -app <a>` lists the bundle names created
+ // implicitly above, sorted, and nothing else (no "default" entry — that
+ // bundle isn't a named environment).
+ out = run("env", "environments", "-account", "bab", "-app", "keymail")
+ names := strings.Fields(strings.TrimSpace(out))
+ if want := []string{"demo", "staging"}; !reflect.DeepEqual(names, want) {
+ t.Fatalf("env environments = %q, want %v", names, want)
+ }
+
+ // An app with no named environments lists none, not an error.
+ out = run("env", "environments", "-account", "bab", "-app", "keymail-bare")
+ if strings.TrimSpace(out) != "" {
+ t.Fatalf("env environments for a bare app = %q, want empty", out)
+ }
+
+ // Reserved-name refusal: "default" is refused, with a message that
+ // explains default already names the base bundle rather than a bare
+ // "invalid name". An invalid-charset name is refused too.
+ msg := runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1")
+ if !strings.Contains(msg, "default") || !strings.Contains(strings.ToLower(msg), "base bundle") {
+ t.Fatalf("env set -environment default = %q, want a refusal naming default as the base bundle", msg)
+ }
+ msg = runFails("secrets", "set", "-account", "bab", "-app", "keymail", "-environment", "default", "X=1")
+ if !strings.Contains(msg, "default") {
+ t.Fatalf("secrets set -environment default = %q, want a refusal", msg)
+ }
+ msg = runFails("env", "set", "-account", "bab", "-app", "keymail", "-environment", "Not_Valid!", "X=1")
+ if !strings.Contains(msg, "Not_Valid!") {
+ t.Fatalf("env set -environment Not_Valid! = %q, want a refusal naming the bad value", msg)
+ }
+ // "-environment \"\"" is indistinguishable from omitting the flag — the
+ // CLI's sentinel for "the default bundle", never a name to refuse. Not a
+ // refusal case: it must succeed, against the default bundle.
+ out = run("env", "list", "-account", "bab", "-app", "keymail", "-environment", "")
+ if !strings.Contains(out, "default") {
+ t.Fatalf(`env list -environment "" = %q, want it treated as the default bundle`, out)
+ }
+
+ // The named bundle lands at the sibling bucket key the design specifies.
+ store := release.NewDirStore(storeDir)
+ if _, _, err := store.Get(context.Background(), "apps/bab/keymail/environments/demo.json"); err != nil {
+ t.Fatalf("named environment manifest not at the sibling key: %v", err)
+ }
+ // And the default bundle is untouched in shape and key.
+ if _, _, err := store.Get(context.Background(), "apps/bab/keymail/config.json"); err != nil {
+ t.Fatalf("default config manifest missing: %v", err)
+ }
+}
+
+// TestEnvironmentMergeVisibleFromCLI is the merge-visible check the plan
+// asks for: set the default bundle plus a named override through the CLI
+// exactly as an operator would, then verify — via the library's LoadMerged,
+// the same function Sync calls per bound route — that the merged view a
+// route bound to that environment would actually receive has the override
+// applied and everything else inherited from default.
+func TestEnvironmentMergeVisibleFromCLI(t *testing.T) {
+ goBin, err := exec.LookPath("go")
+ if err != nil {
+ t.Skip("go toolchain not found")
+ }
+ tmp := t.TempDir()
+ carlosBin := filepath.Join(tmp, "carlos")
+ build := exec.Command(goBin, "build", "-o", carlosBin, ".")
+ build.Env = os.Environ()
+ if out, err := build.CombinedOutput(); err != nil {
+ t.Fatalf("build carlos: %v\n%s", err, out)
+ }
+ storeDir := filepath.Join(tmp, "store")
+ env := append(os.Environ(), "CARLOS_DEPLOYMENT_DIR="+storeDir)
+ run := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command(carlosBin, args...)
+ cmd.Env = env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("carlos %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ }
+
+ run("env", "set", "-account", "bab", "-app", "tito",
+ "STRIPE_KEY=sk_base", "LOG_LEVEL=info", "REGION=eu")
+ run("env", "set", "-account", "bab", "-app", "tito", "-environment", "demo",
+ "STRIPE_KEY=sk_demo")
+
+ store := release.NewDirStore(storeDir)
+ merged, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "demo")
+ if err != nil {
+ t.Fatalf("LoadMerged: %v", err)
+ }
+ want := map[string]string{"STRIPE_KEY": "sk_demo", "LOG_LEVEL": "info", "REGION": "eu"}
+ if len(merged.Vars) != len(want) {
+ t.Fatalf("merged vars = %v, want exactly %v", merged.Vars, want)
+ }
+ for k, v := range want {
+ got, ok := merged.Vars[k]
+ if !ok || got.Value != v {
+ t.Fatalf("merged.Vars[%s] = %+v, want value %q", k, got, v)
+ }
+ }
+
+ // A route with no -environment binding — envName=="" — sees the default
+ // bundle alone, the override absent.
+ base, err := appconfig.LoadMerged(context.Background(), store, "bab", "tito", "")
+ if err != nil {
+ t.Fatalf("LoadMerged (default): %v", err)
+ }
+ if base.Vars["STRIPE_KEY"].Value != "sk_base" {
+ t.Fatalf("default-bound LoadMerged saw STRIPE_KEY=%q, want sk_base — the demo override leaked into the unbound route",
+ base.Vars["STRIPE_KEY"].Value)
+ }
+}
+
// TestRouteCLI exercises `carlos route`, the verb that lets a route's channel
// subscription change without raw sqlite against a production registry. The
// safety rail IS the point of the verb: a route only repoints when the