carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

A store per environment: object storage an instance can own

Date: 2026-09-19 Status: approved in direction (James, 2026-09-19). Two (ruling)s were taken in that conversation; everything else is a (decision) and can be overturned cheaply. Discussion: amadan carlos/platform #159. Extends: 2026-09-02-object-storage-self-serve-design.md (the account entitlement) and the object-storage primitive it builds on.

1. The problem, as found

store.json is per app. MintedBucket is prefix + acct + "-" + app and UserName is "carlos-store-" + acct + "-" + app (internal/objstore/walk.go:62,69), and writeEnv delivers the coordinates through appconfig.Apply (walk.go:689) — the app's default bundle. So every instance of an app shares one bucket and one credential, and there is no way to give one instance its own.

That is correct for an app whose instances are one deployment. It is the blocker for a managed, multi-tenant product, where one app has one instance per customer: the only options are one bucket with per-tenant key prefixes, where isolation becomes app logic and one path bug crosses tenants, or the app bringing its own S3 and re-implementing what the platform already does.

What is already right, and must stay right

  • The entitlement is per ACCOUNT, not per app (store/entitlements/<sqid>.json). Approval is already orthogonal to whatever unit owns a store, so nothing here adds an approval step.
  • The walks are idempotent and resumable, and their lost-CAS retries are guarded on the key the walk started from — the one shape the walk comments call "NOT survivable" (walk.go:258-289).
  • The record's three null-or-present blocks are what make "declared", "granted" and "provisioned" distinguishable.
  • release.PurgeApp hand-parses grant/provisioned out of the store record and refuses an app's auto-purge while either is present (internal/release/trash.go:202-221). The field names are frozen.
  • The per-app walk lock exists (internal/console/storelock.go).

2. The shape, in one paragraph

A store may belong to a named config environment instead of to the app as a whole. carlos store create --app a --environment e declares one; the walk mints a bucket and an IAM user named for (account, app, environment) and delivers the coordinates into that environment's bundle, which is the bundle the instances bound to it already read. An instance gets its own bucket by being the only instance bound to its environment. Nothing on a box changes.

