carlos / platform Public

spec: private storage per instance, declared once on the app (issue #412)

A better answer to #410, which asked for the instance role to be allowed
to create stores. This asks for nobody to gain a new power at all: the
app declares, once, that every instance it creates gets a config
environment of its own with an object store bound to it, and the create
door an `instance`-role credential already reaches does the rest under
that standing declaration.

Builds directly on #408's per-environment store — §3's ruling that a
store is keyed on (account, app, environment) is what makes deriving the
environment from the host at create time free, and §6's "in an approved
account, creating an environment store provisions it with no new
approval" is the whole authority story here.

Scope confirmed with James on 2026-09-22: the policy, the derived name
and the ceiling. The residue listing (#412's point 2) and holding an
instance's first wake until delivery (point 4) are follow-up work, named
in §10 so they are not mistaken for shipped.

The load-bearing decision is §7's ordering: the store is provisioned
BEFORE the record is written, and a failed walk means no instance at
all. The two orders fail differently and only one of them fails visibly
— an instance without its store wakes onto local disk silently, while a
store without its instance is listed by `store status` and is reused
verbatim by a retry of the same host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 4d016241b2d9114f5a11fabb6bce164a8596c01a parent 1447716
1 file changed, +258 −0
  • docs/superpowers/specs/2026-09-22-instance-private-storage-design.md +258 −0
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..e7d476fe6
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-22-instance-private-storage-design.md
@@ -0,0 +1,258 @@
+# 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.
+- 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 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.
+
+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.