carlos / platform Public

store status: an app whose storage is all per-environment showed none of it

Asked about the app's own store, the status door answered 404 "no store is
declared for this app" whenever that store was absent — before it ever
reached the environments listing. So an app that owns a bucket per
environment and none of its own reported that it had no storage at all.

That is not an edge case. It is the shape a managed product is always in:
every tenant is a config bundle, the app itself never declares a store, and
every bucket it owns hangs off an environment. The listing exists because a
store nobody reads still bills and teardown is explicit — and it was
invisible in exactly the case that matters.

The door now lists the environments and answers 200 with a zero record: every
block reads as absent, which is true of the APP, with its environments named
beside it. A genuinely storeless app still gets the 404 it had.

printStoreEnvironments comes out of printStoreStatus's tail so it can run
before the not-declared exit as well as after a full record. One function,
two call sites, and a test that it prints exactly once for an app that has
both — inlining it twice is the obvious way to get it twice.

Found against carlos.exciting.io after deploying the console, not in the
suite. The tests here are the ones that would have caught it.

Spec: docs/superpowers/specs/2026-09-19-per-environment-store-design.md §8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 4d9d5dc5440557ac81f92d72b49d79c0bc10eb2c parent 8116e4c
4 files changed, +147 −9
  • internal/console/apistore.go +27 −1
  • internal/console/apistore_envonly_test.go +52 −0
  • store.go +22 −8
  • store_env_print_test.go +46 −0

CI passed — run details

diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index 6187568c4..79a8fced7 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -515,7 +515,33 @@ func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) {
"no store is declared for this app's "+env+" environment — run `carlos store create --environment "+env+"`")
return
}
- jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`")
+ // The app has no store of its OWN — but it may have environment
+ // stores, and this is the shape where answering "no store" is most
+ // wrong. An app whose storage is entirely per-environment (a managed
+ // product's, where every tenant is a bundle) would have every one of
+ // its buckets hidden behind a 404 that says it has none. That is the
+ // exact opposite of what this listing exists for: a store nobody
+ // reads still bills, and teardown is explicit.
+ envs, lerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
+ if lerr != nil {
+ log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, lerr)
+ jsonError(w, http.StatusBadGateway, "could not read the store record — try again")
+ return
+ }
+ if len(envs) == 0 {
+ jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`")
+ return
+ }
+ // A zero record, not a nil one: every block reads as absent, which is
+ // true, and the environments are named beside it.
+ out, rerr := s.storeReply(ctx, acct, app, "", &objstore.Record{})
+ if rerr != nil {
+ log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, rerr)
+ jsonError(w, http.StatusBadGateway, "could not read the store's status. try again")
+ return
+ }
+ out["environments"] = envs
+ writeJSON(w, http.StatusOK, out)
return
}
out, err := s.storeReply(ctx, acct, app, env, rec)
diff --git a/internal/console/apistore_envonly_test.go b/internal/console/apistore_envonly_test.go
new file mode 100644
index 000000000..b146006e8
--- /dev/null
+++ b/internal/console/apistore_envonly_test.go
@@ -0,0 +1,52 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/objstore"
+)
+
+// An app whose storage is entirely per-environment has NO store of its own.
+// Asked about the app store, the door used to answer 404 "no store is
+// declared for this app" — hiding every bucket the app actually owns, in the
+// one shape where the listing matters most. A managed product is always this
+// shape: every tenant is a bundle, and the app itself never declares a store.
+//
+// Found against a live deployment, not here, which is why this test exists.
+func TestAPIStoreStatusListsEnvironmentsWithNoAppStore(t *testing.T) {
+ ctx := context.Background()
+ fx, token := storeFixture(t, "code-store-env-only")
+
+ // Only an environment store. The app's own is never declared.
+ if status, body := postRaw(t, storeEnvURL(fx, "titogo", "acme"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("declare in acme = %d %s", status, body)
+ }
+ if rec, _, err := objstore.Get(ctx, fx.rel, fx.sqid, "titogo"); err != nil || rec != nil {
+ t.Fatalf("fixture drift: the app's own store exists (%+v, %v)", rec, err)
+ }
+
+ status, body := getRaw(t, storeURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("app-store status with only an environment store = %d %s, want 200", status, body)
+ }
+ var reply map[string]any
+ if err := json.Unmarshal([]byte(body), &reply); err != nil {
+ t.Fatalf("decode %q: %v", body, err)
+ }
+ envs, _ := reply["environments"].([]any)
+ if len(envs) != 1 || envs[0] != "acme" {
+ t.Errorf("environments = %v, want [acme]", envs)
+ }
+ // Every block reads as absent, which is true: the APP has no store.
+ for _, key := range []string{"declared", "grant", "provisioned"} {
+ if reply[key] != nil {
+ t.Errorf("%s = %v, want null — the app itself declared nothing", key, reply[key])
+ }
+ }
+ if reply["environment"] != "" {
+ t.Errorf("environment = %v, want \"\" — this reply is about the app's own store", reply["environment"])
+ }
+}
diff --git a/store.go b/store.go
index f5b865b39..7acdc3d4f 100644
--- a/store.go
+++ b/store.go
@@ -591,6 +591,10 @@ func printStoreStatus(w io.Writer, res apiStoreReply) {
warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)")
}
printStoreEntitlement(w, res.Entitlement)
+ // Before the not-declared exit: an app whose storage is entirely
+ // per-environment has no store of its own, and stopping here would print
+ // "not declared" over a set of live buckets.
+ printStoreEnvironments(w, res)
if res.Declared == nil {
mutedf(w, "not declared — carlos store create --app %s", res.App)
return
@@ -628,14 +632,6 @@ func printStoreStatus(w io.Writer, res apiStoreReply) {
if res.PendingFinish {
warnf(w, "a rotation is pending finish — the previous key is still live: carlos store rotate --finish --app %s", res.App)
}
- // Every OTHER store this app has. Only an app-store status carries these,
- // and each one is a bucket of its own with a credential of its own — a
- // store nobody remembers is a bill nobody remembers, and teardown is
- // explicit, so this line is where an abandoned one is visible.
- if len(res.Environments) > 0 {
- fmt.Fprintf(w, " environments with their own store: %s\n", strings.Join(res.Environments, ", "))
- mutedf(w, " read one with carlos store status --app %s --environment %s", res.App, res.Environments[0])
- }
// Scanning, once it exists. Deliberately three distinct states rather
// than a boolean: "requested" is a member waiting on an operator,
// "enabled" is an engine looking at objects, and "enforcing" is infected
@@ -660,6 +656,24 @@ func printStoreStatus(w io.Writer, res apiStoreReply) {
}
}
+// printStoreEnvironments names every OTHER store this app has. Each one is a
+// bucket of its own with a credential of its own — a store nobody remembers
+// is a bill nobody remembers, and teardown is explicit, so this line is where
+// an abandoned one becomes visible.
+//
+// Printed BEFORE the not-declared exit, because an app whose storage is
+// entirely per-environment has no store of its own: stopping at "not
+// declared" would say the app has no storage while it owns several buckets.
+// Only an app-store status carries these; a status aimed at one environment
+// answers about that environment and lists nothing.
+func printStoreEnvironments(w io.Writer, res apiStoreReply) {
+ if len(res.Environments) == 0 {
+ return
+ }
+ fmt.Fprintf(w, " environments with their own store: %s\n", strings.Join(res.Environments, ", "))
+ mutedf(w, " read one with carlos store status --app %s --environment %s", res.App, res.Environments[0])
+}
+
func printStoreEntitlement(w io.Writer, entitlement *objstore.Entitlement) {
switch {
case entitlement.Approved():
diff --git a/store_env_print_test.go b/store_env_print_test.go
new file mode 100644
index 000000000..e3305d6af
--- /dev/null
+++ b/store_env_print_test.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// printStoreStatus must name an app's environment stores BEFORE it gives up
+// with "not declared". An app whose storage is entirely per-environment has
+// no store of its own, so stopping at the not-declared line would report that
+// it has no storage while it owns a bucket per environment.
+func TestPrintStoreStatusNamesEnvironmentsWithNoAppStore(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{
+ App: "managed",
+ Backend: "aws",
+ Environments: []string{"acme", "globex"},
+ })
+ out := buf.String()
+ for _, want := range []string{
+ "environments with their own store: acme, globex",
+ "carlos store status --app managed --environment acme",
+ "not declared", // still true of the app's OWN store
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("status is missing %q:\n%s", want, out)
+ }
+ }
+}
+
+// ...and exactly once for an app that has both its own store and environment
+// stores — the line was inline before it was extracted, and printing it from
+// two places is the obvious way to get it twice.
+func TestPrintStoreStatusNamesEnvironmentsOnce(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{
+ App: "both",
+ Backend: "aws",
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
+ Environments: []string{"acme"},
+ })
+ if n := strings.Count(buf.String(), "environments with their own store"); n != 1 {
+ t.Fatalf("the environments line appears %d times, want 1:\n%s", n, buf.String())
+ }
+}