carlos / platform Public

console: store reply names the backend; prefix lock covers the mid-grant window

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bee27a5229ba3f3dcfdf55a501a460666551f317 parent 57fc8cc
3 files changed, +116 −7
  • cmd/console/main.go +10 −0
  • internal/console/apistore.go +40 −7
  • internal/console/apistore_test.go +66 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 9696def09..b42f710fa 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -377,6 +377,16 @@ func storeBackend() (objstore.Provisioner, objstore.Deployment) {
if prefix == "" {
log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_BUCKET_PREFIX (bucket names are globally unique; e.g. carlos-)")
}
+ // The prefix is the head of every bucket name this console will ever mint
+ // (objstore.MintedBucket), so a prefix that cannot BEGIN a legal S3 name —
+ // "Carlos-", "carlos_", a leading dot — is a first grant that fails deep
+ // inside the walk, against a live cloud account, for whoever tries first.
+ // Checked against the shortest name it could produce (a 4-char account
+ // sqid and a 1-char app), because that is the tightest case the same
+ // function will judge later.
+ if !objstore.ValidBucketName(prefix + "aaaa-a") {
+ log.Fatalf("console: CARLOS_STORE_BUCKET_PREFIX %q cannot start an S3 bucket name — lowercase letters, digits, dots and hyphens only, starting alphanumeric (e.g. carlos-)", prefix)
+ }
endpoint := strings.TrimSpace(os.Getenv("CARLOS_STORE_ENDPOINT"))
p, err := objstore.NewAWSProvisioner(context.Background(), region, endpoint)
if err != nil {
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index e1aae5bc6..7df12beac 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -22,7 +22,8 @@
// changes nothing a box reads. Delivery happens later, through the app's
// config bundle, and THAT write does the announcing (objstore.Grant).
//
-// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive.md §4, §7.
+// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
+// §4, §7.
package console
import (
@@ -90,6 +91,16 @@ func storeEffectivePrefix(rec *objstore.Record) string {
// missing variables would describe a healthy waiting-for-an-operator store as
// a broken one. Both lists are always present and never null — a client reads
// them rather than testing for absence.
+//
+// backend is the DEPLOYMENT's backend name, "" when none is configured, and
+// it is here because without it `status` cannot answer the question a waiting
+// member actually has: is my declaration queued behind an operator, or does
+// this deployment have no object storage at all and never will? The
+// no-infra-access rule (CLAUDE.md) says the command alone must complete the
+// job, and "declared, nothing happening" with no way to tell those apart
+// fails it. Disclosure-wise it is the same class as secrets_pubkey and the
+// apps domain: a deployment-wide fact about the platform, carrying nothing
+// about any tenant, and every member of it can already see the consequences.
func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) {
prefix := storeEffectivePrefix(rec)
wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != ""
@@ -109,6 +120,7 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec
}
return map[string]any{
"app": app,
+ "backend": s.cfg.StoreDeployment.Backend,
"declared": rec.Declared,
"grant": rec.Grant,
"provisioned": rec.Provisioned,
@@ -201,12 +213,33 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
if rec == nil {
rec = &objstore.Record{}
}
- // THE PREFIX IS FIXED ONCE A BUCKET EXISTS. The delivered names are what a
- // RUNNING app reads; renaming them would leave it looking for variables
- // nothing will write again, while the old ones linger in its config
- // pointing at a live credential. The refusal names the only honest way out
- // rather than pretending this is a retry-able race.
- if rec.Provisioned != nil && storeEffectivePrefix(rec) != prefix {
+ // THE PREFIX IS FIXED ONCE AN OPERATOR HAS GRANTED. The delivered names
+ // are what a RUNNING app reads; renaming them would leave it looking for
+ // variables nothing will write again, while the old ones linger in its
+ // config pointing at a live credential. The refusal names the only honest
+ // way out rather than pretending this is a retry-able race.
+ //
+ // The guard keys on the GRANT, not on `provisioned`, because the grant
+ // walk is a sequence and its middle is exactly where a rename does the
+ // damage: the grant block is written first (§5 step 1) and the delivered
+ // variables land several steps before `provisioned` does. A re-declare
+ // squeezed into that window would be accepted under the old rule, and the
+ // vars already written under the OLD prefix would then be orphaned —
+ // nothing rewrites them, nothing reaps them, and they name a key the next
+ // pass revokes. Same widening Tasks 5 and 6 made to their own guards, for
+ // the same window.
+ if (rec.Grant != nil || rec.Provisioned != nil) && storeEffectivePrefix(rec) != prefix {
+ // Two sentences for two true states: an operator has GRANTED but the
+ // walk has not finished, versus a store that is fully provisioned. The
+ // way out is the same either way, and it is the half of the sentence
+ // that matters — but telling someone their store is "already
+ // provisioned" when no bucket exists yet is exactly the kind of
+ // confident lie an operator then has to talk them out of.
+ if rec.Provisioned == nil {
+ jsonError(w, http.StatusConflict,
+ "a store grant is already under way — the env prefix is fixed; destroy and re-create to change it")
+ return
+ }
jsonError(w, http.StatusConflict,
"store already provisioned — the env prefix is fixed; destroy and re-create to change it")
return
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go
index 86d87c813..78df0c231 100644
--- a/internal/console/apistore_test.go
+++ b/internal/console/apistore_test.go
@@ -119,6 +119,12 @@ func TestAPIStoreDeclareWritesTheRecord(t *testing.T) {
if out["grant"] != nil || out["provisioned"] != nil {
t.Fatalf("grant/provisioned = %v/%v, want null on a fresh declaration", out["grant"], out["provisioned"])
}
+ // This fixture configures no backend, and the answer says so — the field
+ // is what separates "queued behind an operator" from "this deployment
+ // will never provision one".
+ if out["backend"] != "" {
+ t.Fatalf("backend = %v, want \"\" on a deployment with no object storage", out["backend"])
+ }
// No endpoint name: this deployment provisions none, and a name the app
// will never be given would send someone hunting for a missing variable.
wantNames := []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"}
@@ -203,6 +209,66 @@ func TestAPIStoreDeclareRefusesAnInvalidPrefix(t *testing.T) {
}
}
+// TestAPIStoreNamesTheDeploymentBackend: a member with no infra access has no
+// other way to tell a declaration waiting on an operator from one waiting on a
+// deployment that has no object storage at all — and both doors have to agree
+// about it, since `carlos store create` is where most people will look first.
+func TestAPIStoreNamesTheDeploymentBackend(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-backend")
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{
+ Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-",
+ }
+
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("declare = %d %v", status, out)
+ }
+ if out["backend"] != "aws" {
+ t.Fatalf("declare backend = %v, want aws", out["backend"])
+ }
+ status, out = getJSON(t, storeURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %v", status, out)
+ }
+ if out["backend"] != "aws" {
+ t.Fatalf("status backend = %v, want aws", out["backend"])
+ }
+}
+
+// TestAPIStorePrefixIsFixedOnceGranted: the mid-grant window. The grant block
+// is written FIRST and the variables are delivered several steps before
+// `provisioned` lands, so a guard keyed on `provisioned` alone would accept a
+// rename in exactly the window where vars already exist under the old prefix —
+// orphaning them against a key the next pass reaps.
+func TestAPIStorePrefixIsFixedOnceGranted(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-grant-locked")
+ now := time.Now()
+ putRecord(t, fx, "titogo", &objstore.Record{
+ Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"},
+ Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"},
+ })
+
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "OTHER"}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("re-declare mid-grant = %d %v, want 409", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "destroy and re-create") {
+ t.Fatalf("error = %q, want the way out named", msg)
+ }
+ // It must not claim a bucket exists — nothing has been provisioned yet.
+ if msg := str(t, out, "error"); strings.Contains(msg, "already provisioned") {
+ t.Fatalf("error = %q, but this store is granted, not provisioned", msg)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Declared.EnvPrefix != "BLOBS" {
+ t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix)
+ }
+ // The same prefix still refreshes: a re-run of `carlos store create` must
+ // not turn into an error the moment an operator picks the request up.
+ if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token); status != http.StatusOK {
+ t.Fatalf("same-prefix re-declare mid-grant = %d %v, want 200", status, out)
+ }
+}
+
// TestAPIStorePrefixIsFixedOnceProvisioned: the delivered variable names are
// what a RUNNING app reads. Renaming them under a provisioned store would
// leave the app looking for variables nothing will ever write again, so the