spec: a deploy credential for CI, limited to rungs (#229)
Human
Prompt
Do we actually want direct bucket promotes? Shouldn't folks always be using the carlos commands? Why is direct bucket even being discussed?
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
Design for a new `deploy` service role with a rung scope, so CI promotes through the console (audited, serial-bumped, adopted in seconds) with authority no wider than today's scoped release key. Adds a CARLOS_SERVICE_TOKEN path in the CLI and flips the README's advice to CI.
Agent notes
Rulings R1-R4 are Paul's, 2026-10-05. Line references are at main 8d8e4bae. Three items are marked "to check in the plan".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+344
−0
docs/superpowers/specs/2026-10-05-deploy-credential-design.md+344 −0
CI cancelled — superseded by a newer push — run details
diff --git a/docs/superpowers/specs/2026-10-05-deploy-credential-design.md b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md| new file mode 100644 |
| index 000000000..ea6793f3a |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-05-deploy-credential-design.md |
| @@ -0,0 +1,344 @@ |
| +# A deploy credential for CI: promote through the console, limited to rungs |
| + |
| +**Date:** 2026-10-05 |
| +**Revision:** 1. |
| +**Discussion:** carlos/platform#229. |
| +**Code read at:** main `8d8e4bae`. Every line reference below is at that commit. |
| + |
| +## 1. The problem, as found |
| + |
| +titogo's CI promotes every merge to `edge` with `carlos promote` in |
| +direct-bucket mode. It does that because the README tells it to |
| +(`README.md:170`): "CI and operators keep the existing path unchanged ... |
| +logging in is for a human at a laptop, never a box or a pipeline." |
| + |
| +A direct-bucket promote signs a pointer and writes it to S3. The console |
| +never sees it, so the promote skips everything the console adds: |
| + |
| +- the audit line; |
| +- pipeline rules and pending approvals; |
| +- the serial bump (`s.bumpSerial`, `internal/console/apicli.go:906`) that |
| + makes a box's `fastConvergeLoop` adopt within seconds. |
| + |
| +Without the bump, a box picks the promote up on `carlos-adopt.timer`, hourly |
| +by default. Tito's box carries a hand-made 2-minute drop-in to make that |
| +bearable. Measured on 2026-10-05, it adds 30 seconds to 3.5 minutes to every |
| +titogo deploy. |
| + |
| +CI cannot move to the console today, because the narrowest credential it |
| +could hold there is too wide. A `publish` service credential bound to one |
| +app reaches (`internal/console/authcli.go:476-739`): |
| + |
| +- promote and rollback on every channel but stable. For an app with no |
| + pipeline that includes `beta`, and it includes a hotfix that skips the |
| + ladder order (`apicli.go:802-811`: only the stable rung is refused when |
| + `pipe == nil`); |
| +- trash and restore of the app, retention, version-target, domains. |
| + |
| +Today's CI key is a scoped release key for `canary,edge`. It cannot sign |
| +beta or stable, and it cannot do anything else. Moving CI to `publish` would |
| +widen its authority, so the Tito session asked for the feature instead of |
| +working around the gap. That is the right call, and every Carloku customer |
| +with a CI pipeline will need the same thing: on Carloku a customer never |
| +holds bucket credentials, so the console is their only door. |
| + |
| +## 2. Goals and non-goals |
| + |
| +**Goals** |
| + |
| +1. A console service credential that CI can hold with authority no wider |
| + than a scoped release key: ship, and promote or roll back only the rungs |
| + it was granted. |
| +2. CI can supply that credential to the CLI from its environment, with no |
| + credentials file. |
| +3. The README and CLI docs tell CI to use the console. Direct-bucket mode is |
| + documented as the operator's and break-glass path. |
| +4. A console rolled back to a build from before this change refuses the new |
| + credential at every door. |
| + |
| +**Non-goals** |
| + |
| +- Scopes that name pipeline channels (ruling R2). |
| +- Narrowing or changing existing `publish` credentials. |
| +- Expiry on service credentials. Rotate and revoke stay as they are. |
| +- Any change to direct-bucket mode. In particular, a direct promote still |
| + does not bump the serial (ruling R4). |
| +- Changing who restarts titogo's units. That is titogo's own work |
| + (teamtito/titogo#3872). |
| + |
| +## 3. Rulings (Paul, 2026-10-05) |
| + |
| +- **R1.** A new role, `deploy`, plus a rung scope on the credential. Existing |
| + roles are unchanged. |
| +- **R2.** The scope names rungs only, exactly as a scoped release key does. |
| + For an app with a pipeline a `deploy` credential reaches `canary/*` and |
| + nothing else. |
| +- **R3.** The console is the normal path for CI. Direct-bucket mode stays as |
| + the operator's tool: bootstrap before a console exists, deployments with |
| + no console, recovery when the console is down, and the platform's own |
| + releases. |
| +- **R4.** #229's option 2 is declined in both forms. A direct promote that |
| + bumps the serial makes the bypass more attractive. Polling every followed |
| + channel pointer multiplies the one serial read per 2-second tick that the |
| + serial exists to protect. |
| + |
| +## 4. The shape, in one paragraph |
| + |
| +`carlos services create --role deploy --app titogo --rungs canary,edge ci` |
| +mints a service credential. CI exports it as `CARLOS_SERVICE_TOKEN` with |
| +`CARLOS_CONSOLE`, and runs the same `carlos ship` and `carlos promote` it |
| +runs today. The console checks the role at the door, then checks the target |
| +channel against the credential's rungs before it signs anything. The promote |
| +is audited and bumps the serial, so the box adopts in seconds. The runner |
| +holds no AWS role and no signing key. |
| + |
| +## 5. The credential |
| + |
| +### 5.1 Role |
| + |
| +`internal/svcrole/svcrole.go` gains `Deploy Role = "deploy"` in the |
| +catalogue. `Allow()` keeps adding `Admin` to every set, unchanged. |
| + |
| +### 5.2 Rungs |
| + |
| +- Allowed values: `canary`, `edge`, `beta` (`release.RungCanary`, |
| + `RungEdge`, `RungBeta`, `internal/release/ladder.go:30-32`). `canary` |
| + covers every `canary/<branch>` channel, as it does in a key grant. |
| +- `stable` is refused at mint and again at the door, mirroring |
| + `internal/release/grant.go:103` and `:219`. A stable promote needs a |
| + passkey and always has. |
| +- Stored canonically: deduplicated, in ladder order, comma-joined. |
| + |
| +### 5.3 Storage |
| + |
| +One additive column on `cli_tokens`, beside the service columns at |
| +`internal/console/authstore.go:210-216`: |
| + |
| + ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT '' |
| + |
| +`Principal` (`internal/console/svcprincipal.go:48`) gains `Rungs []string`. |
| + |
| +### 5.4 Mint rules |
| + |
| +Both mint doors (`carlos services create` through `handleAPIServiceCreate`, |
| +and the browser form at `POST /a/{sqid}/services/new`) write through |
| +`Store.NewServicePrincipal` (`svcprincipal.go:171`). The rules live there, |
| +so neither door can skip them: |
| + |
| +- role `deploy` requires a non-empty app, the same way `service` does at |
| + `svcprincipal.go:189`; |
| +- role `deploy` requires at least one rung, each one of the three in §5.2; |
| +- any other role with a non-empty rungs value is refused. A rung scope on |
| + `publish` would read as a limit and enforce nothing. |
| + |
| +There is no default rung set. The person minting says what the credential |
| +may move. |
| + |
| +### 5.5 Reading it back |
| + |
| +A `deploy` principal whose stored rungs are empty or do not parse is refused |
| +when the token is resolved. It is never treated as unscoped. This is the |
| +case a hand-edited row or a future bug would produce, and it must fail |
| +closed. |
| + |
| +Rotate keeps the rungs. There is no edit: to change a credential's rungs, |
| +mint a new one and revoke the old. |
| + |
| +### 5.6 CLI and browser |
| + |
| +- `carlos services create` gains `--rungs <list>`. The usage line and the |
| + role list in `services.go:33` and `:157` gain `deploy`. |
| +- `printServiceCreated` (`services.go:236`) states the rungs in words next to |
| + the app scope, and its delivery hint covers the CI case (§8). |
| +- `carlos services ls` and the services page show the rungs. |
| +- The browser mint form offers the role and the three rungs. |
| + |
| +## 6. Doors |
| + |
| +`deploy` is added to the allow set of exactly these routes in |
| +`internal/console/authcli.go`: |
| + |
| +| Door | Why | |
| +| --- | --- | |
| +| `GET /api/cli/apps` | the CLI resolves the app | |
| +| `GET .../{app}/channels` | read what is promoted where | |
| +| `POST .../{app}/releases` | ship | |
| +| `GET .../{app}/releases` | list what was shipped | |
| +| `POST .../{app}/promote` | scoped, §7 | |
| +| `POST .../{app}/rollback` | scoped, §7 | |
| +| `GET .../{app}/tags`, `GET .../{app}/tags/{version}` | read | |
| +| `GET .../{app}/version-target` | read | |
| +| `GET .../{app}/pipeline` | read | |
| +| `GET .../{app}/pending` | read | |
| + |
| +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. |
| + |
| +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. |
| +`refuseServiceControlPlane` (`controlplane.go:108`) applies unchanged. |
| + |
| +Ship is not rung-scoped. Shipping uploads a release and never moves a |
| +pointer. |
| + |
| +## 7. Enforcement at promote and rollback |
| + |
| +### 7.1 One function |
| + |
| +A pure function decides, so it can be table-tested without a server: |
| + |
| + deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error |
| + |
| +For any principal whose role is not `deploy` it returns nil. For a `deploy` |
| +principal it refuses, in this order: |
| + |
| +1. `hotfix` is set. A scoped release key cannot fast-track either. |
| +2. `release.Rung(channel)` (`ladder.go:55`) says the channel is not a rung. |
| +3. The rung is `stable`. |
| +4. The rung is not in the credential's rungs. |
| +5. The app has a pipeline and the rung is not `canary`. |
| + |
| +### 7.2 Where it runs |
| + |
| +- **`handleAPIPromote`** (`apicli.go:701`): after `GetPipeline` has answered |
| + (it fails closed already) and before the rules switch at `:803`. That is |
| + before any proposal is created and before `release.PromoteOnLadder` at |
| + `:880`. |
| +- **`handleAPIRollback`** (`apicli.go:970`): after the pipeline read and |
| + before any pointer or proposal write. |
| + |
| +Everything after the check is unchanged. The ladder order, the pipeline |
| +rules, the audit line and the serial bump all still run for a `deploy` |
| +credential, because it takes the same path as any other caller from that |
| +point on. |
| + |
| +### 7.3 The refusal |
| + |
| +A 403 with a JSON `error` that names the credential and the rungs it holds, |
| +so a CI log says what to fix. The wording goes through copy review (§11). |
| + |
| +### 7.4 What this replaces, stated plainly |
| + |
| +Today the limit on titogo's CI is cryptographic: every box verifies the key |
| +grant, and a pointer signed outside the grant is rejected wherever it lands. |
| +With a `deploy` credential the console signs with its own key |
| +(`s.cfg.Key`), so the limit is the console's check in §7.1. A bug in that |
| +check would not be caught by a box. |
| + |
| +This is accepted under R3. It is why §7.1 is one small pure function with a |
| +table test, and why `stable` keeps its unconditional refusal independent of |
| +that function. |
| + |
| +## 8. The CLI in CI |
| + |
| +Today the CLI finds a console credential only in a credentials file |
| +(`auth.go:140`). `CARLOS_SERVICE_TOKEN` is printed by `services create` as |
| +a delivery hint and read by nothing in the CLI. |
| + |
| +- `resolveMode` (`apimode.go:93`) reads `CARLOS_SERVICE_TOKEN`. When it is |
| + set, the session uses that token and the console named by `--console` or |
| + `CARLOS_CONSOLE`. The credentials file is not consulted. |
| +- `CARLOS_SERVICE_TOKEN` with no console named is an error that names |
| + `CARLOS_CONSOLE`. There is no default console for a token. |
| +- `CARLOS_SERVICE_TOKEN` together with `CARLOS_DEPLOYMENT_BUCKET` or |
| + `CARLOS_DEPLOYMENT_DIR` is refused, with an error naming both variables. |
| + The two modes carry different authority, and `resolveMode` already |
| + refuses `--console` beside either store variable for the same reason |
| + (`apimode.go:86-99`). |
| +- A bare store variable with no token still selects direct mode silently, |
| + unchanged. |
| +- The token is never accepted as a flag, so it never lands in a process |
| + list or a CI log's command echo. |
| +- `carlos auth whoami` reports the service identity when the token is set. |
| + |
| +**To check in the plan.** `CARLOS_SERVICE_TOKEN` is also the name an app |
| +receives its own service credential under at run time |
| +(`internal/console/svcpage.go:143`). A `carlos` invoked inside an instance |
| +would now pick it up. Confirm that is harmless for every box-side and |
| +in-instance caller, or choose a second name for the CLI. |
| + |
| +## 9. Mixed versions and rollback |
| + |
| +- **Old console, `deploy` token.** `cliRoute` checks `allow.Permits(p.Role)` |
| + before any handler runs (`authcli.go:411`). An old binary's sets do not |
| + contain `deploy`, so every door refuses. The plan must pin this with a |
| + test that resolves a service principal carrying a role the binary does |
| + not know and asserts a refusal at every registered `cliRoute`. It must |
| + also confirm the same holds in v0.21.0's `cliRoute` by reading it. |
| +- **Old console, new column.** An old binary ignores `rungs`. That is safe |
| + only because of the line above: it never lets the credential through. |
| +- **New console, old CLI.** `services create --role deploy` fails in the old |
| + CLI's own role check (`services.go:174`). Promote and ship with a token |
| + need the new CLI for §8 only. |
| +- **Roll order.** Console first, then mint, then CI. |
| + |
| +## 10. Audit |
| + |
| +No new audit shape. A `deploy` promote writes the same `promote` activity |
| +line as any CLI promote, attributed to the service credential. Minting, |
| +rotating and revoking a `deploy` credential are audited as service |
| +credentials already are, and the mint line records the rungs. |
| + |
| +## 11. Docs and copy |
| + |
| +- `README.md:170-180` is rewritten: CI uses the console with a `deploy` |
| + credential. Direct-bucket mode is described as the operator's path, with |
| + the four cases in R3. |
| +- `docs/site/cli.md` (the `ci-shipper` example at `:1419`) uses |
| + `--role deploy --rungs canary,edge`. |
| +- Every new user-facing string goes through the copy-review gate before it |
| + is written into a file: CLI usage and help, the mint output, the three |
| + CLI errors in §8, the 403 in §7.3, the browser form, and the two doc |
| + passages above. |
| + |
| +## 12. Testing |
| + |
| +- **Scope function.** A table over role, pipelined, channel and hotfix, |
| + covering every refusal in §7.1 and each allowed case. |
| +- **Handlers.** For promote and rollback: an in-scope rung succeeds, writes |
| + the audit line and bumps the serial; an out-of-scope rung, `stable`, a |
| + hotfix, and a non-canary channel on a pipelined app each give a 403 and |
| + write nothing. |
| +- **Doors.** A test walks every registered `cliRoute` and asserts `deploy` |
| + is permitted on exactly the §6 list. A route added later is refused for |
| + `deploy` unless someone adds it to the list on purpose. |
| +- **Mint.** `deploy` with no app, with no rungs, with `stable`, with an |
| + unknown rung; another role with rungs. Each refused through both doors. |
| +- **Read-back.** A `deploy` row with empty rungs is refused at resolve. |
| +- **Unknown role.** §9's fail-closed test. |
| +- **CLI.** Token plus console selects API mode; token with no console |
| + errors; token plus a store variable errors; a bare store variable is |
| + still direct mode. |
| + |
| +## 13. Rollout |
| + |
| +1. Merge and release the platform. |
| +2. Roll Tito's console and boxes. Tito runs its own console. |
| +3. An owner mints `deploy` for app `titogo`, rungs `canary,edge`. |
| +4. titogo's CI switches: drops its scoped key and grant and |
| + `CARLOS_DEPLOYMENT_BUCKET` (and its AWS role, if nothing else in the |
| + workflow uses it); sets `CARLOS_SERVICE_TOKEN` and |
| + `CARLOS_CONSOLE`. This is a titogo change, tracked on #229. |
| +5. Confirm on the box that an edge promote adopts within seconds, then |
| + remove `carlos-adopt.timer.d/canary-cadence.conf`. |
| +6. Remove the scoped release key and its grant from the runner's secrets. |
| + The grant expires on its own within its 365-day life. |
| + |
| +**To check before step 4.** |
| + |
| +- The console's ship door caps one request at 256 MiB |
| + (`apiShipMaxBytes`, `apicli.go:1404`). Confirm titogo's artifact fits. |
| +- The GitHub runner can reach Tito's console. |
| +- Which doors `carlos canary` uses in API mode. If it needs an `instance` |
| + door, session canaries keep a separate credential, and that is out of |
| + scope here. |
| + |
| +## 14. Out of scope, recorded so nobody rediscovers it |
| + |
| +- A `publish` credential can promote `beta` and hotfix past the ladder on an |
| + app with no pipeline (§1). That is existing behaviour for a role that was |
| + designed to be wide. This spec gives CI a narrower role and does not |
| + change `publish`. |
| +- Pipeline channel scopes (R2). If a customer with a pipeline needs CI to |
| + propose onto a pipeline channel, that is a follow-on spec. |