carlos / platform Public

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>
James Adam pushed by james@keymail.dev b561da22dbddfab58d158a41d288877694bc2230 parent 276556f
4 files changed, +64 −10
  • docs/site/cli.md +5 −2
  • docs/superpowers/specs/2026-09-22-instance-private-storage-design.md +21 −3
  • internal/console/apiinstancesprivatestore_test.go +22 −0
  • internal/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