spec rev 5.2 and plan: CI can run carlos deploy (R6)
Human
Prompt
I think CI should be able to run `carlos deploy`, so I think definitely allow for that. Subagents sounds good.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Records ruling R6. A deploy credential also reaches GET .../instances and GET .../regions, the two reads carlos deploy's routine path makes. First- deploy setup, --host domain attach and the watch's log read stay out of reach; the log read is best-effort, so a refused one only drops the timeout's reason. Plan Task 4, Task 9, Review Focus and the README draft follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+40
−11
docs/superpowers/plans/2026-10-05-deploy-credential.md+14 −9docs/superpowers/specs/2026-10-05-deploy-credential-design.md+26 −2
CI passed — run details
diff --git a/docs/superpowers/plans/2026-10-05-deploy-credential.md b/docs/superpowers/plans/2026-10-05-deploy-credential.md| index 0f7c06723..a99e0336f 100644 |
| --- a/docs/superpowers/plans/2026-10-05-deploy-credential.md |
| +++ b/docs/superpowers/plans/2026-10-05-deploy-credential.md |
| @@ -8,7 +8,7 @@ |
| **Tech Stack:** Go (stdlib, `net/http`, `database/sql` on SQLite), the repo's own `internal/release`, `internal/console`, `internal/svcrole` packages, and the root `carlos` CLI package. |
| -**Spec:** `docs/superpowers/specs/2026-10-05-deploy-credential-design.md` (rev 5.1, approved 2026-10-05). Read §3 (rulings R1 to R5), §5 to §9 and §12 before starting any task. |
| +**Spec:** `docs/superpowers/specs/2026-10-05-deploy-credential-design.md` (rev 5.2, approved 2026-10-05). Read §3 (rulings R1 to R6), §5 to §9 and §12 before starting any task. |
| ## Global Constraints |
| @@ -32,7 +32,7 @@ |
| 2. **A deploy token aimed at a sibling app.** The app binding already 404s a service credential on another app's path. Expected: a `deploy` credential for `hello` gets 404 on `/api/cli/apps/bab/other/promote`, and nothing moves. Pinned in Task 4. |
| 3. **Rotating a deploy credential.** Expected: the new secret keeps the same rungs and promotes exactly as before; the old secret is refused. Pinned in Task 1. |
| 4. **A CI retry of the same promote.** Expected: the second call answers 200 with `"result": "noop"`, writes nothing and bumps nothing. Pinned in Task 7. |
| -5. **`carlos deploy` with a deploy token.** `carlos deploy` reads `GET .../regions`, which is not a §6 door. Expected: CI that uses `ship` then `promote` works; `deploy` is refused at that door with the role gate's message. This is a known limit to report to Paul, not to fix here. Pinned in Task 9 by a test that records it. |
| +5. **`carlos deploy` with a deploy token (R6).** Expected: the routine path on an app that is already set up (instance list, channels, ship, promote, and `--region`'s region read) uses only §6 doors; first-deploy setup and `--host` are refused at the role gate; the watch's log read is refused and the deploy still reports its result. Pinned in Task 9. |
| --- |
| @@ -855,6 +855,8 @@ var deployDoors = []string{ |
| "GET /api/cli/apps/{account}/{app}/version-target", |
| "GET /api/cli/apps/{account}/{app}/pipeline", |
| "GET /api/cli/apps/{account}/{app}/pending", |
| + "GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy |
| + "GET /api/cli/apps/{account}/{app}/regions", // R6: carlos deploy --region |
| } |
| func TestDeployReachesExactlyItsDoors(t *testing.T) { |
| @@ -917,9 +919,11 @@ Expected: FAIL, `deploy reaches: got []`. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList) |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions) |
| ``` |
| -Change nothing else in the table. |
| +The last two are R6. Both handlers go through `instanceGate`, which has no role logic of its own, so adding the role to the set is the whole change. If a test pins the exact role set of either door (search `grep -n '/instances"\|/regions"' internal/console/*_test.go`), add `deploy` to its expectation. Change nothing else in the table. |
| - [ ] **Step 4: Run the package.** Promote and rollback by a deploy credential are now reachable with no scope check. That is closed in Task 5, and nothing is released between the two tasks. |
| @@ -2019,11 +2023,11 @@ func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) { |
| } |
| ``` |
| -Adjust imports (`net/http`, `net/http/httptest`, `context`, `strings`). If `normalizeConsole` refuses the `http://127.0.0.1` origin, read how `TestAPISessionShipMultipart` points a session at an httptest server and do the same. Also add a door-coverage test (Review Focus 5): a fake console that records `METHOD path` for every request and answers the shapes `TestAPISessionShipMultipart` and `TestAPISessionStableRefusal` already use; drive `sess.ship`, `sess.promote`, `sess.rollback` and the channels read with `--account-id bab` resolution (`apiAcct{Seg: "bab", Exact: true}` or whatever `resolvedAccount` builds for an exact sqid; read `apiAccount` at `apimode.go:399`). Map each recorded path back to a route pattern and assert each is in Task 4's §6 list. Then call `sess.appRegions` and assert its path is `.../regions`, which is not in the list; name the test `TestCarlosDeployNeedsADoorDeployDoesNotHave` so the gap is visible. |
| +Adjust imports (`net/http`, `net/http/httptest`, `context`, `strings`). If `normalizeConsole` refuses the `http://127.0.0.1` origin, read how `TestAPISessionShipMultipart` points a session at an httptest server and do the same. Also add a door-coverage test (Review Focus 5): a fake console that records `METHOD path` for every request and answers the shapes `TestAPISessionShipMultipart` and `TestAPISessionStableRefusal` already use; drive `sess.ship`, `sess.promote`, `sess.rollback` and the channels read with `--account-id bab` resolution (`apiAcct{Seg: "bab", Exact: true}` or whatever `resolvedAccount` builds for an exact sqid; read `apiAccount` at `apimode.go:399`). Also drive `sess.instanceList` and `sess.appRegions`, the two reads `carlos deploy` adds (R6). Map each recorded path back to a route pattern and assert each is in Task 4's §6 list; name the test `TestCarlosDeployRoutinePathUsesOnlyDeployDoors`. Separately assert that `sess.logsGet`'s path (`.../logs`) is not in the list, so the watch's diagnosis stays a best-effort read a deploy credential is refused. |
| - [ ] **Step 3: Run and see them fail.** |
| -Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployNeedsADoor' -count=1` |
| +Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployRoutinePath' -count=1` |
| Expected: FAIL (`serviceSessionLabel` undefined). |
| - [ ] **Step 4: Implement** in `resolveMode`, after the existing `--console` conflict check and before `if bucketSet || dirSet { return nil, nil }`: |
| @@ -2151,11 +2155,12 @@ to the rungs CI may move, and give CI the secret and the console's address: |
| carlos services create --role deploy --app blog --rungs canary,edge ci |
| # in CI: |
| export CARLOS_SERVICE_TOKEN=... CARLOS_CONSOLE=https://console.example.com |
| - carlos ship --app blog --version "$GITHUB_SHA" --target linux-arm64 blog-linux-arm64 |
| - carlos promote --app blog "$GITHUB_SHA" edge |
| + carlos deploy --app blog --version "$GITHUB_SHA" --channel edge blog-linux-arm64 |
| -Every promote is audited in the console and reaches the boxes within |
| -seconds. A deploy credential can ship, and promote or roll back only its |
| +`carlos deploy` ships, promotes and watches the app's hosts, the same as |
| +from a laptop. The first deploy of a new app still needs a person: a deploy |
| +credential cannot create the app or set up its first host. Every promote is |
| +audited in the console and reaches the boxes within seconds. A deploy credential can ship, and promote or roll back only its |
| rungs. It cannot reach stable, hotfix, delete the app, or change retention |
| or domains. |
diff --git a/docs/superpowers/specs/2026-10-05-deploy-credential-design.md b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md| index 591e3336b..f7c933dfb 100644 |
| --- a/docs/superpowers/specs/2026-10-05-deploy-credential-design.md |
| +++ b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md |
| @@ -1,7 +1,7 @@ |
| # A deploy credential for CI: promote through the console, limited to rungs |
| **Date:** 2026-10-05 |
| -**Revision:** 5.1. |
| +**Revision:** 5.2. |
| - Rev 1 (d30a74c2) went to Astra (via codex). The findings are in |
| `CODEX-FINDINGS-DEPLOY-CREDENTIAL-R1.md`, unedited. All six were checked |
| @@ -46,6 +46,8 @@ |
| accepted. Rev 5 is unchanged by it. |
| - **Rev 5.1 records Paul's ruling on §16 as R5** (2026-10-05): the |
| in-flight gap is accepted. Nothing else changes. |
| +- **Rev 5.2 records Paul's ruling R6** (2026-10-05): CI must be able to run |
| + `carlos deploy`. §6 gains the two read doors its routine path needs. |
| **Discussion:** carlos/platform#229. |
| **Code read at:** main `8d8e4bae`. Every line reference below is at that commit. |
| @@ -154,6 +156,9 @@ is a separate spec. Neither is part of this one. |
| - **R5.** A `deploy` request in flight when a pipeline is installed may |
| complete, within G1 and G2 (§7.2, §16). The gap is accepted and is not |
| closed by this spec. |
| +- **R6.** CI must be able to run `carlos deploy`, not only `ship` and |
| + `promote`. A `deploy` credential therefore reaches the read doors |
| + `carlos deploy` uses on an app that is already set up (§6). |
| ## 4. The shape, in one paragraph |
| @@ -244,10 +249,29 @@ mint a new one and revoke the old. |
| | `GET .../{app}/version-target` | read | |
| | `GET .../{app}/pipeline` | read | |
| | `GET .../{app}/pending` | read | |
| +| `GET .../{app}/instances` | read: `carlos deploy` lists the instances it will watch (R6) | |
| +| `GET .../{app}/regions` | read: `carlos deploy --region` checks the app's region (R6) | |
| It is added to nothing else. In particular it does not reach: app create, |
| trash, restore, retention (read or write), version-target write, domains, |
| -features, or any `instance`, `operate` or `admin` door. |
| +features, logs, or any other `instance`, `operate` or `admin` door. |
| + |
| +**What `carlos deploy` does with a `deploy` credential (R6).** Its routine |
| +path on an app that is already set up reads the instance list, reads the |
| +channels, ships, promotes, and watches the app's own hosts, and with |
| +`--region` reads the app's region. All of that is in the table. Three |
| +things it does only in particular cases stay out of reach: |
| + |
| +- **First-deploy setup.** Creating the app, enabling instances and minting |
| + the first platform host are writes a person does once. A `deploy` |
| + credential is refused at those doors with the role gate's message, and |
| + the deploy stops there. |
| +- **`--host` on a static deploy** attaches a domain, which is a domains |
| + write. Refused the same way. |
| +- **The watch's diagnosis.** When a watched host times out, `carlos deploy` |
| + reads one line of the app's platform log to say why. That read is |
| + best-effort: refused, the deploy reports the timeout without the reason. |
| + Logs can carry customer data, so a CI credential does not read them. |
| The existing app binding (`apiAccountAppBy`, `apicli.go:70`) applies as it |
| does to every app-bound service credential: another app's path is a 404. |