carlos / platform Public

spec: object-storage primitive design (#55)

Apps declare a store, operator grants, console provisions (S3+IAM),
credentials delivered as sealed config vars; two-key rotation with
explicit finish; purge sweeps the store via the console door.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d41b501e6e06573fa3685d1bb9d19a6cbf0d1ddd parent db76025
1 file changed, +369 −0
  • docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md +369 −0
diff --git a/docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md b/docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
new file mode 100644
index 000000000..e18424b1a
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
@@ -0,0 +1,369 @@
+# Object-storage primitive — design
+
+Issue #55. Apps running on CARLOS keep needing object storage, and today
+every one solves it by hand: create a bucket, write an IAM policy, mint
+keys out-of-band, paste endpoint + credentials into env. The primitive:
+an app declares "I need a store", the operator grants it for the
+deployment, and CARLOS provisions the backend, mints scoped credentials,
+and delivers the coordinates to the app's units as env — plus rotation
+and teardown as platform verbs.
+
+Decided with Paul, 2026-08-10. The section-by-section decisions and the
+alternatives they beat are inline; the shape follows the capability
+model's one-sentence security split (a need that only shapes the app's
+own world is the app's to declare; a need that consumes shared,
+operator-paid resources is the operator's to grant).
+
+## 1. Scope and v1 success criterion
+
+**v1 is done when titogo's file-upload feature works in production on
+the tito deployment via the primitive**: store declared, the dangling
+`titogo-uploads` bucket adopted, prefix-scoped credentials minted, and
+`TITO_S3_*` env delivered to titogo's instances — with zero titogo code
+changes. AWS is the only real backend in v1; Minio appears only as the
+CI contract test (§11) that keeps "S3-compatible" honest and marks the
+seam where Hetzner/R2/Minio provisioning arrives later.
+
+Control plane only, never the data plane. Apps keep their own S3 client
+and talk to the backend directly; a byte-proxy in the data path is a
+hop, a SPOF, and a pager, and is permanently out of scope. CARLOS owns
+provisioning, credentials, delivery, rotation, teardown.
+
+**The delivered contract** — everything an app may assume about its
+store: the S3 wire API, Put / Get / Delete / List on its own bucket,
+with the delivered credentials, against the delivered endpoint. Nothing
+more: no versioning, no SSE-KMS, no conditional-write or
+list-consistency promises. The contract is pinned by the Minio CI test,
+and it is deliberately worded per-*credential*, not per-*app*, so
+per-instance scoped credentials can arrive later without re-wording.
+
+## 2. The record: `apps/<acct>/<app>/store.json`
+
+One new bucket object per store, under the app prefix, CAS
+read-modify-write through the same If-Match discipline as
+`config.json`. Three blocks matching the three actors:
+
+```json
+{
+ "schema": 1,
+ "app": "titogo",
+ "declared": {
+ "env_prefix": "TITO_S3",
+ "at": "2026-08-10T12:00:00Z", "by": "member@example.com"
+ },
+ "grant": {
+ "backend": "aws",
+ "bucket": "titogo-uploads",
+ "at": "2026-08-10T12:05:00Z", "by": "paul@rslw.com"
+ },
+ "provisioned": {
+ "bucket": "titogo-uploads",
+ "region": "eu-west-1",
+ "endpoint": "",
+ "iam_user": "carlos-store-tito-titogo",
+ "user_arn": "arn:aws:iam::…:user/carlos-store/carlos-store-tito-titogo",
+ "key_id": "AKIA…",
+ "prev_key_id": "",
+ "at": "2026-08-10T12:05:07Z"
+ }
+}
+```
+
+- `declared` — the member's half, written by `carlos store create`.
+ `env_prefix` is the one delivery knob (§7); default `CARLOS_STORE`,
+ charset `^[A-Z][A-Z0-9_]{0,31}$`.
+- `grant` — the operator's half, written by the grant door (§4).
+ `bucket` set = adopt this existing bucket; empty = mint the name.
+ `backend` is recorded so the record is self-describing even though v1
+ only ever writes `"aws"`.
+- `provisioned` — written only by the console after a successful
+ provisioning walk. It records coordinates and the access-key **id**;
+ the secret key exists nowhere except sealed inside `config.json`.
+
+**Why an app-level record and not a release-manifest capability.** The
+capability model's removal semantics — manifest stops declaring it,
+converge pass removes it — are exactly wrong for a bucket full of data:
+a rollback to a pre-store version would read as a teardown instruction.
+A store is a property of the app, not of a build (the same reasoning
+that put `exec_delivery` in the config plane). Store lifecycle is
+explicit verbs, never inferred from manifests; rollbacks are inert.
+
+## 3. Package
+
+`internal/objstore`, parallel to `internal/appconfig` and
+`internal/release`. It owns the record shape and CAS helpers, the
+`Provisioner` interface and its AWS implementation, the provisioning /
+rotation / teardown walks, and the env-set rendering. The console is
+its only production caller; the box side never imports the provisioner
+(boxes gain no backend authority — §9 is built around that).
+
+```go
+type Provisioner interface {
+ EnsureBucket(ctx, name string) (region string, err error)
+ EnsureUser(ctx, name, path, bucketARN string) (arn string, err error)
+ MintKey(ctx, user string) (id, secret string, err error)
+ DeleteKey(ctx, user, id string) error // NoSuchEntity == success
+ EmptyBucket(ctx, name string) error // paginated, resumable
+ DeleteBucket(ctx, name string) error // NoSuchBucket == success
+ DeleteUser(ctx, user string) error // keys+policy first; NoSuchEntity == success
+}
+```
+
+Every method is idempotent: "already exists" and "already gone" are
+successes, because every walk that calls them must be re-runnable (§5).
+
+## 4. Actors and doors
+
+- **Member declares**: `carlos store create -app titogo
+ [-env-prefix TITO_S3]` → `POST /api/cli/apps/{acct}/{app}/store`.
+ Ordinary member auth (the `apiAccountApp` token + byte-identical-404
+ gate). Declaring writes `declared` and provisions nothing.
+- **Operator grants**: `carlos store grant -app titogo
+ [-bucket titogo-uploads]` → operator-gated console endpoint, same
+ gate class as production-mode flag flips: operator identity +
+ `requireFreshAuth`, ordered after the owner-404 gate per the console
+ gate-order rule. The grant handler runs the provisioning walk inline
+ (§5) — grant and provision are one verb, because a recorded desire
+ with no acted-on effect is the provisioned-but-not-plumbed gap this
+ issue exists to close.
+- **Console provisions**: the console is the single control-plane actor
+ holding backend authority (§6). Boxes need zero new authority;
+ delivery rides existing rails end to end.
+
+**Why console-provisions beat the alternatives.** A box-side converge
+pass would put IAM-admin credentials on every box and need single-actor
+election for minting. An operator-run CLI with local admin creds would
+make provisioning a manual runbook step again. The console is already
+the deployment's write door, already bumps `control/serial`, already
+audits, and already holds `CARLOS_SECRETS_PUBKEY` (the safe,
+encrypt-only half) for sealing.
+
+**Why sync-idempotent and not a reconciler loop.** The grant handler
+does the backend calls right there; every step is check-then-create, so
+a half-failed walk is repaired by running the verb again, and the error
+surfaces at the operator's terminal where a human is already standing.
+A console reconciler loop would self-heal without a retry, but adds an
+always-on loop holding admin authority for a verb an operator runs a
+handful of times a year, and moves failures from an exit code to
+"check status later". Idempotency gives us retry-to-repair without
+either cost.
+
+## 5. The provisioning walk
+
+Run by the grant handler, in order, each step check-then-create:
+
+1. **Resolve the record**: `store.json` must have `declared`; write
+ `grant` (CAS).
+2. **Ensure bucket** — minted name
+ `<CARLOS_STORE_BUCKET_PREFIX><acct-slug>-<app>` unless `grant.bucket`
+ names an adopted one. Bucket-per-app, not prefix-per-app: a hard
+ blast-radius wall per app, teardown is one bucket delete, and the
+ IAM policy is a two-statement bucket scope instead of the fiddly
+ per-backend prefix-condition dialect the issue warns about.
+3. **Ensure IAM user** under path `/carlos-store/`, inline policy
+ scoped to exactly that bucket (`s3:ListBucket` on the bucket ARN;
+ `s3:GetObject/PutObject/DeleteObject` on `arn:…:::<bucket>/*`).
+4. **Mint access key.**
+5. **Write the env set** into `apps/<acct>/<app>/config.json` through
+ the existing `appconfig` setters (§7). Coordinates as `kind:"env"`,
+ both key halves as `kind:"secret"` sealed with
+ `CARLOS_SECRETS_PUBKEY`.
+6. **Write `provisioned`** into `store.json` (CAS).
+7. **Audit + `bumpSerial`.** Boxes converge within seconds: config-gen
+ touch → 2s serial poll → `appconfig.Sync`/`SyncExec` → restart.
+
+A failure at any step exits non-zero with the step named; re-running
+the grant resumes (steps 2–4 find their resources existing, step 5–6
+rewrites are CAS'd). Nothing is provisioned that isn't recorded within
+the same walk, and the walk is ordered so the *record of the secret*
+(step 5) lands before the record that claims success (step 6).
+
+## 6. Backend configuration and authority
+
+Console environment only — the inert-until-configured convention:
+
+- `CARLOS_STORE_BACKEND` — `aws` is the only accepted value in v1.
+ Unset: every store verb answers 503 "object storage is not configured
+ for this deployment".
+- `CARLOS_STORE_REGION` — bucket + credential region.
+- `CARLOS_STORE_ENDPOINT` — empty for AWS; recorded into
+ `provisioned.endpoint` and delivered (§7) when set.
+- `CARLOS_STORE_BUCKET_PREFIX` — minted-name prefix, e.g.
+ `carlos-store-tito-`. Required when the backend is set; refusing to
+ default it keeps two deployments in one AWS account from colliding.
+
+The console role gains, in pinfra tofu: IAM verbs
+(`CreateUser/DeleteUser/PutUserPolicy/DeleteUserPolicy/CreateAccessKey/
+DeleteAccessKey/ListAccessKeys`) restricted to the `/carlos-store/`
+user path, and S3 admin verbs
+(`CreateBucket/DeleteBucket/ListBucket/DeleteObject`) on
+`arn:aws:s3:::<CARLOS_STORE_BUCKET_PREFIX>*` **plus each adopted
+bucket's ARN, added explicitly when adopting** (the `titogo-uploads`
+grant lands with its ARN in the same tofu change). The role can never
+touch users or buckets outside those scopes.
+
+## 7. Credential delivery: the env surface
+
+Canonical names, one prefix knob. With `env_prefix` `P` (default
+`CARLOS_STORE`), the walk writes:
+
+| Var | Kind | Value |
+|---|---|---|
+| `P_BUCKET` | env | provisioned bucket name |
+| `P_REGION` | env | provisioned region |
+| `P_ENDPOINT` | env | only written when non-empty (non-AWS) |
+| `P_ACCESS_KEY` | secret | access-key id, sealed |
+| `P_SECRET_KEY` | secret | secret key, sealed |
+
+titogo sets `env_prefix: "TITO_S3"` and its existing
+`TITO_S3_BUCKET/REGION/ACCESS_KEY/SECRET_KEY` (+ optional
+`TITO_S3_ENDPOINT`) readers are fed with zero code changes; new apps
+get `CARLOS_STORE_*` by default. The access-key id is sealed alongside
+the secret — it isn't sensitive alone, but delivering the pair the same
+way keeps rotation a single-shaped rewrite.
+
+These are ordinary `config.json` vars: materialization, restart,
+per-route env files, exec spawn-time merge, and purge sweep are all
+existing, tested rails, and `carlos env list` shows the store vars
+where a member already looks. The trade: a member's
+`carlos env unset TITO_S3_BUCKET` breaks their own store wiring until
+the next grant/rotate rewrite. v1 accepts that (it is their own app,
+and `carlos store status` shows the drift by comparing config against
+`provisioned`); var-ownership marking is deliberately not built.
+
+## 8. Rotation: two keys, explicit finish
+
+`carlos store rotate -app X` — member-facing (it touches only the
+app's own store; the no-infra rule says the member must not need an
+operator to cycle a credential):
+
+1. If `prev_key_id` is pending from an earlier rotate, delete it now
+ (this is also what makes the AWS two-keys-per-user limit the state
+ machine: at most one current + one pending-delete).
+2. Mint the new key; reseal `P_ACCESS_KEY`/`P_SECRET_KEY` (CAS).
+3. `store.json`: `prev_key_id` ← old `key_id`, `key_id` ← new. Audit,
+ bump serial.
+
+The old key **stays active** until `carlos store rotate -finish -app X`
+deletes it. No guessing about convergence, no window where a
+not-yet-restarted unit holds a dead credential; the platform never
+claims a rotation completed that it cannot observe. `carlos store
+status` and the console card nag while a finish is pending. An
+operator who needs a hard cut (leaked key) runs `rotate` then `-finish`
+back to back and eats the restart race knowingly.
+
+## 9. Teardown
+
+**App purge sweeps the store.** The 30-day trash cooloff is already
+the platform's "are you sure"; a purged app keeping a paid,
+credentialed bucket is the dangling-estate failure mode this issue
+exists to kill. Trash itself (the fuse's start) revokes nothing: routes
+stop, data survives until purge, restore keeps the store.
+
+The split that makes it honest: auto-purge runs **box-side**
+(`domains.Reconcile`), and boxes have no backend authority — so:
+
+- **Box-side `PurgeApp` skips** any app whose `store.json` carries a
+ `provisioned` block, logging one line per pass naming the console
+ door as the fix. The app stays trashed and unreachable; only the
+ final sweep waits.
+- **Console "Delete now"** (`handlePurgeApp`) does the full job inline,
+ before the prefix sweep: `EmptyBucket` → `DeleteBucket` →
+ `DeleteUser` (keys + policy first), all idempotent, then the normal
+ `release.PurgeApp`. Teardown failure aborts the purge — a retry
+ re-enters cleanly — rather than sweeping `store.json` and stranding
+ an unrecorded bucket.
+- **`carlos store destroy -app X`** — operator door, fresh auth, and
+ the CLI requires typing the bucket name back — tears down the store
+ while keeping the app: backend teardown, delete the store env vars
+ from `config.json`, delete `store.json`, bump serial. This is the
+ undo for adoption mistakes and the migration verb.
+
+**Ordering rule, stated once**: any path that destroys backend state
+reads `store.json` first and deletes it last. A missing or unreadable
+record must never be interpreted as "nothing to tear down" on a path
+that then deletes the record's prefix — hence the box-side skip.
+
+## 10. CLI and console surface
+
+Console API, following the #113 three-layer pattern (handler in
+`internal/console`, `apiSession` method in `apimode.go`, verb file
+dispatched from `main.go`; API-mode only, direct-bucket mode refused
+with a verb-specific message):
+
+| Endpoint | Gate | Verb |
+|---|---|---|
+| `POST /api/cli/apps/{acct}/{app}/store` | member | `carlos store create` |
+| `GET …/store` | member | `carlos store status` |
+| `POST …/store/rotate` | member | `carlos store rotate` |
+| `POST …/store/rotate/finish` | member | `carlos store rotate -finish` |
+| `POST …/store/grant` | operator + fresh auth | `carlos store grant` |
+| `DELETE …/store` | operator + fresh auth | `carlos store destroy` |
+
+`carlos store status` reports: declared/granted/provisioned state, the
+delivered env names, key id + age, pending-finish nag, and drift (a
+store var missing from `config.json`). Every write handler audits and
+bumps the serial. The console app page gains a read-only **Store**
+card (backend, bucket, key age, pending-finish nag); management stays
+CLI-first in v1.
+
+## 11. Testing
+
+- **Provisioner fake** with per-step failure injection: for each step
+ of the walk, fail it once, re-run the grant, assert completion and
+ exactly-one of every resource. Same for teardown and rotate.
+- **CAS conflict** on `store.json`: two concurrent writers, second
+ gets `ErrCASConflict`.
+- **Console gate order**: non-member gets the byte-identical 404 on
+ every store endpoint before any fresh-auth redirect; member cannot
+ reach grant/destroy.
+- **Rotate state machine**: rotate → rotate (pending prev) → finish;
+ the two-key ceiling is never exceeded.
+- **Purge split**: box-side purge skips a provisioned store and logs;
+ console-side purge tears down before sweeping; teardown failure
+ leaves `store.json` intact.
+- **Minio contract test** (CI, dockerized `minio` + `mc admin` minting
+ a bucket-scoped user): Put/Get/Delete/List succeed through the
+ delivered-env client path; a cross-bucket access is denied. This
+ pins the delivered contract (§1) and doubles as the local dev-loop
+ story.
+
+The standard gate applies: `go build ./...`, `go vet ./...`,
+`go test ./... -count=1`, `gofmt -l` clean, `GOOS=darwin` compile.
+
+## 12. Rollout order
+
+Wire-shape review: `store.json` is a new additive object; the
+delivered env vars are ordinary config vars every deployed box already
+materializes. One real ordering constraint:
+
+1. **Platform binaries to both boxes first** — boxes must know the
+ purge-skip (§9) before any store exists, or an old box's auto-purge
+ could sweep `store.json` and strand a bucket. (Consistent with the
+ standing rule: platform binaries first, feature-bearing writes
+ second.)
+2. Console binaries (the only actor that provisions).
+3. pinfra tofu: console-role IAM path grant, `titogo-uploads` ARN,
+ console env (`CARLOS_STORE_*`) on tito.
+4. `carlos store create -app titogo -env-prefix TITO_S3` (member) and
+ `carlos store grant -app titogo -bucket titogo-uploads` (operator)
+ on the tito deployment; verify uploads end-to-end in titogo.
+
+## 13. Non-goals (v1)
+
+- **Per-instance scoped credentials** — the contract is worded
+ per-credential so they can arrive later without app changes; the
+ mechanics (per-prefix policy dialects, credential-estate growth) are
+ explicitly deferred.
+- **Hetzner / R2 / Minio provisioning** — the `Provisioner` seam and
+ the Minio contract test mark the door; no second implementation
+ ships in v1.
+- **Per-environment stores** — one store per app per deployment;
+ `environments/<name>.json` overlays can override the env vars by
+ hand if someone truly needs a split before the primitive grows one.
+- **Data-plane anything** — no proxy, no signed-URL service, no
+ metering/quotas.
+- **Var-ownership marking** in `config.json` (§7 trade accepted).
+- **Migrating the tito litestream replica estate** — existing
+ hand-made replica buckets stay grandfathered; only `titogo-uploads`
+ is adopted.