carlos / platform Public
Build brief: self-serve object storage, stages 4–6
For: an independent implementer (GPT-6 via codex) building from a fixed commit. Reviewer: a second model reads the result afterwards; write for that reading.
Repo: carlos/platform. Branch: worktree-store-self-serve. Base commit: 4e6ea53. Spec: docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md — read it whole before writing anything. It is the contract; this brief only scopes the slice and names the traps.
The one-paragraph shape
An account applies for object storage. A deployment operator approves the account once. From then on any app in that account gets a store from carlos store create alone, and never learns the word "grant". The per-app operator grant verb survives unchanged for unapproved accounts, --bucket adoption, and repair.
Already done — do not rebuild
Stages 1–3 of §14 are committed and green:
09d18f5—Grant's final Put retries a lost CAS, asRotate's does.8e3482f— the console serialises store walks per app (the mutex of §9).0294fa3— theEntitlementrecord, its accessors,GrantBlock.SourceandGrant's newsourceparameter across all callers.
So objstore.Entitlement, GetEntitlement/PutEntitlement, GrantSourceOperator and GrantSourceAccountApproval all exist. Read them before adding anything adjacent.
Build these three stages, one commit each
Stage 4 — console doors: apply, approvals, approve, decline. No behaviour change anywhere else; the doors are dormant until stage 5 reads them.
Stage 5 — the provision door and the CLI's two-door create. Behaviour changes here. Everything before it is inert.
Stage 6 — CLI verbs, the status line, and the two changed printer sentences (store.go:261 and :348).
Traps the spec already paid for — honour them exactly
- §5's authority shape is
apimail.go's pause door, NOTapiAccountApp's.cliUser→ fleet-operator bit →CARLOS_STORE_OPERATORS→ bare byte-identicalhttp.NotFound; then resolve the account viewer-independently viaAccountBySqid, asmailPauseTargetdoes. Routing approve/decline through a member gate deadlocks approval — an operator has no reason to be a member of the account they are approving. This was blocker 2 of the 2026-09-03 review; do not reintroduce it. - §4's implementation gate comes first. Before the record is used, prove by test that
store/entitlements/is unreachable through the edge store door. ReadcanonicalKey/aclReadininternal/edge/storedoor.gorather than trusting the prefix's shape. The review flagged this suspected, not confirmed. - A grant already written but not yet provisioned must let the walk RUN to completion, not refuse it. That was blocker 1; §13 names the regression test.
- State is derived from the blocks, never stored beside them. No object = never applied;
appliedalone = pending;decisionpresent = decided. - A declined owner may re-apply —
applyoverwritesappliedand clearsdecisionunder CAS. The anti-spam control is the existingwritebudget keyed on the address, not a new rule.
Out of scope for this run — do not touch
- Stage 7 (console UI, the notification of §15,
StoreProvisionedand the bucket count) and stage 8 (docs/site/cli.md). A later run. - Anything under
infra/. The host role's provisioning IAM and its permissions boundary are real and blocking, but they ride with #368 — see §1's third blocker and §12. - New root env vars. The spec adds none, deliberately.
main, and any other branch or worktree.
How to work
- Test-first, following §13's split: walks against
MemProvisioner, doors against the console fixture, printers against literal replies with no stub server. §13 lists the cases; treat that list as the minimum, not the target. AGENTS.mdgoverns — read it. In particular:go build ./...,go vet ./...,go test ./... -count=1andgofmt -l .all clean before you call a stage done. UseGOCACHE=/home/paulca/.claude/tmp/gocache-codex GOFLAGS=-mod=mod GOPROXY=off.- Do not run
git commit,git add, or any other writing git command. Your sandbox holds.gitread-only on purpose and that is the right division of labour: you write the code, the orchestrator makes the commits. Leave the tree dirty and finish with a commit plan — for each stage, the exact file list and a commit message in this repo's voice (readgit logfor the register: what changed, and the reasoning that made it that shape). - User-facing copy. This repo's owner gates new user-facing wording behind a review he runs himself. The wording the spec already fixes — §6's two changed sentences at
store.go:261and:348, and any string the spec quotes — is settled; write it as specified. For any string you must invent (a new CLI message, a new error), write the clearest thing you can and list every one of them in your final summary under a headingNEW USER-FACING COPY, with its file and line. They go to review as a batch after the build. Do not block on this, and do not start a review server. - If the spec is wrong or underdetermined, say so in the commit plan and in your final summary rather than inventing a rule silently. A named gap is worth more than a guess.
Resuming from the first run
A first run (2026-09-08) was stopped by sandbox blockers before it could commit. Its test drafts are already applied to the working tree, uncommitted:
internal/edge/storedoor_test.go— the §4 implementation-gate test. It found that stage 3 claimed this test and it did not exist; the test now exists and passes, andaclReadalready excludesstore/entitlements/, so no policy change is needed.internal/console/apistore_entitlement_test.go— stage 4 door tests.internal/console/apistore_provision_test.go— stage 5 provision tests.internal/console/svcroutes_test.go— four closed routes, count 17 → 21.
These drafts never ran — the console fixture could not open a listener in that run. Treat them as a starting point to be run, corrected and extended, not as verified work. The first run's own notes flag two gaps in them: stage 5 still needs a concurrent two successful provisions regression that mints one key (the existing queued test revokes before the second request), and stage 6 has no tests yet.
That run's full findings — twelve of them, including that the spec's route paths (/a/{sqid}/apps/{app}/store/provision) do not match the actual CLI route family (/api/cli/...) — are in /tmp/store-self-serve-resume.md. Read that file. Where it proposes a resolution, the proposal is sound unless the spec contradicts it; say in your commit plan which ones you took.