| 1 | # Build brief: self-serve object storage, stages 4–6 |
| 2 | |
| 3 | **For:** an independent implementer (GPT-6 via codex) building from a fixed |
| 4 | commit. **Reviewer:** a second model reads the result afterwards; write for that |
| 5 | reading. |
| 6 | |
| 7 | **Repo:** carlos/platform. **Branch:** `worktree-store-self-serve`. |
| 8 | **Base commit:** `4e6ea53`. **Spec:** |
| 9 | `docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md` — |
| 10 | read it whole before writing anything. It is the contract; this brief only |
| 11 | scopes the slice and names the traps. |
| 12 | |
| 13 | ## The one-paragraph shape |
| 14 | |
| 15 | An account applies for object storage. A deployment operator approves the |
| 16 | account once. From then on any app in that account gets a store from |
| 17 | `carlos store create` alone, and never learns the word "grant". The per-app |
| 18 | operator `grant` verb survives unchanged for unapproved accounts, `--bucket` |
| 19 | adoption, and repair. |
| 20 | |
| 21 | ## Already done — do not rebuild |
| 22 | |
| 23 | Stages 1–3 of §14 are committed and green: |
| 24 | |
| 25 | - `09d18f5` — `Grant`'s final Put retries a lost CAS, as `Rotate`'s does. |
| 26 | - `8e3482f` — the console serialises store walks per app (the mutex of §9). |
| 27 | - `0294fa3` — the `Entitlement` record, its accessors, `GrantBlock.Source` |
| 28 | and `Grant`'s new `source` parameter across all callers. |
| 29 | |
| 30 | So `objstore.Entitlement`, `GetEntitlement`/`PutEntitlement`, |
| 31 | `GrantSourceOperator` and `GrantSourceAccountApproval` all exist. Read them |
| 32 | before adding anything adjacent. |
| 33 | |
| 34 | ## Build these three stages, one commit each |
| 35 | |
| 36 | **Stage 4 — console doors: apply, approvals, approve, decline.** No behaviour |
| 37 | change anywhere else; the doors are dormant until stage 5 reads them. |
| 38 | |
| 39 | **Stage 5 — the provision door and the CLI's two-door `create`.** Behaviour |
| 40 | changes here. Everything before it is inert. |
| 41 | |
| 42 | **Stage 6 — CLI verbs, the status line, and the two changed printer sentences** |
| 43 | (`store.go:261` and `:348`). |
| 44 | |
| 45 | ## Traps the spec already paid for — honour them exactly |
| 46 | |
| 47 | - **§5's authority shape is `apimail.go`'s pause door, NOT `apiAccountApp`'s.** |
| 48 | `cliUser` → fleet-operator bit → `CARLOS_STORE_OPERATORS` → bare |
| 49 | byte-identical `http.NotFound`; then resolve the account |
| 50 | viewer-independently via `AccountBySqid`, as `mailPauseTarget` does. Routing |
| 51 | approve/decline through a member gate deadlocks approval — an operator has no |
| 52 | reason to be a member of the account they are approving. This was blocker 2 |
| 53 | of the 2026-09-03 review; do not reintroduce it. |
| 54 | - **§4's implementation gate comes first.** Before the record is used, prove by |
| 55 | test that `store/entitlements/` is unreachable through the edge store door. |
| 56 | Read `canonicalKey`/`aclRead` in `internal/edge/storedoor.go` rather than |
| 57 | trusting the prefix's shape. The review flagged this suspected, not |
| 58 | confirmed. |
| 59 | - **A grant already written but not yet provisioned must let the walk RUN to |
| 60 | completion**, not refuse it. That was blocker 1; §13 names the regression |
| 61 | test. |
| 62 | - **State is derived from the blocks, never stored beside them.** No object = |
| 63 | never applied; `applied` alone = pending; `decision` present = decided. |
| 64 | - **A declined owner may re-apply** — `apply` overwrites `applied` and clears |
| 65 | `decision` under CAS. The anti-spam control is the existing `write` budget |
| 66 | keyed on the address, not a new rule. |
| 67 | |
| 68 | ## Out of scope for this run — do not touch |
| 69 | |
| 70 | - Stage 7 (console UI, the notification of §15, `StoreProvisioned` and the |
| 71 | bucket count) and stage 8 (`docs/site/cli.md`). A later run. |
| 72 | - Anything under `infra/`. The host role's provisioning IAM and its permissions |
| 73 | boundary are real and blocking, but they ride with #368 — see §1's third |
| 74 | blocker and §12. |
| 75 | - New root env vars. The spec adds none, deliberately. |
| 76 | - `main`, and any other branch or worktree. |
| 77 | |
| 78 | ## How to work |
| 79 | |
| 80 | - Test-first, following §13's split: walks against `MemProvisioner`, doors |
| 81 | against the console fixture, printers against literal replies with no stub |
| 82 | server. §13 lists the cases; treat that list as the minimum, not the target. |
| 83 | - `AGENTS.md` governs — read it. In particular: `go build ./...`, |
| 84 | `go vet ./...`, `go test ./... -count=1` and `gofmt -l .` all clean before |
| 85 | you call a stage done. Use |
| 86 | `GOCACHE=/home/paulca/.claude/tmp/gocache-codex GOFLAGS=-mod=mod GOPROXY=off`. |
| 87 | - **Do not run `git commit`, `git add`, or any other writing git command.** |
| 88 | Your sandbox holds `.git` read-only on purpose and that is the right |
| 89 | division of labour: you write the code, the orchestrator makes the commits. |
| 90 | Leave the tree dirty and finish with a **commit plan** — for each stage, the |
| 91 | exact file list and a commit message in this repo's voice (read `git log` for |
| 92 | the register: what changed, and the reasoning that made it that shape). |
| 93 | - **User-facing copy.** This repo's owner gates new user-facing wording behind |
| 94 | a review he runs himself. The wording the spec already fixes — §6's two |
| 95 | changed sentences at `store.go:261` and `:348`, and any string the spec |
| 96 | quotes — is settled; write it as specified. For any string you must invent |
| 97 | (a new CLI message, a new error), write the clearest thing you can and |
| 98 | **list every one of them in your final summary** under a heading |
| 99 | `NEW USER-FACING COPY`, with its file and line. They go to review as a batch |
| 100 | after the build. Do not block on this, and do not start a review server. |
| 101 | - If the spec is wrong or underdetermined, say so in the commit plan and in |
| 102 | your final summary rather than inventing a rule silently. A named gap is |
| 103 | worth more than a guess. |
| 104 | |
| 105 | ## Resuming from the first run |
| 106 | |
| 107 | A first run (2026-09-08) was stopped by sandbox blockers before it could |
| 108 | commit. Its test drafts are **already applied to the working tree**, uncommitted: |
| 109 | |
| 110 | - `internal/edge/storedoor_test.go` — the §4 implementation-gate test. It found |
| 111 | that stage 3 *claimed* this test and it did not exist; the test now exists |
| 112 | and passes, and `aclRead` already excludes `store/entitlements/`, so no |
| 113 | policy change is needed. |
| 114 | - `internal/console/apistore_entitlement_test.go` — stage 4 door tests. |
| 115 | - `internal/console/apistore_provision_test.go` — stage 5 provision tests. |
| 116 | - `internal/console/svcroutes_test.go` — four closed routes, count 17 → 21. |
| 117 | |
| 118 | **These drafts never ran** — the console fixture could not open a listener in |
| 119 | that run. Treat them as a starting point to be run, corrected and extended, |
| 120 | not as verified work. The first run's own notes flag two gaps in them: stage 5 |
| 121 | still needs a concurrent *two successful provisions* regression that mints one |
| 122 | key (the existing queued test revokes before the second request), and stage 6 |
| 123 | has no tests yet. |
| 124 | |
| 125 | That run's full findings — twelve of them, including that the spec's route |
| 126 | paths (`/a/{sqid}/apps/{app}/store/provision`) do not match the actual CLI |
| 127 | route family (`/api/cli/...`) — are in `/tmp/store-self-serve-resume.md`. |
| 128 | **Read that file.** Where it proposes a resolution, the proposal is sound |
| 129 | unless the spec contradicts it; say in your commit plan which ones you took. |
| 130 | |