carlos / platform Public

Merge pull request #413 from carlosframework/412-storage-isolation-config

App policy: every instance gets its own environment and store (#412)
James Adam pushed by james@keymail.dev 1c4ee6738eee5dbad471abcbf8f5edeb0b7919a0 parent 1447716parent b561da2
15 files changed, +1508 −21
  • apimode.go +19 −4
  • canary.go +1 −1
  • cmd/console/main.go +18 −0
  • deploy.go +1 −1
  • docs/site/cli.md +51 −2
  • docs/superpowers/specs/2026-09-22-instance-private-storage-design.md +276 −0
  • instances_cli.go +37 −8
  • instances_privatestorage_test.go +83 −0
  • internal/console/apiinstances.go +126 −5
  • internal/console/apiinstancesprivatestore_test.go +495 −0
  • internal/console/apistore.go +11 −0
  • internal/console/instanceprivatestore.go +312 −0
  • internal/console/server.go +11 −0
  • internal/provision/provision.go +20 −0
  • internal/provision/provision_test.go +47 −0

CI passed — run details

diff --git a/apimode.go b/apimode.go
index 737cbe2e7..a1d9574d8 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2769,6 +2769,12 @@ type apiInstanceManifest struct {
Channel string `json:"channel"`
Regions []string `json:"regions"`
Seed []string `json:"seed"`
+ // PrivateStorage is the app's standing policy that every instance it
+ // creates gets a config environment of its own and an object store
+ // bound to it (issue #412). Echoed by the door so `carlos instances
+ // enable` can print the policy in force rather than the one just
+ // asked for.
+ PrivateStorage bool `json:"private_storage"`
}
// enableError carries the instances-manifest door's STATUS alongside its
@@ -2811,11 +2817,12 @@ func enableUnavailable(err error) bool {
// pointers for the same reason apiConfigApply's ExecDelivery is: absent
// means "preserve what the manifest carries", and seed's explicit empty
// slice means "clear" — three requests the wire must keep apart.
-func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, channel *string, seed *[]string) (apiInstanceManifest, error) {
+func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string, channel *string, seed *[]string, privateStorage *bool) (apiInstanceManifest, error) {
in := struct {
- Channel *string `json:"channel,omitempty"`
- Seed *[]string `json:"seed,omitempty"`
- }{channel, seed}
+ Channel *string `json:"channel,omitempty"`
+ Seed *[]string `json:"seed,omitempty"`
+ PrivateStorage *bool `json:"private_storage,omitempty"`
+ }{channel, seed, privateStorage}
status, body, err := s.putJSON(ctx, apiPath(a, app, "/instances-manifest"), in)
if err != nil {
return apiInstanceManifest{}, err
@@ -2898,6 +2905,14 @@ type apiInstanceRecord struct {
// measured it (instanceListRecord.DBBytes). nil when unknown, or from a
// console that predates the field — the DB column prints a dash.
DBBytes *int64 `json:"db_bytes,omitempty"`
+ // PrivateStoreBucket is the bucket the CREATE door provisioned for this
+ // instance under the app's private-storage policy (issue #412). Set on
+ // exactly one response — the create that minted it — and empty
+ // everywhere else, including on a list, because it is a fact about what
+ // one request did rather than a column of the record. What names the
+ // store afterwards is Environment, and `carlos store status` is what
+ // reads it.
+ PrivateStoreBucket string `json:"private_store_bucket"`
}
// apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status)
diff --git a/canary.go b/canary.go
index 30c1309ce..da7f63e9c 100644
--- a/canary.go
+++ b/canary.go
@@ -378,7 +378,7 @@ func runCanaryDeploy(args []string) {
// instancesEnable is idempotent and is the ONLY source of the domain
// suffix — nothing here composes an apps domain by hand.
- m, err := sess.instancesEnable(ctx, acct, app2, nil, nil)
+ m, err := sess.instancesEnable(ctx, acct, app2, nil, nil, nil)
if err != nil {
log.Fatalf("canary: enable instances for %s: %v", app2, err)
}
diff --git a/cmd/console/main.go b/cmd/console/main.go
index f8716ab43..ae91ed32e 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -293,6 +293,7 @@ func serve() {
ScanInterval: envDuration("CARLOS_STORE_SCAN_INTERVAL", 24*time.Hour),
ScanMaxSigAge: envDuration("CARLOS_CLAMD_MAX_SIG_AGE", 168*time.Hour),
StoreDeployment: storeDeployment,
+ StoreMaxPerApp: envCount("CARLOS_STORE_MAX_PER_APP", 0),
StoreOperators: storeOperators(),
// CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads
// (internal/edge/wildcard.go's newWildcardFromEnv) for the same
@@ -1005,6 +1006,23 @@ func envDuration(k string, def time.Duration) time.Duration {
return d
}
+// envCount reads a positive count. Zero from an UNSET variable means "the
+// console's own default" (the caller supplies def = 0 and the server side
+// substitutes); an explicitly set zero or negative is fatal, because a
+// deployment that meant to cap something and typed it wrong should be told
+// rather than quietly given the default it was trying to change.
+func envCount(k string, def int) int {
+ v := strings.TrimSpace(os.Getenv(k))
+ if v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n <= 0 {
+ log.Fatalf("console: %s %q must be a positive whole number", k, v)
+ }
+ return n
+}
+
// envBytes reads a plain byte count. Same fatal-on-garbage posture.
func envBytes(k string, def int64) int64 {
v := strings.TrimSpace(os.Getenv(k))
diff --git a/deploy.go b/deploy.go
index c13a0ace3..67079baec 100644
--- a/deploy.go
+++ b/deploy.go
@@ -848,7 +848,7 @@ var errNoStaticHost = errors.New("no static host")
// about to promote to, and a record minted on a different one would serve
// something else.
func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string, messages ...string) (string, error) {
- m, err := sess.instancesEnable(ctx, acct, app, nil, nil)
+ m, err := sess.instancesEnable(ctx, acct, app, nil, nil, nil)
if err != nil {
return "", err
}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 3df903a7b..6abbbc90f 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -580,6 +580,53 @@ bind it here, then restart the instance to pick the values up.
carlos instances set-environment --app jam --host new-queue.jam.bab.oncarlos.com --environment jam-canary
```
+#### Storage of its own, for every instance
+
+`--private-storage` on `enable` is one declaration that changes what every
+later `create` does. The app says, once, that each of its instances gets a
+config environment of its own with an object store bound to it — and from
+then on `carlos instances create` derives the environment from the host's
+first label, provisions that environment's bucket and IAM user, and delivers
+the coordinates into the bundle the new instance reads. No `store create`, no
+operator, no second command.
+
+```sh
+carlos instances enable --app jam --private-storage
+carlos instances create --app jam --host acme.jam.bab.oncarlos.com
+# created instance acme.jam.bab.oncarlos.com (channel stable, environment acme)
+# private storage: bucket carlos-bab-jam-acme
+```
+
+This is the shape for a managed product with one instance per customer, and
+the split is the point: declaring the policy is a signed-in person's act that
+no service credential can perform, while spending it is the ordinary create
+door an `instance`-role credential already reaches. So a public signup path
+provisions a tenant's bucket without ever holding the power to create one.
+
+Three things to know before you turn it on:
+
+- The account needs an **approved storage entitlement** (`carlos store
+ apply`) and the deployment needs object storage configured. `enable`
+ refuses the policy rather than letting it fail once per signup.
+- The host's first label becomes the environment name, so it must be a legal
+ one: lowercase letters, digits and hyphens, at most 32 characters, and not
+ `default`. A label that cannot name a store is refused at `create`, before
+ anything is written.
+- **Nothing is ever torn down automatically.** Deleting an instance leaves
+ its bucket, exactly as it already leaves the parked database. `carlos store
+ status --app <a>` lists every store the app has, which is where an
+ abandoned one is visible; destroying it is the console's own action, behind
+ the typed bucket name and a passkey.
+
+An app may have at most 1000 environment stores by default; a deployment sets
+its own limit with `CARLOS_STORE_MAX_PER_APP`. The limit exists because each
+store is an IAM user as well as a bucket, and AWS allows 5,000 IAM users per
+account — so it bounds one app, not the deployment. Five apps at the default
+would reach that quota between them.
+
+`--private-storage=false` turns the policy off. Instances created before it
+keep the bindings and buckets they already have.
+
Typed on a box with no sub-verb, `carlos instances` does something different:
it lists that box's own registry routes, backing and owning unit included.
Off the box, [carlos routes](/docs/cli#carlos-routes) answers the same
@@ -1262,8 +1309,10 @@ one app, which also means it cannot create apps.
One door stays shut to every role: `carlos instances enable`, which writes the
manifest bounding which domain, pool and regions an instance may be created
-in. A person enables the app once and the credential provisions inside those
-bounds afterwards.
+in — and, with `--private-storage`, whether each instance gets an object store
+of its own. A person enables the app once and the credential provisions inside
+those bounds afterwards, including minting a tenant's bucket it could never
+have created directly.
The secret prints once, same as a fleet token, and the same distinction
applies at the other end: `rotate` refuses the credential's next call with
diff --git a/docs/superpowers/specs/2026-09-22-instance-private-storage-design.md b/docs/superpowers/specs/2026-09-22-instance-private-storage-design.md
new file mode 100644
index 000000000..518e979cd
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-22-instance-private-storage-design.md
@@ -0,0 +1,276 @@
+# Private storage per instance: policy on the app, provisioning by the platform
+
+**Date:** 2026-09-22
+**Status:** implemented in this branch. Scope confirmed with James on
+2026-09-22: §5 (the policy), §6 (the derived name), §7 (provisioning at
+create) and §8 (the ceiling). §10's two items are NOT built and are named
+there so they are not mistaken for shipped.
+**Issue:** GitHub #412 (a better answer to #410, which this makes
+unnecessary).
+**Builds on:** `2026-09-19-per-environment-store-design.md` (#408) — the
+per-environment store primitive this spends. Nothing here works without it.
+
+## 1. The problem, as found
+
+#408 gave a store an environment to belong to. It did not give anyone a way
+to reach one without a person.
+
+Creating an environment store is three acts: `carlos store create --app a
+--environment e`, then a provisioning walk, then binding an instance record
+to `e`. The first two run behind `apiAccountApp` plus the account
+entitlement, and the CLI verb that drives them is a member's. An
+`instance`-role service credential — the one a public signup path can hold —
+can create an instance record and nothing else.
+
+So every signup on Managed Jelly (lazyatom/jam#187) stops for a person to
+run one command. #410 asked for the fix that widens the role. This is the
+fix that widens nobody: **the human declares the policy once, on the app,
+and the create door does the rest under that standing declaration.**
+
+### What is already right, and must stay right
+
+- **The entitlement is per ACCOUNT** (`store/entitlements/<sqid>.json`), and
+ #408 §6 already says that in an approved account, creating an environment
+ store provisions it with no new approval. That sentence is the whole
+ authority story here; this spec adds no new one.
+- **A store is keyed on `(account, app, environment)`** (#408 §3, ruling).
+ Deriving the environment from the host at create time keeps that key
+ exactly and adds no per-host config source.
+- **Teardown is explicit** (#408 §3, ruling). Nothing here destroys
+ anything, and §10 says what that costs.
+- **The create door's refusal order** — gate, budget, manifest, host,
+ region, channel, write — and its byte-identical 404 before membership is
+ proven. Everything this spec adds sits AFTER the manifest read, so a
+ non-member still cannot learn that an app carries the policy.
+
+## 2. The shape, in one paragraph
+
+An app may declare that **every instance it creates gets a config
+environment of its own, with an object store bound to that environment**.
+The declaration is a bool on the instances manifest, written by
+`carlos instances enable --app a --private-storage` — a signed-in person's
+act, which no service credential can perform. From then on `POST
+…/instances` derives the environment from the host's first label, provisions
+that environment's store under the account's existing entitlement, and binds
+the record to it. An `instance`-role credential gains nothing it could not
+already do; it simply lands in a world where the app's policy has already
+been decided.
+
+## 3. Rulings
+
+- **(ruling, James 2026-09-22) The policy is declared on the app, and only a
+ person may declare it.** `handleAPIInstanceEnable` is registered with the
+ zero Set and gated by `apiPerson`; that is the wall, and it is the same
+ wall that makes this a better answer than #410.
+- **(ruling, James 2026-09-22) Scope is the policy, the derived name and the
+ ceiling.** The residue listing (#412's point 2) and holding an instance's
+ first wake until its store is delivered (#412's point 4) are follow-up
+ work, named in §10.
+
+## 4. Non-goals
+
+- Automatic teardown of anything (#408 §3's ruling, unchanged).
+- A residue listing for deleted instances (#412 point 2). §10.
+- Gating an instance's first wake on delivery (#412 point 4). §10.
+- Per-store usage attribution, scanning environment stores — #408's own
+ non-goals, unchanged.
+- An account-wide (rather than per-app) store ceiling. §8 says why the cap
+ is shaped per app, what that leaves unbounded, and what a real one costs.
+- Per-instance secrets. The derived environment is the primitive they would
+ hang off; this spec builds the primitive and stops.
+
+## 5. The policy
+
+**(decision) One additive bool on `provision.Manifest`.**
+
+```go
+// PrivateStorage is the app's standing policy that every instance created
+// under this manifest gets a config environment of its own and an object
+// store bound to it. Absent/false is what every manifest in a production
+// bucket says today, and means exactly today's behaviour.
+PrivateStorage bool `json:"private_storage,omitempty"`
+```
+
+A plain bool, not a pointer, and the difference from `Record.Hibernate` and
+`Record.StatusNamed` is worth stating because those two are pointers for a
+reason that does not apply here. Those fields are CONVERGED — a box reads
+them every pass, so "absent" has to be distinguishable from "an explicit
+false" or the first pass after deploy silently acts on a value nobody wrote.
+This field is read at one door, at create time, and false IS the legacy
+behaviour byte for byte. There is nothing for absence to mean that false
+does not already mean.
+
+**(decision) The enable door takes it as a POINTER on the wire** —
+`private_storage` absent preserves what the manifest carries, present
+replaces it — matching `channel` and `seed`, which are pointers on that
+door for exactly this reason. `carlos instances enable --private-storage`
+sends true; `--private-storage=false` sends false; omitting the flag sends
+nothing.
+
+**(decision) The door refuses a policy it knows cannot be honoured.** Two
+checks, both before the manifest is written:
+
+- The deployment has no object-storage backend (`CARLOS_STORE_BACKEND`
+ unset) → 503 naming the variable.
+- The account's entitlement is not approved → 403 naming `carlos store
+ apply`.
+
+This is a departure from the enable door's habit of writing whatever policy
+it is handed, and it is deliberate. The policy's entire purpose is that
+creates afterwards succeed with no person in the loop. A manifest that
+guarantees every future create fails is not policy, it is a trap that fires
+once per signup, and the two sentences above are the only two that name the
+fix. Turning the policy OFF is never refused — an account whose entitlement
+was later declined must be able to get back to a working create door.
+
+## 6. The derived name
+
+**(decision) The environment is the host's first label** — `Record.Slug`,
+which the create door already computes as `strings.TrimSuffix(host, "." +
+m.Domain)`. It is the one name that is predictable from the host, already
+unique within the app (the record key is the host), and readable in `store
+status` and the console's Environments view at a hundred of them, which is
+#412's point 1 in full.
+
+**(decision) An explicit `environment` in the request body wins.** The
+policy fills a blank; it does not seize the axis. A caller who names an
+environment gets a store for THAT environment — which is still the
+invariant this spec exists for, since what is promised is that the record's
+bundle carries `CARLOS_STORE_*`, not that the bundle has any particular
+name. It keeps `carlos canary` working on a private-storage app, and two
+instances naming one environment share its store exactly as they share its
+config, which #408 §5 already rules is visible rather than refused.
+
+**(decision) Three names are checked BEFORE anything is written**, because
+a DNS label is not an environment name and an environment name is not
+always spendable:
+
+| check | why it can fail |
+| --- | --- |
+| `appconfig.ValidEnvironmentName(slug)` | a label may be 63 characters, an environment name 32; and `default` is a legal label but names the app's own bundle |
+| `objstore.ValidBucketName(MintedBucketIn(...))` | S3's 63 |
+| `objstore.ValidUserName(UserNameIn(...))` | IAM's 64 |
+
+Each refuses with 400 and a sentence naming the part the caller can
+shorten. The walk checks the last two itself (#408's implementation gate),
+but reaching them there would mean an instance record already written over
+an estate that cannot exist.
+
+## 7. Provisioning, and the order it happens in
+
+**(ruling, James 2026-09-22 — implied by the scope choice, stated here
+because it is the load-bearing decision of this file) The store is
+provisioned BEFORE the record is written, and a provisioning failure means
+no record at all.**
+
+The two orders fail differently, and only one of them fails visibly:
+
+- *Record first.* Provisioning then fails, and an instance exists whose
+ bundle has no `CARLOS_STORE_*`. It wakes, finds no coordinates, and runs
+ on local disk — which is the exact outcome the policy was declared to
+ prevent, arrived at silently, with no verb to repair it (there is no
+ `carlos instances create --resume`).
+- *Store first.* The record write then fails, and a declared-or-provisioned
+ store exists for an environment no instance is bound to. It is on the
+ screen in `store status`, and — because the environment is derived from
+ the host — **a retry of the same host reuses it exactly**: `GrantIn` is
+ idempotent, so the second attempt short-circuits and costs nothing.
+
+So the property this spec asserts is: **no instance record is ever created
+under this policy without its store already provisioned and delivered.**
+That is stated as such because it is the whole feature; a create that
+half-succeeded would be worse than one that refused.
+
+One window stays open and is named rather than papered over. The create door
+already writes the record and then re-reads the sibling-apex check, deleting
+the record if a sibling app claimed the label mid-create. On that path the
+store survives the record. It is logged in those words, beside the existing
+`UNDO FAILED` line, because the alternative — tearing down a bucket from a
+code path nobody drove — would make this the platform's first automatic
+destroyer of tenant data, which #408 §3 rules out.
+
+**(decision) The walk runs exactly as `handleAPIStoreProvision` runs it**:
+the per-store walk lock (`storeWalks.lock(sqid, app, env)`), the seal key,
+`storeGrantTimeout`, `GrantSourceAccountApproval`, and the entitlement
+re-read after the lock. Not a second implementation of provisioning — the
+same calls in the same order, factored into one helper both doors use, so
+the two cannot drift.
+
+**(decision) The env prefix is inherited from the app's own store.** A
+private-storage app that already declared `carlos store create --env-prefix
+JELLY_S3` reads those names in its code; an instance store delivering
+`CARLOS_STORE_BUCKET` into the same app would be a second vocabulary for one
+thing. With no app store declared, the default `CARLOS_STORE` prefix is
+taken, which is what a fresh declaration would take.
+
+## 8. The ceiling
+
+**(decision) A cap on how many ENVIRONMENT stores one app may have**,
+`CARLOS_STORE_MAX_PER_APP`, default 1000. A bucket plus an IAM user per
+instance is a bigger footprint than an instance, and the binding limit is
+IAM's: 5,000 users per cloud account, an adjustable quota
+([AWS service quotas](https://docs.aws.amazon.com/general/latest/gr/iam-service.html)).
+A signup path that can mint them needs a bar it stops at.
+
+**(correction, James 2026-09-23) The first draft of this section said 100,
+and that was a number picked for tidiness rather than derived from the
+quota.** A fifth of one app's fair share would have refused an ordinary
+managed product at its hundredth customer — a real refusal in exchange for
+no real protection. 1000 is a fifth of the account quota, so one app can run
+away without exhausting it while five cannot.
+
+**What the cap does not bound**, stated because the per-app shape invites the
+wrong reading: five apps at the default reach 5,000 between them, and the
+deployment's own IAM principals are not in that sum. This is a brake on one
+runaway signup path, not a budget for the cloud account. An account-wide
+bound needs a counter kept on the side rather than one derived by listing,
+and §4 lists it as a non-goal.
+
+Per app, not per account, for a reason of cost: the count comes from one
+`objstore.StoreEnvironments` prefix listing of the app, which the create
+door can afford. A deployment-wide count would mean listing every app's
+stores on every create.
+
+Two properties:
+
+- **It is enforced on both doors**, `POST …/instances` and `POST
+ …/store?environment=`. A ceiling only the automatic path respected would
+ be a ceiling anyone could step over by hand.
+- **Re-using an environment that already has a store is never capped.** The
+ cap is on the FOOTPRINT, and a second instance bound to an existing
+ environment adds no bucket. Without this, a retry of a create that failed
+ after provisioning would be refused by the cap it had just reached.
+
+The refusal is a 409 naming the count and the cap.
+
+## 9. What a member sees
+
+```
+carlos instances enable --app a --private-storage
+carlos instances create --app a --host acme.a.<sqid>.<apps-domain>
+ created instance acme.a.… (channel stable, environment acme)
+ private storage: bucket carlos-bab-a-acme
+```
+
+and `carlos store status --app a` lists the acme store beside every other,
+which is #408 §5's listing doing its job unchanged.
+
+## 10. Not built, and named so it is not mistaken for shipped
+
+- **#412's point 2, the residue listing.** `instances delete` leaves the
+ parked database and blobs today, and after this change it also leaves an
+ environment bundle and a store. The issue says this "probably should not
+ ship without at least a way to list what deleted instances left behind",
+ and that judgement stands: the residue is now created automatically, so it
+ accrues once per cancelled customer. What exists in the meantime is `store
+ status`, which lists every store of the app including ones no instance
+ reads — the honest half of #408 §8, and enough to find an orphan if you go
+ looking. The follow-up is a verb that does the looking.
+- **#412's point 4, holding the wake.** The provisioning walk completes
+ before the record is written (§7), so the coordinates are in the bundle
+ before the record a box could reconcile from exists at all. That is
+ strictly better than the manager's "refuse the seed until the bucket is
+ seen" workaround, and it is not the same as a guarantee: delivery is a
+ config-bundle write and the box's sync pass is what carries it to the
+ instance, so a wake racing the first sync can still find no coordinates.
+ Closing that needs the box side, and it is not in this branch.
diff --git a/instances_cli.go b/instances_cli.go
index 5ffc16865..fe631ed67 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -59,6 +59,11 @@ func runInstancesEnable(args []string) {
channel := channelFlag(fs, "release channel stamped into every record at create (store-mode default stable; signed-in mode: omitted preserves the manifest's)")
regions := fs.String("regions", "", "comma-separated region allow-list (store mode only); empty = any shape-valid region")
seed := fs.String("seed", "", "argv of the app's first-boot init command, whitespace-split (strings.Fields)")
+ // A bool flag rather than a string, read through flagWasSet for the
+ // same absent-vs-explicit split --channel and --seed use: omitting it
+ // preserves whatever policy the manifest already carries, and
+ // --private-storage=false turns the policy off.
+ privateStorage := fs.Bool("private-storage", false, "give every instance of this app a config environment and an object store of its own (issue #412); --private-storage=false turns the policy off")
consoleFlag := consoleOption(fs, "console to enable through (signed-in mode only; default: the only logged-in one)")
acctRaw, acctIDRaw, acctOf := accountFlags(fs)
fs.Usage = flagUsage(fs, instancesEnableUsage)
@@ -83,6 +88,10 @@ func runInstancesEnable(args []string) {
if flagWasSet(fs, "channel") {
channelReq = channel
}
+ var privateStorageReq *bool
+ if flagWasSet(fs, "private-storage") {
+ privateStorageReq = privateStorage
+ }
var seedReq *[]string
if flagWasSet(fs, "seed") {
// parseSeed("") is an empty (non-nil after this) argv: an explicit
@@ -97,11 +106,17 @@ func runInstancesEnable(args []string) {
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, instancesEnableUsage)
- m, err := sess.instancesEnable(context.Background(), acct, app2, channelReq, seedReq)
+ m, err := sess.instancesEnable(context.Background(), acct, app2, channelReq, seedReq, privateStorageReq)
if err != nil {
log.Fatalf("instances enable: %v", err)
}
fmt.Fprintf(stdout, "%s for %s under %s\n", colorFor(stdout).ok("enabled instances"), m.App, m.Domain)
+ // The policy IN FORCE, from the door's echo, not the flag that was
+ // typed: an enable that never mentioned storage still prints what
+ // the app does, which is the fact the next create will act on.
+ if m.PrivateStorage {
+ mutedf(stdout, " every instance gets its own config environment and object store\n")
+ }
mutedf(stdout, " first instance: carlos instances create --app %s --host <label>.%s\n", m.App, m.Domain)
return
}
@@ -148,18 +163,29 @@ func runInstancesEnable(args []string) {
}
}
m := provision.Manifest{
- Schema: 1,
- App: *app,
- Domain: *domain,
- Pool: *pool,
- Channel: *channel,
- Regions: regionList,
- Seed: parseSeed(*seed),
+ Schema: 1,
+ App: *app,
+ Domain: *domain,
+ Pool: *pool,
+ Channel: *channel,
+ Regions: regionList,
+ Seed: parseSeed(*seed),
+ PrivateStorage: *privateStorage,
}
if err := instancesEnable(context.Background(), openStore(), acctOf(), *app, m); err != nil {
log.Fatal(err)
}
fmt.Printf("%s for %s under %s\n", colorFor(os.Stdout).ok("enabled instances"), *app, *domain)
+ if *privateStorage {
+ // Store mode writes the manifest straight to the bucket, so nothing
+ // checked that this deployment HAS object storage or that the
+ // account is approved for it — the console's enable door does both
+ // and refuses, but the operator path has no console to ask. Named
+ // here so the policy is not discovered to be inert at the first
+ // signup.
+ mutedf(os.Stdout, " every instance gets its own config environment and object store\n")
+ mutedf(os.Stdout, " the console minting them needs CARLOS_STORE_BACKEND and an approved store entitlement for this account\n")
+ }
}
// parseSeed splits --seed's raw value on whitespace (strings.Fields) into the
@@ -299,6 +325,9 @@ func runInstancesCreate(args []string) {
}
fmt.Fprintf(stdout, "%s %s (channel %s%s)\n", colorFor(stdout).ok("created instance"),
rec.Host, rec.Channel, environmentSuffix(rec.Environment))
+ if rec.PrivateStoreBucket != "" {
+ mutedf(stdout, " private storage: bucket %s\n", rec.PrivateStoreBucket)
+ }
mutedf(stdout, " the host comes live on the next reconcile pass on its box")
}
diff --git a/instances_privatestorage_test.go b/instances_privatestorage_test.go
new file mode 100644
index 000000000..1531196fe
--- /dev/null
+++ b/instances_privatestorage_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// The three requests `--private-storage` has to keep apart on the wire
+// (issue #412). Absent must be BYTE-IDENTICAL to what an enable sent before
+// the flag existed: a `carlos instances enable -channel beta` that quietly
+// unset a tenant's storage isolation is the failure this pins.
+func TestInstancesEnableSendsPrivateStorageOnlyWhenGiven(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "jam", "domain": "jam.example.com", "private_storage": true,
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ yes, no := true, false
+
+ m, err := s.instancesEnable(context.Background(), apiAcct{Seg: "bah"}, "jam", nil, nil, &yes)
+ if err != nil {
+ t.Fatalf("instancesEnable: %v", err)
+ }
+ if !strings.Contains(gotBody, `"private_storage":true`) {
+ t.Errorf("request body = %s, want it to ask for private storage", gotBody)
+ }
+ // The ECHO is what the CLI prints, not the flag that was typed: an
+ // enable that never mentioned storage still has to report what the app
+ // does, because that is the fact the next create acts on.
+ if !m.PrivateStorage {
+ t.Error("decoded manifest lost the policy the door echoed")
+ }
+
+ gotBody = ""
+ if _, err := s.instancesEnable(context.Background(), apiAcct{Seg: "bah"}, "jam", nil, nil, &no); err != nil {
+ t.Fatalf("instancesEnable (off): %v", err)
+ }
+ if !strings.Contains(gotBody, `"private_storage":false`) {
+ t.Errorf("request body = %s, want an explicit false", gotBody)
+ }
+
+ gotBody = ""
+ if _, err := s.instancesEnable(context.Background(), apiAcct{Seg: "bah"}, "jam", nil, nil, nil); err != nil {
+ t.Fatalf("instancesEnable (absent): %v", err)
+ }
+ if strings.Contains(gotBody, "private_storage") {
+ t.Errorf("request body = %s, want no key at all when the flag was not given", gotBody)
+ }
+}
+
+// The create response's bucket is decoded, because the line it prints is the
+// only place a member ever learns the name of the bucket their signup path
+// just minted.
+func TestInstanceCreateDecodesThePrivateStoreBucket(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": "acme.jam.example.com", "channel": "stable",
+ "environment": "acme", "private_store_bucket": "carlos-bah-jam-acme",
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "jam",
+ "acme.jam.example.com", "", "", "", "", nil, nil)
+ if err != nil {
+ t.Fatalf("instanceCreate: %v", err)
+ }
+ if rec.PrivateStoreBucket != "carlos-bah-jam-acme" {
+ t.Errorf("decoded bucket = %q, want carlos-bah-jam-acme", rec.PrivateStoreBucket)
+ }
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 1256c0024..48e87f5d4 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -536,6 +536,12 @@ type apiRecordView struct {
provision.Record
HiddenGate string `json:"gate,omitempty"`
Gated bool `json:"gated"`
+ // PrivateStoreBucket is the bucket the create door provisioned for this
+ // instance under the app's private-storage policy (issue #412), empty on
+ // every other record and every other door. Not part of the record: it is
+ // a fact about what this one request did, and the record's own
+ // Environment is what names the store afterwards.
+ PrivateStoreBucket string `json:"private_store_bucket,omitempty"`
}
func recordView(rec provision.Record) apiRecordView {
@@ -826,6 +832,33 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
if rec.Kind == "static" && platformNamespace && rec.Pool == "" {
rec.Steering = "latency"
}
+ // The app's private-storage policy (issue #412, spec §6): every instance
+ // gets a config environment of its own, named for the host's first
+ // label, with an object store bound to it.
+ //
+ // An EXPLICIT environment in the request wins, and the policy still
+ // applies to it. The policy fills a blank; it does not seize the axis.
+ // What it promises is that this record's bundle carries the store
+ // coordinates — not that the bundle has any particular name — so
+ // honouring the caller's own name keeps `carlos canary` working on such
+ // an app, and two instances naming one environment share its store
+ // exactly as they already share its config (#408 §5: sharing is
+ // visible, not refused).
+ //
+ // Stamped BEFORE ValidateShape so the shape rail sees the same
+ // environment the box will, and before the ceiling and the walk below so
+ // neither can be asked about a record that was never legal.
+ if m.PrivateStorage {
+ env, derived := rec.Environment, false
+ if env == "" {
+ env, derived = rec.Slug, true
+ }
+ if ref := s.instanceStoreNames(acct, app, env, derived); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ rec.Environment = env
+ }
// The shape rails, at the door — kind vocabulary, one upstream shape per
// record, addr syntax, and the combinations that would wedge a host (a
// static route with an upstream, a hibernating addr-backed one). The
@@ -837,6 +870,47 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
+ // THE STORE IS PROVISIONED BEFORE THE RECORD IS WRITTEN, and a failure
+ // here means no record at all (issue #412, spec §7). This ordering is
+ // the load-bearing decision of the feature, so it is argued here rather
+ // than assumed:
+ //
+ // Record first, and a failed walk leaves an instance whose bundle has no
+ // store coordinates. It wakes, finds none, and runs on local disk — the
+ // exact outcome the policy was declared to prevent, arrived at silently,
+ // with no verb to repair it (there is no `instances create --resume`).
+ //
+ // Store first, and a failed record write leaves a store for an
+ // environment no instance is bound to. That is visible — `store status`
+ // lists every store of the app — and, because the environment is DERIVED
+ // FROM THE HOST, a retry of the same host reuses it exactly: GrantIn is
+ // idempotent, so the second attempt short-circuits and costs nothing.
+ //
+ // So: no instance record is ever created under this policy without its
+ // store already provisioned and delivered.
+ privateBucket := ""
+ if m.PrivateStorage {
+ if ref := s.storeCeiling(ctx, acct, app, rec.Environment); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
+ // r.Context(), not ctx: the walk takes storeGrantTimeout, which is
+ // longer than this handler's own deadline.
+ storeRec, ok := s.instanceStoreProvision(r.Context(), w, acct, app, rec.Environment, principal)
+ if !ok {
+ return
+ }
+ privateBucket = storeBucketName(storeRec)
+ // And a fresh deadline for what is left. The walk can have consumed
+ // every second of the handler's original budget, and the record
+ // write below inheriting a spent context would fail immediately
+ // after the bucket had already been paid for — the one shape of
+ // failure this whole ordering exists to avoid.
+ cancel()
+ var recancel context.CancelFunc
+ ctx, recancel = context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer recancel()
+ }
if err := provision.CreateRecord(ctx, s.cfg.Releases, rec); err != nil {
if errors.Is(err, release.ErrCASConflict) {
// A lost race against a second signup for the same host, or a
@@ -858,6 +932,15 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
if derr := provision.DeleteRecord(ctx, s.cfg.Releases, acct.Sqid, app, host); derr != nil {
log.Printf("console: api instances create %s/%s/%s: %s was claimed mid-create and UNDO FAILED: %v", acct.Sqid, app, host, siblingLabel, derr)
}
+ if privateBucket != "" {
+ // The one window where this feature's ordering leaves
+ // residue, logged in those words rather than papered over:
+ // tearing the bucket down from a path nobody drove would
+ // make this the platform's first automatic destroyer of
+ // tenant data, which the per-environment store spec rules
+ // out (#408 §3). It is listed by `carlos store status`.
+ log.Printf("console: api instances create %s/%s/%s: %s was claimed mid-create; the record was withdrawn and its store %s WAS LEFT BEHIND", acct.Sqid, app, host, siblingLabel, privateBucket)
+ }
jsonError(w, http.StatusConflict,
host+" is app "+siblingLabel+"'s platform apex — it was created while this instance was being written; the instance was not kept")
return
@@ -877,7 +960,9 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// a box-consumed write (carlos-deploy §3): announce it, and the instance
// is serving seconds after signup instead of on the hour.
s.bumpSerial(ctx)
- writeJSON(w, http.StatusCreated, recordView(rec))
+ out := recordView(rec)
+ out.PrivateStoreBucket = privateBucket
+ writeJSON(w, http.StatusCreated, out)
}
// instanceChannel answers "which channel is this record born on" for a
@@ -1472,9 +1557,15 @@ func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request)
// different requests on the wire (the apiConfigApply precedent): an
// omitted field preserves what the manifest already carries, an explicit
// value replaces it — including seed's explicit [] meaning "clear".
+ //
+ // PrivateStorage joined them on 2026-09-22 (issue #412) as a pointer for
+ // exactly the same reason: an app that already gives its instances their
+ // own storage must not lose the policy to a `carlos instances enable
+ // -channel beta` that never mentioned it.
var req struct {
- Channel *string `json:"channel"`
- Seed *[]string `json:"seed"`
+ Channel *string `json:"channel"`
+ Seed *[]string `json:"seed"`
+ PrivateStorage *bool `json:"private_storage"`
}
if !readJSON(w, r, &req) {
return
@@ -1547,6 +1638,21 @@ func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request)
if req.Seed != nil {
m.Seed = *req.Seed
}
+ // Turning the policy ON is checked against what this deployment and this
+ // account can actually do; turning it OFF never is (spec §5). The
+ // asymmetry is deliberate: a policy that cannot be honoured fails once
+ // per signup in a door whose caller is a machine, so the person
+ // declaring it is told now — but an account whose entitlement was later
+ // declined must be able to get its create door working again without
+ // waiting on an operator.
+ if req.PrivateStorage != nil && *req.PrivateStorage && !m.PrivateStorage {
+ if !s.instancePrivateStorageAllowed(ctx, w, acct) {
+ return
+ }
+ }
+ if req.PrivateStorage != nil {
+ m.PrivateStorage = *req.PrivateStorage
+ }
if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, *m, etag); err != nil {
if errors.Is(err, release.ErrCASConflict) {
jsonError(w, http.StatusConflict,
@@ -1557,16 +1663,31 @@ func (s *Server) handleAPIInstanceEnable(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadGateway, "could not enable instances — try again")
return
}
- s.audit(acct, app, addr, "instance-enable", "enabled instances under "+domain+" (cli)")
+ s.audit(acct, app, addr, "instance-enable", "enabled instances under "+domain+privateStorageAuditSuffix(req.PrivateStorage)+" (cli)")
// The manifest names the channel every minted route follows, so a change
// here changes what boxes converge onto — reconcile reads it each pass.
s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]any{
"app": app, "domain": m.Domain, "pool": m.Pool, "channel": m.Channel,
- "regions": m.Regions, "seed": m.Seed,
+ "regions": m.Regions, "seed": m.Seed, "private_storage": m.PrivateStorage,
})
}
+// privateStorageAuditSuffix names the policy in the audit line only when
+// this request SPOKE to it. An enable that changed a channel says nothing
+// about storage, and a row that restated the standing policy every time
+// would make the one request that changed it impossible to find.
+func privateStorageAuditSuffix(req *bool) string {
+ switch {
+ case req == nil:
+ return ""
+ case *req:
+ return ", every instance with storage of its own"
+ default:
+ return ", instances sharing the app's storage"
+ }
+}
+
// instanceRegionAllowed applies the manifest's region allow-list. An empty
// list means any shape-valid region (spec §3.1) — including the empty,
// unconstrained one, which is what a single-region deployment uses.
diff --git a/internal/console/apiinstancesprivatestore_test.go b/internal/console/apiinstancesprivatestore_test.go
new file mode 100644
index 000000000..b11754521
--- /dev/null
+++ b/internal/console/apiinstancesprivatestore_test.go
@@ -0,0 +1,495 @@
+package console
+
+// The app-declared private-storage policy (issue #412). What these pin, in
+// the order they would hurt if lost:
+//
+// - the create door provisions the store BEFORE it writes the record, so
+// a failed walk leaves no instance at all — never an instance that wakes
+// onto local disk because its bundle had no coordinates;
+// - the environment is the host's first label, and the coordinates land in
+// THAT bundle, not the app's default one;
+// - an app without the policy is byte-for-byte unchanged;
+// - the ceiling holds on both doors, and re-using an existing store is not
+// capped;
+// - declaring the policy is a person's act, refused in the two states
+// where it could only ever be a trap.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// privateStorageFixture is instanceFixture with the store plane armed: a
+// backend, a sealing key, and an approved account entitlement — the three
+// things the policy spends, and the standing approval #408 §6 says is the
+// only one needed.
+func privateStorageFixture(t *testing.T, code string) (*consoleFixture, *objstore.MemProvisioner, string, string) {
+ t.Helper()
+ fx, token := instanceFixture(t, code)
+ pub, priv, _ := sealedFor(t, "irrelevant")
+ fake := objstore.NewMemProvisioner("eu-west-1")
+ fx.srv.cfg.SecretsPub = pub
+ fx.srv.cfg.StoreProvisioner = fake
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1", BucketPrefix: "carlos-"}
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ return fx, fake, token, priv
+}
+
+// privateManifest is titoManifest carrying the policy. The domain is the
+// app's own (not the platform namespace), which keeps the sibling-apex
+// branch out of these tests — it has its own coverage.
+func privateManifest() provision.Manifest {
+ m := titoManifest()
+ m.PrivateStorage = true
+ return m
+}
+
+func createInstance(t *testing.T, fx *consoleFixture, token, body string) (int, string) {
+ t.Helper()
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, body)
+ return status, out
+}
+
+func envStore(t *testing.T, fx *consoleFixture, env string) *objstore.Record {
+ t.Helper()
+ rec, _, err := objstore.GetIn(context.Background(), fx.rel, release.OpsAccount, "titogo", env)
+ if err != nil {
+ t.Fatalf("read %s store: %v", env, err)
+ }
+ return rec
+}
+
+func envVars(t *testing.T, fx *consoleFixture, env string) map[string]appconfig.Var {
+ t.Helper()
+ m, err := appconfig.LoadEnvironment(context.Background(), fx.rel, release.OpsAccount, "titogo", env)
+ if err != nil {
+ t.Fatalf("read %s bundle: %v", env, err)
+ }
+ return m.Vars
+}
+
+// The whole chain, as a signup path drives it: one instance create, and the
+// tenant has a bucket of its own whose coordinates are already in the bundle
+// the record binds to. No `store create`, no `store provision`, no operator.
+func TestInstanceCreateProvisionsPrivateStore(t *testing.T) {
+ fx, fake, token, priv := privateStorageFixture(t, "code-priv-chain")
+ enableInstances(t, fx, "titogo", privateManifest())
+
+ status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s, want 201", status, out)
+ }
+ var echoed struct {
+ Environment string `json:"environment"`
+ PrivateStoreBucket string `json:"private_store_bucket"`
+ }
+ if err := json.Unmarshal([]byte(out), &echoed); err != nil {
+ t.Fatalf("decode %s: %v", out, err)
+ }
+ // The environment is the host's FIRST LABEL, which is the one name
+ // predictable from the host (spec §6).
+ if echoed.Environment != "acme" {
+ t.Fatalf("echoed environment = %q, want acme", echoed.Environment)
+ }
+ if echoed.PrivateStoreBucket != "carlos-bab-titogo-acme" {
+ t.Fatalf("echoed bucket = %q, want carlos-bab-titogo-acme", echoed.PrivateStoreBucket)
+ }
+ // The record a box reconciles from carries the binding.
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "acme" {
+ t.Fatalf("stored environment = %q, want acme", stored.Environment)
+ }
+ // The store is real, provisioned, and its own.
+ rec := envStore(t, fx, "acme")
+ if rec == nil || rec.Provisioned == nil {
+ t.Fatalf("acme store = %+v, want a provisioned one", rec)
+ }
+ if rec.Provisioned.Bucket != "carlos-bab-titogo-acme" || rec.Provisioned.IAMUser != "carlos-store-bab-titogo-acme" {
+ t.Fatalf("provisioned = %+v, want the account/app/environment triple", rec.Provisioned)
+ }
+ // The grant's authority is the ACCOUNT entitlement, not an operator —
+ // which is the whole reason no new power was granted to anyone.
+ if rec.Grant == nil || rec.Grant.Source != objstore.GrantSourceAccountApproval {
+ t.Fatalf("grant = %+v, want source %q", rec.Grant, objstore.GrantSourceAccountApproval)
+ }
+ if _, ok := fake.Buckets["carlos-bab-titogo-acme"]; !ok {
+ t.Fatalf("backend buckets = %v, want the instance's own", fake.Buckets)
+ }
+ // Delivery goes to the INSTANCE's bundle. A credential in the app's
+ // default bundle would be read by every other instance — the isolation
+ // failing in exactly the direction that matters.
+ vars := envVars(t, fx, "acme")
+ if v, ok := vars["CARLOS_STORE_BUCKET"]; !ok || v.Value != "carlos-bab-titogo-acme" {
+ t.Fatalf("acme bundle CARLOS_STORE_BUCKET = %+v, want the instance's bucket", v)
+ }
+ secret, ok := vars["CARLOS_STORE_SECRET_KEY"]
+ if !ok || secret.Kind != "secret" {
+ t.Fatalf("acme bundle CARLOS_STORE_SECRET_KEY = %+v, want a sealed secret", secret)
+ }
+ if openSecret(t, priv, secret) == "" {
+ t.Fatal("the delivered secret did not open")
+ }
+ if got := appVars(t, fx, "titogo"); len(got) != 0 {
+ t.Fatalf("the app's default bundle = %v, want nothing — a shared credential is the isolation failing", got)
+ }
+}
+
+// An app without the policy is what it was before this branch: a record, no
+// store, no bundle, and no backend call. This is the test that fails if the
+// policy is ever read as on-by-default.
+func TestInstanceCreateWithoutPolicyTouchesNoStore(t *testing.T) {
+ fx, fake, token, _ := privateStorageFixture(t, "code-priv-off")
+ enableInstances(t, fx, "titogo", titoManifest())
+
+ status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s, want 201", status, out)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "" {
+ t.Fatalf("stored environment = %q, want empty — no policy, no derived binding", stored.Environment)
+ }
+ if rec := envStore(t, fx, "acme"); rec != nil {
+ t.Fatalf("a store was created without the policy: %+v", rec)
+ }
+ untouched(t, fake, "a create with no private-storage policy")
+}
+
+// The policy fills a blank; it does not seize the axis. A caller who names
+// an environment gets a store for THAT one — which is still the invariant,
+// since what is promised is that the record's bundle carries the
+// coordinates, not that the bundle has any particular name.
+func TestInstanceCreateHonoursAnExplicitEnvironment(t *testing.T) {
+ fx, _, token, _ := privateStorageFixture(t, "code-priv-explicit")
+ enableInstances(t, fx, "titogo", privateManifest())
+
+ status, out := createInstance(t, fx, token,
+ `{"host":"acme.go.tito.io","region":"ie","environment":"shared-eu"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %s, want 201", status, out)
+ }
+ stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Environment != "shared-eu" {
+ t.Fatalf("stored environment = %q, want the one the caller named", stored.Environment)
+ }
+ if rec := envStore(t, fx, "shared-eu"); rec == nil || rec.Provisioned == nil {
+ t.Fatalf("shared-eu store = %+v, want a provisioned one", rec)
+ }
+ if rec := envStore(t, fx, "acme"); rec != nil {
+ t.Fatalf("a store was also made for the derived name: %+v", rec)
+ }
+}
+
+// A DNS label is not an environment name, and the create door is where that
+// has to be said: labels run to 63 characters, environment names to 32, and
+// "default" is a legal label that names the app's own bundle. Every refusal
+// leaves nothing behind.
+func TestInstanceCreateRefusesALabelThatCannotNameAStore(t *testing.T) {
+ fx, fake, token, _ := privateStorageFixture(t, "code-priv-badlabel")
+ enableInstances(t, fx, "titogo", privateManifest())
+
+ for _, label := range []string{
+ "default",
+ "this-label-is-far-too-long-to-be-an-environment-name",
+ } {
+ host := label + ".go.tito.io"
+ status, out := createInstance(t, fx, token, fmt.Sprintf(`{"host":%q,"region":"ie"}`, host))
+ if status != http.StatusBadRequest {
+ t.Errorf("create at %s = %d %s, want 400", host, status, out)
+ continue
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", host); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("a refused create at %s left a record (err %v)", host, err)
+ }
+ }
+ untouched(t, fake, "a create whose label cannot name a store")
+}
+
+// An EXPLICIT environment gets the same rails, and the same 400. Without
+// this the refusal comes from inside the walk instead — a 502 the caller
+// cannot act on, over a name they could have shortened.
+func TestInstanceCreateRefusesAnUnnameableExplicitEnvironment(t *testing.T) {
+ fx, fake, token, _ := privateStorageFixture(t, "code-priv-badenv")
+ enableInstances(t, fx, "titogo", privateManifest())
+ // 32 characters — a legal environment name, and an IAM principal of
+ // "carlos-store-bab-titogo-" + 32 = 56, under the ceiling. So this is
+ // the bucket half: "carlos-" + "bab-titogo-" + 32 = 50. Both fit, so
+ // the case that must refuse is the one the config plane itself refuses.
+ status, out := createInstance(t, fx, token,
+ `{"host":"acme.go.tito.io","region":"ie","environment":"UPPER"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("create with an unusable environment = %d %s, want 400", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("a refused create left a record (err %v)", err)
+ }
+ untouched(t, fake, "a create whose explicit environment cannot name a store")
+}
+
+// The IAM ceiling, and the arithmetic that makes it reachable at all. Both
+// names carry the same body: the bucket is <prefix><body> against S3's 63,
+// the principal is "carlos-store-" + <body> against IAM's 64. So under any
+// prefix longer than "carlos-store-" the BUCKET refusal always fires first,
+// and the IAM guard is only ever reached on a short prefix with a long app.
+// Encoded here so the guard cannot quietly become dead code.
+//
+// body = "bab" + "-" + app(25) + "-" + env(25) = 55.
+// bucket = "" + 55 = 55, legal. principal = 13 + 55 = 68, one refusal over.
+func TestInstanceCreateRefusesAnUnnameableIAMPrincipal(t *testing.T) {
+ const app = "a-rather-long-application" // 25
+ label := strings.Repeat("b", 25)
+
+ fx, token := instanceFixture(t, "code-priv-iamceiling")
+ if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ pub, _, _ := sealedFor(t, "irrelevant")
+ fake := objstore.NewMemProvisioner("eu-west-1")
+ fx.srv.cfg.SecretsPub = pub
+ fx.srv.cfg.StoreProvisioner = fake
+ fx.srv.cfg.StoreDeployment = objstore.Deployment{Backend: "mem", Region: "eu-west-1"}
+ approveStoreAccount(t, fx, "operator@keymail.dev")
+ enableInstances(t, fx, app, provision.Manifest{
+ Schema: 1, App: app, Domain: "go.tito.io", Channel: "stable", PrivateStorage: true})
+
+ host := label + ".go.tito.io"
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", app), token,
+ fmt.Sprintf(`{"host":%q}`, host))
+ if status != http.StatusBadRequest {
+ t.Fatalf("create = %d %s, want 400", status, out)
+ }
+ if !strings.Contains(out, "64") {
+ t.Errorf("refusal %q does not name AWS's ceiling", out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", app, host); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("a refused create left a record (err %v)", err)
+ }
+ untouched(t, fake, "a create whose principal cannot be named")
+}
+
+// A ceiling only the automatic path respected would be a ceiling anyone
+// could step over by hand.
+func TestStoreCreateRefusedAtTheCeiling(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-ceiling")
+ fx.srv.cfg.StoreMaxPerApp = 1
+
+ if status, body := postRaw(t, storeEnvURL(fx, "titogo", "one"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("declare one = %d %s", status, body)
+ }
+ status, body := postRaw(t, storeEnvURL(fx, "titogo", "two"), map[string]any{}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("declare past the cap = %d %s, want 409", status, body)
+ }
+ // Re-declaring the one that exists is not a new footprint, so it stands.
+ if status, body := postRaw(t, storeEnvURL(fx, "titogo", "one"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("re-declare one = %d %s, want 200", status, body)
+ }
+ // And the app's OWN store is not one of the capped collection.
+ if status, body := postRaw(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("declare the app's own store = %d %s, want 200", status, body)
+ }
+}
+
+// An app that already declared a prefix reads those names in its code. An
+// instance store delivering CARLOS_STORE_BUCKET into the same app would be a
+// second vocabulary for one thing.
+func TestInstanceStoreInheritsTheAppsEnvPrefix(t *testing.T) {
+ fx, _, token, _ := privateStorageFixture(t, "code-priv-prefix")
+ enableInstances(t, fx, "titogo", privateManifest())
+ if status, out := postRaw(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "JELLY_S3"}, token); status != http.StatusOK {
+ t.Fatalf("declare the app store = %d %v", status, out)
+ }
+
+ if status, out := createInstance(t, fx, token, `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ vars := envVars(t, fx, "acme")
+ if _, ok := vars["JELLY_S3_BUCKET"]; !ok {
+ t.Fatalf("acme bundle = %v, want the app's own JELLY_S3 prefix", varNames(vars))
+ }
+ if _, ok := vars["CARLOS_STORE_BUCKET"]; ok {
+ t.Fatalf("acme bundle also carries the default prefix: %v", varNames(vars))
+ }
+}
+
+func varNames(m map[string]appconfig.Var) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+
+// Declaring the policy is refused in the two states where it could only ever
+// be a trap — a deployment with no object storage, and an account nobody
+// approved. It fails once here, to the person, instead of once per signup in
+// a door whose caller is a machine.
+func TestEnableRefusesAPolicyItCannotHonour(t *testing.T) {
+ t.Run("no backend", func(t *testing.T) {
+ fx, _, token, _ := privateStorageFixture(t, "code-priv-enable-nobackend")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.StoreProvisioner = nil
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"private_storage":true}`)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("enable = %d %s, want 503", status, out)
+ }
+ if !strings.Contains(out, "CARLOS_STORE_BACKEND") {
+ t.Errorf("refusal %q does not name the variable", out)
+ }
+ assertNoPolicy(t, fx)
+ })
+ t.Run("unapproved account", func(t *testing.T) {
+ fx, token := instanceFixture(t, "code-priv-enable-unapproved")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.StoreProvisioner = objstore.NewMemProvisioner("eu-west-1")
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"private_storage":true}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("enable = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "carlos store apply") {
+ t.Errorf("refusal %q does not name the way out", out)
+ }
+ assertNoPolicy(t, fx)
+ })
+}
+
+func assertNoPolicy(t *testing.T, fx *consoleFixture) {
+ t.Helper()
+ m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo")
+ if err == nil && m.PrivateStorage {
+ t.Fatal("a refused enable wrote the policy anyway")
+ }
+}
+
+// Turning it OFF is never refused, and the asymmetry is the point: an
+// account whose entitlement was later declined must be able to get its
+// create door working again without waiting on an operator.
+func TestEnableTurningPrivateStorageOffIsNeverRefused(t *testing.T) {
+ fx, token := instanceFixture(t, "code-priv-enable-off")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ enableInstances(t, fx, "titogo", provision.Manifest{
+ Schema: 1, App: "titogo", Domain: "bab.oncarlos.test", PrivateStorage: true})
+ // No backend, no entitlement — the state the ON path refuses in.
+ fx.srv.cfg.StoreProvisioner = nil
+
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"private_storage":false}`)
+ if status != http.StatusOK {
+ t.Fatalf("enable off = %d %s, want 200", status, out)
+ }
+ m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m.PrivateStorage {
+ t.Fatal("the policy survived being turned off")
+ }
+}
+
+// An enable that never mentions storage preserves the policy — the same
+// absent-vs-explicit split channel and seed already have. A `carlos
+// instances enable -channel beta` that silently unset a tenant's isolation
+// is the failure this pins.
+func TestEnablePreservesPrivateStorage(t *testing.T) {
+ fx, _, token, _ := privateStorageFixture(t, "code-priv-enable-preserve")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ enableInstances(t, fx, "titogo", provision.Manifest{
+ Schema: 1, App: "titogo", Domain: "bab.oncarlos.test", PrivateStorage: true})
+
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), token, `{"channel":"beta"}`)
+ if status != http.StatusOK {
+ t.Fatalf("enable = %d %s, want 200", status, out)
+ }
+ var res struct {
+ Channel string `json:"channel"`
+ PrivateStorage bool `json:"private_storage"`
+ }
+ if err := json.Unmarshal([]byte(out), &res); err != nil {
+ t.Fatal(err)
+ }
+ if res.Channel != "beta" || !res.PrivateStorage {
+ t.Fatalf("reply = %+v, want channel beta with the policy intact", res)
+ }
+ m, err := provision.GetManifest(context.Background(), fx.rel, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !m.PrivateStorage {
+ t.Fatal("a channel-only enable dropped the private-storage policy")
+ }
+}
+
+// Declaring the policy stays a PERSON's act — the wall that makes this a
+// better answer than widening the instance role. An app token creates
+// instances all day and cannot write the manifest they are created under.
+func TestPrivateStoragePolicyStaysAHumanAct(t *testing.T) {
+ fx, _, token, _ := privateStorageFixture(t, "code-priv-human")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ appToken := mintInstanceToken(t, fx, token, "titogo")
+
+ status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), appToken, `{"private_storage":true}`)
+ if status != http.StatusForbidden && status != http.StatusUnauthorized {
+ t.Fatalf("app token enable = %d %s, want a refusal", status, out)
+ }
+ assertNoPolicy(t, fx)
+}
+
+// mintInstanceToken is `carlos instances token` — the service credential a
+// signup path holds.
+func mintInstanceToken(t *testing.T, fx *consoleFixture, personToken, app string) string {
+ t.Helper()
+ u := fx.ts.URL + "/api/cli/apps/bab/" + app + "/instance-token"
+ status, _, out := rawReq(t, http.MethodPost, u, personToken, `{}`)
+ if status != http.StatusCreated {
+ t.Fatalf("mint instance token = %d %s", status, out)
+ }
+ var res struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal([]byte(out), &res); err != nil {
+ t.Fatal(err)
+ }
+ return res.Token
+}
+
+// The ceiling in force, and the two ways a deployment names it. Zero in the
+// config is "not configured", never "no stores allowed" — an unset variable
+// turning an app into one that can hold no store at all is the wrong
+// direction for a default to fail in.
+//
+// The number itself is pinned because it is a JUDGEMENT about AWS's quota
+// rather than an arithmetic consequence of anything: IAM allows 5,000 users
+// per cloud account, so one app's share is a fifth of that. An earlier draft
+// said 100, which would have refused an ordinary managed product at its
+// hundredth customer while protecting nothing (James, 2026-09-23). A future
+// change to the constant should be a decision someone took, not a drift.
+func TestStoreCeilingDefault(t *testing.T) {
+ s := &Server{}
+ if got := s.storeMaxPerApp(); got != 1000 {
+ t.Errorf("unconfigured ceiling = %d, want 1000", got)
+ }
+ s.cfg.StoreMaxPerApp = 25
+ if got := s.storeMaxPerApp(); got != 25 {
+ t.Errorf("configured ceiling = %d, want 25", got)
+ }
+}
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
index 79a8fced7..07019da19 100644
--- a/internal/console/apistore.go
+++ b/internal/console/apistore.go
@@ -398,6 +398,17 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
defer cancel()
+ // The environment-store ceiling (issue #412 §8) is enforced HERE as well
+ // as on the automatic path. A cap that only the create-an-instance door
+ // respected would be a cap anyone could step over by hand, and the
+ // footprint it bounds — a bucket and an IAM user per store, against
+ // IAM's per-account limits — does not care which door minted it.
+ // Re-declaring a store that already exists is never capped; storeCeiling
+ // carries that carve-out.
+ if ref := s.storeCeiling(ctx, acct, app, env); ref != nil {
+ jsonError(w, ref.Status, ref.Msg)
+ return
+ }
rec, etag, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env)
if err != nil {
// Logged, not returned: a store error carries bucket names, endpoints
diff --git a/internal/console/instanceprivatestore.go b/internal/console/instanceprivatestore.go
new file mode 100644
index 000000000..b5a9d10aa
--- /dev/null
+++ b/internal/console/instanceprivatestore.go
@@ -0,0 +1,312 @@
+// The app-declared private-storage policy (issue #412, spec
+// 2026-09-22-instance-private-storage-design.md): an app may say once, on
+// its instances manifest, that every instance it creates gets a config
+// environment of its own and an object store bound to that environment.
+//
+// The whole point is the SPLIT. Declaring the policy is
+// `handleAPIInstanceEnable` — person-only, zero Set, no service credential
+// reaches it. Spending it is `handleAPIInstanceCreate`, which an
+// `instance`-role credential can already reach. So a public signup path
+// provisions a tenant's own bucket without ever holding the power to create
+// one, which is what makes this a better answer than widening the role
+// (#410, which this makes unnecessary).
+//
+// Nothing here is a second implementation of provisioning. The walk this
+// file drives is the one `handleAPIStoreProvision` drives, with the same
+// lock, the same seal key, the same timeout and the same
+// GrantSourceAccountApproval — factored so the automatic path and the
+// member's own `carlos store create --environment` cannot drift.
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// storeMaxPerAppDefault is the ceiling on ENVIRONMENT stores under one app
+// when the deployment names none (spec §8, issue #412's point 3).
+//
+// A bucket plus an IAM user per instance is a bigger footprint than an
+// instance, and the binding limit is IAM's: 5,000 users per cloud account,
+// an adjustable quota
+// (https://docs.aws.amazon.com/general/latest/gr/iam-service.html). So a
+// signup path that can mint them needs a bar it stops at — but the bar has
+// to be sized against that number rather than picked for looking tidy. An
+// earlier draft said 100, which was a fifth of one app's fair share of the
+// quota and would have refused a perfectly ordinary managed product at its
+// hundredth customer (James, 2026-09-23).
+//
+// Per APP rather than per account, and that is a cost decision stated as
+// such: the count is one prefix listing of the app, which the create door
+// can afford on every request. A deployment-wide count would mean listing
+// every app's stores on every create.
+//
+// WHAT THIS DOES NOT BOUND, said plainly because the per-app shape invites
+// the wrong reading: five apps at this default reach 5,000 between them, and
+// the deployment's own IAM principals are not in that sum. This is a brake
+// on ONE runaway signup path, not a budget for the cloud account. A real
+// account-wide bound needs a counter kept on the side rather than one
+// derived by listing, and it is not here.
+const storeMaxPerAppDefault = 1000
+
+// storeMaxPerApp is the ceiling in force. Zero in the config means "not
+// configured", not "no stores allowed": a cap of zero would turn an
+// unset variable into an app that can have no environment store at all.
+func (s *Server) storeMaxPerApp() int {
+ if s.cfg.StoreMaxPerApp > 0 {
+ return s.cfg.StoreMaxPerApp
+ }
+ return storeMaxPerAppDefault
+}
+
+// storeCeiling refuses an app that has reached its environment-store cap.
+//
+// env is the environment ABOUT to be given a store, and a name that already
+// has one is never refused: the cap is on the FOOTPRINT, and re-declaring
+// an existing store adds no bucket and no principal. Without that carve-out
+// a retry of a create that failed just after provisioning would be turned
+// away by the very ceiling it had reached, with no way forward at all.
+//
+// A listing failure is a refusal, not a pass. This is a spend gate; reading
+// "how many are there already" as "none" on a bad round trip is the one
+// error direction that costs money.
+func (s *Server) storeCeiling(ctx context.Context, acct *Account, app, env string) *doorRefusal {
+ if env == "" {
+ return nil // the app's own store is not one of the capped collection
+ }
+ envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: store ceiling %s/%s: %v", acct.Sqid, app, err)
+ return &doorRefusal{Status: http.StatusBadGateway,
+ Msg: "could not count this app's stores — try again"}
+ }
+ for _, e := range envs {
+ if e == env {
+ return nil
+ }
+ }
+ max := s.storeMaxPerApp()
+ if len(envs) < max {
+ return nil
+ }
+ return &doorRefusal{Status: http.StatusConflict,
+ Msg: fmt.Sprintf("%s already has %d environment stores, which is this deployment's limit of %d — destroy one in the console, or ask an operator to raise CARLOS_STORE_MAX_PER_APP",
+ app, len(envs), max)}
+}
+
+// instanceStoreNames refuses an environment that cannot carry a store, for
+// an app under the policy (spec §6).
+//
+// derived says where the name came from — the host's first label, or the
+// caller's own `environment` — and it changes only the sentence, never the
+// rails. The first label is the derived name because it is the one
+// predictable from the host, already unique within the app (the record key
+// IS the host), and readable in `store status` at a hundred of them, which
+// is #412's point 1.
+//
+// Three refusals, all BEFORE anything is written, because each of them is
+// otherwise discovered somewhere worse:
+//
+// - a DNS label is not an environment name. Labels run to 63 characters
+// and environment names to 32, and "default" is a perfectly legal label
+// that names the app's own bundle rather than a named overlay.
+// - the bucket name is S3's 63. The walk checks this itself, but reaching
+// it there means an instance record already written over an estate that
+// cannot exist — and the walk's own refusal arrives as a 502 the caller
+// cannot act on, not a 400 naming the part to shorten.
+// - the principal name is IAM's 64, for the same reason. An environment
+// store's name carries three components, so a long enough (account,
+// app, environment) is simply unnameable.
+//
+// Each sentence names the part the caller can shorten, because from a
+// signup path the host label is usually the only one they control.
+func (s *Server) instanceStoreNames(acct *Account, app, env string, derived bool) *doorRefusal {
+ source := "the environment you named"
+ fix := "choose a shorter environment name"
+ if derived {
+ source = "the host's first label"
+ fix = "choose a different host label"
+ }
+ if !appconfig.ValidEnvironmentName(env) {
+ return &doorRefusal{Status: http.StatusBadRequest,
+ Msg: fmt.Sprintf("%s gives every instance its own storage, which needs %s to be a usable environment name — %q is not (want ^[a-z0-9-]{1,32}$, and not \"default\"). Please %s", app, source, env, fix)}
+ }
+ bucket := objstore.MintedBucketIn(s.cfg.StoreDeployment, acct.Sqid, app, env)
+ if !objstore.ValidBucketName(bucket) {
+ return &doorRefusal{Status: http.StatusBadRequest,
+ Msg: fmt.Sprintf("this instance's bucket would be named %q, which is %d characters and not a legal S3 name — %s", bucket, len(bucket), fix)}
+ }
+ user := objstore.UserNameIn(acct.Sqid, app, env)
+ if !objstore.ValidUserName(user) {
+ return &doorRefusal{Status: http.StatusBadRequest,
+ Msg: fmt.Sprintf("this instance's IAM user would be named %q, which is %d characters, over AWS's %d — %s, or shorten the app name", user, len(user), objstore.MaxIAMUserName, fix)}
+ }
+ return nil
+}
+
+// instanceStoreProvision declares and provisions one environment's store
+// under the account's standing entitlement, and answers w itself on every
+// refusal. The caller has already proved membership and spent its write
+// budget; what is left is the store plane's own gates, in the order
+// handleAPIStoreProvision applies them.
+//
+// actor is the create door's principal — a person's address or the service
+// credential's `app:<sqid>/<app>` audit actor — and it lands in the grant
+// block, so a bucket minted by a signup path names the signup path.
+//
+// The env prefix is INHERITED from the app's own store when it has one. An
+// app that declared `carlos store create --env-prefix JELLY_S3` reads those
+// names in its code, and an instance store delivering CARLOS_STORE_BUCKET
+// into the same app would be a second vocabulary for one thing. With no app
+// store declared, the default prefix is taken — what a fresh declaration
+// would have taken anyway.
+//
+// ctx must be the REQUEST context, not a short-deadline derivative of it:
+// the walk below takes its own storeGrantTimeout, which is longer than the
+// create door's own deadline, and a walk that inherited a fifteen-second
+// budget would abandon a bucket it had already paid for.
+func (s *Server) instanceStoreProvision(ctx context.Context, w http.ResponseWriter, acct *Account, app, env, actor string) (*objstore.Record, bool) {
+ if s.cfg.StoreProvisioner == nil {
+ // The manifest carries a policy this deployment cannot honour. The
+ // enable door refuses to write one in this state (§5), so reaching
+ // here means the backend was unwired afterwards — a configuration
+ // fact, named as one.
+ jsonError(w, http.StatusServiceUnavailable,
+ "object storage is not configured for this deployment (CARLOS_STORE_BACKEND), and "+app+" gives every instance its own storage — no instance can be created until one is wired or the policy is turned off (carlos instances enable --private-storage=false)")
+ return nil, false
+ }
+ preCtx, preCancel := context.WithTimeout(ctx, storeTimeout)
+ defer preCancel()
+ if _, ok := s.storeProvisionApproval(preCtx, w, acct); !ok {
+ return nil, false
+ }
+ pub, ok := s.storeSealKey(w)
+ if !ok {
+ return nil, false
+ }
+ // The same lock handleAPIStoreProvision takes, keyed the same way, so a
+ // signup creating an instance and a member running `carlos store create
+ // --environment` against the same name serialise against each other.
+ defer s.storeWalks.lock(acct.Sqid, app, env)()
+ // The walk's deadline starts AFTER the lock, for the reason that door
+ // states: a request that queued behind a full-length walk would
+ // otherwise reach the backend holding a spent context.
+ walkCtx, cancel := context.WithTimeout(ctx, storeGrantTimeout)
+ defer cancel()
+ // Re-read under the lock: a queued request may have waited while an
+ // operator declined the account.
+ e, ok := s.storeProvisionApproval(walkCtx, w, acct)
+ if !ok {
+ return nil, false
+ }
+ // The declaration the walk requires. GrantIn refuses a store nobody
+ // declared (ErrNotDeclared) because the env prefix is the member's
+ // choice and a store with nowhere to be delivered is not one to invent
+ // — under this policy the APP made that choice, once, on the manifest,
+ // so the declaration is written here rather than demanded of a caller
+ // who has no verb to write it with.
+ prefix, ok := s.storeInheritedPrefix(walkCtx, w, acct, app)
+ if !ok {
+ return nil, false
+ }
+ rec, etag, err := objstore.GetIn(walkCtx, s.cfg.Releases, acct.Sqid, app, env)
+ if err != nil {
+ log.Printf("console: instance store read %s/%s/%s: %v", acct.Sqid, app, env, err)
+ jsonError(w, http.StatusBadGateway, "could not read this instance's store record — try again")
+ return nil, false
+ }
+ if rec == nil {
+ rec = &objstore.Record{}
+ }
+ // Only when absent. A re-run over an existing declaration must not
+ // re-stamp its author and timestamp, and must not move a prefix the app
+ // is already reading — that is the one rewrite handleAPIStoreCreate
+ // refuses with a 409, and this path has no member to show it to.
+ if rec.Declared == nil {
+ rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: actor}
+ if _, err := objstore.PutIn(walkCtx, s.cfg.Releases, acct.Sqid, app, env, rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict,
+ "this instance's store record changed while it was being created — try again")
+ return nil, false
+ }
+ log.Printf("console: instance store declare %s/%s/%s: %v", acct.Sqid, app, env, err)
+ jsonError(w, http.StatusBadGateway, "could not declare this instance's store — nothing was created; try again")
+ return nil, false
+ }
+ }
+ out, err := objstore.GrantIn(walkCtx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment,
+ pub, acct.Sqid, app, env, e.Decision.By, objstore.GrantSourceAccountApproval, "", time.Now())
+ if err != nil {
+ // Logged, not returned: a walk error carries bucket names, IAM ARNs
+ // and request ids. The sentence the caller gets is the one they can
+ // act on, and it is honest about what is now true — the declaration
+ // may stand, the instance does not, and a retry of the SAME host
+ // resumes this exact store because its name came from the host.
+ log.Printf("console: instance store provision %s/%s/%s: %v", acct.Sqid, app, env, err)
+ jsonError(w, http.StatusBadGateway,
+ "could not provision this instance's storage, so the instance was not created — run the same command again to resume it")
+ return nil, false
+ }
+ // Audited from here as well as from the store doors, because this is the
+ // one path where a bucket is minted by a caller that is not a person:
+ // an account reading its own rows has to be able to see that its signup
+ // path spent money, and by whom it was authorised.
+ s.audit(acct, app, actor, "store-provision",
+ "provisioned "+storeBucketName(out)+" for instance environment "+env+" (instance create)")
+ return out, true
+}
+
+// storeInheritedPrefix reads the app's own store for the variable-name
+// prefix a new environment store should deliver under. Absent is not an
+// error — an app with no store of its own is the ordinary shape for a
+// private-storage app, and takes the default.
+func (s *Server) storeInheritedPrefix(ctx context.Context, w http.ResponseWriter, acct *Account, app string) (string, bool) {
+ rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: instance store prefix %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read this app's store settings — try again")
+ return "", false
+ }
+ return storeEffectivePrefix(rec), true
+}
+
+// instancePrivateStorageAllowed is the enable door's half: the two states in
+// which declaring the policy would be a trap rather than a policy (spec §5).
+//
+// A manifest that says "every instance gets a bucket" on a deployment with
+// no object storage, or in an account nobody approved, does not fail when it
+// is written. It fails once per signup, afterwards, in a door whose caller
+// is a machine — and the two sentences below are the only two that name the
+// fix. So they are said here, to the person, at the moment they declare it.
+//
+// Turning the policy OFF is never refused, and that asymmetry is the point:
+// an account whose entitlement was later declined must be able to get its
+// create door working again without an operator.
+func (s *Server) instancePrivateStorageAllowed(ctx context.Context, w http.ResponseWriter, acct *Account) bool {
+ if s.cfg.StoreProvisioner == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "object storage is not configured for this deployment (CARLOS_STORE_BACKEND) — an app cannot be given per-instance storage here")
+ return false
+ }
+ e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid)
+ if err != nil {
+ log.Printf("console: instances enable entitlement %s: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not read this account's storage approval — try again")
+ return false
+ }
+ if !e.Approved() {
+ jsonError(w, http.StatusForbidden,
+ "this account is not approved for object storage, so its instances cannot be given storage of their own — run `carlos store apply` and ask an operator to approve it first")
+ return false
+ }
+ return true
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 7c1aedd22..ed4352157 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -592,6 +592,17 @@ type Config struct {
// lives — a walk that guessed would create the next bucket somewhere
// else, and the record would say so afterwards.
StoreDeployment objstore.Deployment
+ // StoreMaxPerApp caps how many ENVIRONMENT stores one app may have
+ // (CARLOS_STORE_MAX_PER_APP; 0 = storeMaxPerAppDefault). The app's own
+ // store is not one of them and is never capped.
+ //
+ // It exists because #412 made a store something a signup path can mint:
+ // a bucket plus an IAM user per instance is a bigger footprint than an
+ // instance, and IAM's limits are per cloud account, so the automatic
+ // path needs a bar it stops at. Enforced on the MANUAL door too
+ // (handleAPIStoreCreate) — a ceiling only the automatic path respected
+ // would be a ceiling anyone could step over by hand.
+ StoreMaxPerApp int
// StoreOperators are the addresses allowed to drive the GRANT door, and
// only that one (CARLOS_STORE_OPERATORS). Its own list rather than
// console ownership on purpose: granting CREATES backend estate and
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 7c68ed826..e958c2dc8 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -190,6 +190,26 @@ type Manifest struct {
Channel string `json:"channel,omitempty"`
Regions []string `json:"regions,omitempty"`
Seed []string `json:"seed,omitempty"`
+
+ // PrivateStorage is the app's standing policy that every instance
+ // created under this manifest gets a config environment of its own and
+ // an object store bound to that environment (issue #412, spec
+ // 2026-09-22-instance-private-storage-design.md §5). Declared once, by
+ // a signed-in person, at `carlos instances enable --private-storage`;
+ // spent by the create door, which an `instance`-role service credential
+ // can already reach. That split is the point: the policy widens no
+ // role, so a signup path provisions a tenant's bucket without ever
+ // holding the power to create one.
+ //
+ // A plain bool, not a pointer, and the difference from Record's
+ // Hibernate and StatusNamed is worth stating because their reason does
+ // not apply here. Those are CONVERGED columns — a box re-reads them
+ // every pass, so "absent" has to be distinguishable from an explicit
+ // false or the first pass after deploy acts on a value nobody wrote.
+ // This is read at one door, at create time, and false IS the behaviour
+ // every manifest in a production bucket already has. There is nothing
+ // for absence to mean that false does not already mean.
+ PrivateStorage bool `json:"private_storage,omitempty"`
}
// ManifestKey, RecordsPrefix and RecordKey are the bucket key conventions
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 1ab77449c..e233f5bce 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -1115,3 +1115,50 @@ func TestLayoutJournalPath(t *testing.T) {
t.Errorf("LayoutJournalPath = %q", got)
}
}
+
+// PrivateStorage is additive and its ZERO VALUE is the legacy behaviour: a
+// manifest written before the field existed — every manifest in a
+// production bucket — must read as an app whose instances share the app's
+// storage, which is what those apps do (issue #412, spec §5).
+func TestManifestPrivateStorageIsAdditive(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+
+ // The bytes an older console wrote: no such key at all.
+ legacy := []byte(`{"schema":1,"app":"tito","domain":"go.tito.io","channel":"stable"}`)
+ if _, err := s.Put(ctx, ManifestKey("bad", "tito"), legacy, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := GetManifest(ctx, s, "bad", "tito")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.PrivateStorage {
+ t.Fatal("a manifest written before the field reads as private-storage — every app in production would start minting buckets")
+ }
+
+ got.PrivateStorage = true
+ if err := PutManifest(ctx, s, "bad", "tito", *got); err != nil {
+ t.Fatal(err)
+ }
+ back, err := GetManifest(ctx, s, "bad", "tito")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !back.PrivateStorage {
+ t.Fatalf("policy did not survive a round trip: %+v", back)
+ }
+ // omitempty: an app without the policy writes no key, so nothing an old
+ // reader parses changes shape.
+ back.PrivateStorage = false
+ if err := PutManifest(ctx, s, "bad", "tito", *back); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := s.Get(ctx, ManifestKey("bad", "tito"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), "private_storage") {
+ t.Errorf("an app without the policy still writes the key: %s", raw)
+ }
+}