carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.

// 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). 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.