carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev d94bcfd1d3dbca4a867fc8352c43ef86fbe43d79 parent f975b47
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.