spec: self-serve object storage via account apply/approve (#381)
An account applies for object storage, a deployment operator approves the account, and from then on any app in it gets a store from `carlos store create` alone — the grant walk runs inline and the member never learns the word "grant". The per-app operator `grant` verb stays as the override for unapproved accounts, --bucket adoption and repair. Records the four rulings taken in the brainstorm (per-account not per-app; unlimited but metered; owners-only apply; inline provisioning), and the two findings that shaped it: the metering half already exists (the economics pass sizes every provisioned store daily into MeterStorageGBMonth), and alertRecipients mails console owners through Keymail grants — a different population from CARLOS_STORE_OPERATORS — so notification needs the intersection and a visible queue rather than assuming mail reaches anyone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+392
−0
docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md+392 −0
diff --git a/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md b/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md| new file mode 100644 |
| index 000000000..572dd5c71 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md |
| @@ -0,0 +1,392 @@ |
| +# Self-serve object storage: an account applies, an operator approves |
| + |
| +**Date:** 2026-09-02 |
| +**Status:** approved in principle (Paul, 2026-09-02, in the brainstorm this |
| +document records). Four decisions were taken there and are marked **(ruling)**; |
| +everything else is marked **(decision)** and can be overturned cheaply. |
| + |
| +**Issue:** #381. Extends the object-storage primitive (#55, spec |
| +`2026-08-10-object-storage-primitive-design.md`, referenced below as *the |
| +primitive spec*). |
| + |
| +## 1. The problem, as found |
| + |
| +On 2026-09-02 James reported that carloku had no object storage. He was right, |
| +and the way he came to report it is the bug. |
| + |
| +`james@keymail.dev` (account `bac`, app `jam`) declared a store on 2026-08-29 |
| +under the default `CARLOS_STORE` prefix. `carlos store status` told him the |
| +truth — the declaration is recorded, and this deployment has no backend |
| +configured — and there his information ended. Four days later he raised it in |
| +conversation, out of band, to the one person who could act. |
| + |
| +Two separate operator-side steps stood between him and a bucket, and he could |
| +act on neither: |
| + |
| +1. **The deployment had no backend at all.** `CARLOS_STORE_BACKEND`, plus the |
| + `CARLOS_STORE_REGION` and `CARLOS_STORE_BUCKET_PREFIX` that |
| + `storeBackend()` makes fatal at boot if the first is set without them |
| + (`cmd/console/main.go:492-524`), live only in the console's root env. Turning |
| + object storage on for a whole deployment is an SSM edit and a restart. That |
| + is #368's problem in its most expensive instance: the primitive has shipped |
| + since #55 and the flagship has never had it switched on, because switching it |
| + on is not a `carlos` verb. |
| +2. **Every grant is a manual operator verb.** Even with a backend, each app |
| + needs a person on `CARLOS_STORE_OPERATORS` to run `carlos store grant --app |
| + <a>`, once, per app, forever. |
| + |
| +This spec fixes (2) and makes (1) visible. (1)'s actual fix is #368 and is a |
| +non-goal here (§11). |
| + |
| +### What is already right, and must stay right |
| + |
| +Worth stating plainly, because the temptation is to rebuild things that work. |
| + |
| +**The record's shape is right.** Three blocks for three actors — the member |
| +declares, an operator grants, the console's walk records what it provisioned — |
| +is exactly the tri-state a waiting member needs, and `null`-or-present is what |
| +makes "declared, waiting" distinguishable from "provisioned" |
| +(`internal/objstore/objstore.go`, `Record`'s doc comment). |
| + |
| +**The status door already answers the right question.** `backend` is in the |
| +reply *specifically* so a member can tell "queued behind an operator" from |
| +"this deployment has no object storage and never will" |
| +(`internal/console/apistore.go:105-112`). That reasoning is this spec's |
| +reasoning, one level up. |
| + |
| +**The metering is already built, and this spec adds none.** The economics pass |
| +walks every app's store record daily, sizes the provisioned bucket |
| +(`sizeProvisionedStore`, `internal/console/economicspass.go:990`), records |
| +`StoreBytes` per app and distinguishes "could not measure" from "zero" with |
| +`StoreUnswept`. That rolls into `MeterStorageGBMonth` on |
| +`usage/accounts/<acct>/<month>.json`, which `PriceAccount` already prices at |
| +`StorageGBMonthUSD` against a free tier defaulting to 1 GB |
| +(`internal/economics/scenario.go:162`). **"Unlimited but metered" is therefore a |
| +statement about what already happens, not work to be done.** When the credit |
| +card arrives it reads a statement that has been written every day since. |
| + |
| +**The money gate is right in principle.** `grant` creates a bucket, an IAM |
| +user, an inline policy and an access key in the *deployment's* cloud account. |
| +`internal/console/apistore.go:14` calls it the deployment's first explicit |
| +operator-identity gate. Nothing here removes that gate. It moves the decision |
| +it guards from per-app to per-account, so it is made deliberately once instead |
| +of being a queue nobody is watching. |
| + |
| +## 2. The shape, in one paragraph |
| + |
| +An account applies for object storage. An operator approves the account. From |
| +then on, any app in that account gets a store from `carlos store create` alone |
| +— the walk runs inline and the member never learns the word "grant". The |
| +per-app operator `grant` verb stays exactly as it is, for unapproved accounts, |
| +for `--bucket` adoption, and for repair. |
| + |
| +## 3. Rulings taken in the brainstorm |
| + |
| +- **(ruling)** The entitlement is **per account**, not per app. The code |
| + already agrees: `MintedBucket` is `d.BucketPrefix + acct + "-" + app` |
| + (`internal/objstore/walk.go:62`) and `UserName` is |
| + `"carlos-store-" + acct + "-" + app` (`walk.go:70`), so every bucket and |
| + every principal this deployment mints is already named into its account. |
| +- **(ruling)** Approval entitles an account to **unlimited stores, metered**. |
| + No quota, no bucket cap. §10 records the risk this accepts. |
| +- **(ruling)** **Account owners only** may apply. Applying is an account-level |
| + commitment and will later be a billing one, so it sits with the authority |
| + that already gates transfer, delete and destroy (`RoleOwner`, |
| + `internal/console/store.go:573`). |
| +- **(ruling)** In an approved account, `carlos store create` **provisions |
| + inline** — one command, no second step. This mirrors `carlos email enable`, |
| + which already declares, ensures and delivers in one verb. |
| + |
| +## 4. The entitlement record: `accounts/<sqid>/store.json` |
| + |
| +A new object, sibling to `accounts/<sqid>/account.json` and parallel in spirit |
| +to the per-app `apps/<acct>/<app>/store.json`. |
| + |
| +```go |
| +const EntitlementSchema = 1 |
| + |
| +// Entitlement is accounts/<sqid>/store.json — two blocks for two actors |
| +// (an owner applies, an operator decides), the account-level twin of Record's |
| +// three-for-three. |
| +type Entitlement struct { |
| + Schema int `json:"schema"` |
| + Account string `json:"account"` |
| + Applied *Applied `json:"applied,omitempty"` |
| + Decision *Decision `json:"decision,omitempty"` |
| +} |
| + |
| +type Applied struct { |
| + By string `json:"by"` // the owner's address |
| + At time.Time `json:"at"` |
| + Note string `json:"note,omitempty"` // what they want it for, free text |
| +} |
| + |
| +type Decision struct { |
| + Approved bool `json:"approved"` |
| + By string `json:"by"` // the operator's address |
| + At time.Time `json:"at"` |
| + Reason string `json:"reason,omitempty"` // required on a decline |
| +} |
| +``` |
| + |
| +**(decision) State is derived, never stored.** No object = never applied. |
| +`applied` with no `decision` = pending. `decision` present = decided, and |
| +`Approved` says which way. This is the same null-or-present tri-state |
| +`Record` uses, and for the same reason: a `state string` field can disagree |
| +with the blocks beside it, and then two readers disagree about the account. |
| + |
| +**(decision) A declined owner may re-apply.** `apply` overwrites `applied` and |
| +clears `decision`, under CAS. Circumstances change and a permanent no is a |
| +support ticket. The anti-spam control is the one already in the codebase — the |
| +shared `write` budget keyed on the address, exactly as `handleAPIStoreCreate` |
| +uses it (`apistore.go:184`) — not a special rule invented here. |
| + |
| +**(decision) `Bucket`-style CAS via a new `objstore.GetEntitlement` / |
| +`PutEntitlement` pair**, mirroring the existing `Get`/`Put` (`objstore.go:84`, |
| +`:108`) including their `(nil, "", nil)`-means-absent idiom, so callers that |
| +already know one know the other. |
| + |
| +**Why the bucket and not the console database.** `MaterializeAccounts` is |
| +explicit that the bucket is the control plane and the console is not the only |
| +minter: `carlos accounts create` writes `accounts/<sqid>/account.json` and a |
| +registry row without ever opening the console database, and the console |
| +catches up from those objects (`internal/console/materialize.go:14-30`). An |
| +entitlement held only in console SQL would be invisible bucket-side and would |
| +not survive a console rebuilt by materialisation. |
| + |
| +**Why a new object and not a field on `account.json`.** That manifest is read |
| +by boxes and by the edge (`internal/edge/storedoor.go:358`, |
| +`domains.AccountManifest`). Widening it makes every box parse entitlement data |
| +no box needs. |
| + |
| +## 5. Authority |
| + |
| +| Verb | Who | Gate | |
| +|---|---|---| |
| +| apply | account **owner** | member gate, then `RoleOwner` | |
| +| approve / decline | deployment operator | `CARLOS_STORE_OPERATORS` | |
| +| approvals (list) | deployment operator | `CARLOS_STORE_OPERATORS` | |
| +| grant (per-app, unchanged) | deployment operator | `CARLOS_STORE_OPERATORS` | |
| + |
| +**(decision) Approving reuses `CARLOS_STORE_OPERATORS` rather than minting a |
| +second list.** It is the same authority — spending this deployment's cloud |
| +bill — and `MailOperators`' doc comment (`internal/console/server.go:577-585`) |
| +sets the precedent for when a *separate* list is warranted: when the authority |
| +is over a different thing (sending reputation, not money). This is the same |
| +thing. Empty stays fail-closed. |
| + |
| +**The 404-before-403 discipline carries over.** On the per-account doors the |
| +member gate runs first, so a stranger gets the same 404 as for an unknown |
| +account and learns nothing (`apistore.go:15-17`). The `approvals` list door has |
| +no account in its path, so it is a plain operator door that 404s non-operators, |
| +following `authcli.go:696`'s sibling idiom. |
| + |
| +**`storeOperator`'s exact-match rule is load-bearing and must be reused, not |
| +re-implemented.** Its doc comment records why `strings.EqualFold` is wrong |
| +here: U+017F (ſ) folds to "s" and survives `ToLower`, so `owner@example.teſt` |
| +would match `owner@example.test` and be handed the authority to spend money |
| +(`apistore.go:360-375`). New doors call `s.storeOperator`. |
| + |
| +## 6. What changes at declare time |
| + |
| +`handleAPIStoreCreate` keeps every gate and every refusal it has today, in the |
| +same order, and gains one branch at the end. |
| + |
| +After the declaration is written and its CAS has succeeded, if **all** of: |
| + |
| +- the deployment has a backend (`s.cfg.StoreProvisioner != nil`), and |
| +- the account's entitlement is approved, and |
| +- the record is not already granted, |
| + |
| +then the handler runs the same `objstore.Grant` the grant door runs, inline, |
| +with the same `storeWalkGates` and `storeSealKey` checks that door makes |
| +(`apistore.go:383-430`), and returns the provisioned reply. Otherwise: today's |
| +behaviour, byte for byte. |
| + |
| +**(decision) The grant block records why it exists.** `objstore.GrantBlock` |
| +gains `Source string`, mirroring `mail.GrantBlock.Source`, whose doc comment |
| +already established the vocabulary and the reason — *"records WHY the grant |
| +exists ("tier:free", "tier:pro", "operator") so a cap can be recomputed when a |
| +tier changes without silently discarding an operator's deliberate override"* |
| +(`internal/mail/mail.go:43-45`). Values here: `"operator"` for today's per-app |
| +path, `"account-approval"` for the new one. `By` is set to the operator who |
| +approved the **account** — a real person who really did authorise this spend, |
| +without the record pretending they ran a per-app grant. |
| + |
| +> **`Source` MUST be additive, and a rename here is a fleet-wide silent |
| +> failure.** `release.PurgeApp` parses `grant` and `provisioned` out of these |
| +> bytes with its own local struct — `release` cannot import `objstore`, |
| +> because `objstore` imports `release` — and refuses the box-side auto-purge |
| +> when either block is present. `Record`'s PARSER LIST comment says additive |
| +> changes are safe and these two key names are not free to move; |
| +> `TestPurgeGuardSeesOurRecord` in `walk_test.go` exists to fail loudly. |
| +> Adding a field inside `grant` is safe. Renaming `grant` is not. |
| + |
| +A record written before `Source` existed decodes it as `""`, which reads |
| +correctly as "granted by the operator path, before we recorded why" — the same |
| +additive-field reasoning `mail.Provisioned.PauseKind` documents. |
| + |
| +## 7. When inline provisioning fails |
| + |
| +`create` is cheap today and always succeeds. It will not be, and this is the |
| +part most likely to be got wrong. |
| + |
| +**(decision) Declaration first, committed, then walk.** The order is: |
| + |
| +1. Write the declaration and commit its CAS. This is the state an operator |
| + needs in order to act, and it must survive everything after it. |
| +2. Run the walk. |
| +3. If the walk fails — AWS unreachable, the console's role missing |
| + `s3:CreateBucket` or `iam:CreateUser`, a bucket name already taken — answer |
| + with the declaration reported as landed, the provisioning failure named, and |
| + a **non-2xx status**; the CLI exits non-zero. |
| + |
| +The record is then left declared-not-granted: a state the system already |
| +understands, that `carlos store status` already renders, and that a re-run of |
| +`create` or an operator `grant` resumes, because the walks are idempotent by |
| +construction (the package doc's "idempotent walks", and `Grant`'s own "a re-run |
| +never re-chooses the bucket", `walk.go:144`). **No new stuck state is |
| +introduced**, and the record never claims a bucket that does not exist. |
| + |
| +The failure sentence follows the existing rule: the caller gets a fixed |
| +sentence, the detail is logged, because a store error carries bucket names, |
| +endpoints and request ids (`apistore.go`'s existing `log.Printf` + |
| +`jsonError` pairing). |
| + |
| +## 8. Surface |
| + |
| +### CLI |
| + |
| +``` |
| +carlos store apply --account <a> [--note "..."] owner |
| +carlos store approvals operator |
| +carlos store approve --account <a> operator |
| +carlos store decline --account <a> --reason "..." operator |
| +``` |
| + |
| +`carlos store status --app <a>` gains one line naming the account's |
| +entitlement state, so a waiting member distinguishes: |
| + |
| +- *not applied* — "this account is not approved for object storage — `carlos |
| + store apply`" |
| +- *pending* — "applied by <owner> on <date>, waiting for a deployment operator" |
| +- *approved* — nothing extra; their store provisioned on declare |
| +- *declined* — the reason, and that they may apply again |
| + |
| +This is the same no-infra-access reasoning that put `backend` in the reply: the |
| +command alone must complete the job (AGENTS.md), and "declared, nothing |
| +happening" with no way to tell those four apart fails it. |
| + |
| +**(decision) The entitlement rides on the existing store reply** (`storeReply`, |
| +`apistore.go:113`) as a new `entitlement` key, rather than requiring a second |
| +round trip. `storeReply` is deliberately the ONE answer shape both doors give |
| +so they can never drift; a second shape would reintroduce exactly the drift |
| +that comment is guarding against. |
| + |
| +### Console |
| + |
| +- **Account settings** — an Object storage panel. Owners see the state and, when |
| + not applied, an apply control with the optional note. Non-owner members see |
| + the state read-only. This is where `appsettings.go:829`'s existing "This |
| + console has no storage backend configured" refusal already lives, so the two |
| + sentences sit together. |
| +- **Operator view** — pending applications, oldest first, each with account, |
| + applicant, note, and approve/decline. |
| + |
| +## 9. Notification |
| + |
| +This is the half that actually fixes the reported bug: James waited four days |
| +because nothing told anyone he was waiting. It is therefore **not optional**. |
| + |
| +**It is also more constrained than it looks.** `alertRecipients` mails the |
| +console's OWN operators (`s.store.Operators()`) and needs a per-operator |
| +Keymail OAuth grant, splitting off those it cannot write as into |
| +`run.missing` (`internal/console/alertwatch.go:151-168`). `CARLOS_STORE_OPERATORS` |
| +is a list of addresses with no such grant implied. **The two populations are |
| +not the same and may not overlap at all.** |
| + |
| +**(decision) The console queue is the primary surface; mail is the nudge.** |
| + |
| +- On apply: best-effort keymail to each address that is on |
| + `CARLOS_STORE_OPERATORS` **and** has a grant token. Addresses on the list |
| + with no grant are logged the way `run.missing` already is — never a silent |
| + drop, and never a failed write. |
| +- On decision: best-effort keymail to the applying owner. |
| +- A send failure **never** fails the write. The application is durable in the |
| + bucket the moment it is written; mail is a courtesy on top of it. |
| +- Because mail may reach nobody, the operator view carries a pending count |
| + where an operator will actually see it, so the queue is discoverable without |
| + any mail at all. |
| + |
| +## 10. The risk this accepts, deliberately |
| + |
| +"Unlimited, metered" means **AWS's soft limit of ~100 buckets per account |
| +becomes the deployment's real ceiling**, discovered at provision time as a raw |
| +AWS error in front of whichever tenant happens to declare next. |
| + |
| +**(decision) Do not gate on it. Surface it.** The approvals view shows how many |
| +buckets the deployment has minted, so an operator approving the account that |
| +takes it past a comfortable threshold can see it before saying yes rather than |
| +after. A hard cap is a quota by another name and was ruled out in §3. |
| + |
| +## 11. Non-goals |
| + |
| +- **Credit-card gating.** Later. The statement it will read is already written |
| + daily (§1); nothing here needs to anticipate its shape. |
| +- **Fixing `CARLOS_STORE_BACKEND` as a root env edit.** That is #368. This spec |
| + makes the consequence visible and adds no new root env vars of its own. |
| +- **Quotas or bucket caps.** Ruled out in §3. |
| +- **A generalised capability-keyed entitlement system.** Email's tier |
| + vocabulary (`"tier:free"`, `"tier:pro"`) suggests where this ends up, but |
| + building the general machine before its second caller exists means guessing |
| + at email's requirements. `Entitlement` is shaped so that migration is a |
| + later addition, not a rewrite; no speculative capability column ships now. |
| + |
| +## 12. Testing |
| + |
| +Following the primitive spec's §11 split — walks against `MemProvisioner`, |
| +doors against the console fixture, printers against literal replies with no |
| +stub server in the loop. |
| + |
| +**Record.** Round-trip; the four derived states; absent reads as |
| +`(nil, "", nil)`; CAS conflict on concurrent apply. |
| + |
| +**Authority.** A non-owner member's apply is refused. A non-operator's |
| +`approvals` gets 404, not 403. A stranger still gets the account gate's 404 on |
| +the per-account doors. `storeOperator`'s ſ-vs-s case has a test — the existing |
| +one must keep passing and the new doors must route through the same function. |
| + |
| +**Declare-time branches**, the heart of it: |
| + |
| +- approved + backend → provisioned inline, `Source == "account-approval"`, |
| + `By ==` the approving operator. |
| +- approved + **no** backend → declared, today's backend warning, nothing |
| + minted. |
| +- not approved → declared, pointed at `apply`, nothing minted. |
| +- already granted → the walk does not run a second time. |
| +- prefix-change guard still refuses in every one of the above. |
| + |
| +**Failure.** A walk that fails after the declaration commits leaves the |
| +declaration present, answers non-2xx, and a re-run resumes to a provisioned |
| +store with the same bucket. |
| + |
| +**Regression.** `TestPurgeGuardSeesOurRecord` still passes with `Source` |
| +present — the guard must still see `grant` and refuse the purge. |
| + |
| +**Printers.** Each of the four entitlement states renders its own sentence, and |
| +the approved state adds no line. Tested against a literal reply, per |
| +`store_test.go`'s existing pattern. |
| + |
| +## 13. Rollout order |
| + |
| +1. `Entitlement` record + accessors + `GrantBlock.Source` (additive, inert). |
| +2. Console doors: apply, approvals, approve, decline. Still no behaviour change |
| + at declare time. |
| +3. The declare-time branch. This is the moment behaviour changes; everything |
| + before it is dormant. |
| +4. CLI verbs and the status line. |
| +5. Console UI and notification. |
| +6. `docs/site/cli.md`. |
| + |
| +Each stage builds and tests clean on its own, and stages 1-2 are safe to land |
| +before the branch in stage 3 exists. |