# 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.
