carlos / platform Public

docs: spec + plan for route-scoped config delivery (#195, #196, #64)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue
Paul Campbell f5b1d893d4eca050c6fcf668f6fbb4165ec6c3fd parent 53d70b5
2 files changed, +2397 −0
  • docs/superpowers/plans/2026-08-16-route-scoped-config-delivery.md +2028 −0
  • docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md +369 −0
diff --git a/docs/superpowers/plans/2026-08-16-route-scoped-config-delivery.md b/docs/superpowers/plans/2026-08-16-route-scoped-config-delivery.md
new file mode 100644
index 000000000..b0a1cef75
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-route-scoped-config-delivery.md
@@ -0,0 +1,2028 @@
+# Route-scoped config delivery — 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:** Close #195, #196 and #64 — the three defects in titogo's
+"deliver one sealed credential to one service" runbook: scope
+`exec_delivery` to the route's bound environment, make a route's
+environment binding converge in seconds instead of an hour, and let a
+member read a route's binding back without box access.
+
+**Architecture:** Three independent changes on one branch. (1)
+`SyncExec`'s opt-in check reads the route's bound environment bundle,
+falling back to the app default — an overlay, exactly like vars. (2) The
+agent's fast poll loop fingerprints the registry-side facts that decide
+what config a route gets, so a binding change is a change it can see. (3)
+A new account-scoped `GET /api/cli/apps/{account}/{app}/routes` served
+from the console's own registry, fronted by `carlos routes`.
+
+**Tech Stack:** Go 1.x, stdlib only. `internal/appconfig` (config plane),
+`internal/host` (agent loops), `internal/console` (HTTP API), root package
+(CLI).
+
+**Spec:** `docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md`
+
+## Global Constraints
+
+- **Gate before every commit:** `go build ./...`, `go vet ./...`,
+ `go test ./... -count=1` all clean, every package; `gofmt -l` clean for
+ files you touched. Sandbox note: use
+ `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go
+ commands may need the sandbox disabled ("read-only file system" from the
+ toolchain is the tell).
+- **Wire-shape changes are additive.** Live manifests exist in production
+ buckets; anything written before this change must still parse, and an
+ older carlos parsing a bundle this writes must not break.
+- **TDD.** Write the failing test, run it, watch it fail for the right
+ reason, then implement.
+- **Nothing merges to `main` without a PR.** Never offer or take a local
+ merge to `main`.
+- **A test that shells out to the carlos binary must build its env through
+ `carlosTestEnv`** and set `cmd.Dir` to a scratch dir.
+- **Do not change `LoadMerged`.** The merged manifest stays vars-only and
+ never carries `ExecDelivery`. The opt-in is resolved by reading the two
+ bundles' fields directly.
+- Vocabulary, fixed: at app scope the CLI words are `file` / `off`; at
+ environment scope they are `file` / `inherit`, and `off` is refused
+ there by name.
+
+---
+
+### Task 1: Environment-scoped `exec_delivery` write path
+
+**Files:**
+- Modify: `internal/appconfig/store.go:229-283` (`SetExecDelivery`)
+- Modify: `internal/appconfig/appconfig.go:22-56` (`Manifest.ExecDelivery`
+ doc comment, `ValidExecDelivery`)
+- Test: `internal/appconfig/environments_test.go`
+
+**Interfaces:**
+- Consumes: `getForWriteAt(ctx, s, key, app) (Manifest, string, bool, error)`,
+ `Key(acct, app) string`, `EnvironmentKey(acct, app, name) string`,
+ `touchLogged(ctx, s, acct, app)`, `DefaultBundle = "default"`.
+- Produces:
+ `func SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) error`
+ — `envName == ""` writes the default bundle (identical to today),
+ otherwise the named bundle. `SetExecDelivery` stays as a wrapper with
+ its existing signature; every current caller keeps working unchanged.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/appconfig/environments_test.go`. Use whatever
+store/helper constructor the neighbouring tests in that file already use
+(read the top of the file first — do not invent one).
+
+```go
+func TestSetExecDeliveryAtEnvironmentRoundTrips(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t) // match this file's existing helper
+ // A bundle must exist before a flag can be written into it, exactly as
+ // the default bundle must.
+ if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil {
+ t.Fatalf("set: %v", err)
+ }
+ m, err := LoadEnvironment(ctx, s, "bab", "titogo", "relay")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.ExecDelivery != ExecDeliveryFile {
+ t.Fatalf("environment bundle exec_delivery = %q, want %q", m.ExecDelivery, ExecDeliveryFile)
+ }
+ // The vars already in the bundle survive the flag write — this is a
+ // read-modify-write, not a replace.
+ if m.Vars["A"].Value != "1" {
+ t.Fatalf("A = %q, want it preserved at \"1\"", m.Vars["A"].Value)
+ }
+ // And the DEFAULT bundle is untouched: an environment-scoped write that
+ // leaked into config.json would opt the whole fleet in, which is the
+ // bug this closes.
+ def, found, err := getIfExists(ctx, s, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if found && def.ExecDelivery != "" {
+ t.Fatalf("default bundle exec_delivery = %q, want it untouched", def.ExecDelivery)
+ }
+}
+
+func TestSetExecDeliveryAtEnvironmentClearsToInherit(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ""); err != nil {
+ t.Fatalf("clear: %v", err)
+ }
+ m, err := LoadEnvironment(ctx, s, "bab", "titogo", "relay")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.ExecDelivery != "" {
+ t.Fatalf("exec_delivery = %q, want %q (inherit)", m.ExecDelivery, "")
+ }
+}
+
+func TestSetExecDeliveryAtRefusesAbsentBundle(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ // Nothing has ever been written for this app.
+ err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", "")
+ if err == nil {
+ t.Fatal("clearing the flag on a bundle that does not exist must be refused, not silently create one")
+ }
+ if !strings.Contains(err.Error(), "relay") {
+ t.Fatalf("refusal %q must name the bundle it could not find", err)
+ }
+}
+
+func TestSetExecDeliveryAtRefusesInvalidValue(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", "socket"); err == nil {
+ t.Fatal("an unknown exec_delivery value must be refused before it reaches the bucket")
+ }
+}
+
+func TestSetExecDeliveryAtBumpsGeneration(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay",
+ map[string]Var{"A": {Kind: "env", Value: "1"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ _, before, err := s.Get(ctx, GenKey("bab", "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ _, after, err := s.Get(ctx, GenKey("bab", "titogo"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatal("an environment-scoped exec_delivery write must bump the generation object — the fast loop watches nothing else for it")
+ }
+}
+```
+
+Add `"strings"` to that file's imports if it is not already there.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/appconfig/ -run TestSetExecDeliveryAt -count=1`
+Expected: FAIL — `undefined: SetExecDeliveryAt`.
+
+- [ ] **Step 3: Implement**
+
+In `internal/appconfig/store.go`, replace `SetExecDelivery`'s body with a
+delegation and add the generalized form beneath it:
+
+```go
+// SetExecDelivery writes (`file`) or clears (`""`) the DEFAULT bundle's
+// exec_delivery field — issue #18's TCP-addr exec route opt-in (see
+// Manifest.ExecDelivery). The app-level door, unchanged in behaviour and
+// signature; SetExecDeliveryAt is the general one.
+func SetExecDelivery(ctx context.Context, s release.Store, acct, app, value string) error {
+ return SetExecDeliveryAt(ctx, s, acct, app, "", value)
+}
+
+// SetExecDeliveryAt writes or clears exec_delivery in ONE bundle: the
+// default bundle (envName == "") or a named environment's, via the same
+// CAS read-modify-write path SetEnv/SetSecret/Unset use. value must be
+// ValidExecDelivery; anything else is refused here, before it ever
+// reaches the bucket, mirroring applyAt's var-name/var-value validation.
+//
+// Environment scope is issue #195 (ruled 2026-08-16): the app-level flag
+// answered "does this APP get files", so opting one relay route in wrote
+// a file for every router route of the same app too. The flag now lives
+// wherever the route's config comes from, and clearing it in a named
+// bundle means INHERIT the app default — not "off". Route scope is still
+// refused: it would need a registry column, a record field and reconcile
+// convergence, three doors onto one bit, where the environment binding is
+// a scope the operator has already chosen.
+//
+// Turning the field off against a bundle that does not exist is refused,
+// not silently turned into a create — the same "unset-only against a
+// nonexistent object" refusal applyAt documents, for the same reason:
+// minting an empty bundle just to hold an absent flag would satisfy
+// `carlos route -environment`'s existence rail and Sync's "configured,
+// then fully unset" state for nothing the operator asked for.
+func SetExecDeliveryAt(ctx context.Context, s release.Store, acct, app, envName, value string) error {
+ if !release.ValidAccount(acct) {
+ return fmt.Errorf("config: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return fmt.Errorf("config: invalid app %q", app)
+ }
+ if !ValidExecDelivery(value) {
+ return fmt.Errorf("config: invalid exec_delivery %q — want %q or \"\" (off/inherit)", value, ExecDeliveryFile)
+ }
+ key, label := Key(acct, app), DefaultBundle
+ if envName != "" {
+ if !ValidEnvironmentName(envName) {
+ return fmt.Errorf("config: invalid environment name %q", envName)
+ }
+ key, label = EnvironmentKey(acct, app, envName), envName
+ }
+ m, etag, found, err := getForWriteAt(ctx, s, key, app)
+ if err != nil {
+ return err
+ }
+ if !found && value == "" {
+ return fmt.Errorf("config: %s has no %s bundle — nothing to turn off", app, label)
+ }
+ m.ExecDelivery = value
+ m.Schema, m.App = 1, app
+ raw, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ return err
+ }
+ // The opt-in flag changes what SyncExec delivers for this app's routes,
+ // so it is a config change like any other and earns the same fast
+ // convergence. The generation object is per-APP, not per-bundle
+ // (generation.go), so an environment-scoped write signals through the
+ // same key an app-level one does — which is why the fast loop needs no
+ // new bundle awareness for this half.
+ touchLogged(ctx, s, acct, app)
+ return nil
+}
+```
+
+Then rewrite the `APP-LEVEL ONLY` paragraph of `Manifest.ExecDelivery`'s
+doc comment in `internal/appconfig/appconfig.go` (the block currently
+beginning "APP-LEVEL ONLY: read from the default bundle (config.json)
+alone."):
+
+```go
+ // BUNDLE-LEVEL, resolved as an overlay (issue #195, ruled 2026-08-16).
+ // SyncExec reads this field from the bundle a route's config actually
+ // comes from: a route bound to environment e takes e's value when e
+ // STATES one, and inherits the default bundle's when it does not. An
+ // unbound route reads the default bundle alone, exactly as before.
+ // LoadMerged still never carries this field into its merged result —
+ // the merged manifest is what gets rendered into a file, and a delivery
+ // flag has no business appearing there; SyncExec's checkOptedIn reads
+ // the two bundles' fields directly instead.
+ //
+ // The 2026-08-03 ruling this narrows said a named environment's
+ // manifest is never a valid place for the field, because "the reader is
+ // the app's own supervisor, one program per app". That premise is false
+ // in the deployment the feature was built for: the titogo app
+ // supervises its relay and its router fleet through different units, so
+ // an app-level flag thrown to deliver one sealed credential to the
+ // relay wrote a file for every router too.
+ //
+ // STILL NOT PER-ROUTE. A per-route flag would need a registry column
+ // and — since a Provisioned route's Channel and Environment belong to
+ // its instance record — a record field and reconcile convergence too:
+ // three doors onto one bit, and the "config-that-lies" hazard the
+ // original ruling named is real at that granularity, where routes
+ // sharing one bundle would disagree about whether it is delivered.
+ // The environment binding is one door onto a scope that already exists.
+ //
+ // Additive to schema 1 (schema is not bumped for this field), so any
+ // other value is refused at SET time (SetExecDeliveryAt,
+ // ValidExecDelivery) and, since an older binary must still be able to
+ // parse a manifest a newer one wrote, ignored — loudly, once per pass —
+ // at SYNC time (see SyncExec's checkOptedIn) rather than causing a hard
+ // failure. An older binary reading a bundle this one wrote ignores the
+ // field entirely (it consults the default bundle alone), which is the
+ // pre-change behaviour, not a fault.
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/appconfig/ -count=1`
+Expected: PASS, including every pre-existing test — `SetExecDelivery`'s
+behaviour must be byte-identical.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig/store.go internal/appconfig/appconfig.go internal/appconfig/environments_test.go
+git commit -m "appconfig: exec_delivery can be set on a named environment bundle (#195)"
+```
+
+---
+
+### Task 2: `SyncExec` resolves the opt-in per environment
+
+**Files:**
+- Modify: `internal/appconfig/syncexec.go:116-192` (the `optedInRes` /
+ `optedIn` / `checkOptedIn` / `inScope` block) and `:374-387` (the
+ stale-secrets notice loop)
+- Test: `internal/appconfig/syncexec_test.go`
+
+**Interfaces:**
+- Consumes: `SetExecDeliveryAt` (Task 1) in tests only;
+ `getIfExistsAt(ctx, s, key) (*Manifest, bool, error)`,
+ `EnvironmentKey(acct, app, name) string`.
+- Produces: no exported change. `SyncExec`'s signature is untouched.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/appconfig/syncexec_test.go`. Read the top of that file
+first and reuse its existing fixture helpers (store constructor, registry
+constructor, route builder, key generation) rather than inventing new
+ones — the names below are the shape, not necessarily the spelling.
+
+```go
+// TestSyncExecOptInResolvesPerEnvironment is issue #195's matrix: which
+// hosts get a <host>.config.env, for each combination of the app-level
+// flag and the bound bundle's own. Two TCP-addr exec routes of ONE app,
+// one bound to "relay" and one unbound — titogo's exact shape.
+func TestSyncExecOptInResolvesPerEnvironment(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ appLevel string // "" | ExecDeliveryFile
+ envLevel string // "absent" | "silent" | "file" | "inherit" | "bogus"
+ wantRelayFile bool
+ wantUnboundFile bool
+ }{
+ {"both off", "", "silent", false, false},
+ {"app on, env silent — inherit on", ExecDeliveryFile, "silent", true, true},
+ {"app off, env on — the narrow case", "", "file", true, false},
+ {"app on, env on", ExecDeliveryFile, "file", true, true},
+ {"app off, env bundle absent", "", "absent", false, false},
+ {"app on, env bundle absent — inherit on", ExecDeliveryFile, "absent", true, true},
+ {"app on, env states an unknown value — that statement decides", ExecDeliveryFile, "bogus", false, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+ dir := t.TempDir()
+ // The default bundle always exists and always has a var, so the
+ // only thing under test is WHETHER a file is written.
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"SHARED": "1"}); err != nil {
+ t.Fatal(err)
+ }
+ if tc.appLevel != "" {
+ if err := SetExecDelivery(ctx, s, "bab", "titogo", tc.appLevel); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tc.envLevel != "absent" {
+ if err := SaveEnvironment(ctx, s, "bab", "titogo", "relay",
+ map[string]Var{"SEALED": {Kind: "env", Value: "2"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+ switch tc.envLevel {
+ case "file":
+ if err := SetExecDeliveryAt(ctx, s, "bab", "titogo", "relay", ExecDeliveryFile); err != nil {
+ t.Fatal(err)
+ }
+ case "bogus":
+ // Written past the setter's validation on purpose: this
+ // is what an OLDER binary sees when a NEWER one invents
+ // a value, and it must not brick the pass.
+ writeRawManifest(t, ctx, s, EnvironmentKey("bab", "titogo", "relay"),
+ &Manifest{Schema: 1, App: "titogo", ExecDelivery: "socket",
+ Vars: map[string]Var{"SEALED": {Kind: "env", Value: "2"}}})
+ }
+ }
+ reg := newTestRegistry(t)
+ mustAddRoute(t, reg, Route{Host: "relay.example", Kind: "instance", App: "titogo",
+ Account: "bab", Addr: "127.0.0.1:9001", Environment: "relay"})
+ mustAddRoute(t, reg, Route{Host: "router.example", Kind: "instance", App: "titogo",
+ Account: "bab", Addr: "127.0.0.1:9002"})
+
+ if err := SyncExec(ctx, reg, s, nil, dir, nil); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ assertConfigFile(t, dir, "relay.example", tc.wantRelayFile)
+ assertConfigFile(t, dir, "router.example", tc.wantUnboundFile)
+ })
+ }
+}
+
+// assertConfigFile asserts a route's <host>.config.env exists (or not).
+func assertConfigFile(t *testing.T, dir, host string, want bool) {
+ t.Helper()
+ _, err := os.Stat(filepath.Join(dir, host+configEnvSuffix))
+ switch {
+ case want && err != nil:
+ t.Fatalf("%s: want a config file, got %v", host, err)
+ case !want && err == nil:
+ t.Fatalf("%s: want NO config file, one was written", host)
+ }
+}
+
+// TestSyncExecEnvBundleFetchErrorLeavesFileAlone: an unreadable
+// environment bundle is not a determination that the route is un-opted —
+// an existing file must survive it byte-identical.
+func TestSyncExecEnvBundleFetchErrorLeavesFileAlone(t *testing.T) {
+ // Build the opted-in, converged state first, snapshot the file's bytes,
+ // then make the environment bundle's key fail on Get and run one more
+ // pass. Use whatever failing-store wrapper this file already has for
+ // the default-bundle fetch-failure test; extend it to the environment
+ // key rather than writing a second wrapper.
+}
+```
+
+If the file has no raw-manifest writer, add one beside the test:
+
+```go
+// writeRawManifest puts a manifest at key without going through a setter —
+// the only way to stage a value a current binary would refuse to write.
+func writeRawManifest(t *testing.T, ctx context.Context, s release.Store, key string, m *Manifest) {
+ t.Helper()
+ raw, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, key, raw, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+(Check the store's `Put` etag convention for an unconditional write in the
+existing tests and match it — `""` here is a placeholder for whatever this
+repo's "no precondition" spelling is.)
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/appconfig/ -run TestSyncExecOptInResolvesPerEnvironment -count=1`
+Expected: FAIL on the "app off, env on" row — no file is written for
+`relay.example`, because the opt-in is still read from the default bundle.
+
+- [ ] **Step 3: Implement**
+
+In `internal/appconfig/syncexec.go`, replace the `optedIn` map,
+`checkOptedIn` closure and `inScope` closure with:
+
+```go
+ // optKey keys the opt-in decision on the bundle a route's config
+ // actually resolves through, not on the app alone (issue #195). Two
+ // routes of one app bound to different environments can now differ, so
+ // the cache — and the once-per-pass logs below — must too.
+ type optKey struct{ acct, app, env string }
+ optedIn := map[optKey]optedInRes{}
+ // envBundles caches the named-bundle read the same way loadDefault
+ // caches the default's: a box with many routes on one (app,
+ // environment) pair costs one fetch, and the logs below fire at most
+ // once per pair per pass rather than once per route.
+ type envRes struct {
+ m *Manifest
+ found bool
+ err error
+ }
+ envBundles := map[optKey]envRes{}
+ loadEnvBundle := func(acct, app, env string) envRes {
+ k := optKey{acct, app, env}
+ if e, done := envBundles[k]; done {
+ return e
+ }
+ m, found, err := getIfExistsAt(ctx, s, EnvironmentKey(acct, app, env))
+ if err != nil {
+ err = fmt.Errorf("%s/%s (environment %q): config fetch failed: %w", acct, app, env, err)
+ }
+ e := envRes{m: m, found: found, err: err}
+ envBundles[k] = e
+ return e
+ }
+ checkOptedIn := func(acct, app, env string) optedInRes {
+ k := optKey{acct, app, env}
+ if c, done := optedIn[k]; done {
+ return c
+ }
+ var c optedInRes
+ // The route's OWN bundle first, and only when it STATES something:
+ // a named bundle that is silent on the field inherits the app
+ // default, exactly as a key it does not set inherits the default
+ // bundle's value. Absence is inheritance, never "off" — an
+ // operator who has never mentioned exec_delivery in a bundle has
+ // not asked for delivery to stop.
+ decided := false
+ if env != "" {
+ eb := loadEnvBundle(acct, app, env)
+ switch {
+ case eb.err != nil:
+ // NOT a determination — same contract as the default
+ // bundle's fetch-failure branch below: say so loudly, leave
+ // every file alone, retry next pass.
+ fmt.Printf(" %s/%s: exec_delivery unresolved for environment %q (config fetch failed), leaving this bundle's TCP-addr route file(s) as-is: %v\n", acct, app, env, eb.err)
+ c, decided = optedInRes{v: false, known: false}, true
+ case eb.found && eb.m.ExecDelivery == ExecDeliveryFile:
+ c, decided = optedInRes{v: true, known: true}, true
+ case eb.found && eb.m.ExecDelivery != "":
+ // A value THIS binary doesn't understand, in a bundle that
+ // does state one: a statement, so it decides — treated as
+ // not opted in, loudly, once per bundle per pass, never a
+ // hard failure (additive discipline, CLAUDE.md).
+ fmt.Printf(" %s/%s: config: exec_delivery %q in environment %q is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, eb.m.ExecDelivery, env)
+ c, decided = optedInRes{v: false, known: true}, true
+ }
+ // found-but-silent, and absent, both fall through to the app
+ // default. A bundle bound but missing is a defect the route's
+ // own resolution reports (LoadMerged refuses it), and its file
+ // is left as-is there — the opt-in verdict never reaches a
+ // write in that case.
+ }
+ if !decided {
+ def := loadDefault(acct, app)
+ switch {
+ case def.err != nil:
+ // NOT a determination: this app may well be opted in and this
+ // box just failed to see it this pass — indistinguishable here
+ // from any other route's ordinary fetch-failure blip. Silence
+ // would mean an opted-in app's delivery can stall with zero
+ // trace in the log, on the exact path this feature exists to
+ // light up, so say so loudly — the same "unresolved, left
+ // as-is" contract the resolution loop below already gives a
+ // bound-group fetch failure.
+ //
+ // It is safe to leave any existing file exactly as it is
+ // regardless of how long this persists, because reapConfigEnv
+ // keys on ROUTE EXISTENCE, never on scope (its live map is
+ // built from every route's Host, full stop — see its own doc
+ // comment). A route that drops out of scope for one pass, for
+ // ANY reason, is simply not visited by the resolution loop,
+ // the materialization loop, or reap; its file is untouched by
+ // construction. Next pass retries from scratch.
+ fmt.Printf(" %s/%s: exec_delivery unresolved (config fetch failed), leaving this app's TCP-addr route file(s) as-is: %v\n", acct, app, def.err)
+ c = optedInRes{v: false, known: false}
+ case !def.found:
+ c = optedInRes{v: false, known: true}
+ case def.m.ExecDelivery == ExecDeliveryFile:
+ c = optedInRes{v: true, known: true}
+ case def.m.ExecDelivery == "":
+ c = optedInRes{v: false, known: true}
+ default:
+ fmt.Printf(" %s/%s: config: exec_delivery %q in the default bundle is not a value this binary understands — ignoring it (not opted in); upgrade carlos if this is expected\n", acct, app, def.m.ExecDelivery)
+ c = optedInRes{v: false, known: true}
+ }
+ }
+ optedIn[k] = c
+ return c
+ }
+ // inScope is the single door both loops below use to decide whether a
+ // route is in scope this pass, and — for the addr shape — that it must
+ // never be bounced. One function so the two loops structurally cannot
+ // diverge on either question.
+ inScope := func(rt registry.Route) (ok, addrShape bool) {
+ if execSyncable(rt) {
+ return true, false
+ }
+ if execAddrCandidate(rt) && checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment).v {
+ return true, true
+ }
+ return false, false
+ }
+```
+
+Update the `optedInRes` doc comment above it to say the verdict is per
+`(account, app, environment)`, not per app.
+
+Then, in the stale-secrets notice loop near the end of `SyncExec`, change
+the one call site:
+
+```go
+ c := checkOptedIn(release.RouteAccount(rt), rt.App, rt.Environment)
+```
+
+Finally update the package doc at the top of the file: the sentence
+beginning "A third eligibility case (issue #18): a TCP-addr exec route
+[...] whose app has opted in via its default bundle's additive
+`"exec_delivery": "file"` manifest field" becomes "whose BOUND BUNDLE has
+opted in — the route's named environment when it states a value, the
+app's default bundle otherwise (issue #195)".
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/appconfig/ -count=1`
+Expected: PASS, all rows of the matrix and every pre-existing SyncExec
+test — the unbound-route behaviour must be unchanged.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig/syncexec.go internal/appconfig/syncexec_test.go
+git commit -m "appconfig: SyncExec resolves exec_delivery from the route's bound bundle (#195)"
+```
+
+---
+
+### Task 3: `ConfigRouteFingerprints`
+
+**Files:**
+- Create: `internal/appconfig/routefingerprint.go`
+- Test: `internal/appconfig/routefingerprint_test.go`
+
+**Interfaces:**
+- Consumes: `execSyncable(rt)`, `execAddrCandidate(rt)` (syncexec.go),
+ `syncable(rt)` (sync.go), `release.RouteAccount(rt)`,
+ `registry.BackingUnit`.
+- Produces:
+ `func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string)`
+ — two hex digests, one per delivery half.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/appconfig/routefingerprint_test.go`:
+
+```go
+package appconfig
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func execRoute(host, env string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "titogo",
+ Account: "bab", Addr: "127.0.0.1:9001", Environment: env}
+}
+
+func unitRoute(host, env string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "keymail",
+ Account: "bae", Socket: "/run/x.sock", Backing: registry.BackingUnit,
+ Environment: env}
+}
+
+func TestConfigRouteFingerprintsMoveOnRebinding(t *testing.T) {
+ before := []registry.Route{execRoute("relay.example", "")}
+ after := []registry.Route{execRoute("relay.example", "relay")}
+ be, bu := ConfigRouteFingerprints(before)
+ ae, au := ConfigRouteFingerprints(after)
+ if be == ae {
+ t.Fatal("binding an exec route to an environment must move the exec digest — this is issue #196")
+ }
+ if bu != au {
+ t.Fatal("an exec route's rebinding must not move the UNIT digest — that would ask for a root pass for nothing")
+ }
+}
+
+func TestConfigRouteFingerprintsUnitHalfIsIndependent(t *testing.T) {
+ before := []registry.Route{unitRoute("keymail.dev", "")}
+ after := []registry.Route{unitRoute("keymail.dev", "staging")}
+ be, bu := ConfigRouteFingerprints(before)
+ ae, au := ConfigRouteFingerprints(after)
+ if bu == au {
+ t.Fatal("binding a unit-backed route to an environment must move the unit digest")
+ }
+ if be != ae {
+ t.Fatal("a unit route's rebinding must not move the exec digest")
+ }
+}
+
+func TestConfigRouteFingerprintsIgnoreOrderAndIrrelevantColumns(t *testing.T) {
+ a := execRoute("a.example", "")
+ b := execRoute("b.example", "relay")
+ e1, _ := ConfigRouteFingerprints([]registry.Route{a, b})
+ e2, _ := ConfigRouteFingerprints([]registry.Route{b, a})
+ if e1 != e2 {
+ t.Fatal("registry order must not move the digest — it is not stable across passes")
+ }
+ c := execRoute("a.example", "")
+ c.Version = "deadbeef"
+ c.LastError = "whatever"
+ e3, _ := ConfigRouteFingerprints([]registry.Route{c, b})
+ if e1 != e3 {
+ t.Fatal("a column that cannot change what config a route gets must not move the digest — every version stamp would trigger a pass")
+ }
+}
+
+func TestConfigRouteFingerprintsMoveWhenARouteLeavesScope(t *testing.T) {
+ both := []registry.Route{execRoute("a.example", ""), execRoute("b.example", "")}
+ one := []registry.Route{execRoute("a.example", "")}
+ e1, _ := ConfigRouteFingerprints(both)
+ e2, _ := ConfigRouteFingerprints(one)
+ if e1 == e2 {
+ t.Fatal("a route leaving the registry must move the digest — its file needs reaping")
+ }
+ // Leaving scope by status, not by deletion, counts the same way.
+ failed := []registry.Route{execRoute("a.example", ""), execRoute("b.example", "")}
+ failed[1].Status = "failed"
+ e3, _ := ConfigRouteFingerprints(failed)
+ if e1 == e3 {
+ t.Fatal("a route falling out of the eligibility predicate must move the digest")
+ }
+}
+
+func TestConfigRouteFingerprintsIgnoreOutOfScopeRoutes(t *testing.T) {
+ base := []registry.Route{execRoute("a.example", "")}
+ withStatic := append([]registry.Route{}, base...)
+ withStatic = append(withStatic, registry.Route{Host: "site.example", Kind: "static", App: "site", Account: "bab"})
+ e1, u1 := ConfigRouteFingerprints(base)
+ e2, u2 := ConfigRouteFingerprints(withStatic)
+ if e1 != e2 || u1 != u2 {
+ t.Fatal("a route neither half can deliver config to must not move either digest")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/appconfig/ -run TestConfigRouteFingerprints -count=1`
+Expected: FAIL — `undefined: ConfigRouteFingerprints`.
+
+- [ ] **Step 3: Implement**
+
+Create `internal/appconfig/routefingerprint.go`:
+
+```go
+// Route-shape fingerprints: the registry-side half of "has this box's
+// config delivery got anything to do".
+//
+// The agent's fast loop (host package's configGenLoop) decides whether to
+// converge by reading each config-carrying app's generation object out of
+// the bucket. That catches every BUCKET-side change — a var set, a secret
+// rotated, an exec_delivery flag flipped — and nothing at all on the
+// REGISTRY side. But a route's Environment is a registry column, and it
+// decides which bundle the route's config comes from, so binding a route
+// to an environment changes what that route should be served while moving
+// no etag anywhere. Delivery then waits for the hourly configSyncLoop:
+// issue #196, an hour of a live route serving the wrong merge, whose only
+// symptom is the app saying a credential is missing.
+//
+// These digests give that loop something to watch. They live here, beside
+// ExecConfigApps and UnitConfigApps and composed from the SAME eligibility
+// predicates those two use, for the reason both of those give for living
+// where they do: a fingerprint that disagreed with the sweep about which
+// routes matter would either burn passes forever or miss changes silently.
+package appconfig
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ConfigRouteFingerprints returns one digest per delivery half over the
+// registry-side facts that decide what config a route gets: which routes
+// are in scope at all, and — for each — the (account, app, environment)
+// triple its manifest resolves from.
+//
+// Set MEMBERSHIP is what carries scope changes: a route that gains
+// Status: "failed", loses its app stamp, or is deleted outright simply
+// stops contributing a row, which moves the digest exactly as a rebinding
+// does. That is deliberate — those transitions all change what should be
+// on disk (a file to remove, a file to stop updating), and the pass they
+// trigger resolves each case properly.
+//
+// The OPT-IN is deliberately not here, for the reason ExecConfigApps gives
+// for omitting it too: reading it costs a bucket fetch per app per tick,
+// which is exactly what the fast loop exists to avoid. It does not need to
+// be: exec_delivery is a bundle write, and every bundle write bumps the
+// app's generation object (SetExecDeliveryAt), which the loop already
+// watches.
+//
+// Two digests, not one, because the two halves are delivered by different
+// processes: the exec half by the agent itself (SyncExec), the unit half
+// by a root pass the agent can only REQUEST. A shared digest would ask for
+// a root config run every time an exec route was rebound.
+func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string) {
+ var execRows, unitRows []string
+ for _, rt := range routes {
+ row := rt.Host + "|" + release.RouteAccount(rt) + "|" + rt.App + "|" + rt.Environment
+ if execSyncable(rt) || execAddrCandidate(rt) {
+ execRows = append(execRows, row)
+ }
+ if syncable(rt) && rt.Backing == registry.BackingUnit {
+ unitRows = append(unitRows, row)
+ }
+ }
+ return digestRows(execRows), digestRows(unitRows)
+}
+
+// digestRows sorts and hashes. Sorted because registry order is not stable
+// across passes and an order-sensitive digest would fire constantly;
+// NUL-separated because a hostname cannot contain one, so no two distinct
+// row sets can concatenate to the same bytes.
+func digestRows(rows []string) string {
+ sort.Strings(rows)
+ h := sha256.New()
+ for _, r := range rows {
+ h.Write([]byte(r))
+ h.Write([]byte{0})
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+```
+
+Add `"github.com/carlosframework/platform/internal/release"` to the
+imports (the row uses `release.RouteAccount`).
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/appconfig/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/appconfig/routefingerprint.go internal/appconfig/routefingerprint_test.go
+git commit -m "appconfig: fingerprint the registry-side facts that decide a route's config (#196)"
+```
+
+---
+
+### Task 4: The fast loop watches the fingerprints
+
+**Files:**
+- Modify: `internal/host/agent.go:571-670` (`genCache`, `genPollPass`) and
+ `:792-850` (`configGenLoop`)
+- Test: `internal/host/agent_test.go`
+
+**Interfaces:**
+- Consumes: `appconfig.ConfigRouteFingerprints(routes) (exec, unit string)`
+ (Task 3).
+- Produces: `type genCache struct` with `etags map[[2]string]string` and
+ unexported digest fields; `func newGenCache() *genCache`;
+ `genPollPass(ctx, routes, store, c *genCache) (execChanged, unitChanged bool, readErrs []error)`.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/host/agent_test.go`:
+
+```go
+// TestGenPollPassSyncsOnRouteRebinding is issue #196: a route bound to an
+// environment must converge from the FAST loop, not an hour later. The
+// bucket does not move at all here — only the registry column does.
+func TestGenPollPassSyncsOnRouteRebinding(t *testing.T) {
+ ctx := context.Background()
+ s := newGenStore(t) // match this file's existing helper
+ routes := []registry.Route{execRouteFixture("relay.example", "")}
+ cache := newGenCache()
+ genPollPass(ctx, routes, s, cache) // first sight: silent
+
+ rebound := []registry.Route{execRouteFixture("relay.example", "relay")}
+ execChanged, unitChanged, errs := genPollPass(ctx, rebound, s, cache)
+ if len(errs) > 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if !execChanged {
+ t.Fatal("binding a route to an environment must make the exec half sync — this is the hour titogo lost")
+ }
+ if unitChanged {
+ t.Fatal("an exec route's rebinding must not request a root config pass")
+ }
+ // And the pass AFTER it is quiet again: a digest that re-fired every
+ // tick would run SyncExec forever.
+ if execChanged, _, _ := genPollPass(ctx, rebound, s, cache); execChanged {
+ t.Fatal("the pass after a rebinding must be silent again")
+ }
+}
+
+// TestGenPollPassFirstSightOfRouteShapeIsSilent: a box coming up has never
+// seen the digest before, and must not treat that as a change — every
+// instance on the box would sync at once on boot.
+func TestGenPollPassFirstSightOfRouteShapeIsSilent(t *testing.T) {
+ ctx := context.Background()
+ s := newGenStore(t)
+ routes := []registry.Route{execRouteFixture("relay.example", "relay")}
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, newGenCache())
+ if len(errs) > 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if execChanged || unitChanged {
+ t.Fatal("the first pass must record the shape silently — configSyncLoop's first pass owns boot convergence")
+ }
+}
+```
+
+Add an `execRouteFixture` helper beside them if the file has no equivalent:
+
+```go
+func execRouteFixture(host, env string) registry.Route {
+ return registry.Route{Host: host, Kind: "instance", App: "titogo",
+ Account: "bab", Addr: "127.0.0.1:9001", Environment: env}
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/host/ -run TestGenPollPass -count=1`
+Expected: FAIL — `undefined: newGenCache` (and, once that compiles, a
+`execChanged == false` failure on the rebinding).
+
+- [ ] **Step 3: Implement**
+
+In `internal/host/agent.go`, add above `genPollPass`:
+
+```go
+// genCache is everything configGenLoop remembers between ticks: one
+// generation etag per config-carrying app, and the previous tick's
+// route-shape digests. One object rather than two parameters because the
+// two are remembered for exactly one reason — deciding what "changed"
+// means — and a caller holding one without the other would silently lose
+// half the loop's sensitivity.
+type genCache struct {
+ etags map[[2]string]string
+ // exec/unit are appconfig.ConfigRouteFingerprints' two digests as of
+ // the last pass that computed them; shapeSeen is false until the first
+ // such pass. The bool is not redundant with an empty string: a box with
+ // no config-carrying routes at all has a real, stable digest of the
+ // empty set, and treating that as "never seen" would make its first
+ // route look like a change on the tick after boot rather than on the
+ // tick it appeared.
+ exec, unit string
+ shapeSeen bool
+}
+
+func newGenCache() *genCache { return &genCache{etags: map[[2]string]string{}} }
+```
+
+Change `genPollPass`'s signature and body. The signature becomes:
+
+```go
+func genPollPass(ctx context.Context, routes []registry.Route, store release.Store, c *genCache) (execChanged, unitChanged bool, readErrs []error) {
+```
+
+Inside, replace every `cache[...]` with `c.etags[...]`, and add the shape
+comparison at the top of the function, before the etag loop:
+
+```go
+ // The registry-side half (issue #196). Cheap — no bucket read at all —
+ // and checked first so a rebinding converges on the same tick a var
+ // change would, rather than waiting for one.
+ execFP, unitFP := appconfig.ConfigRouteFingerprints(routes)
+ if c.shapeSeen {
+ execChanged = execChanged || execFP != c.exec
+ unitChanged = unitChanged || unitFP != c.unit
+ }
+ c.exec, c.unit, c.shapeSeen = execFP, unitFP, true
+```
+
+Extend `genPollPass`'s doc comment with a bullet in the "Semantics" list:
+
+```go
+// - The ROUTE SHAPE is watched too, not just the bucket: a route's
+// Environment binding lives in the box's own registry and moves no
+// etag anywhere, so without this the fast loop cannot see a rebinding
+// at all and delivery waits out configSyncLoop's hourly floor (issue
+// #196 — ten minutes of a live route serving the wrong merge, whose
+// only symptom is the app reporting a missing credential). First sight
+// of the shape is silent for the same reason first sight of an app is.
+// Covers BOTH doors onto the column: `carlos route -environment` on
+// the box, and the provisioning reconciler converging Environment from
+// an instance record — which is the sanctioned door for a Provisioned
+// row, and which a fix in `carlos route` would have missed entirely.
+```
+
+In `configGenLoop`, change the cache construction and the call:
+
+```go
+ // One cache for the life of the process: what "changed" means is
+ // exactly "differs from the last tick that could read it".
+ cache := newGenCache()
+```
+
+(the `genPollPass(ctx, routes, store, cache)` call site needs no edit).
+
+Then fix the existing tests mechanically — these three substitutions over
+`internal/host/agent_test.go`:
+
+- `cache := map[[2]string]string{}` → `cache := newGenCache()`
+- `cache[[2]string{` → `cache.etags[[2]string{`
+- `_, ok := cache[` → `_, ok := cache.etags[`
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/host/ -count=1`
+Expected: PASS, including all 26 pre-existing `genPollPass` assertions.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/agent.go internal/host/agent_test.go
+git commit -m "host: the fast config loop notices a route's environment rebinding (#196)"
+```
+
+---
+
+### Task 5: `carlos env exec-delivery -environment`
+
+**Files:**
+- Modify: `appconfig_cli.go:358-435` (`runEnvExecDelivery`), `:331-356`
+ (`printEnvList`), `:20-58` (`envUsage`)
+- Test: `appconfig_cli_test.go` (or the file in the root package that
+ already tests `env` subcommands — check `e2e_test.go` for the
+ established pattern first)
+
+**Interfaces:**
+- Consumes: `appconfig.SetExecDeliveryAt(ctx, store, acct, app, envName, value)`
+ (Task 1); `apiConfigApply{Environment string, ExecDelivery *string}`
+ (existing wire struct in `apimode.go`).
+- Produces: no exported change.
+
+- [ ] **Step 1: Write the failing test**
+
+The refusals are the behaviour worth pinning. Follow the file's existing
+convention for driving a CLI verb — if these tests shell out to the built
+binary, they MUST use `carlosTestEnv` and set `cmd.Dir` to a scratch dir
+(CLAUDE.md; the fallback is the operator's real login and console).
+
+```go
+func TestEnvExecDeliveryRefusesOffAtEnvironmentScope(t *testing.T) {
+ out, err := runCarlos(t, "env", "exec-delivery", "-app", "titogo", "-environment", "relay", "off")
+ if err == nil {
+ t.Fatal(`"off" must be refused for a named environment — clearing the field there means inherit`)
+ }
+ if !strings.Contains(out, "inherit") {
+ t.Fatalf("the refusal must name the spelling that IS accepted; got %q", out)
+ }
+}
+
+func TestEnvExecDeliveryRefusesUnknownValueAtEnvironmentScope(t *testing.T) {
+ out, err := runCarlos(t, "env", "exec-delivery", "-app", "titogo", "-environment", "relay", "socket")
+ if err == nil {
+ t.Fatal("an unknown value must be refused")
+ }
+ if !strings.Contains(out, "file") || !strings.Contains(out, "inherit") {
+ t.Fatalf("the refusal must name both accepted spellings; got %q", out)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test . -run TestEnvExecDelivery -count=1`
+Expected: FAIL — `-environment` is currently refused outright, so the
+first test fails on the message and the second passes for the wrong
+reason.
+
+- [ ] **Step 3: Implement**
+
+Rewrite `runEnvExecDelivery`:
+
+```go
+// runEnvExecDelivery is `carlos env exec-delivery` — issue #18's opt-in
+// verb, deliberately NOT `env set -app <a> EXEC_DELIVERY=...`: the field
+// is not a config var (it's never rendered into an EnvironmentFile, never
+// listed alongside KEY/VALUE pairs), it's a manifest-level flag with its
+// own validation and its own bundle, so it gets its own verb surface
+// instead of overloading env set's KEY=value parsing with a special-cased
+// key name.
+//
+// -environment (issue #195) writes the flag into that bundle instead of
+// the app default. The vocabulary changes with the scope, and that is the
+// point: at app scope the second word is "off", because clearing the
+// default bundle's field really does stop delivery; in a named bundle it
+// is "inherit", because clearing it there hands the question back to the
+// app default. Spelling the second case "off" would read as "stop
+// delivering" while doing the opposite whenever the app default is on,
+// which is precisely the kind of quiet lie this whole feature exists to
+// avoid — so "off" is refused there, by name, rather than accepted as a
+// synonym.
+func runEnvExecDelivery(args []string) {
+ fs := flag.NewFlagSet("env exec-delivery", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ environment := fs.String("environment", "",
+ "named environment bundle to set the flag in (omit for the app's default bundle)")
+ acctRaw, acctIDRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ usage := "usage: carlos env exec-delivery -app <a> [-console <origin>] file|off\n" +
+ " or: carlos env exec-delivery -app <a> -environment <name> [-console <origin>] file|inherit"
+ if fs.NArg() != 1 {
+ log.Fatal(usage)
+ }
+ checkEnvironmentFlag(*environment)
+ // The wire and store vocabulary is one value either way — "" clears the
+ // field. What differs is which SECOND WORD spells the clear, and which
+ // bundle it clears in.
+ var value, cleared string
+ switch {
+ case fs.Arg(0) == appconfig.ExecDeliveryFile:
+ value = appconfig.ExecDeliveryFile
+ case *environment == "" && fs.Arg(0) == "off":
+ value, cleared = "", "off"
+ case *environment != "" && fs.Arg(0) == "inherit":
+ value, cleared = "", "inherit"
+ case *environment != "" && fs.Arg(0) == "off":
+ log.Fatalf(`carlos env exec-delivery: %q is not a value for a named environment — say "inherit" instead. `+
+ `Clearing exec_delivery in a bundle does not turn delivery off, it hands the question back to the app's `+
+ `default bundle (config.json), so a route bound to %q keeps getting files whenever the app-level flag is on. `+
+ `To stop delivery for the whole app: carlos env exec-delivery -app %s off`,
+ "off", *environment, *app)
+ default:
+ want := `"off"`
+ if *environment != "" {
+ want = `"inherit"`
+ }
+ log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or %s", fs.Arg(0), appconfig.ExecDeliveryFile, want)
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ appName := *app
+ if sess != nil {
+ printUsing(os.Stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ appName = resolvedApp(sess, acct, *app, usage)
+ // The console's PUT decodes ExecDelivery as a pointer where nil
+ // means "no change", so a clear must still be spelled as a word.
+ // The word is the scope's own: the server refuses "off" for a named
+ // bundle exactly as this function does, so relaying the operator's
+ // own vocabulary keeps the two refusals identical.
+ wire := cleared
+ if value != "" {
+ wire = value
+ }
+ if _, err := sess.configApply(context.Background(), acct, appName,
+ apiConfigApply{Environment: *environment, ExecDelivery: &wire}); err != nil {
+ log.Fatal(err)
+ }
+ } else {
+ if *app == "" {
+ log.Fatal(usage)
+ }
+ if err := appconfig.SetExecDeliveryAt(context.Background(), openStore(), acctOf(), *app, *environment, value); err != nil {
+ log.Fatal(err)
+ }
+ }
+ if value == "" {
+ fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), cleared, appName, bundleLabel(*environment))
+ } else {
+ fmt.Printf("%s %s for %s (%s)\n", colorFor(os.Stdout).ok("exec-delivery"), value, appName, bundleLabel(*environment))
+ }
+}
+```
+
+In `printEnvList`, drop the environment suppression:
+
+```go
+ // exec_delivery is printed for whichever bundle is being listed — it can
+ // now be set in a named one (issue #195). A bundle that is SILENT on it
+ // prints nothing, rather than printing the value it inherits: the
+ // listing shows what this bundle holds, and rendering an inherited
+ // value would make the bundle look like it holds a statement it does
+ // not, which is exactly the confusion that makes the app-level flag
+ // hard to reason about in the first place.
+ if execDelivery != "" {
+ fmt.Printf("exec-delivery: %s\n", execDelivery)
+ }
+```
+
+Update `envUsage`: the command line becomes
+
+```
+ exec-delivery -app <a> [-environment <name>] file|off|inherit opt an app's (or one bundle's) TCP-addr exec routes into config delivery
+```
+
+and replace the "exec-delivery is APP-LEVEL ONLY" paragraph with:
+
+```
+exec-delivery sets "exec_delivery" in one bundle. "file" makes SyncExec
+write /data/<host>.config.env for the TCP-addr exec routes that resolve
+through that bundle (routes carlos proxies to but does not spawn — e.g.
+titogo's router fleet) exactly as it already does for socket-exec routes,
+but those routes are NEVER bounced: carlos does not own the process, so
+the app's own supervisor owns the restart.
+
+Without -environment it is the app default, and "off" clears it. With
+-environment it is that bundle alone, and "inherit" clears it — a bundle
+that says nothing takes the app default's answer, so clearing there is not
+the same as turning delivery off. To deliver to ONE service: turn the app
+default off, and set "file" on the bundle its route is bound to.
+
+Turning the app default off does NOT delete files already written for
+routes that lose the opt-in — they hold decrypted secrets and carlos
+cannot know when the app's own supervisor has stopped reading them. It
+names each one in its log every pass until you delete it by hand.
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test . -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add appconfig_cli.go appconfig_cli_test.go
+git commit -m "cli: carlos env exec-delivery -environment (#195)"
+```
+
+---
+
+### Task 6: The console's config door accepts environment scope
+
+**Files:**
+- Modify: `internal/console/apiconfig.go:230-241` (GET's exec_delivery
+ emission), `:369-388` (POST's exec_delivery validation), `:427-441`
+ (POST's second CAS write)
+- Test: `internal/console/apiconfig_test.go`
+
+**Interfaces:**
+- Consumes: `appconfig.SetExecDeliveryAt` (Task 1).
+- Produces: no Go-level export change. Wire: `exec_delivery` accepts
+ `"file"` and `"inherit"` when `environment` is non-empty, and `"file"` /
+ `"off"` when it is empty; GET emits the named bundle's own value.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/console/apiconfig_test.go`, following that file's
+existing request helpers:
+
+```go
+func TestAPIConfigPutExecDeliveryAtEnvironmentScope(t *testing.T) {
+ // PUT {"environment":"relay","exec_delivery":"file"} must land in the
+ // NAMED bundle and leave the default bundle's field empty.
+}
+
+func TestAPIConfigPutRefusesOffAtEnvironmentScope(t *testing.T) {
+ // PUT {"environment":"relay","exec_delivery":"off"} → 400, and the
+ // message must name "inherit".
+}
+
+func TestAPIConfigPutAcceptsInheritAtEnvironmentScope(t *testing.T) {
+ // PUT {"environment":"relay","exec_delivery":"inherit"} clears the
+ // named bundle's field; a following GET omits exec_delivery.
+}
+
+func TestAPIConfigPutRefusesInheritAtAppScope(t *testing.T) {
+ // PUT {"exec_delivery":"inherit"} with no environment → 400: there is
+ // nothing above the default bundle to inherit FROM, so accepting the
+ // word would silently mean "off".
+}
+
+func TestAPIConfigGetEmitsNamedBundleExecDelivery(t *testing.T) {
+ // After the environment-scoped set, GET ?environment=relay carries
+ // exec_delivery; GET with no environment does not.
+}
+```
+
+Write each body out against the file's own helpers — the comments state
+the assertion, the code must make it.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/console/ -run TestAPIConfig -count=1`
+Expected: FAIL — the environment-scoped PUT is currently a 400 ("app-level
+only") and GET suppresses the field for a named bundle.
+
+- [ ] **Step 3: Implement**
+
+In `handleAPIConfigGet`, replace the emission condition:
+
+```go
+ // exec_delivery is emitted for whichever bundle was asked for: since
+ // issue #195 it can be set in a named one, where it means "routes bound
+ // to this environment get files". Omitted when the bundle is silent on
+ // it rather than reported as the inherited value — the answer describes
+ // this bundle, and an inherited value would read as a statement the
+ // bundle does not make.
+ if m.ExecDelivery != "" {
+ out["exec_delivery"] = m.ExecDelivery
+ }
+```
+
+In `handleAPIConfigPut`, replace the `req.ExecDelivery` validation block:
+
+```go
+ execDelivery := ""
+ if req.ExecDelivery != nil {
+ // The wire's clearing word is the SCOPE's own, and the two are not
+ // interchangeable: clearing the default bundle's flag stops
+ // delivery, while clearing a named bundle's hands the question back
+ // to the default. Accepting "off" for a named bundle would let a
+ // caller ask to stop delivery and get the opposite whenever the app
+ // default is on. Both refusals are worded here, once, so the CLI
+ // and any other client read the same sentence.
+ switch *req.ExecDelivery {
+ case appconfig.ExecDeliveryFile:
+ execDelivery = appconfig.ExecDeliveryFile
+ case "off":
+ if envName != "" {
+ jsonError(w, http.StatusBadRequest,
+ "exec_delivery \"off\" is app-level — in a named environment, clearing the flag means "+
+ "\"inherit\" (routes bound to it fall back to the app's default bundle), which is not the "+
+ "same as turning delivery off. Send \"inherit\" for this bundle, or send \"off\" with no "+
+ "environment to turn the app's default off.")
+ return
+ }
+ execDelivery = ""
+ case "inherit":
+ if envName == "" {
+ jsonError(w, http.StatusBadRequest,
+ "exec_delivery \"inherit\" needs an environment — the default bundle has nothing above it to "+
+ "inherit from. Send \"off\" to clear it.")
+ return
+ }
+ execDelivery = ""
+ default:
+ want := `"off"`
+ if envName != "" {
+ want = `"inherit"`
+ }
+ jsonError(w, http.StatusBadRequest,
+ fmt.Sprintf("invalid exec_delivery %q — want %q or %s", *req.ExecDelivery, appconfig.ExecDeliveryFile, want))
+ return
+ }
+ }
+```
+
+And the second CAS write:
+
+```go
+ if req.ExecDelivery != nil {
+ // A SECOND CAS write, and deliberately not folded into the one
+ // above: it targets one field of one bundle through a different
+ // setter, and the two cannot be one transaction. The audit row for
+ // the set/unset half is written above, before this runs — if this
+ // fails, the trail still says truthfully what landed.
+ if err := appconfig.SetExecDeliveryAt(ctx, s.cfg.Releases, acct.Sqid, app, envName, execDelivery); err != nil {
+ s.configStoreError(w, fmt.Sprintf("api config exec-delivery %s/%s (%s)", acct.Sqid, app, bundle), err)
+ return
+ }
+ label := *req.ExecDelivery
+ s.audit(acct, app, addr, "exec-delivery", "exec-delivery "+label+" ("+bundle+") (cli)")
+ s.bumpSerial(ctx)
+ }
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/apiconfig.go internal/console/apiconfig_test.go
+git commit -m "console: config API accepts environment-scoped exec_delivery (#195)"
+```
+
+---
+
+### Task 7: `GET /api/cli/apps/{account}/{app}/routes`
+
+**Files:**
+- Create: `internal/console/apiroutes.go`
+- Modify: `internal/console/authcli.go` (registration, beside the config
+ family at :294-296), `internal/console/apiinstances.go:214-296`
+ (extract `healthByHost` from `instanceHealth`)
+- Test: `internal/console/apiroutes_test.go`
+
+**Interfaces:**
+- Consumes: `s.apiAccountApp(w, r) (string, *Account, string, bool)`,
+ `openRegistry(path) (*registry.Registry, error)`,
+ `s.OwnerAppMetrics(ctx, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error)`,
+ `healthWord(kind, status string, hibernate, reporting, expectReadings bool) string`,
+ `expectsReadings(provisioned bool, unit, backing string) bool`,
+ `release.RouteAccount(rt) string`.
+- Produces: `func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request)`;
+ `func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry.Route) map[string]string`.
+ Wire: `{"routes":[{"host","kind","upstream","app","channel","environment","status","health","hibernate","unit","backing","provisioned"}]}`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/console/apiroutes_test.go`. Copy the gate assertions from
+`apiinstances_test.go` verbatim in shape — the 404 discipline is the
+security boundary and must be pinned identically here.
+
+```go
+func TestAPIRoutesRequiresMembership(t *testing.T) {
+ // A signed-in caller who is not a member of the account gets the
+ // family's byte-identical 404 — not a 403, not an empty list.
+}
+
+func TestAPIRoutesRefusesAnAppTokenOutright(t *testing.T) {
+ // This door is member-only: an app token that CAN reach
+ // /instances must not reach /routes. Assert the 404 (or whatever
+ // apiAccountApp answers a non-person bearer) rather than a 200.
+}
+
+func TestAPIRoutesListsOnlyThisAppsRoutes(t *testing.T) {
+ // A registry holding: this app's route, another app's route in the
+ // SAME account, and a route stamped to another account entirely.
+ // Only the first appears.
+}
+
+func TestAPIRoutesReportsTheEnvironmentBinding(t *testing.T) {
+ // The whole point of issue #64: a route with Environment "relay"
+ // reports "relay", and an unbound one reports "".
+}
+
+func TestAPIRoutesSaysSoWhenTheConsoleServesNoRegistry(t *testing.T) {
+ // cfg.Registry == "": a 503 naming the situation, never an empty
+ // list — "you have no routes" would be a lie.
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/console/ -run TestAPIRoutes -count=1`
+Expected: FAIL — `undefined: handleAPIRoutes`, and the endpoint 404s
+because nothing is registered.
+
+- [ ] **Step 3: Implement**
+
+First, extract the health computation from `instanceHealth` so one
+function computes the word. In `internal/console/apiinstances.go`, replace
+`instanceHealth`'s final block (from `am, err := s.OwnerAppMetrics(...)` to
+its `return out`) with a call, and add the extracted function:
+
+```go
+ for h, word := range s.healthByHost(ctx, routeFor) {
+ out[h] = word
+ }
+ return out
+}
+
+// healthByHost is the metrics half of a health answer, shared by
+// instanceHealth (records joined to routes) and handleAPIRoutes (routes
+// directly), so the two — and the Running tab, which uses the same
+// healthWord — can never disagree about an instance's state.
+//
+// routeFor arrives ALREADY SCOPED to the caller's own account and app.
+// This function performs no tenant check of its own and must not be
+// handed an unfiltered registry listing: OwnerAppMetrics' allowlist is
+// exactly the hosts and stamped units it is given, so a foreign unit name
+// reaching unitByHost below would widen the metrics view past the
+// caller's own app.
+func (s *Server) healthByHost(ctx context.Context, routeFor map[string]registry.Route) map[string]string {
+ out := make(map[string]string, len(routeFor))
+ hosts := make([]string, 0, len(routeFor))
+ unitByHost := map[string]string{}
+ for h, rt := range routeFor {
+ hosts = append(hosts, h)
+ if rt.Unit != "" {
+ unitByHost[h] = rt.Unit
+ }
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
+ if err != nil {
+ // An unreadable sweep is not knowledge that these instances are
+ // down: every host reads as not-reporting below, which healthWord
+ // turns into "No readings" or "Not responding" per expectsReadings —
+ // the same answer the page gives when the sweep is unavailable.
+ log.Printf("console: health by host: metrics: %v", err)
+ am = nil
+ }
+ rep := make(map[string]bool, len(am))
+ for _, m := range am {
+ if m.Reporting() {
+ rep[m.Host] = true
+ }
+ }
+ for h, rt := range routeFor {
+ status := rt.Status
+ if status == "" {
+ status = "ready" // registry contract: empty status means ready
+ }
+ out[h] = healthWord(rt.Kind, status, rt.Hibernate, rep[h],
+ expectsReadings(rt.Provisioned, rt.Unit, rt.Backing))
+ }
+ return out
+}
+```
+
+Keep `instanceHealth`'s tenant-boundary doc comment and its `routeFor`
+construction exactly where they are — only the metrics half moves.
+
+Create `internal/console/apiroutes.go`:
+
+```go
+// The routes read (`carlos routes`, issue #64): what routes this app
+// actually has on this deployment, and — the reason the issue was
+// reopened — what named config environment each one is bound to.
+//
+// Before this, a route's environment binding could be read exactly one
+// way: sqlite3 against /data/registry.db on the box. `carlos route` is a
+// setter, `carlos apps` is console-side, and the instance-record API
+// answers for RECORDS, so a hand-added route (titogo's relay, the one that
+// needed the binding) had nothing to read at all. That made verifying the
+// scope of a sealed-credential delivery the one step of the whole
+// procedure needing box access, in a flow whose selling point is that a
+// developer needs no AWS anything — and it is why issue #196 took an hour
+// to see rather than a minute.
+//
+// GATE: apiAccountApp, the member-proving lookup, NOT the instance family's
+// instanceGate. An app token is a machine credential minted for one
+// instance to register itself; this answer describes every route of the
+// app — upstream addresses, owning units, environment bindings — which is
+// an operator's view, not a workload's. The narrower gate is the whole
+// difference, so do not "unify" it with the instance family's.
+//
+// SCOPE: the console reads its OWN box's registry, so in a multi-box fleet
+// the other boxes' routes do not appear here. That is the same blind spot
+// instanceHealth has today; internal/fleetapi is the eventual fix (#145)
+// and is unmerged. The CLI says so in its own help rather than letting a
+// short list read as a complete one.
+package console
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// routeListTimeout bounds the metrics join, the only part of this handler
+// that can be slow: the registry is a local file.
+const routeListTimeout = 15 * time.Second
+
+// apiRoute is one row of the answer. Every field is drawn from the
+// registry row EXCEPT health, which is the same word instanceHealth and
+// the Running tab compute (healthByHost).
+type apiRoute struct {
+ Host string `json:"host"`
+ Kind string `json:"kind"`
+ Upstream string `json:"upstream"`
+ App string `json:"app"`
+ Channel string `json:"channel"`
+ Environment string `json:"environment"`
+ Status string `json:"status"`
+ Health string `json:"health"`
+ Hibernate bool `json:"hibernate"`
+ Unit string `json:"unit,omitempty"`
+ Backing string `json:"backing,omitempty"`
+ Provisioned bool `json:"provisioned"`
+}
+
+// routeUpstream renders the one cell that answers "where does this route
+// send traffic": a socket wins over addresses (registry.Route's own
+// precedence), a multi-upstream set is shown whole rather than reduced to
+// its first element, and a tunnel-backed route says so instead of
+// rendering an empty cell that would read as a broken row.
+func routeUpstream(rt registry.Route) string {
+ switch {
+ case rt.Socket != "":
+ return rt.Socket
+ case rt.Addrs != "":
+ return rt.Addrs
+ case rt.Addr != "":
+ return rt.Addr
+ case rt.Tunnel != "":
+ return "tunnel:" + rt.Tunnel
+ }
+ return ""
+}
+
+func (s *Server) handleAPIRoutes(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Registry == "" {
+ // Never an empty list: a console with no registry cannot know any
+ // route's state, and "[]" would read as "this app has no routes".
+ jsonError(w, http.StatusServiceUnavailable,
+ "this console serves no route registry, so it cannot say what routes exist")
+ return
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: api routes: registry %s: %v", s.cfg.Registry, err)
+ jsonError(w, http.StatusBadGateway, "could not read the route registry — try again")
+ return
+ }
+ all, err := reg.List()
+ if err != nil {
+ log.Printf("console: api routes: registry list: %v", err)
+ jsonError(w, http.StatusBadGateway, "could not read the route registry — try again")
+ return
+ }
+ // The tenant boundary, and the only one: the registry read above
+ // returned every route on this box, other accounts' included. A row
+ // enters routeFor only if it is stamped to THIS app and resolves to
+ // THIS account — release.RouteAccount, so an unstamped account column
+ // resolves the same way every other read of it resolves rather than
+ // being silently excluded or silently included.
+ routeFor := map[string]registry.Route{}
+ for _, rt := range all {
+ if rt.App != app || release.RouteAccount(rt) != acct.Sqid {
+ continue
+ }
+ routeFor[rt.Host] = rt
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), routeListTimeout)
+ defer cancel()
+ health := s.healthByHost(ctx, routeFor)
+ rows := make([]apiRoute, 0, len(routeFor))
+ for _, rt := range routeFor {
+ rows = append(rows, apiRoute{
+ Host: rt.Host, Kind: rt.Kind, Upstream: routeUpstream(rt), App: rt.App,
+ Channel: rt.Channel, Environment: rt.Environment, Status: rt.Status,
+ Health: health[rt.Host], Hibernate: rt.Hibernate, Unit: rt.Unit,
+ Backing: rt.Backing, Provisioned: rt.Provisioned,
+ })
+ }
+ // Sorted by host: registry order is not stable across passes, and a
+ // listing that reshuffles between calls is hard to read a diff out of.
+ sortRoutesByHost(rows)
+ writeJSON(w, http.StatusOK, map[string]any{"routes": rows})
+}
+
+func sortRoutesByHost(rows []apiRoute) {
+ sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host })
+}
+```
+
+Add `"sort"` to the import block.
+
+Register it in `internal/console/authcli.go`, immediately after the config
+family's three lines:
+
+```go
+ // The routes read (apiroutes.go, issue #64) — the member-only door onto
+ // this box's registry rows for one app, so a route's environment
+ // binding can be read back without a box shell. NOT instanceGate: see
+ // that file's package comment for why an app token must not reach it.
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/routes", s.handleAPIRoutes)
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS, including every pre-existing `instanceHealth` test — the
+extraction must not change a single health word.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/apiroutes.go internal/console/apiroutes_test.go internal/console/authcli.go internal/console/apiinstances.go
+git commit -m "console: GET /api/cli/apps/{account}/{app}/routes (#64)"
+```
+
+---
+
+### Task 8: `carlos routes`
+
+**Files:**
+- Create: `routes_cli.go`
+- Modify: `apimode.go` (add the client method beside `instanceList` at
+ :1838), `cli.go` (verb dispatch — find the switch that routes
+ `"instances"` to `runInstances` and add `"routes"` beside it), and the
+ top-level usage/help text that lists verbs
+- Test: `routes_cli_test.go`
+
+**Interfaces:**
+- Consumes: `apiPath(a apiAcct, app, tail string) string`,
+ `s.getJSON(ctx, path) (int, []byte, error)`,
+ `apiErrorFromBody(status, body) error`, `requireAPISession(verb,
+ consoleFlag) *apiSession`, `resolvedAccount`, `resolvedApp`,
+ `newTable(stdout)`, `printUsing`, `mutedf`.
+- Produces:
+ `type apiRouteRow struct` (client mirror of the wire shape);
+ `func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error)`;
+ `func runRoutes(args []string)`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `routes_cli_test.go`. Follow the root package's existing
+CLI-against-a-fake-console pattern (read `instances_cli_test.go` first and
+reuse its fake-console harness rather than building a second one).
+
+```go
+func TestRoutesListsTheEnvironmentColumn(t *testing.T) {
+ // A fake console answering {"routes":[{"host":"connect.go.tito.io",
+ // "environment":"relay",...}]} must produce a table whose row carries
+ // both the host and "relay" — this is the read issue #64 exists for.
+}
+
+func TestRoutesRefusesDirectStoreMode(t *testing.T) {
+ // With CARLOS_DEPLOYMENT_BUCKET set and no session, the verb refuses by
+ // name: the registry lives on a box, and the box-side listing is
+ // `carlos instances`. Assert the refusal names that command.
+}
+
+func TestRoutesSaysWhenTheAppHasNoRoutes(t *testing.T) {
+ // An empty list prints a line saying so, not an empty table — and the
+ // line mentions that only this deployment's own box is covered.
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test . -run TestRoutes -count=1`
+Expected: FAIL — `undefined: runRoutes`.
+
+- [ ] **Step 3: Implement**
+
+Add to `apimode.go`, beneath `instanceList`:
+
+```go
+// apiRouteRow is one row of the console's routes answer
+// (internal/console/apiroutes.go). Every field is additive on the wire: a
+// console that predates one sends nothing and it decodes as the zero
+// value, which is why the table below prints "—" for an empty ENVIRONMENT
+// rather than treating absence as a binding.
+type apiRouteRow struct {
+ Host string `json:"host"`
+ Kind string `json:"kind"`
+ Upstream string `json:"upstream"`
+ App string `json:"app"`
+ Channel string `json:"channel"`
+ Environment string `json:"environment"`
+ Status string `json:"status"`
+ Health string `json:"health"`
+ Hibernate bool `json:"hibernate"`
+ Unit string `json:"unit"`
+ Backing string `json:"backing"`
+ Provisioned bool `json:"provisioned"`
+}
+
+// routeList reads one app's routes on the console's own box (issue #64).
+func (s *apiSession) routeList(ctx context.Context, a apiAcct, app string) ([]apiRouteRow, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/routes"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Routes []apiRouteRow `json:"routes"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("routes: unreadable response: %w", err)
+ }
+ return res.Routes, nil
+}
+```
+
+Create `routes_cli.go`:
+
+```go
+// `carlos routes` — what routes this app has, and what each one is bound
+// to (issue #64).
+//
+// API MODE ONLY, and that is the point of the verb existing at all. The
+// facts it prints live in a box's registry, and reading them used to mean
+// sqlite3 on the box: `carlos route` is a setter, `carlos apps` answers
+// from the console's app list, and `carlos instances list` answers for
+// instance RECORDS, so a hand-added route had no reader anywhere. Since a
+// route's ENVIRONMENT binding is what decides which sealed secrets it
+// gets, "which routes is this credential delivered to" was a question only
+// answerable with box access — the one step of titogo's whole secrets
+// procedure that needed it.
+//
+// The box-side listing is `carlos instances` (cli.go), unchanged: an
+// operator standing on the box already has the registry in front of them,
+// and a second box-local verb answering the same question would be two
+// commands to keep in step for no gain.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+)
+
+const routesUsage = `usage: carlos routes -app <a> [-account <sqid|name>] [-match <substr>] [-console <origin>]
+
+Lists the routes this app has on the console's deployment: where each one
+sends traffic, which release channel it follows, and which named config
+environment it is bound to (the binding that decides which secrets it
+gets — see carlos env, carlos secrets).
+
+Signed-in only (carlos auth login). On a box, the local listing is
+"carlos instances".`
+
+func runRoutes(args []string) {
+ fs := flag.NewFlagSet("routes", flag.ExitOnError)
+ app := fs.String("app", "", "app whose routes to list")
+ match := fs.String("match", "", "only hosts containing this substring")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ sess := requireAPISession("routes", *consoleFlag)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, routesUsage)
+ rows, err := sess.routeList(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatalf("routes: %v", err)
+ }
+ shown := 0
+ w := newTable(stdout)
+ fmt.Fprintln(w, "HOST\tKIND\tUPSTREAM\tCHANNEL\tENVIRONMENT\tSTATUS\tHEALTH")
+ for _, r := range rows {
+ if *match != "" && !strings.Contains(r.Host, *match) {
+ continue
+ }
+ shown++
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ r.Host, dashIfEmpty(r.Kind), dashIfEmpty(r.Upstream), dashIfEmpty(r.Channel),
+ // An empty ENVIRONMENT is a real answer — the route takes the
+ // app's default bundle — so it prints as a dash rather than a
+ // blank cell an operator would read as missing data.
+ dashIfEmpty(r.Environment), dashIfEmpty(r.Status), dashIfEmpty(r.Health))
+ }
+ w.Flush()
+ if shown == 0 {
+ mutedf(stdout, "no routes for %s on this deployment's box", app2)
+ }
+ // Said every time, not only when the list is empty: this console reads
+ // its OWN box's registry, so on a multi-box fleet a short answer is not
+ // a complete one, and an operator must not read it as one (#145).
+ mutedf(stdout, "(routes on this deployment's own box; other boxes in a fleet are not listed)")
+}
+
+// dashIfEmpty renders an empty cell as "—" so a column that is legitimately
+// blank reads as an answer rather than as missing data.
+func dashIfEmpty(s string) string {
+ if s == "" {
+ return "—"
+ }
+ return s
+}
+```
+
+If `dashIfEmpty` (or an equivalent) already exists in the root package,
+use that one and delete this copy — check before adding.
+
+Register the verb in `cli.go`'s dispatch switch beside `"instances"`, and
+add it to the top-level usage text:
+
+```
+ routes list an app's routes and what each is bound to
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test . -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add routes_cli.go routes_cli_test.go apimode.go cli.go
+git commit -m "cli: carlos routes (#64)"
+```
+
+---
+
+### Task 9: The box-side listing shows the binding, and the docs catch up
+
+**Files:**
+- Modify: `cli.go:284-310` (the bare `carlos instances` table)
+- Modify: `README.md` (the command list, if it enumerates verbs — check
+ first)
+- Test: `cli_test.go` (or wherever the bare listing is already tested)
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces: nothing new.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestInstancesListingShowsTheEnvironmentBinding(t *testing.T) {
+ // A registry with one route bound to "relay": the bare `carlos
+ // instances` table must carry an ENV column and the value, so an
+ // operator standing on the box can read the binding without sqlite3.
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test . -run TestInstancesListing -count=1`
+Expected: FAIL — no ENV column.
+
+- [ ] **Step 3: Implement**
+
+In `cli.go`'s `runInstances` bare-listing branch:
+
+```go
+ // BACKING and UNIT are the two halves of one answer — who owns this
+ // route's process — so they print side by side: a route with a UNIT is
+ // cycled by that unit on adoption, whatever BACKING says (and BACKING is
+ // empty for every such route, which is the shape that made issue #110
+ // invisible from this listing).
+ //
+ // ENV is the named config bundle the route is bound to (issue #64): it
+ // decides which secrets this route's process is delivered, and reading
+ // it used to mean sqlite3 against the registry. Empty means the app's
+ // default bundle, which is a real answer, so it prints blank rather
+ // than being omitted.
+ fmt.Fprintln(w, "HOST\tAPP\tKIND\tCHANNEL\tENV\tSTATUS\tVERSION\tHIB\tBACKING\tUNIT")
+ for _, t := range routes {
+ hib := ""
+ if t.Hibernate {
+ hib = "yes"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ t.Host, t.App, t.Kind, t.Channel, t.Environment, t.Status, t.Version, hib, t.Backing, t.Unit)
+ }
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./... -count=1`
+Expected: PASS, every package.
+
+- [ ] **Step 5: Full gate, then commit**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+gofmt -l $(git diff --name-only main... | grep '\.go$')
+git add -A
+git commit -m "cli: carlos instances shows a route's environment binding (#64)"
+```
+
+---
+
+### Task 10: Branch close-out
+
+**Files:**
+- Modify: the spec (`docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md`)
+ only if implementation diverged from it — the spec must describe what
+ was built, not what was planned.
+
+- [ ] **Step 1: Run the full gate one more time from a clean tree**
+
+```bash
+go build ./... && go vet ./... && go test ./... -count=1
+GOOS=darwin go build ./...
+```
+
+The `GOOS=darwin` compile is not optional: `internal/host` links into the
+CLI, which people run on Macs, and CI runs this.
+
+- [ ] **Step 2: Reconcile the spec with what was built**
+
+Read the spec section by section against the diff. Fix any place the
+implementation went a different way. Commit any edits.
+
+- [ ] **Step 3: Push the branch and open the PR**
+
+```bash
+git push -u origin titogo-bugs
+gh pr create --title "Route-scoped config delivery: exec_delivery per environment, fast rebinding convergence, carlos routes" --body "$(cat <<'EOF'
+Closes #195, #196, #64 — the three defects in titogo's "deliver one sealed
+credential to one service" runbook.
+
+**#195** `exec_delivery` is read from the bundle a route's config actually
+resolves through: the route's named environment when it states a value,
+the app's default bundle otherwise. Additive — every deployment on an
+app-level `file` today delivers to exactly the same routes. Reverses half
+of the 2026-08-03 issue #18 ruling (ruled 2026-08-16); route scope is
+still refused, and the comment says why.
+
+**#196** The agent's fast config loop fingerprints the registry-side facts
+that decide what config a route gets, so binding a route to an environment
+converges in ~3s instead of waiting out the hourly floor. Covers both
+doors onto the column — `carlos route -environment` and the reconciler
+converging `Environment` from an instance record — where the fix suggested
+in the issue would have missed the second.
+
+**#64** `carlos routes` over a new member-only
+`GET /api/cli/apps/{account}/{app}/routes`, so a route's environment
+binding can be read back without a box shell. Works for hand-added routes
+with no instance record, which is the case that made this urgent. The bare
+box-side `carlos instances` listing gains an ENV column too.
+
+Known limitation, stated in the code and the CLI's own help: the console
+reads its own box's registry, so other boxes in a fleet are not listed
+(#145).
+
+Spec: `docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md`
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+
+https://claude.ai/code/session_015gyhxeoXGBW4WCFRnMqBue
+EOF
+)"
+```
+
+- [ ] **Step 4: Post the migration note on #195**
+
+titogo's migration has a manual step the issue does not mention. Comment
+on #195 with it: turning the app-level flag off leaves the router routes'
+`*.config.env` files in place (they hold decrypted secrets and carlos
+cannot know when the app's supervisor stopped reading them), naming them
+in the log every pass until deleted by hand.
+
+---
+
+## Deploy (after the PR merges — a human's action, not part of this plan)
+
+Ship the platform binary to both boxes, promote, adopt, restart the
+consoles for the API half. The agent half takes effect on the agent's next
+start; the `exec_delivery` half on the next pass after that. Verify with
+`carlos routes -app titogo` from off-box, which is the whole point of #64.
diff --git a/docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md b/docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md
new file mode 100644
index 000000000..c8c4e92bb
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-route-scoped-config-delivery-design.md
@@ -0,0 +1,369 @@
+# Route-scoped config delivery — design
+
+2026-08-16. Closes #195, #196, #64.
+
+Three issues filed from titogo, all from one runbook: deliver one sealed
+credential to one service without hand-writing a file on the box. The
+procedure is `carlos secrets set -environment relay`, then `carlos route
+-host connect.go.tito.io -environment relay`, then `carlos env
+exec-delivery -app titogo file`. Each of the three steps has a defect, and
+they compose into an hour lost to a symptom that reads as a bad credential.
+
+| # | Defect | Fix |
+|---|--------|-----|
+| #195 | `exec_delivery` is app-level, so opting one route in opts the whole fleet in | Read the flag from the route's bound environment bundle, inheriting the app default |
+| #196 | Binding a route to an environment doesn't re-materialize its config for up to an hour | Fingerprint the in-scope route set in the agent's fast loop |
+| #64 | A route's environment binding cannot be read back off-box | `carlos routes`, over a new account-scoped CLI API endpoint |
+
+The three share a branch and nothing else: no file is touched by more than
+one of them.
+
+## 1. Environment-scoped `exec_delivery` (#195)
+
+### 1.1 What the flag does today
+
+`SyncExec` (`internal/appconfig/syncexec.go`) materializes a route's merged
+config into `<dataDir>/<host>.config.env`. Two route shapes reach it:
+
+- **socket-exec** (`execSyncable`): the activator spawns the process, so
+ carlos owns it. In scope unconditionally, and bounced when the rendered
+ file changes.
+- **TCP-addr exec** (`execAddrCandidate`, issue #18): carlos proxies to
+ `127.0.0.1:<port>` but neither spawns nor owns the process — titogo's
+ router fleet. In scope only if the app opted in, and **never** bounced;
+ the file carries `execAddrConfigHeader` saying so, and the app's own
+ supervisor owns noticing it.
+
+`Manifest.ExecDelivery` (`"exec_delivery": "file"`) is that opt-in. It is
+read from the app's default bundle, `apps/<acct>/<app>/config.json`, alone.
+
+The flag decides **whether a file is written for a route**. It has never
+had anything to do with the file's *contents*: those come from
+`LoadMerged`, the default bundle overlaid by whatever environment the route
+is bound to, and that half has been per-route since it shipped.
+
+### 1.2 The defect
+
+Because the flag is read from one app-level object, it answers "does this
+**app** get files" — one answer for every TCP-addr exec route of the app on
+the box. titogo needed sealed Apple Wallet material delivered to
+`connect.go.tito.io` alone; turning the flag on wrote a file for every
+router route too. Harmless in practice (nothing reads them) but it is a
+fleet-wide switch thrown to deliver one service's credential, and it has to
+be explained every time someone reads the runbook.
+
+### 1.3 The ruling this reverses
+
+`appconfig.go:35-50` records the issue #18 design comment (2026-08-03):
+
+> APP-LEVEL ONLY [...] a named environment's manifest is never a valid
+> place for this field [...] The reader is the app's own supervisor, one
+> program per app, not per-route or per-environment: per-route or
+> per-environment opt-in would let half a fleet read config the other half
+> silently ignores, the "config-that-lies" failure mode.
+
+The premise — one supervisor program per app — is false in the deployment
+that motivated the feature. The `titogo` app supervises the relay and the
+router fleet through different units. Splitting delivery along the
+environment binding is therefore not "half a fleet ignoring config": it is
+the same axis the operator already chose when they bound the route, and the
+same axis the secrets themselves are scoped on.
+
+**Ruled 2026-08-16 (Paul):** environment scope is allowed; **route** scope
+is still refused. A per-route flag would need a registry column and — per
+the 2026-08-10 rule that a `Provisioned` route's Channel and Environment
+belong to its instance record — a record field and reconcile convergence
+too, three doors onto one bit. The environment bundle is one door onto a
+scope that already exists.
+
+### 1.4 Resolution
+
+`checkOptedIn` is keyed on `(account, app, environment)` instead of
+`(account, app)`. For a route bound to environment `e`:
+
+| Bundle state | Verdict |
+|--------------|---------|
+| route bound to no environment | app default bundle's value (today's behaviour, unchanged) |
+| `e.json` carries `exec_delivery` | that value decides |
+| `e.json` is silent on it | **inherit** the app default |
+| `e.json` absent, or unreadable | inherit / unresolved, as §1.6 |
+
+Inheritance is what keeps this additive: every deployment carrying an
+app-level `file` today delivers to exactly the same routes after the
+upgrade. An operator narrows by turning the app-level flag off and setting
+`file` on the one bundle.
+
+`LoadMerged` is **not** changed: the merged manifest stays vars-only and
+never carries `ExecDelivery` forward. The opt-in is resolved by
+`checkOptedIn` reading the two bundles' fields directly, not by merging
+them. Two reasons: the merged manifest is what gets *rendered into a file*,
+and a delivery flag has no business appearing there; and `LoadMerged` is
+shared with the unit path (`sync.go`), which has no opt-in concept at all.
+
+### 1.5 CLI
+
+```
+carlos env exec-delivery -app <a> file|off (unchanged)
+carlos env exec-delivery -app <a> -environment <e> file|inherit (new)
+```
+
+At environment scope the spellings are `file` and `inherit`, and `off` is
+**refused there by name**, with a message pointing at the app-level form.
+In a bundle, clearing the field means "inherit"; a verb that spelled that
+`off` would read as "stop delivering" while doing the opposite.
+
+An explicit per-environment override-to-off has no caller today and is not
+built. Leaving it out keeps the value space free for a `"none"` sentinel
+later, which an older binary already handles correctly: its unknown-value
+branch logs once and falls back to not-opted-in, which is what `none` would
+mean.
+
+`carlos env list -app <a> -environment <e>` prints the bundle's own
+`exec-delivery:` line when the field is set, and says nothing when it is
+not (inherit is the absence of a statement, and printing the inherited
+value would make the bundle look like it holds one). `printEnvList`
+already receives the field for a named bundle and suppresses it; the
+suppression is what goes.
+
+### 1.5.1 Console door
+
+`carlos env exec-delivery` has an API mode, so the console's config door
+moves with it (`internal/console/apiconfig.go`):
+
+- **GET** currently emits `exec_delivery` only when `envName == ""`. It
+ emits the named bundle's own field too, still omitting it when unset.
+- **POST** currently refuses `exec_delivery` outright for a named bundle
+ ("exec_delivery is app-level only"). It accepts `"file"` and a new
+ `"inherit"` at environment scope, and refuses `"off"` there by name —
+ the same refusal the CLI gives, worded once on the server so the
+ operator reads the server's sentence rather than a paraphrase.
+
+The two-CAS-writes shape of an apply-plus-exec_delivery request is
+unchanged; the second write becomes `SetExecDeliveryAt` against the
+bundle's own key.
+
+### 1.6 Failure modes
+
+Unchanged in kind from today, extended to the second bundle:
+
+- **Env bundle fetch error** → `known: false`, the "unresolved, leaving
+ this app's TCP-addr route file(s) as-is" branch. Not a determination; the
+ file is untouched and the next pass retries.
+- **Env bundle absent while a route is bound to it** → inherit the app
+ default for the opt-in question. The route's *resolution* errors anyway
+ (`LoadMerged` refuses a bound-but-absent bundle) and its file is left
+ as-is, so the opt-in verdict never reaches a write in this case.
+- **Unrecognized value in an env bundle** → same additive discipline as the
+ default bundle: log once per `(acct, app, env)` per pass, treat as not
+ opted in.
+
+The once-per-pass logging discipline moves from per-`(acct, app)` to
+per-`(acct, app, env)`, so a box with many routes on one app still logs
+once per distinct bundle it consulted, never once per route.
+
+### 1.7 Migration cost, stated
+
+Turning the app-level flag off does **not** delete the router routes'
+`*.config.env` files. That is the standing ruling at `syncexec.go:343-387`:
+an un-opted route's file is frozen, warned about once per pass, and never
+removed, because deleting it would silently change what the app's own
+supervisor layers in at a moment carlos cannot announce. titogo's migration
+therefore ends with a hand deletion of the router files once their
+supervisor has stopped reading them, and a per-pass log line until then.
+Existing behaviour; it lands on them because of this change, so the issue
+and the runbook say so.
+
+### 1.8 Fast loop
+
+`ExecConfigApps` is unaffected: it is the route-*shape* half only and
+deliberately does not consult the opt-in (consulting it would cost a bucket
+read per app per tick, which is what the fast loop exists to avoid). The
+generation object is per-app and already covers every bundle
+(`applyEnvironment` touches it), so an environment bundle write already
+signals correctly.
+
+## 2. The fast loop watches the registry (#196)
+
+### 2.1 The defect
+
+`genPollPass` (`internal/host/agent.go:625`) decides "did anything change"
+from bucket generation etags alone. A route's `environment` lives in the
+box's own registry, so a binding change moves no etag and the fast loop
+cannot see it. Delivery waits for `configSyncLoop`'s hourly floor.
+
+Reproduced on `carlos-tito-ie` 2026-08-15: route bound at 06:11, file still
+holding the default bundle at 06:18. The nudge that fixed it was re-writing
+three identical secret values — which bumped `config-gen.json` and gave the
+fast loop the etag movement it was waiting for.
+
+The natural order of the documented flow is exactly the order that fails:
+seal the values, then bind the route. Everything then *looks* right — the
+bundle exists, the binding is right, `carlos secrets list` shows the keys —
+and the process reads a file with none of them. The only symptom is the
+app's own startup line saying the credential is missing, which reads as a
+bad credential rather than an undelivered one.
+
+### 2.2 Why not bump the generation from `carlos route`
+
+The issue proposes it, and it is the wrong door. Since 2026-08-10 a
+`Provisioned` route's Environment belongs to its **instance record**, and
+the reconciler converges it from `apps/<sqid>/<app>/instances/<host>.json`
+on every pass — `carlos route -environment` refuses such a row outright.
+So the sanctioned way to change a route's environment does not go through
+`carlos route` at all, and a fix there would miss it. It would also need
+bucket write credentials in a box-local verb that has never had them.
+
+The agent-side fix covers both doors, needs no new credential, and catches
+any third door added later.
+
+### 2.3 Fix
+
+New in `internal/appconfig`, beside `ExecConfigApps` and `UnitConfigApps`
+so the eligibility predicates cannot drift from the sweeps that use them:
+
+```go
+// ConfigRouteFingerprints returns one digest per delivery half over the
+// registry-side facts that decide what config a route gets.
+func ConfigRouteFingerprints(routes []registry.Route) (exec, unit string)
+```
+
+For each route passing that half's predicate (`execSyncable ||
+execAddrCandidate` for exec; `syncable && Backing == BackingUnit` for
+unit), contribute `host|account|app|environment`; sort; hash.
+
+Set membership carries scope changes for free: a route entering or leaving
+scope moves the digest exactly as a rebinding does, so a route that becomes
+`ready`, gains a `Unit`, or is deleted also gets a prompt pass.
+
+`genPollPass` keeps the two previous digests alongside its etag cache and
+ORs a movement into that half's `changed` flag. First sight records
+silently, matching the existing boot rule — a box coming up must not bounce
+every instance it has because it has never seen the digest before;
+`configSyncLoop`'s ~1min first pass owns boot convergence.
+
+The opt-in is deliberately **not** in the fingerprint, for the reason
+`ExecConfigApps` gives: it costs a bucket read per app per tick. An
+`exec_delivery` change is a bundle write, which bumps the generation object
+already.
+
+Result: convergence goes from ≤1 hour to ~3s, through both doors. A
+spurious digest movement costs one pass that writes nothing — `SyncExec`
+and `Sync` both compare rendered bytes and act only on a real change, so it
+can never cost a restart.
+
+## 3. `carlos routes` (#64)
+
+### 3.1 The defect
+
+After binding a route to an environment there is no way to confirm it.
+`carlos route` is a setter, `carlos apps` is console-side, and the console
+API exposes channels but not routes — so verifying the scope of a
+credential delivery means `sqlite3 /data/registry.db` on the box. That is
+the one step in titogo's whole secrets procedure that still needs box
+access, in a flow whose selling point is that a developer needs no AWS
+anything. It is also why #196 took an hour to see.
+
+### 3.2 API
+
+```
+GET /api/cli/apps/{account}/{app}/routes
+```
+
+Registered in the existing CLI-API family (`internal/console/authcli.go`)
+so it inherits `apiAccountApp`'s membership proof and the family's
+byte-identical 404 verbatim: an unknown account, someone else's account, an
+unclaimed app, and an app token probing a foreign path are one answer.
+
+Served from the console's own registry — the same open-and-list
+`instanceHealth` already does — filtered to
+`rt.App == app && release.RouteAccount(rt) == acct`. That is the tenant
+boundary the reconciler itself uses, and the account fallback is
+`release.RouteAccount`'s, the same resolution every other read of a route's
+account performs.
+
+Response rows carry: `host`, `kind`, `upstream` (addr or socket, whichever
+the row has), `app`, `channel`, `environment`, `status`, `health`,
+`hibernate`, `unit`, `backing`, `provisioned`. `health` is `healthWord`,
+computed the same way the Running tab and `carlos instances list` compute
+it, so the three cannot disagree.
+
+A console with no `Registry` configured cannot know any route's state; it
+answers with an explicit "this console serves no registry" rather than an
+empty list, which would read as "you have no routes".
+
+### 3.3 CLI
+
+```
+carlos routes -app <a> [-account <sqid|name>] [-match <substr>] [-console <origin>]
+```
+
+API mode only, like the instance record verbs: a routes listing is a
+console-authorized read, and the box-local operator equivalent already
+exists (§3.4). Columns:
+
+```
+HOST KIND UPSTREAM CHANNEL ENVIRONMENT STATUS HEALTH
+```
+
+This works for hand-added routes with no instance record — which is exactly
+`connect.go.tito.io`, so adding an ENVIRONMENT column to `carlos instances
+list` (record-backed) would not have closed this issue.
+
+### 3.4 Box side
+
+The bare `carlos instances` listing (`cli.go:260`) is already #64's
+"registry mode": host, app, kind, channel, status, version, hibernate,
+backing, unit. It gains one column, `ENV`. A second box-local verb
+answering the same question is not built.
+
+### 3.5 Limitation, stated not papered over
+
+The console reads *its own box's* registry, so in a multi-box fleet the
+other boxes' routes do not appear. This is the same blind spot
+`instanceHealth` has today. `internal/fleetapi` is the eventual fix and is
+unmerged (#145). The API doc comment and the CLI's own help say so.
+
+## 4. Testing
+
+TDD throughout, against the suites that already own these paths.
+
+- `internal/appconfig/syncexec_test.go` — the §1.4 resolution matrix as a
+ table: unbound/bound × default on/off × bundle on/inherit/absent/unknown,
+ asserting which hosts get a file. Plus: the file's *contents* are
+ unchanged by the flag's scope, and a bundle fetch error leaves an
+ existing file byte-identical.
+- `internal/appconfig/environments_test.go` — `SetExecDelivery` at
+ environment scope round-trips, refuses `off`, refuses an invalid value,
+ and bumps the generation object.
+- `appconfig_cli_test.go` / `e2e_test.go` — `-environment` + `off` is
+ refused by name; `-environment` + `inherit` clears the field.
+- `internal/console/apiconfig_test.go` — GET emits a named bundle's own
+ `exec_delivery` and omits it when unset; POST accepts `file`/`inherit`
+ at environment scope and refuses `off` there with the server's own
+ sentence.
+- A new fingerprint test beside `genPollPass`'s existing tests: a rebinding
+ moves the right half's digest and not the other's; first sight is silent;
+ a route leaving scope moves the digest; an unrelated column (Version)
+ does not.
+- `internal/console/apiinstances_test.go` gate patterns applied to the new
+ endpoint: non-member gets the 404, an app token bound elsewhere gets the
+ 404, a member sees only their own app's rows, a foreign-stamped row on
+ the same box never appears.
+- `carlosTestEnv` + a scratch `cmd.Dir` for anything shelling out to the
+ binary (CLAUDE.md's rule; the fallback is the operator's real login).
+
+Gate before any commit lands: `go build ./...`, `go vet ./...`,
+`go test ./... -count=1`, `gofmt -l` clean on touched files.
+
+## 5. Deploy
+
+Ordinary: PR, merge, ship the platform binary to both boxes, promote,
+adopt, restart the consoles for the API half. The agent half (§2) takes
+effect on the next agent restart; the `exec_delivery` half (§1) takes
+effect on the next pass after that.
+
+No wire-shape migration: `exec_delivery` in an environment bundle is
+additive to schema 1, an older binary parsing such a bundle ignores the
+field entirely (it reads the flag from the default bundle only), and a
+newer binary reading an older deployment sees no environment-scoped field
+and inherits — which is today's behaviour exactly.