console, docs: the store ceiling is 1000 per app, sized against IAM's real quota
100 was picked for tidiness — it is the number #412's own text reaches for when it asks that `store status` stay readable "at a hundred of them" — and that is a readability observation, not a limit. AWS allows 5,000 IAM users per cloud account (an adjustable quota), so 100 was a fifth of one app's fair share: a real refusal, at an ordinary managed product's hundredth customer, in exchange for no real protection (James, 2026-09-23). 1000 is a fifth of the account quota. One app can run away without exhausting it; five cannot. The comment and spec §8 now say what the cap does NOT bound, 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; it is now a named non-goal (§4) rather than something the per-app cap could be mistaken for. The number is pinned by a test, since it is a judgement about a quota rather than an arithmetic consequence of anything — a later change to it should be a decision someone took, not a drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed,
+64
−10
docs/site/cli.md+5 −2docs/superpowers/specs/2026-09-22-instance-private-storage-design.md+21 −3internal/console/apiinstancesprivatestore_test.go+22 −0internal/console/instanceprivatestore.go+16 −5
CI passed — run details
diff --git a/docs/site/cli.md b/docs/site/cli.md| index b1a32165e..6abbbc90f 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -618,8 +618,11 @@ Three things to know before you turn it on: |
| 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 100 environment stores by default; a deployment sets |
| -its own limit with `CARLOS_STORE_MAX_PER_APP`. |
| +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. |
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| index e7d476fe6..518e979cd 100644 |
| --- a/docs/superpowers/specs/2026-09-22-instance-private-storage-design.md |
| +++ b/docs/superpowers/specs/2026-09-22-instance-private-storage-design.md |
| @@ -74,6 +74,8 @@ been decided. |
| - 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. |
| @@ -204,9 +206,25 @@ 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 100. A bucket plus an IAM user per |
| -instance is a bigger footprint than an instance, and IAM's limits are per |
| -cloud account, so a signup path that can mint them needs a bar it stops at. |
| +`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 |
diff --git a/internal/console/apiinstancesprivatestore_test.go b/internal/console/apiinstancesprivatestore_test.go| index 33c08936e..b11754521 100644 |
| --- a/internal/console/apiinstancesprivatestore_test.go |
| +++ b/internal/console/apiinstancesprivatestore_test.go |
| @@ -471,3 +471,25 @@ func mintInstanceToken(t *testing.T, fx *consoleFixture, personToken, app string |
| } |
| 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/instanceprivatestore.go b/internal/console/instanceprivatestore.go| index d68dd3cf1..b5a9d10aa 100644 |
| --- a/internal/console/instanceprivatestore.go |
| +++ b/internal/console/instanceprivatestore.go |
| @@ -35,16 +35,27 @@ import ( |
| // 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 IAM's limits are per cloud account — so a signup path that |
| -// can mint them needs a bar it stops at. 100 is the number the issue itself |
| -// reaches for when it asks that `store status` and the console's |
| -// Environments view stay readable "at a hundred of them". |
| +// 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. |
| -const storeMaxPerAppDefault = 100 |
| +// |
| +// 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 |