carlos / platform Public

plan: object-storage primitive — 13 tasks, TDD, ReapKeys crash-repair

Plan self-review surfaced two walk-level leaks (grant re-mint on healthy
store; stray keys after mint-then-crash in grant AND rotate) — fixed by
a healthy-store short-circuit plus a ReapKeys verb on the Provisioner,
reflected back into the spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1c5f7acb393a0018f6fedea158157f1c442b0f81 parent c06997f
2 files changed, +875 −0
  • docs/superpowers/plans/2026-08-10-object-storage-primitive.md +870 −0
  • docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md +5 −0
diff --git a/docs/superpowers/plans/2026-08-10-object-storage-primitive.md b/docs/superpowers/plans/2026-08-10-object-storage-primitive.md
new file mode 100644
index 000000000..ea810a912
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-10-object-storage-primitive.md
@@ -0,0 +1,870 @@
+# Object-Storage Primitive Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Issue #55 — apps declare an object store, an allowlisted operator grants it, the console provisions bucket + prefix-scoped IAM credentials and delivers them as sealed config vars; rotation and teardown are platform verbs.
+
+**Architecture:** New `internal/objstore` package owns the `store.json` record, a `Provisioner` seam (AWS impl + in-memory fake), and four idempotent walks (Grant, Rotate, RotateFinish, Teardown) that the console calls synchronously. Delivery rides the existing `internal/appconfig` rails untouched. `release.PurgeApp` gains a refuse-if-provisioned guard so box-side auto-purge can never strand a bucket.
+
+**Tech Stack:** Go, aws-sdk-go-v2 (s3 + new iam dependency), existing `release.Store` CAS discipline, existing appconfig seal/deliver crypto, Minio in CI for the contract test.
+
+**Spec:** `docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md` — read it before starting any task. Section references (§N) below point there.
+
+## Global Constraints
+
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, `gofmt -l` clean for touched files, and `GOOS=darwin go build ./...` compiles (`internal/host` links into the Mac CLI).
+- Sandbox: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off` (except the one `go get` in Task 7, which needs `GOPROXY` unset and the sandbox disabled).
+- Wire shapes are additive: `store.json` is a new object; never change the shape of any existing bucket object.
+- Absence idiom: a missing bucket object is `errors.Is(err, os.ErrNotExist)` on `Store.Get`. CAS losses are `release.ErrCASConflict`. `Store.Put` with `ifMatch=""` is create-only.
+- Console gate order (existing rule): session → CSRF → owner (byte-identical 404) → `requireFreshAuth` LAST.
+- Env var name charset for the prefix: `^[A-Z][A-Z0-9_]{0,31}$`.
+- Every console write handler: rate-limit via `s.cli.write.allow(addr, time.Now())`, `s.audit(...)` on success, `s.bumpSerial(ctx)` only when a box-consumed object changed.
+- Commit after every task with a message shaped like the repo's (`objstore: <what>`); never commit with the gate red.
+- Suggested dispatch models are per-task hints (Paul's delegation policy); the reviewer for console/security tasks is Opus or better.
+
+---
+
+### Task 1: `internal/objstore` — record, keys, validation
+
+**Suggested model:** Sonnet.
+
+**Files:**
+- Create: `internal/objstore/objstore.go`
+- Create: `internal/objstore/objstore_test.go`
+
+**Interfaces:**
+- Consumes: `internal/release` — `release.Store`, `release.AppPrefix(acct, app string) string`, `release.ErrCASConflict`, `release.ValidAccount`, `release.ValidApp`.
+- Produces (later tasks rely on these exact names):
+ - `const Schema = 1`
+ - `type Declared struct { EnvPrefix string; At time.Time; By string }` (json: `env_prefix`, `at`, `by`)
+ - `type GrantBlock struct { Backend string; Bucket string; At time.Time; By string }` (json: `backend`, `bucket,omitempty`, `at`, `by`)
+ - `type Provisioned struct { Bucket, Region, Endpoint, IAMUser, UserARN, KeyID, PrevKeyID string; At time.Time }` (json: `bucket`, `region`, `endpoint,omitempty`, `iam_user`, `user_arn`, `key_id`, `prev_key_id,omitempty`, `at`)
+ - `type Record struct { Schema int; App string; Declared *Declared; Grant *GrantBlock; Provisioned *Provisioned }` (json: `schema`, `app`, `declared,omitempty`, `grant,omitempty`, `provisioned,omitempty`)
+ - `func Key(acct, app string) string` → `release.AppPrefix(acct, app) + "store.json"`
+ - `func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error)` — `(nil, "", nil)` when absent; validates acct/app names first like `appconfig.getIfExists`.
+ - `func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error)` — marshals indented, CAS via etag (`""` = create-only), stamps `rec.Schema = Schema`.
+ - `func ValidEnvPrefix(p string) bool`
+ - `const DefaultEnvPrefix = "CARLOS_STORE"`
+ - `func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string)` — returns `prefix+"_BUCKET"` etc. (`_REGION`, `_ENDPOINT`, `_ACCESS_KEY`, `_SECRET_KEY`).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package objstore
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestKey(t *testing.T) {
+ if got := Key("bab", "titogo"); got != "apps/bab/titogo/store.json" {
+ t.Fatalf("Key = %q", got)
+ }
+}
+
+func TestValidEnvPrefix(t *testing.T) {
+ for p, want := range map[string]bool{
+ "TITO_S3": true, "CARLOS_STORE": true, "A": true,
+ "tito": false, "1X": false, "": false, "_X": false,
+ "TOO_LONG_" + string(make([]byte, 40)): false,
+ } {
+ if ValidEnvPrefix(p) != want {
+ t.Errorf("ValidEnvPrefix(%q) != %v", p, want)
+ }
+ }
+}
+
+func TestEnvNames(t *testing.T) {
+ b, r, e, ak, sk := EnvNames("TITO_S3")
+ if b != "TITO_S3_BUCKET" || r != "TITO_S3_REGION" || e != "TITO_S3_ENDPOINT" ||
+ ak != "TITO_S3_ACCESS_KEY" || sk != "TITO_S3_SECRET_KEY" {
+ t.Fatalf("EnvNames = %q %q %q %q %q", b, r, e, ak, sk)
+ }
+}
+
+func TestGetAbsentIsNilNil(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rec, etag, err := Get(context.Background(), s, "bab", "titogo")
+ if rec != nil || etag != "" || err != nil {
+ t.Fatalf("absent: rec=%v etag=%q err=%v", rec, etag, err)
+ }
+}
+
+func TestPutGetRoundTripAndCAS(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := &Record{App: "titogo", Declared: &Declared{EnvPrefix: "TITO_S3"}}
+ etag, err := Put(ctx, s, "bab", "titogo", rec, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, gotTag, err := Get(ctx, s, "bab", "titogo")
+ if err != nil || got == nil || got.Schema != Schema || got.Declared.EnvPrefix != "TITO_S3" {
+ t.Fatalf("Get = %+v, %v", got, err)
+ }
+ if gotTag != etag {
+ t.Fatalf("etag mismatch %q vs %q", gotTag, etag)
+ }
+ // create-only Put on an existing record loses
+ if _, err := Put(ctx, s, "bab", "titogo", rec, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("create-only over existing: %v", err)
+ }
+ // stale etag loses
+ rec.Grant = &GrantBlock{Backend: "aws"}
+ if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Put(ctx, s, "bab", "titogo", rec, etag); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("stale etag: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail** — `go test ./internal/objstore/ -count=1`; expected: compile error (package empty).
+
+- [ ] **Step 3: Implement `objstore.go`**
+
+```go
+// Package objstore is the object-storage primitive's control plane (issue
+// #55, spec 2026-08-10): the per-app store record, the Provisioner seam the
+// console drives, and the idempotent walks that provision, rotate, and tear
+// down a store. It never touches the data plane — apps talk to their bucket
+// directly with the delivered credentials.
+package objstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const Schema = 1
+
+// DefaultEnvPrefix names the delivered env set when a declaration doesn't
+// choose its own (§7 of the spec).
+const DefaultEnvPrefix = "CARLOS_STORE"
+
+type Declared struct {
+ EnvPrefix string `json:"env_prefix"`
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type GrantBlock struct {
+ Backend string `json:"backend"`
+ Bucket string `json:"bucket,omitempty"` // set = adopt this existing bucket
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type Provisioned struct {
+ Bucket string `json:"bucket"`
+ Region string `json:"region"`
+ Endpoint string `json:"endpoint,omitempty"`
+ IAMUser string `json:"iam_user"`
+ UserARN string `json:"user_arn"`
+ KeyID string `json:"key_id"`
+ PrevKeyID string `json:"prev_key_id,omitempty"`
+ At time.Time `json:"at"`
+}
+
+// Record is apps/<acct>/<app>/store.json — three blocks for three actors
+// (member declares, operator grants, console provisions). The secret key is
+// never here; it exists only sealed inside config.json.
+type Record struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ Declared *Declared `json:"declared,omitempty"`
+ Grant *GrantBlock `json:"grant,omitempty"`
+ Provisioned *Provisioned `json:"provisioned,omitempty"`
+}
+
+func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" }
+
+var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`)
+
+func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) }
+
+// EnvNames renders the delivered variable names for a prefix (§7).
+func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string) {
+ return prefix + "_BUCKET", prefix + "_REGION", prefix + "_ENDPOINT",
+ prefix + "_ACCESS_KEY", prefix + "_SECRET_KEY"
+}
+
+// Get reads the record. Absent is (nil, "", nil) — same idiom as
+// appconfig.getIfExists, because "no store" is a state, not an error.
+func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
+ if !release.ValidAccount(acct) {
+ return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, "", fmt.Errorf("objstore: invalid app %q", app)
+ }
+ raw, etag, err := s.Get(ctx, Key(acct, app))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ return nil, "", err
+ }
+ var rec Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return nil, "", fmt.Errorf("objstore: %s: %w", Key(acct, app), err)
+ }
+ return &rec, etag, nil
+}
+
+// Put writes the record with the caller's CAS position; etag "" is
+// create-only. Every writer is a read-modify-write, so a lost race is
+// release.ErrCASConflict for the caller to surface, never to absorb.
+func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) {
+ rec.Schema = Schema
+ rec.App = app
+ body, err := json.MarshalIndent(rec, "", " ")
+ if err != nil {
+ return "", err
+ }
+ return s.Put(ctx, Key(acct, app), append(body, '\n'), etag)
+}
+```
+
+- [ ] **Step 4: Run tests, verify pass** — `go test ./internal/objstore/ -count=1`; expected: PASS.
+- [ ] **Step 5: Gate + commit** — full gate from Global Constraints, then `git add internal/objstore && git commit -m "objstore: store.json record, keys, env-name rendering"`.
+
+---
+
+### Task 2: Provisioner seam + in-memory fake
+
+**Suggested model:** Sonnet.
+
+**Files:**
+- Create: `internal/objstore/provisioner.go`
+- Create: `internal/objstore/memprovisioner.go`
+- Create: `internal/objstore/memprovisioner_test.go`
+
+**Interfaces:**
+- Produces:
+ - `type Provisioner interface` exactly as below.
+ - `type MemProvisioner struct` with exported state maps and `FailNext map[string]error` fault injection keyed by method name (`"EnsureBucket"`, `"EnsureUser"`, `"MintKey"`, `"DeleteKey"`, `"ReapKeys"`, `"EmptyBucket"`, `"DeleteBucket"`, `"DeleteUser"`), consumed one-shot.
+ - `func NewMemProvisioner(region string) *MemProvisioner`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package objstore
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestMemProvisionerIdempotence(t *testing.T) {
+ ctx := context.Background()
+ p := NewMemProvisioner("eu-west-1")
+ for i := 0; i < 2; i++ { // every verb is safe to repeat
+ if region, err := p.EnsureBucket(ctx, "b1"); err != nil || region != "eu-west-1" {
+ t.Fatalf("EnsureBucket #%d: %q %v", i, region, err)
+ }
+ if arn, err := p.EnsureUser(ctx, "u1", "/carlos-store/", "arn:aws:s3:::b1"); err != nil || arn == "" {
+ t.Fatalf("EnsureUser #%d: %v", i, err)
+ }
+ }
+ id1, sec1, err := p.MintKey(ctx, "u1")
+ if err != nil || id1 == "" || sec1 == "" {
+ t.Fatal(err)
+ }
+ id2, _, err := p.MintKey(ctx, "u1")
+ if err != nil || id2 == id1 {
+ t.Fatalf("second mint: %q vs %q, %v", id2, id1, err)
+ }
+ if err := p.DeleteKey(ctx, "u1", id1); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.DeleteKey(ctx, "u1", id1); err != nil { // already gone == success
+ t.Fatal(err)
+ }
+ id3, _, err := p.MintKey(ctx, "u1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := p.ReapKeys(ctx, "u1", []string{id3}); err != nil {
+ t.Fatal(err)
+ }
+ if got := len(p.Users["u1"].Keys); got != 1 { // id2 reaped, id3 kept
+ t.Fatalf("after reap: %d keys", got)
+ }
+ if err := p.EmptyBucket(ctx, "b1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.DeleteBucket(ctx, "b1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.DeleteBucket(ctx, "b1"); err != nil { // already gone == success
+ t.Fatal(err)
+ }
+ if err := p.DeleteUser(ctx, "u1"); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.DeleteUser(ctx, "u1"); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestMemProvisionerFailNext(t *testing.T) {
+ ctx := context.Background()
+ p := NewMemProvisioner("eu-west-1")
+ boom := errors.New("boom")
+ p.FailNext["EnsureBucket"] = boom
+ if _, err := p.EnsureBucket(ctx, "b1"); !errors.Is(err, boom) {
+ t.Fatalf("want injected failure, got %v", err)
+ }
+ if _, err := p.EnsureBucket(ctx, "b1"); err != nil { // one-shot
+ t.Fatalf("retry should succeed: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run, verify fail** — `go test ./internal/objstore/ -count=1`; expected: FAIL (undefined types).
+- [ ] **Step 3: Implement**
+
+`provisioner.go`:
+
+```go
+package objstore
+
+import "context"
+
+// Provisioner is the backend seam (§3): everything the console needs a
+// backend to do, and nothing an app ever calls. Every method is idempotent —
+// "already exists" and "already gone" are successes — because every walk
+// that drives one must be re-runnable after a half-failure (§5).
+//
+// v1 ships the AWS implementation and this package's in-memory fake. A
+// Hetzner/R2/Minio provisioner arrives here later without touching a walk.
+type Provisioner interface {
+ // EnsureBucket creates the bucket if absent and answers its region.
+ EnsureBucket(ctx context.Context, name string) (region string, err error)
+ // EnsureUser creates the credential principal if absent, scoped to
+ // exactly bucketARN (ListBucket on it; Get/Put/DeleteObject under it).
+ EnsureUser(ctx context.Context, name, path, bucketARN string) (arn string, err error)
+ MintKey(ctx context.Context, user string) (id, secret string, err error)
+ DeleteKey(ctx context.Context, user, id string) error
+ // ReapKeys deletes every access key of user NOT named in keep. The grant
+ // walk's crash-safety depends on it: a key minted before a failed
+ // delivery is recorded nowhere, so only list-and-delete can find it.
+ ReapKeys(ctx context.Context, user string, keep []string) error
+ // EmptyBucket deletes every object; paginated and resumable.
+ EmptyBucket(ctx context.Context, name string) error
+ DeleteBucket(ctx context.Context, name string) error
+ // DeleteUser removes the user's keys and inline policy first.
+ DeleteUser(ctx context.Context, user string) error
+}
+```
+
+`memprovisioner.go`: maps guarded by a `sync.Mutex` — `Buckets map[string]map[string][]byte` (bucket → key → body), `Users map[string]memUser` where `memUser` holds `ARN string` and `Keys map[string]string` (key id → secret); `nextKey int` for deterministic ids (`"MEMKEY1"`, `"MEMSEC1"`, …); every method first checks-and-consumes `FailNext[method]`. `EnsureBucket` returns the constructor's region. `EnsureUser` ARN = `"arn:mem:iam::user" + path + name`.
+
+- [ ] **Step 4: Run, verify pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: Provisioner seam and in-memory fake"`.
+
+---
+
+### Task 3: Grant walk — provision + deliver
+
+**Suggested model:** Opus (this is the walk everything else trusts).
+
+**Files:**
+- Create: `internal/objstore/walk.go`
+- Create: `internal/objstore/walk_test.go`
+
+**Interfaces:**
+- Consumes: Task 1 record + Task 2 provisioner; `appconfig.Apply(ctx, s, acct, app, sets map[string]appconfig.Var, unsets []string) (int, error)`; `appconfig.Var{Kind, Value, Envelope}`; `appconfig.Seal(pub *ecdh.PublicKey, plaintext []byte) (string, error)`; `appconfig.ParsePublicKey(b64, src string) (*ecdh.PublicKey, error)`; `appconfig.Get(ctx, s, acct, app) (*Manifest, error)`.
+- Produces:
+ - `type Deployment struct { Backend, Region, Endpoint, BucketPrefix string }`
+ - `func MintedBucket(d Deployment, acct, app string) string` → `d.BucketPrefix + acct + "-" + app`
+ - `func UserName(acct, app string) string` → `"carlos-store-" + acct + "-" + app`
+ - `const UserPath = "/carlos-store/"`
+ - `func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time) (*Record, error)`
+ - Sentinel errors: `ErrNotDeclared`, `ErrEnvPrefixLocked` (Task 8/9 map them to HTTP).
+
+**The walk, in spec §5 order** (each numbered step is check-then-create):
+
+```go
+func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment,
+ sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time) (*Record, error) {
+ // 1. resolve + write the grant block
+ rec, etag, err := Get(ctx, s, acct, app)
+ if err != nil {
+ return nil, err
+ }
+ if rec == nil || rec.Declared == nil {
+ return nil, ErrNotDeclared
+ }
+ bucket := adoptBucket
+ if rec.Grant != nil && rec.Grant.Bucket != "" {
+ bucket = rec.Grant.Bucket // a re-run never re-chooses the bucket
+ }
+ if bucket == "" {
+ bucket = MintedBucket(d, acct, app)
+ }
+ if rec.Grant == nil {
+ rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, At: now, By: actor}
+ if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ }
+ // Idempotence short-circuit: a healthy provisioned store makes a grant
+ // re-run a no-op — no fresh key, no rewrite. "Healthy" means the record
+ // says provisioned AND every delivered var is present in config.json
+ // (envDelivered is a small helper diffing EnvNames against
+ // appconfig.Get's Vars, shared with Task 8's missing_vars).
+ if rec.Provisioned != nil {
+ ok, err := envDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.Endpoint != "")
+ if err != nil {
+ return nil, err
+ }
+ if ok {
+ return rec, nil
+ }
+ }
+ // 2–3. backend resources
+ region, err := p.EnsureBucket(ctx, bucket)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: ensure bucket %s: %w", bucket, err)
+ }
+ user := UserName(acct, app)
+ arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err)
+ }
+ // 4. key. Reap first: reaching here means delivery is missing, so any
+ // existing CURRENT key is either undelivered (its secret died with a
+ // failed config write — unrecoverable) or stranded by a crash between
+ // mint and record, which no record can name. Only list-and-delete finds
+ // both; a pending-finish key from an old rotation is kept for its
+ // explicit finish.
+ keep := []string(nil)
+ if rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "" {
+ keep = append(keep, rec.Provisioned.PrevKeyID)
+ }
+ if err := p.ReapKeys(ctx, user, keep); err != nil {
+ return nil, fmt.Errorf("objstore: reap keys for %s: %w", user, err)
+ }
+ id, secret, err := p.MintKey(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err)
+ }
+ // 5. deliver — the secret's ONLY landing place (spec §7)
+ if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil {
+ return nil, err
+ }
+ // 6. record success — after the secret is recorded, never before (§5).
+ // PrevKeyID carries over untouched: the reap above already removed the
+ // only stale current key, and a pending-finish key from an old rotation
+ // still deserves its explicit finish.
+ prev := ""
+ if rec.Provisioned != nil {
+ prev = rec.Provisioned.PrevKeyID
+ }
+ rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint,
+ IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now}
+ if _, err := Put(ctx, s, acct, app, rec, etag); err != nil {
+ return nil, err
+ }
+ return rec, nil
+}
+```
+
+`writeEnv` builds the sets map with `EnvNames(prefix)`: coordinates as `appconfig.Var{Kind: "env", Value: …}`, both key halves sealed via `appconfig.Seal(sealPub, []byte(v))` into `appconfig.Var{Kind: "secret", Envelope: …}`; `_ENDPOINT` only when endpoint non-empty; one `appconfig.Apply` call (which also touches config-gen).
+
+- [ ] **Step 1: Write the failing tests** — in `walk_test.go`, with a helper `testSealKeypair(t)` using `appconfig.GenerateKey()` + `appconfig.ParsePublicKey`/`ParsePrivateKey`:
+ - `TestGrantUnDeclaredRefused`: no record → `ErrNotDeclared`.
+ - `TestGrantMintsAndDelivers`: declare (write a Record with `Declared`), run `Grant` with `MemProvisioner` and `Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-store-tito-"}`, adoptBucket `""`; assert bucket named `carlos-store-tito-bab-titogo` exists in fake; user `carlos-store-bab-titogo` exists; record's `Provisioned.KeyID` matches the fake's minted id; `appconfig.Get` shows `TITO_S3_BUCKET` env var and `TITO_S3_SECRET_KEY` envelope that `appconfig.Open(priv, …)` decrypts to the fake's secret; no `TITO_S3_ENDPOINT` key (empty endpoint).
+ - `TestGrantAdoptsExistingBucket`: adoptBucket `"titogo-uploads"` → provisioned bucket is `titogo-uploads`, minted name unused.
+ - `TestGrantResumesAfterEachStepFailure`: loop over method names `EnsureBucket, EnsureUser, MintKey`; inject `FailNext[m]`, run `Grant`, assert error; run again, assert success and exactly one bucket + one user in the fake. For the delivery step, use a store wrapper failing the first `Put` to `config.json` (a small `failingStore struct{ release.Store; failKeyOnce string }` in the test file), assert the retry completes, the delivered `_ACCESS_KEY` matches the SECOND minted id, and the fake holds exactly ONE key (the undelivered first was reaped).
+ - `TestGrantRerunKeepsBucketChoice`: grant with adoptBucket, then re-run with adoptBucket `""` — bucket unchanged.
+ - `TestGrantIdempotentWhenHealthy`: grant, then grant again — same `KeyID` in the record, exactly one key in the fake, config untouched (compare the sealed envelope strings before/after: unchanged).
+- [ ] **Step 2: Run, verify fail.**
+- [ ] **Step 3: Implement `walk.go`** (code above + `writeEnv` + sentinels).
+- [ ] **Step 4: Run, verify pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: grant walk — provision, mint, deliver, record"`.
+
+---
+
+### Task 4: Rotate + RotateFinish walks
+
+**Suggested model:** Opus.
+
+**Files:**
+- Modify: `internal/objstore/walk.go`
+- Modify: `internal/objstore/walk_test.go`
+
+**Interfaces:**
+- Produces:
+ - `func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, acct, app string, now time.Time) (*Record, error)`
+ - `func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error)`
+ - Sentinels: `ErrNotProvisioned`, `ErrNoPendingFinish`.
+
+**Behaviour (spec §8):** `Rotate`: record must be `Provisioned` else `ErrNotProvisioned`; if `PrevKeyID != ""` delete it first (the 2-key ceiling is the state machine); then `ReapKeys(user, keep=[KeyID])` — a crash in an earlier rotate between mint and reseal leaves a stray key no record names, and without the reap the next mint wedges at the ceiling; mint new key; reseal both key vars via the same `writeEnv` names (coordinates unchanged — pass current bucket/region/endpoint from the record); update record `PrevKeyID = old KeyID`, `KeyID = new`, CAS. Old key stays ACTIVE. `RotateFinish`: `PrevKeyID == ""` → `ErrNoPendingFinish`; delete prev key, clear `PrevKeyID`, CAS. No config change on finish.
+
+- [ ] **Step 1: Failing tests** — `TestRotateKeepsOldKeyActive` (after rotate: fake user has BOTH keys; config's sealed `_ACCESS_KEY` decrypts to the NEW id; record PrevKeyID = old id), `TestRotateTwiceReapsPending` (rotate; rotate again → first key deleted from fake, second pends, third live: exactly 2 keys in fake at all times), `TestRotateResumesAfterResealFailure` (failingStore wrapper fails the config write once: first run errs, re-run succeeds, fake never exceeds 2 keys, delivered `_ACCESS_KEY` decrypts to the final `KeyID`), `TestRotateFinishDeletesPrev` (+ `ErrNoPendingFinish` on repeat), `TestRotateUnprovisionedRefused`.
+- [ ] **Step 2: Run, verify fail.** **Step 3: Implement.** **Step 4: Run, verify pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: two-key rotation with explicit finish"`.
+
+---
+
+### Task 5: Teardown walk
+
+**Suggested model:** Opus.
+
+**Files:**
+- Modify: `internal/objstore/walk.go`
+- Modify: `internal/objstore/walk_test.go`
+
+**Interfaces:**
+- Produces: `func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app string) error`.
+- Consumes: `appconfig.Apply` with `unsets` (Task 3's import set).
+
+**Behaviour (spec §9):** read record; absent or no `Provisioned` → delete `store.json` if it exists (declared-only records die trivially) and return nil. Otherwise, in order: `EmptyBucket` → `DeleteBucket` → `DeleteUser` (which reaps keys+policy) → unset all five delivered var names via `appconfig.Apply(ctx, s, acct, app, nil, unsets)` (unsetting an absent var is a no-op — pass all five including `_ENDPOINT`) → delete `store.json` LAST (`s.Delete(ctx, Key(acct, app))`). Any failure returns with the record intact — the ordering rule stated once in §9: read first, delete the record last.
+
+- [ ] **Step 1: Failing tests** — `TestTeardownFullSweep` (grant, put an object into the fake bucket, teardown: fake has no bucket/user, config has no `TITO_S3_*` vars, `Get` returns nil record), `TestTeardownResumable` (inject failure into each of `EmptyBucket, DeleteBucket, DeleteUser` in turn: first run errs, record still provisioned; re-run completes), `TestTeardownDeclaredOnly` (record with only `Declared` → record deleted, no provisioner calls needed — assert via a `FailNext` on every method that would explode if called... simpler: assert fake state unchanged), `TestTeardownIdempotentOnAbsent` (no record → nil).
+- [ ] **Step 2–4: fail → implement → pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: teardown walk, record deleted last"`.
+
+---
+
+### Task 6: `release.PurgeApp` refuses a provisioned store
+
+**Suggested model:** Opus (root-side blast radius; this is the guard the rollout order depends on).
+
+**Files:**
+- Modify: `internal/release/trash.go` (PurgeApp, after the production-flag check, before the List)
+- Modify: `internal/release/trash_test.go`
+
+**Interfaces:**
+- Produces: `var ErrStoreProvisioned = errors.New("release: app has a provisioned object store — tear it down via the console before purging")` in `trash.go`.
+- NOTE for the implementer: `release` cannot import `objstore` (objstore imports release). PurgeApp parses the ONE fact it needs from `store.json` with a local anonymous struct. This makes `release` a parser of the store record's shape — recorded in comments on BOTH sides (the "consumer list is everything that PARSES the shape" rule this repo has paid for three times).
+
+- [ ] **Step 1: Failing test** in `trash_test.go`:
+
+```go
+func TestPurgeRefusesProvisionedStore(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ pub, priv := testSigningKey(t) // use this file's existing key helper; adjust the call to match it
+ _ = priv
+ if err := TrashApp(ctx, s, "bab", "titogo", "who", time.Now().Add(-31*24*time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ // provisioned store.json → refuse
+ body := []byte(`{"schema":1,"app":"titogo","provisioned":{"bucket":"b","key_id":"k"}}`)
+ if _, err := s.Put(ctx, "apps/bab/titogo/store.json", body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "titogo", time.Now()); !errors.Is(err, ErrStoreProvisioned) {
+ t.Fatalf("want ErrStoreProvisioned, got %v", err)
+ }
+ // declared-only store.json → purge proceeds and sweeps it
+ body = []byte(`{"schema":1,"app":"titogo","declared":{"env_prefix":"X"}}`)
+ if _, err := s.Put(ctx, "apps/bab/titogo/store.json", body, etagOfExisting(t, s, "apps/bab/titogo/store.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "titogo", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+(The implementer adapts the two helpers — signing-key setup and the etag fetch — to whatever this test file already uses for `TestPurgeApp*`; both exist there. TrashApp's timestamp comes from its `now` parameter, so backdating is the argument, not a sleep.)
+
+- [ ] **Step 2: Run, verify fail** — `go test ./internal/release/ -run TestPurgeRefuses -count=1`.
+- [ ] **Step 3: Implement the guard** in `PurgeApp` between the production check and the `s.List`:
+
+```go
+ // A provisioned object store refuses the purge outright (objstore spec
+ // §9): auto-purge runs on boxes, boxes hold no backend authority, and a
+ // swept store.json is a paid bucket nothing remembers. The console's
+ // purge door tears the store down first and only then re-enters here.
+ // Parsed minimally in place — release cannot import objstore (it imports
+ // release), so this file is on store.json's parser list, deliberately.
+ if raw, _, err := s.Get(ctx, prefix+"store.json"); err == nil {
+ var rec struct {
+ Provisioned *struct{} `json:"provisioned"`
+ }
+ if jerr := json.Unmarshal(raw, &rec); jerr == nil && rec.Provisioned != nil {
+ return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned)
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("release: cannot read %sstore.json — refusing to purge: %w", prefix, err)
+ }
+```
+
+An UNREADABLE record refuses too — same direction as the production flag.
+
+- [ ] **Step 4: Run package tests, verify pass** — `go test ./internal/release/ -count=1`.
+- [ ] **Step 5: Add a mirror test in objstore** (`walk_test.go`): `TestPurgeGuardSeesOurRecord` — write a provisioned `Record` via `Put`, call `release.PurgeApp` (after `release.TrashApp` backdated past cooloff, with a test key), assert `errors.Is(err, release.ErrStoreProvisioned)`. This pins the two packages' parse agreement.
+- [ ] **Step 6: Gate + commit** — `git commit -m "release: PurgeApp refuses a provisioned object store"`.
+
+---
+
+### Task 7: AWS provisioner
+
+**Suggested model:** Sonnet (mechanical SDK plumbing against a fixed interface).
+
+**Files:**
+- Create: `internal/objstore/aws.go`
+- Create: `internal/objstore/aws_test.go` (construction-only — no AWS calls in CI)
+- Modify: `go.mod`/`go.sum` (new dependency)
+
+**Interfaces:**
+- Produces: `func NewAWSProvisioner(ctx context.Context, region, endpoint string) (Provisioner, error)` — loads default AWS config in `region`; S3 client applies `s3compat`-style options when `endpoint != ""` (BaseEndpoint + `UsePathStyle`); IAM client is plain (IAM has no S3-compatible twin — a non-empty endpoint plus backend `aws` is still IAM-against-AWS in v1).
+
+- [ ] **Step 1: Add the dependency** (sandbox off for this one command): `go get github.com/aws/aws-sdk-go-v2/service/iam@latest && go mod tidy`.
+- [ ] **Step 2: Implement `aws.go`** — `awsProvisioner{s3 *s3.Client, iam *iam.Client, region string}`:
+ - `EnsureBucket`: `CreateBucket` (with `CreateBucketConfiguration{LocationConstraint}` unless region is `us-east-1`); treat `BucketAlreadyOwnedByYou`/`BucketAlreadyExists`-owned as success (`errors.As` on the typed errors); return the configured region.
+ - `EnsureUser`: `CreateUser` with `Path`, `EntityAlreadyExistsException` = success (then `GetUser` for the ARN); `PutUserPolicy` (idempotent overwrite) with policy name `carlos-store` and document:
+
+```json
+{"Version": "2012-10-17", "Statement": [
+ {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "<bucketARN>"},
+ {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "<bucketARN>/*"}
+]}
+```
+
+ - `MintKey`: `CreateAccessKey`.
+ - `DeleteKey`: `DeleteAccessKey`, `NoSuchEntityException` = success.
+ - `ReapKeys`: `ListAccessKeys` (`NoSuchEntity` = success, nothing to reap) → `DeleteAccessKey` for every id not in `keep`.
+ - `EmptyBucket`: `ListObjectsV2` paginator + `DeleteObjects` batches of 1000; `NoSuchBucket` = success.
+ - `DeleteBucket`: `NoSuchBucket` = success.
+ - `DeleteUser`: `ListAccessKeys` → `DeleteAccessKey` each → `DeleteUserPolicy` (`NoSuchEntity` ok) → `DeleteUser` (`NoSuchEntity` ok).
+- [ ] **Step 3: Construction test** — `NewAWSProvisioner(ctx, "eu-west-1", "")` returns a non-nil `Provisioner` with no error (config load works offline); type-assert it satisfies the interface at compile time with `var _ Provisioner = (*awsProvisioner)(nil)`.
+- [ ] **Step 4: Gate + commit** — the darwin cross-compile matters here (new dependency). `git commit -m "objstore: AWS provisioner (S3 + IAM, path-scoped)"`.
+
+---
+
+### Task 8: Console — config plumbing + member endpoints (create/status)
+
+**Suggested model:** Opus (console security surface).
+
+**Files:**
+- Modify: `internal/console/server.go` (Config fields + 2 route registrations in the `/api/cli` block near `authcli.go`'s siblings — put the `HandleFunc` calls where the config ones live, `internal/console/authcli.go:277-279`)
+- Create: `internal/console/apistore.go`
+- Create: `internal/console/apistore_test.go`
+- Modify: `cmd/console/main.go` (env → Config wiring)
+
+**Interfaces:**
+- Consumes: `objstore.Get/Put/Record/ValidEnvPrefix/DefaultEnvPrefix/EnvNames`, `appconfig.Get`.
+- Produces:
+ - `console.Config` gains: `StoreProvisioner objstore.Provisioner` (nil = backend unconfigured), `StoreDeployment objstore.Deployment`, `StoreOperators []string`.
+ - Routes: `POST /api/cli/apps/{account}/{app}/store` → `handleAPIStoreCreate`; `GET /api/cli/apps/{account}/{app}/store` → `handleAPIStoreGet`.
+ - JSON reply shape both endpoints share (Task 11's CLI parses this): `{"app": …, "declared": {...}|null, "grant": {...}|null, "provisioned": {...}|null, "env_names": [...], "pending_finish": bool, "missing_vars": [...]}` — provisioned echoed WITHOUT `key_id`s? No: key ids are not secrets and `status` prints them; echo the full record minus nothing (the secret never enters the record).
+
+**handleAPIStoreCreate** (member door, gates in `handleAPIRestart`'s order): `apiAccountApp` → rate limit → `Releases == nil` 503 → decode body `{"env_prefix": string}` (empty → `DefaultEnvPrefix`; invalid → 400 naming the charset) → read record; if `Provisioned != nil` and prefix differs from `Declared.EnvPrefix` → 409 "store already provisioned — the env prefix is fixed; destroy and re-create to change it" → else write/update `Declared{EnvPrefix, At: time.Now(), By: addr}` (CAS; conflict → 409 "changed underneath you — try again") → audit `store-create` → 200 with the shared reply shape. No serial bump (nothing box-visible changed).
+
+**handleAPIStoreGet**: `apiAccountApp` → read record (absent → 404 `jsonError` "no store is declared for this app") → compute `missing_vars` by diffing `EnvNames(prefix)` against `appconfig.Get`'s Vars (skip `_ENDPOINT` when `Provisioned.Endpoint` is empty; skip the whole diff when not provisioned) and `pending_finish = Provisioned != nil && Provisioned.PrevKeyID != ""` → 200.
+
+**cmd/console/main.go** wiring, next to the `CARLOS_SECRETS_PUBKEY` block (`cmd/console/main.go:304`): read `CARLOS_STORE_BACKEND`; if set it must be `"aws"` (anything else: `log.Fatal` — a typo'd backend must not boot a console that answers "not configured"); require `CARLOS_STORE_REGION` and `CARLOS_STORE_BUCKET_PREFIX` non-empty, read optional `CARLOS_STORE_ENDPOINT`, build `objstore.NewAWSProvisioner`, fill `StoreDeployment`; parse `CARLOS_STORE_OPERATORS` (split on `,`, trim, drop empties) regardless of backend.
+
+- [ ] **Step 1: Failing tests** in `apistore_test.go`, following `apirestart_test.go`'s harness (in-process server, DirStore, bearer token helper): declare happy path (+ default prefix), invalid prefix 400, non-member 404 byte-identical to unknown-account 404 (copy the existing byte-identical assertion pattern from `authowner_test.go`), prefix-locked 409 after hand-writing a provisioned record, status absent 404, status shows `missing_vars` when config lacks the delivered names and `pending_finish` when `PrevKeyID` set.
+- [ ] **Step 2: Run, verify fail.** **Step 3: Implement.** **Step 4: Run, verify pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "console: store declare/status member endpoints + backend config plumbing"`.
+
+---
+
+### Task 9: Console — grant, rotate, rotate-finish endpoints
+
+**Suggested model:** Opus (the authority door).
+
+**Files:**
+- Modify: `internal/console/apistore.go`, `internal/console/apistore_test.go`
+- Modify: `internal/console/server.go` (3 routes: `POST …/store/grant`, `POST …/store/rotate`, `POST …/store/rotate/finish`)
+
+**Interfaces:**
+- Consumes: `objstore.Grant/Rotate/RotateFinish`, `appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")`.
+- Produces: same JSON reply shape as Task 8.
+
+**handleAPIStoreGrant** gates, in order: `apiAccountApp` (member first — so the 404 posture is identical to every sibling) → **allowlist**: `addr` must be in `s.cfg.StoreOperators` else 403 `jsonError` "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" — after the 404 gate, a member learning this door exists reveals nothing about other accounts → rate limit → `Releases == nil` 503 → `StoreProvisioner == nil` 503 "object storage is not configured for this deployment (CARLOS_STORE_BACKEND)" → `SecretsPub == ""` 503 (cannot seal → cannot deliver → must not mint) → decode body `{"bucket": string}` (optional adopt) → `objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, pub, acct.Sqid, app, addr, bucket, time.Now())` with a 60s context timeout (bucket creation is slow) → map `ErrNotDeclared` to 409 "no store is declared — run carlos store create first"; other errors log + 502 with the fixed sentence pattern ("could not provision the store — nothing was granted that isn't recorded; re-run to resume") → audit `store-grant` with the bucket name in the detail → `s.bumpSerial(ctx)` → 200.
+
+**handleAPIStoreRotate**: member gates as in create → `StoreProvisioner`/`SecretsPub` 503s → `objstore.Rotate` → `ErrNotProvisioned` 409 → audit `store-rotate` → bump → 200. **handleAPIStoreRotateFinish**: same, `objstore.RotateFinish`, `ErrNoPendingFinish` → 409, audit, **no bump** (no box-consumed object changed).
+
+- [ ] **Step 1: Failing tests**: grant as non-allowlisted member → 403 (and the member-vs-stranger 404 still byte-identical); grant with no backend → 503; grant happy path against `MemProvisioner` (assert: fake has bucket+user, config has sealed vars, record provisioned, serial bumped — read `control/serial` before/after); grant maps `ErrNotDeclared` → 409; rotate happy + finish happy + finish-twice 409; rotate keeps serial-bump discipline (bump on rotate, none on finish).
+- [ ] **Step 2–4: fail → implement → pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "console: store grant behind operator allowlist, rotate + finish"`.
+
+---
+
+### Task 10: Console browser — purge integration, destroy door, Settings card
+
+**Suggested model:** Opus, and this task's review is the one to be pickiest about (gate order + destruction).
+
+**Files:**
+- Modify: `internal/console/ownership.go` (`handlePurgeApp`)
+- Modify: `internal/console/appsettings.go` (`SettingsView`, `assembleSettings`, new `handleStoreDestroy`)
+- Modify: `internal/console/templates/app_settings.html`
+- Modify: `internal/console/server.go` (route `POST /a/{sqid}/apps/{app}/store/destroy`)
+- Modify: `internal/console/deletion_test.go` or `appsettings_test.go` (whichever file's harness fits; follow the existing purge tests)
+
+**Interfaces:**
+- Consumes: `objstore.Get/Teardown`, `release.ErrStoreProvisioned`.
+- Produces: `SettingsView` gains `Store *StoreCard` where `type StoreCard struct { Declared bool; EnvPrefix string; Granted bool; Backend string; Bucket string; Provisioned bool; KeyID string; KeyAge string; PendingFinish bool; CanDestroy bool }` (`CanDestroy` = provisioned && `s.cfg.StoreProvisioner != nil`).
+
+**handlePurgeApp change** — insert between `requireFreshAuth` (`internal/console/ownership.go:308`) and `release.PurgeApp` (`:315`):
+
+```go
+ // A provisioned store is torn down BEFORE the prefix sweep — this door
+ // is the only purge path with backend authority (objstore spec §9;
+ // box-side auto-purge refuses via release.ErrStoreProvisioned). Failure
+ // aborts the purge with the record intact: re-entering resumes.
+ if rec, _, rerr := objstore.Get(r.Context(), s.cfg.Releases, acct.Sqid, app); rerr == nil && rec != nil && rec.Provisioned != nil {
+ if s.cfg.StoreProvisioner == nil {
+ fail("“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down.")
+ return
+ }
+ if terr := objstore.Teardown(r.Context(), s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app); terr != nil {
+ log.Printf("console: purge store teardown %s/%s: %v", acct.Sqid, app, terr)
+ fail("Could not tear down “" + app + "”'s object store — nothing was deleted; try again.")
+ return
+ }
+ s.audit(acct, app, addr, "store-destroy", "tore down the object store (bucket "+rec.Provisioned.Bucket+") during permanent deletion")
+ } else if rerr != nil {
+ log.Printf("console: purge store read %s/%s: %v", acct.Sqid, app, rerr)
+ fail("Could not read “" + app + "”'s object-store record — nothing was deleted; try again.")
+ return
+ }
+```
+
+Also extend `purgeAdvice` (same file) to map `release.ErrStoreProvisioned` to the backend-unconfigured sentence above — belt and braces if the guard and this door ever race.
+
+**handleStoreDestroy** — clone `handlePurgeApp`'s ceremony exactly (`s.owner` → CSRF `subtle.ConstantTimeCompare` → typed-name check → `requireFreshAuth` LAST), with the typed confirmation being the BUCKET name (`r.FormValue("confirm")` vs `rec.Provisioned.Bucket`); refusals flash back to `/a/{sqid}/apps/{app}/settings` (write a local `fail` helper mirroring `trashFailure`'s shape but redirecting to the settings tab); then `objstore.Teardown` → audit `store-destroy` → `s.bumpSerial(r.Context())` (env vars were unset — boxes must converge) → flash ok → redirect to settings. A declared-only store (nothing provisioned) is destroyable without the bucket ceremony? No — keep one door: `confirm` must equal the bucket when provisioned, or the APP name when declared-only.
+
+**Settings card** — `assembleSettings` fills `StoreCard` from `objstore.Get` (best-effort like the rest of that function: read failure logs and leaves `Store` nil); template section appended to `app_settings.html` following its existing card markup conventions (match the surrounding HTML structure and class names when editing; keep icons to the vendored Lucide set): shows declared prefix, granted backend/bucket, key id + age, a "finish pending" nag naming `carlos store rotate -finish`, and the destroy form (bucket-name input + CSRF hidden field, matching the delete form above it). Not provisioned + not declared → the card renders one line naming `carlos store create`.
+
+- [ ] **Step 1: Failing tests**: purge of a provisioned-store app with `StoreProvisioner: nil` → flash failure, bucket objects intact; purge with `MemProvisioner` → store gone from fake AND app prefix swept; destroy door: wrong typed bucket refused before fresh-auth (assert no step-up redirect happened), happy path tears down + bumps serial; settings page renders the card fields for a provisioned record.
+- [ ] **Step 2–4: fail → implement → pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "console: store teardown in purge door, destroy ceremony, settings card"`.
+
+---
+
+### Task 11: CLI — `carlos store` verb
+
+**Suggested model:** Sonnet (precise brief, established three-layer pattern).
+
+**Files:**
+- Create: `store.go` (repo root, next to `restart.go`)
+- Create: `store_test.go`
+- Modify: `apimode.go` (apiSession methods + reply struct)
+- Modify: `main.go` (dispatch `case "store": runStore(args)` + usage line in the help text where sibling verbs list theirs)
+
+**Interfaces:**
+- Consumes: Task 8/9's JSON reply shape; `apiSession.postJSON/getJSON`, `apiPath(a, app, "/store")`, `requireRestartAPISession`-style session acquisition (write a `requireStoreAPISession` mirroring it with a store-specific refusal sentence for direct-bucket mode), `accountFlags`, `apiAccount`, `validAppName`, `printUsing(stdout, sess)`.
+- Produces in `apimode.go`:
+
+```go
+type apiStoreReply struct {
+ App string `json:"app"`
+ Declared *objstore.Declared `json:"declared"`
+ Grant *objstore.GrantBlock `json:"grant"`
+ Provisioned *objstore.Provisioned `json:"provisioned"`
+ EnvNames []string `json:"env_names"`
+ PendingFinish bool `json:"pending_finish"`
+ MissingVars []string `json:"missing_vars"`
+}
+func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix string) (apiStoreReply, error)
+func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app string) (apiStoreReply, error)
+func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, bucket string) (apiStoreReply, error)
+func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiStoreReply, error)
+```
+
+Each mirrors `restart` (`apimode.go:437`): postJSON/getJSON, 200 decode, 404/other via `apiErrorFromBody`.
+
+**`store.go`** — `runStore(args)`: first positional arg is the subcommand (`create|status|grant|rotate`), remaining args flag-parsed per subcommand (the `carlos apps` file is the dispatch precedent — mirror its arg slicing). Flags: all take `-app`, account flags, `-console`; `create` adds `-env-prefix`, `grant` adds `-bucket`, `rotate` adds `-finish`. Output is honest about what happened vs what converges (the `restart.go` lesson): create prints the declared prefix + "nothing is provisioned until an operator grants it"; grant prints bucket/region/user + "credentials are sealed into the app's config — instances pick them up within seconds"; rotate prints the new key id + "the old key stays active until `carlos store rotate -finish -app …`"; finish prints "previous key deleted"; status prints the record blocks, `missing_vars` as a drift warning, and the pending-finish nag.
+
+- [ ] **Step 1: Failing tests** — follow `restart_test.go`'s harness (httptest console stub + `carlosTestEnv` + `cmd.Dir` scratch rule for anything that shells out; prefer in-process function tests like the sibling verbs use): subcommand routing, usage error on missing `-app`, one happy-path per subcommand against a stub answering the reply shape, rotate `-finish` hits `/rotate/finish`.
+- [ ] **Step 2–4: fail → implement → pass.**
+- [ ] **Step 5: Gate + commit** — `git commit -m "cli: carlos store create/status/grant/rotate"`.
+
+---
+
+### Task 12: Minio contract test + CI
+
+**Suggested model:** Sonnet.
+
+**Files:**
+- Create: `internal/objstore/contract_test.go`
+- Modify: `.github/workflows/ci.yml`
+
+**Interfaces:** none new — this pins spec §1's delivered contract from the outside.
+
+- [ ] **Step 1: Write the test** — `TestDeliveredContractMinio` skips unless `CARLOS_MINIO_TEST=1`; reads `MINIO_ENDPOINT`, `MINIO_SCOPED_KEY`, `MINIO_SCOPED_SECRET`, `MINIO_BUCKET`, `MINIO_DENIED_BUCKET` from env; builds an S3 client exactly the way a consuming app would from delivered env (static creds, BaseEndpoint, `UsePathStyle`, region `us-east-1`); asserts: PutObject, GetObject round-trips the body, ListObjectsV2 shows the key, DeleteObject removes it, and PutObject to `MINIO_DENIED_BUCKET` fails with an access-denied API error (assert via `smithy.APIError` code `AccessDenied`).
+- [ ] **Step 2: CI job** — new job `minio-contract` in `ci.yml` alongside the existing gate job:
+
+```yaml
+ minio-contract:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with: { go-version-file: go.mod }
+ - name: start minio
+ run: |
+ docker run -d --name minio -p 9000:9000 \
+ -e MINIO_ROOT_USER=root -e MINIO_ROOT_PASSWORD=rootsecret \
+ quay.io/minio/minio server /data
+ for i in $(seq 1 30); do curl -sf http://127.0.0.1:9000/minio/health/live && break; sleep 1; done
+ - name: mint scoped user
+ run: |
+ curl -sfL https://dl.min.io/client/mc/release/linux-amd64/mc -o mc && chmod +x mc
+ ./mc alias set local http://127.0.0.1:9000 root rootsecret
+ ./mc mb local/contract-bucket local/denied-bucket
+ cat > /tmp/policy.json <<'EOF'
+ {"Version": "2012-10-17", "Statement": [
+ {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]},
+ {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]}
+ ]}
+ EOF
+ ./mc admin policy create local contract-only /tmp/policy.json
+ ./mc admin user add local scopeduser scopedsecret1
+ ./mc admin policy attach local contract-only --user scopeduser
+ - name: contract test
+ env:
+ CARLOS_MINIO_TEST: "1"
+ MINIO_ENDPOINT: http://127.0.0.1:9000
+ MINIO_SCOPED_KEY: scopeduser
+ MINIO_SCOPED_SECRET: scopedsecret1
+ MINIO_BUCKET: contract-bucket
+ MINIO_DENIED_BUCKET: denied-bucket
+ run: go test ./internal/objstore/ -run TestDeliveredContractMinio -count=1 -v
+```
+
+- [ ] **Step 3: Verify locally if docker is available** (`docker run` as above + the env + `go test -run TestDeliveredContractMinio`); if the sandbox has no docker, verify the skip path runs green and note in the commit body that CI exercises the live path.
+- [ ] **Step 4: Gate + commit** — `git commit -m "objstore: minio contract test pinning the delivered S3 contract"`.
+
+---
+
+### Task 13: Whole-branch review + gate + PR
+
+**Suggested model:** Fable (whole-branch final review — the tier-changes-outcome case).
+
+- [ ] **Step 1: Full gate** — `go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` on all touched files, `GOOS=darwin go build ./...`.
+- [ ] **Step 2: Spec conformance read** — re-read the spec top to bottom against the diff; every §-numbered behaviour must have a test or a named justification in the PR body. Known deliberate deltas to re-verify are still deltas-with-reasons: grant allowlist (spec §4 amendment), destroy-is-browser-only (spec §9 amendment), grant operator must be an account member (v1 limit, §4 amendment).
+- [ ] **Step 3: Invoke the requesting-code-review skill**, then open a draft PR titled `objstore: object-storage primitive (#55)` with body covering: the three-actor record, the walks, the purge guard + rollout order (box binaries BEFORE first grant — pin this in its own bolded paragraph), the pinfra follow-ups (console role IAM path grant, `titogo-uploads` ARN, `CARLOS_STORE_*` env on tito), and `Closes #55`.
+- [ ] **Step 4: Post-merge/deploy checklist recorded in the PR body** (not executed by this plan): roll boxes → roll consoles → tofu → `carlos store create`/`grant` on tito → verify titogo uploads end-to-end → file the teamtito/titogo migration issue (session task #7).
+
+---
+
+## Self-review notes (already applied)
+
+- Spec §5 walk order vs Task 3 code: the grant block lands before backend calls so a crash between steps 1 and 2 leaves an auditable intent — consistent with "nothing provisioned that isn't recorded".
+- Key-leak analysis for Task 3: a crash between mint and record leaves an active key no record names, so re-run repair cannot rely on recorded ids — that is why `Provisioner` carries `ReapKeys` (list-and-delete-except) rather than the walk guessing. A grant re-run only reaches the reap+mint leg when delivery is provably missing; the reap keeps only a pending-finish rotation key. Net: no unrecorded key survives a repair run, and the estate never exceeds AWS's 2-key ceiling.
+- Type names cross-checked: `Deployment`/`StoreDeployment`, `GrantBlock` (not `Grant` — the function owns that name), reply struct shared by Tasks 8/9/11.
+- Every spec §11 test row maps to a task step; the §11 "purge split" row is Tasks 6 + 10 together.
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
index c95cd5bd3..c7f806045 100644
--- a/docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
+++ b/docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md
@@ -103,6 +103,11 @@ type Provisioner interface {
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
+ ReapKeys(ctx, user string, keep []string) error // delete every key not in keep
+ // (added in planning 2026-08-10: a crash
+ // between mint and record leaves an active
+ // key NO record names; only list-and-delete
+ // repairs it, so re-run-to-repair needs this)
EmptyBucket(ctx, name string) error // paginated, resumable
DeleteBucket(ctx, name string) error // NoSuchBucket == success
DeleteUser(ctx, user string) error // keys+policy first; NoSuchEntity == success