carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 52aac91e74db5140f5de0b38a0503af3d21f1eb0 parent 2248be9
2 files changed, +40 −11
  • docs/superpowers/plans/2026-10-05-deploy-credential.md +14 −9
  • docs/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.