3. Rulings

  • (ruling, James 2026-09-19) A store is keyed on (account, app, environment), not on a route host. Config delivery already resolves per (account, app, environment) (appconfig.LoadMerged, syncexec.go's groupKey); there is no per-host config source, and adding one means a new precedence layer inside the box-side sync that every route's config passes through. Keying on the environment reuses the delivery axis exactly as it is.
  • (ruling, James 2026-09-19) Teardown stays an explicit verb. Deleting an instance or abandoning an environment never destroys a bucket. provision.DeleteRecord already "deliberately leaves the parked database and blobs untouched", and objstore.Teardown is only ever called from a door a person drove. Nothing in this change becomes the platform's first automatic destroyer of tenant data. §8 covers what stops an abandoned store from billing silently.

4. Wire shapes

(decision) The default store does not move. With no --environment, every key, name and delivery target is byte-for-byte what it is today: apps/<acct>/<app>/store.json, bucket <prefix><acct>-<app>, user carlos-store-<acct>-<app>, delivered to config.json. Existing records, buckets and credentials are untouched, and every existing test stays green unchanged.

(decision) An environment store is a sibling collection.

apps/<acct>/<app>/store.json the app's own store (unchanged) apps/<acct>/<app>/stores/<env>.json one per named environment

Nothing in the bucket nests keys under an instance today; the nearest precedent is apps/<sqid>/<app>/sidecars/<name>.json, a flat sibling collection, and this follows it.

(decision) The record shape is objstore.Record, unchanged, with one additive field:

// Environment is the named config bundle this store belongs to. "" is the
// app's own store — the only shape that existed before 2026-09-19 — and is
// omitted from the JSON, so every record written before this field existed
// parses as the app store it is.
Environment string `json:"environment,omitempty"`

Like App, it is stamped by Put from the key it is written to and never trusted from a caller: a record that lies about its own identity is refused, not guessed at.

(decision) Names gain the environment as a third component.

MintedBucket: <prefix><acct>-<app>-<env> UserName: carlos-store-<acct>-<app>-<env>

> Implementation gate. Both are bounded before this lands. S3 bucket > names are at most 63 characters and IAM user names at most 64; account, > app and environment names are each already bounded by their own > validators. The walk must refuse a combination that cannot be named, > before any backend call, with a sentence naming which part is too > long — never a half-provisioned estate discovered at CreateBucket.

(decision) Delivery targets the environment's bundle. writeEnv calls appconfig.ApplyEnvironment(..., env, ...) when Environment != "", and appconfig.Apply otherwise. The variable names are unchanged (<PREFIX>_BUCKET, _REGION, _ENDPOINT, _ACCESS_KEY, _SECRET_KEY), and the two key halves are still sealed before they are written.

Because a named bundle overlays the default per key, an app with both an app store and an environment store delivers the environment's coordinates to instances bound to it and the app's to everyone else. That is the overlay doing exactly what it already does.

5. What a member sees

carlos store create --app a --environment e [--env-prefix P]
carlos store status --app a [--environment e]
carlos store grant  --app a --environment e            (operator, unchanged gates)
carlos store rotate --app a --environment e [--finish]

(ruling, James 2026-09-21) There is no carlos store destroy. An earlier draft of this section listed one. Destroying a store stays what it already was: a console action behind the heaviest ceremony in the platform — owner role, CSRF, the bucket name typed exactly, and fresh passkey auth. A CLI verb cannot ask for a passkey, so adding one would have quietly traded that ceremony for a bearer token on the one action with no trash and no undo.

The console's destroy door takes the environment instead (§8), and the settings page grows one card per environment store, each with its own confirm-and-destroy form.

(decision) status with no --environment lists every store the app has, the app's own first, then one line per environment: its bucket, its state, and which instances read it. An app with only the default store prints what it prints today.

(decision) The doors refuse what cannot be delivered. A store for an environment name that fails appconfig.ValidEnvironmentName is refused. An environment bundle that does not exist yet is not refused: delivery creates it, matching "an environment does not require a default to exist".

(decision) Sharing is visible, not refused. Two instances bound to one environment share its store, exactly as they share its config. status says which instances read each store, so a shared bucket is something you can see rather than something you discover.

6. Authority

Unchanged in every respect. apiAccountApp first, then the operator allowlist for grant, then the write budget, then the backend gates. The account entitlement remains the single approval: in an approved account, creating an environment store provisions it with no new approval, which is what lets a signup path mint one without an operator in the loop.

(decision) The walk lock key gains the environment — acct + "/" + app + "/" + env. Two stores of one app share no estate: their buckets, users and delivered bundles are all distinct, so serialising across them would cost concurrency and buy nothing. That is the same argument storelock.go already makes for two apps in one account.

7. The purge guard

release.PurgeApp today does one Get of store.json and refuses the purge while grant or provisioned is present. With environment stores it must also list apps/<acct>/<app>/stores/ and apply the same test to every record there.

This is the one change in this spec that is a safety property rather than a feature. A miss means an app is auto-purged while a bucket, an IAM user and a live access key survive with nothing naming them. It lands in the first slice, with the test that proves it, and release keeps hand-parsing the two frozen field names rather than importing objstore.

8. Metering, and the abandoned store

(decision) v1 sums every store of an app into the app's existing usage key. storagePass gets one Get per app today and writes day.Apps[acct+"/"+app]. It will instead read the app store plus every record under stores/, size each, and sum. The StorageDay wire shape does not change, so nothing downstream of usage has to move, and an account's bill is the same arithmetic over more buckets.

Per-store attribution — what discussion #140 asks for — is a later, additive change to the usage shape, and this spec deliberately does not make it.

Because teardown is explicit (§3), an abandoned environment store keeps billing. Three things make that visible rather than silent:

  • store status lists every store of the app, including ones no instance reads. An orphan is on the screen.
  • A store whose environment no bundle and no instance references is named in status as unused, in those words.
  • The purge guard (§7) refuses to auto-purge the app while any store is provisioned, so the estate cannot be forgotten by being trashed.

9. Scanning

(decision) v1 scans the app's own store only. Sweep is (acct, app)-scoped and keeps one cursor per app (internal/objstore/sweep.go), and the scan-result namespace is the same pair. Extending both is real work with no bearing on whether a tenant can have its own bucket.

What this spec will not do is let the surface lie about it: store status on an environment store says scanning is not available for it yet, and the scan doors refuse --environment with that sentence rather than silently scanning the wrong bucket or claiming a clean result.

10. Slices

  1. The primitive. ✅ Environment on the record, the key layout, the name builders and their length refusal, writeEnv's delivery target, and the walks threaded through. Plus §7's purge guard and its test.
  2. The doors. ✅ ?environment= on create/status/grant/rotate/finish, the lock key, storeReply carrying the environment, and the refusals of §5.
  3. The CLI and the docs. ✅ --environment on create/status/grant/rotate, status listing every store, and docs/site/cli.md.
  4. Teardown. ✅ The account-teardown path covering every store, and the console destroy door taking an environment — with one card and one confirm-and-destroy form per environment store on the settings page. No CLI verb, per §5's ruling.
  5. Metering. ✅ §8's sum, over the app's own store and every environment store, into the one existing usage key.

Not built, and named here so it is not mistaken for shipped: §8's "unused" line. store status lists every environment that has a store, which is where an abandoned one becomes visible — but it does not say whether any INSTANCE is bound to that environment, which is what "unused" claims. Doing it properly means reading the app's instance records and joining on their environment binding, and a line that said "unused" from anything less would be a guess about a live bucket. The listing is the honest half; the join is follow-up work.

11. Non-goals

  • Per-host stores. §3's ruling.
  • Automatic teardown of anything. §3's ruling.
  • Per-store usage attribution (#140).
  • Scanning environment stores (§9).
  • Any change to the account entitlement, its approval queue or its notifications.