carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Deploy Credential Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A `deploy` service credential, limited to rungs, that lets CI ship, promote and roll back through the console with no more authority than a scoped release key.
6
7**Architecture:** A new `svcrole.Deploy` role and a `rungs` column on `cli_tokens`. A pure function, `deployScopeAllows`, decides every promote and rollback by a `deploy` credential. It runs early in the handler for the message, and authoritatively inside the release primitives through a new `Authorize` hook placed between the pointer read and the conditional write. A `deploy` promote is signed by a scoped key minted for that one request, so boxes still verify the rung limit. The CLI reads `CARLOS_SERVICE_TOKEN` and `CARLOS_CONSOLE` so CI needs no credentials file.
8
9**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.
10
11**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.
12
13## Global Constraints
14
15- Rungs a `deploy` credential may hold: `canary`, `edge`, `beta`. `stable` is refused at mint and at every door (spec §5.2).
16- `deploy` requires a non-empty app and at least one rung. Any other role with rungs is refused (spec §5.4).
17- A stored `deploy` row whose rungs are empty or do not parse resolves to no principal at all (spec §5.5).
18- A `deploy` credential is permitted on exactly the doors in spec §6, and on nothing else.
19- A caller that passes no `Authorize` hook gets exactly today's behaviour and today's store calls (spec §7.2).
20- A `deploy` request never falls back to the root signer. If the scoped signer cannot be built, the request is refused (spec §7.4).
21- Request-local grant lifetime: 5 minutes. Grant name: `deploy.` + the credential's name.
22- The CLI reads `CARLOS_SERVICE_TOKEN` and the console from `--console` or `CARLOS_CONSOLE` only. Token plus `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` is refused (spec §8). `CARLOS_SERVICE_TOKEN` was checked for collisions on 2026-10-05: nothing in the platform or in titogo reads it today; it is only the name `services create` tells people to deliver the secret under, so the CLI keeps that name.
23- `carlos auth whoami` is unchanged (spec §8).
24- Existing `publish` credentials and existing roles are unchanged (R1).
25- Every user-facing string in this plan is a draft with an ID (D1 to D31) until Task 0 replaces it with Paul's approved text. Implementers copy the approved text from the Copy table verbatim. No new string uses an em dash.
26- Tests run inside the sandbox with `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off` (re-export in every shell; nothing persists between commands). Task 12 runs the full gate outside the sandbox.
27- Commits in this worktree use `git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit`, and end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
28
29## Review Focus
30
311. **Rung lists as people type them.** `--rungs "edge, canary"`, `Edge`, `edge,edge`, a trailing comma. Expected: spaces and empties dropped, duplicates collapsed, stored in ladder order (`canary,edge`); anything not exactly a lower-case rung name is refused with D10 naming it. Pinned in Task 1.
322. **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.
333. **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.
344. **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.
355. **`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.
36
37---
38
39## File map
40
41| File | Responsibility | Tasks |
42| --- | --- | --- |
43| `internal/svcrole/svcrole.go` | the `Deploy` role in the catalogue | 1 |
44| `internal/console/deployscope.go` (new) | rung parsing, `deployScopeAllows`, the refusal type, `deploySigner` | 1, 5, 7 |
45| `internal/console/authstore.go` | `rungs` column, `CLIToken.Rungs`, row scanning | 1 |
46| `internal/console/svcprincipal.go` | `Principal.Rungs`, `NewServicePrincipalScoped`, read-back | 1 |
47| `internal/console/apiservices.go` | API mint door and listing | 2 |
48| `internal/console/svcpage.go`, `templates/account_settings.html`, `server.go` | browser mint form and listing | 2 |
49| `internal/console/authcli.go` | the §6 door list | 4 |
50| `internal/console/apicli.go` | promote and rollback handlers | 5, 7 |
51| `internal/release/authorize.go` (new) | `AuthorizationError` | 6 |
52| `internal/release/release.go` | the `Authorize` hook in promote and rollback | 6 |
53| `services.go`, `apimode.go` | `services create --rungs`, `services ls`, `resolveMode` | 3, 9 |
54| `README.md`, `docs/site/cli.md` | CI uses the console | 11 |
55| tests | one new `_test.go` per task, named in each task | all |
56
57---
58
59### Task 0: Copy review (orchestrator with Paul; no subagent)
60
61Every string below is a draft. This task replaces the drafts with Paul's approved text before any task writes them into a file.
62
63**Files:**
64- Modify: this plan's Copy table, in place.
65
66- [ ] **Step 1: Count each string at the base commit.** For each draft, grep the tree to confirm it does not already exist under another ID (a shared string needs its own review):
67
68```bash
69cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential
70for s in "deploy credential" "rungs" "CARLOS_SERVICE_TOKEN is set"; do echo "== $s"; grep -rn --include=*.go --include=*.html --include=*.md "$s" . | grep -v docs/superpowers | wc -l; done
71```
72
73- [ ] **Step 2: Run the copy-review skill** (`copy-review`) with every row of the Copy table, IDs D1 to D31. Wait for Paul's edits.
74- [ ] **Step 3: Write the approved text into the Copy table verbatim**, replacing each draft. Mark the table header "approved 2026-MM-DD".
75- [ ] **Step 4: Commit.**
76
77```bash
78git add docs/superpowers/plans/2026-10-05-deploy-credential.md
79git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "plan: deploy credential copy approved
80
81Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
82```
83
84### Copy table (approved 2026-10-05, all 32 strings as drafted)
85
86`%s` and `%q` are Go format verbs filled at run time. `{{...}}` is template syntax.
87
88| ID | Where | Draft |
89| --- | --- | --- |
90| D1 | `services create` `--role` help | `role: publish, deploy, instance, operate, service or admin (required)` |
91| D2 | `services create` usage | `usage: carlos services create --role <publish\|deploy\|instance\|operate\|service\|admin> [--app <app>] [--rungs <list>] [--account <sqid\|name>] [--account-id <sqid>] [--console <origin>] <name>` |
92| D3 | `services create` `--rungs` help | `rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)` |
93| D4 | CLI pre-check | `services create: --rungs is only for --role deploy` |
94| D5 | mint output, deploy | `minted service:%s/%s (role deploy, app %s, rungs %s)` |
95| D6 | `services ls` column header | `RUNGS` |
96| D7 | API mint refusal: deploy, no app | `a deploy credential must name one app (--app)` |
97| D8 | API mint refusal: deploy, no rungs | `a deploy credential must name its rungs (--rungs): canary, edge or beta` |
98| D9 | API mint refusal: stable | `stable cannot be delegated: moving it needs a passkey in the console` |
99| D10 | API mint refusal: unknown rung | `unknown rung %q: one of canary, edge, beta` |
100| D11 | API mint refusal: rungs on another role | `only a deploy credential takes rungs` |
101| D12 | form flash: deploy, no app | `A deploy credential must name one app. Nothing was minted.` |
102| D13 | form flash: deploy, no rungs | `A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.` |
103| D14 | form flash: stable | `Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.` |
104| D15 | form flash: unknown rung | `“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.` |
105| D16 | form flash: rungs on another role | `Only a deploy credential takes rungs. Nothing was minted.` |
106| D17 | audit detail, deploy mint | `minted service credential %s with role deploy over app %s, rungs %s (%s)` (last verb is `cli` or `console`) |
107| D18 | form: rung checkboxes label | `Rungs, for a deploy credential:` |
108| D19 | listing column header (page) | `Rungs` |
109| D20 | secret banner, deploy | `Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, rungs {{.Rungs}}:` |
110| D21 | 403: hotfix | `deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.` |
111| D22 | 403: channel outside rungs, not a rung, or stable | `deploy credential %s may move %s only, and %s is not one of them.` (first list e.g. `canary/<name>, edge`) |
112| D23 | 403: bare canary | `deploy credential %s may move canary/<name> channels, not canary itself.` |
113| D24 | 403: pipelined app | `%s has a pipeline, so deploy credential %s may move canary/<name> channels only.` |
114| D25 | console log line, scoped signer failure | `console: deploy signer for %s/%s: %v` |
115| D26 | 503: scoped signer failure | `could not prepare a signing key for this deploy credential. Nothing was promoted.` |
116| D27 | CLI: token, no console | `CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console` |
117| D28 | CLI: token and store variable | `CARLOS_SERVICE_TOKEN and CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are both set. The token works through the console and the store variables go straight to the bucket. Unset one of them.` |
118| D29 | CLI session label (`using X as …`) | `a service credential (CARLOS_SERVICE_TOKEN)` |
119| D30 | README.md replacing lines 170-180 | see Task 11 Step 1 |
120| D31 | docs/site/cli.md `carlos services` section | see Task 11 Step 2 |
121| D31b | docs/site/cli.md, the role list | `` `publish` ships releases and manages the app; `` (replaces "`publish` ships releases;") |
122
123---
124
125### Task 1: The role, the rungs column, minting rules and read-back
126
127**Files:**
128- Modify: `internal/svcrole/svcrole.go` (catalogue, comments)
129- Modify: `internal/svcrole/svcrole_test.go`
130- Create: `internal/console/deployscope.go`
131- Modify: `internal/console/authstore.go:99-125` (`CLIToken`), `:209-216` (ALTER list), `scanCLITokenRows` and every SELECT that feeds it
132- Modify: `internal/console/svcprincipal.go` (`Principal`, `CLIPrincipal`, `NewServicePrincipal`)
133- Create: `internal/console/deploycred_test.go`
134
135**Interfaces:**
136- Produces:
137 - `svcrole.Deploy Role = "deploy"`, in `All()` right after `Publish`.
138 - `func canonicalRungs(in []string) (string, error)` returns `""` for no rungs, else `canary,edge,beta` order joined by `,`. Errors: `ErrStableNotDelegable`, `*UnknownRungError{Rung string}`.
139 - `func parseStoredRungs(s string) ([]string, bool)`.
140 - `Principal.Rungs []string` (nil for every role but `deploy`).
141 - `CLIToken.Rungs string` (canonical stored form) and `func (t CLIToken) RungList() string` (`"canary, edge"`).
142 - `func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error)`.
143 - Sentinels: `ErrAccountScopedDeploy`, `ErrDeployNeedsRungs`, `ErrRungsNeedDeploy`, `ErrStableNotDelegable`.
144
145- [ ] **Step 1: Write the failing svcrole test.** Append to `internal/svcrole/svcrole_test.go`:
146
147```go
148func TestDeployIsMintableAndNarrow(t *testing.T) {
149 got, ok := Parse("deploy")
150 if !ok || got != Deploy {
151 t.Fatalf("Parse(\"deploy\") = %q, %v", got, ok)
152 }
153 all := All()
154 if len(all) < 2 || all[0] != Publish || all[1] != Deploy {
155 t.Fatalf("All() = %v; want deploy right after publish", all)
156 }
157 // Existing doors do not admit it: adding a role must not widen anything.
158 for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} {
159 if door.Permits(Deploy) {
160 t.Fatalf("%v admits deploy", door.Roles())
161 }
162 }
163 if !Allow(Deploy).Permits(Admin) {
164 t.Fatal("Allow adds Admin to every set; that changed")
165 }
166}
167```
168
169- [ ] **Step 2: Run it and see it fail.**
170
171Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -run TestDeployIsMintableAndNarrow -count=1`
172Expected: FAIL, `undefined: Deploy`.
173
174- [ ] **Step 3: Add the role.** In `internal/svcrole/svcrole.go`, after the `Publish` constant:
175
176```go
177 // Deploy is a deploy pipeline's credential: ship, and promote or roll
178 // back only the rungs the credential was minted with. It is always bound
179 // to one app, and it never reaches app lifecycle, retention or domains.
180 // The rungs live on the credential, not here: this package knows roles,
181 // not channels (spec 2026-10-05-deploy-credential-design.md §5).
182 Deploy Role = "deploy"
183```
184
185Change the catalogue line to:
186
187```go
188var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin}
189```
190
191Change the `Admin` comment's first line from "the union of the four above" to "the union of the five above".
192
193- [ ] **Step 4: Run the svcrole package.**
194
195Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -count=1`
196Expected: PASS.
197
198- [ ] **Step 5: Write the failing console tests.** Create `internal/console/deploycred_test.go`:
199
200```go
201package console
202
203import (
204 "errors"
205 "reflect"
206 "testing"
207 "time"
208
209 "github.com/carlosframework/platform/internal/svcrole"
210)
211
212func TestCanonicalRungs(t *testing.T) {
213 for _, tc := range []struct {
214 in []string
215 want string
216 }{
217 {nil, ""},
218 {[]string{" ", ""}, ""},
219 {[]string{"edge"}, "edge"},
220 {[]string{"edge", " canary ", "edge", ""}, "canary,edge"},
221 {[]string{"beta", "canary", "edge"}, "canary,edge,beta"},
222 } {
223 got, err := canonicalRungs(tc.in)
224 if err != nil || got != tc.want {
225 t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
226 }
227 }
228 if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) {
229 t.Errorf("stable: err = %v, want ErrStableNotDelegable", err)
230 }
231 for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} {
232 var ue *UnknownRungError
233 if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad {
234 t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err)
235 }
236 }
237}
238
239func TestParseStoredRungs(t *testing.T) {
240 if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) {
241 t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok)
242 }
243 // Not canonical, empty, or naming stable: never a usable scope.
244 for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} {
245 if got, ok := parseStoredRungs(bad); ok {
246 t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got)
247 }
248 }
249}
250
251func TestMintDeployRules(t *testing.T) {
252 st := newDeployStore(t)
253 now := time.Now()
254 mint := func(app string, role svcrole.Role, rungs []string) error {
255 _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, "paul@example.test", now)
256 return err
257 }
258 if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) {
259 t.Errorf("deploy with no app: %v", err)
260 }
261 if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) {
262 t.Errorf("deploy with no rungs: %v", err)
263 }
264 if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) {
265 t.Errorf("deploy with stable: %v", err)
266 }
267 if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) {
268 t.Errorf("publish with rungs: %v", err)
269 }
270 // The old entry point cannot mint a deploy credential: it has no rungs.
271 if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) {
272 t.Errorf("NewServicePrincipal(deploy): %v", err)
273 }
274}
275
276func TestDeployPrincipalReadsBackItsRungs(t *testing.T) {
277 st := newDeployStore(t)
278 now := time.Now()
279 raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, "paul@example.test", now)
280 if err != nil {
281 t.Fatal(err)
282 }
283 p, ok := st.CLIPrincipal(raw, now)
284 if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) {
285 t.Fatalf("CLIPrincipal = %+v, %v", p, ok)
286 }
287 // Review Focus 3: rotation keeps the rungs, and the old secret dies.
288 fresh, err := st.RotateServicePrincipal(1, "ci", now)
289 if err != nil {
290 t.Fatal(err)
291 }
292 if _, ok := st.CLIPrincipal(raw, now); ok {
293 t.Fatal("the old secret still resolves after rotate")
294 }
295 p, ok = st.CLIPrincipal(fresh, now)
296 if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) {
297 t.Fatalf("after rotate: %+v, %v", p, ok)
298 }
299 rows, err := st.ServicePrincipals(1)
300 if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" {
301 t.Fatalf("ServicePrincipals = %+v, %v", rows, err)
302 }
303}
304
305func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) {
306 st := newDeployStore(t)
307 now := time.Now()
308 for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} {
309 raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now)
310 if err != nil {
311 t.Fatal(err)
312 }
313 if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil {
314 t.Fatal(err)
315 }
316 if p, ok := st.CLIPrincipal(raw, now); ok {
317 t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p)
318 }
319 }
320 // A non-deploy row that somehow carries rungs is refused too.
321 raw, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now)
322 if err != nil {
323 t.Fatal(err)
324 }
325 if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil {
326 t.Fatal(err)
327 }
328 if _, ok := st.CLIPrincipal(raw, now); ok {
329 t.Error("a publish row carrying rungs resolved")
330 }
331}
332
333// newDeployStore is a bare console store, enough for the store-level tests.
334func newDeployStore(t *testing.T) *Store {
335 t.Helper()
336 st, err := Open(t.TempDir() + "/console.db")
337 if err != nil {
338 t.Fatal(err)
339 }
340 t.Cleanup(func() { st.Close() })
341 return st
342}
343```
344
345- [ ] **Step 6: Run them and see them fail.**
346
347Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1`
348Expected: FAIL to compile, `undefined: canonicalRungs` and the other new names.
349
350- [ ] **Step 7: Create `internal/console/deployscope.go`** with the rung helpers (Task 5 and Task 7 add to this file):
351
352```go
353// The deploy credential's scope: which rungs it may move, and the one
354// function that decides whether a promote or rollback by it is in scope.
355//
356// Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md.
357package console
358
359import (
360 "errors"
361 "strings"
362
363 "github.com/carlosframework/platform/internal/release"
364)
365
366// deployRungs is the order rungs are stored and shown in. stable is not in
367// it: no delegated credential may move stable (spec §5.2).
368var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta}
369
370// ErrStableNotDelegable is a mint that names stable.
371var ErrStableNotDelegable = errors.New("console: stable cannot be delegated")
372
373// UnknownRungError is a mint that names something that is not a rung. It
374// carries the name so both mint doors can say which one (D10, D15).
375type UnknownRungError struct{ Rung string }
376
377func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung }
378
379// canonicalRungs turns what a person typed into the stored form: spaces and
380// empty entries dropped, duplicates collapsed, ladder order, joined by ",".
381// It returns "" when nothing was named. It never lower-cases: "Edge" is not
382// a rung, and accepting it would make the stored form differ from the input
383// in a way the minter did not ask for.
384func canonicalRungs(in []string) (string, error) {
385 seen := map[string]bool{}
386 for _, raw := range in {
387 r := strings.TrimSpace(raw)
388 if r == "" {
389 continue
390 }
391 if r == release.RungStable {
392 return "", ErrStableNotDelegable
393 }
394 known := false
395 for _, d := range deployRungs {
396 if r == d {
397 known = true
398 }
399 }
400 if !known {
401 return "", &UnknownRungError{Rung: r}
402 }
403 seen[r] = true
404 }
405 var out []string
406 for _, d := range deployRungs {
407 if seen[d] {
408 out = append(out, d)
409 }
410 }
411 return strings.Join(out, ","), nil
412}
413
414// parseStoredRungs reads the column back. Only the exact canonical form of a
415// non-empty set is accepted: anything else is a row this console did not
416// write, and a deploy credential with an unreadable scope must not resolve
417// (spec §5.5).
418func parseStoredRungs(s string) ([]string, bool) {
419 if s == "" {
420 return nil, false
421 }
422 parts := strings.Split(s, ",")
423 canon, err := canonicalRungs(parts)
424 if err != nil || canon != s {
425 return nil, false
426 }
427 return parts, true
428}
429```
430
431- [ ] **Step 8: Add the column and the listing field** in `internal/console/authstore.go`.
432
433Add to the ALTER list (after the `rotated_at` line, `:216`):
434
435```go
436 `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`,
437```
438
439Add to `CLIToken` (after `RotatedAt`):
440
441```go
442 // Rungs is a deploy credential's scope in its stored, canonical form
443 // ("canary,edge"); empty for every other kind and role.
444 Rungs string
445```
446
447And the display method, right after the struct:
448
449```go
450// RungList is Rungs for a person to read: "canary, edge".
451func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") }
452```
453
454In `scanCLITokenRows` (`:558`), add `&t.Rungs` as the last `Scan` destination. Then find every query that feeds it and append `, rungs` as the last selected column, in the same position:
455
456```bash
457grep -n "scanCLITokenRows(" internal/console/*.go
458```
459
460Each caller's `SELECT ... rotated_at` becomes `SELECT ... rotated_at, rungs`. `ServicePrincipals` (`svcprincipal.go`) and `CLITokens` (`authstore.go:595`) are two of them; update every one the grep lists.
461
462- [ ] **Step 9: Add `Rungs` to `Principal`, the scoped mint and the read-back** in `internal/console/svcprincipal.go`.
463
464In `Principal`, after `Name`:
465
466```go
467 // Rungs is a deploy credential's scope, in ladder order. nil for every
468 // other role, and never empty for a deploy principal: CLIPrincipal
469 // refuses a deploy row it cannot read a scope from.
470 Rungs []string
471```
472
473Add the sentinels next to `ErrAccountScopedService`:
474
475```go
476// ErrAccountScopedDeploy is a deploy credential minted without an app.
477var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app")
478
479// ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is
480// no default set: the minter says what the credential may move.
481var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung")
482
483// ErrRungsNeedDeploy is rungs named for any other role, where they would
484// read as a limit and enforce nothing.
485var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs")
486```
487
488Replace the body of `NewServicePrincipal` with a delegation, keeping its doc comment, and move the body into the new function:
489
490```go
491func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) {
492 return s.NewServicePrincipalScoped(accountID, name, app, role, nil, mintedBy, now)
493}
494
495// NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which
496// only a deploy credential takes. The rules live here rather than in a
497// handler for the reason the Service rule does: there are two mint doors.
498func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) {
499 if name == "" {
500 return "", errors.New("console: a service credential needs a name")
501 }
502 if _, ok := svcrole.Parse(string(role)); !ok {
503 return "", fmt.Errorf("console: unknown service role %q", role)
504 }
505 if role == svcrole.Service && app == "" {
506 return "", ErrAccountScopedService
507 }
508 canon, err := canonicalRungs(rungs)
509 if err != nil {
510 return "", err
511 }
512 if role == svcrole.Deploy {
513 if app == "" {
514 return "", ErrAccountScopedDeploy
515 }
516 if canon == "" {
517 return "", ErrDeployNeedsRungs
518 }
519 } else if canon != "" {
520 return "", ErrRungsNeedDeploy
521 }
522 // ... the existing transaction from NewServicePrincipal, unchanged except
523 // for the INSERT, which gains the rungs column:
524```
525
526with the INSERT becoming:
527
528```go
529 if _, err := tx.Exec(`INSERT INTO cli_tokens
530(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs)
531VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`,
532 hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil {
533 return "", err
534 }
535```
536
537Keep the whole existing comment block about `role == svcrole.Service && app == ""` above that check.
538
539In `CLIPrincipal`, select the column and check it. The query becomes:
540
541```go
542 var storedHash, addr, kind, app, role, name, rungs string
543 var accountID, expires, revoked int64
544 err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs
545FROM cli_tokens WHERE token_hash = ?`, hash).
546 Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs)
547```
548
549and the `cliKindService` case becomes:
550
551```go
552 case cliKindService:
553 r, ok := svcrole.Parse(role)
554 if !ok {
555 return Principal{}, false
556 }
557 var scope []string
558 if r == svcrole.Deploy {
559 // Fail closed: a deploy row with no readable scope, or with no
560 // app, is not treated as unscoped (spec §5.5).
561 scope, ok = parseStoredRungs(rungs)
562 if !ok || app == "" {
563 return Principal{}, false
564 }
565 } else if rungs != "" {
566 return Principal{}, false
567 }
568 p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope}
569```
570
571- [ ] **Step 10: Run the new tests, then the whole console package.**
572
573Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1`
574Expected: PASS.
575
576Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/svcrole/ -count=1`
577Expected: PASS. If a test that counts roles or lists `All()` fails, read it: it is asserting the catalogue, and the fix is to add `deploy` to its expectation, not to change the catalogue.
578
579- [ ] **Step 11: Commit.**
580
581```bash
582gofmt -l internal/ ; git add internal/svcrole internal/console/deployscope.go internal/console/authstore.go internal/console/svcprincipal.go internal/console/deploycred_test.go
583git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "console: deploy role and rung-scoped service credentials (#229)
584
585Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
586```
587
588---
589
590### Task 2: The console's mint doors and listings
591
592**Files:**
593- Modify: `internal/console/apiservices.go` (`serviceView`, `handleAPIServiceCreate`, `handleAPIServiceList`)
594- Modify: `internal/console/svcpage.go` (`ServiceSecretView`, `handleServiceCreatePage`, the page fill near `:177`)
595- Modify: `internal/console/server.go:1723-1724` (page struct)
596- Modify: `internal/console/templates/account_settings.html` (banner, listing, form)
597- Create: `internal/console/deploymint_test.go`
598
599**Interfaces:**
600- Consumes: Task 1's `NewServicePrincipalScoped`, sentinels, `UnknownRungError`, `CLIToken.Rungs`, `RungList`.
601- Produces: API create accepts `"rungs": ["edge"]` and answers `"rungs": ["canary","edge"]`; listing rows carry `"rungs": [...]` (empty array, never null, for non-deploy rows).
602
603- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploymint_test.go`:
604
605```go
606package console
607
608import (
609 "net/http"
610 "strings"
611 "testing"
612 "time"
613)
614
615func TestAPIMintDeploy(t *testing.T) {
616 fx := newConsoleFixture(t)
617 if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
618 t.Fatal(err)
619 }
620 token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-mint")
621 u := fx.ts.URL + "/api/cli/services/bab"
622 status, out := postJSON(t, u, map[string]any{"name": "ci", "role": "deploy", "app": "hello", "rungs": []string{"edge", "canary"}}, token)
623 if status != http.StatusCreated {
624 t.Fatalf("mint deploy = %d %v", status, out)
625 }
626 if got, _ := out["rungs"].([]any); len(got) != 2 || got[0] != "canary" || got[1] != "edge" {
627 t.Fatalf("rungs in response = %v", out["rungs"])
628 }
629 for _, tc := range []struct {
630 body map[string]any
631 want string // D-text fragment from the approved Copy table
632 }{
633 {map[string]any{"name": "a1", "role": "deploy", "rungs": []string{"edge"}}, "must name one app"},
634 {map[string]any{"name": "a2", "role": "deploy", "app": "hello"}, "must name its rungs"},
635 {map[string]any{"name": "a3", "role": "deploy", "app": "hello", "rungs": []string{"stable"}}, "stable cannot be delegated"},
636 {map[string]any{"name": "a4", "role": "deploy", "app": "hello", "rungs": []string{"prod"}}, `"prod"`},
637 {map[string]any{"name": "a5", "role": "publish", "app": "hello", "rungs": []string{"edge"}}, "only a deploy credential"},
638 } {
639 status, out := postJSON(t, u, tc.body, token)
640 if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) {
641 t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want)
642 }
643 }
644 status, out = getJSON(t, u, token)
645 if status != http.StatusOK {
646 t.Fatalf("list = %d %v", status, out)
647 }
648 rows, _ := out["services"].([]any)
649 if len(rows) != 1 {
650 t.Fatalf("listing = %v", rows)
651 }
652 if r, _ := rows[0].(map[string]any)["rungs"].([]any); len(r) != 2 {
653 t.Fatalf("listing rungs = %v", rows[0])
654 }
655}
656```
657
658The create and list doors are `POST` and `GET /api/cli/services/{account}` (`authcli.go:789-790`). The `want` fragments must match the approved Copy table text for D7 to D11; adjust them to the approved wording.
659
660Add a browser-form test in the same file, modelled on the existing mint-form tests (find them with `grep -n "services/new" internal/console/*_test.go`), asserting: a deploy mint with rung checkboxes `edge` and `canary` renders the secret banner containing `rungs canary, edge` (D20); a deploy mint with no app flashes D12; with no rungs flashes D13; a publish mint with a rung flashes D16; and in each refusal no row was created (`fx.st.ServicePrincipals(OpsAccountID)` unchanged). Use the same session and passkey helpers those tests use.
661
662- [ ] **Step 2: Run them and see them fail.**
663
664Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestAPIMintDeploy|TestFormMintDeploy' -count=1`
665Expected: FAIL (`rungs` missing from the response; refusals answer 500 or mint).
666
667- [ ] **Step 3: API door.** In `handleAPIServiceCreate`, add `Rungs []string \`json:"rungs"\`` to the request struct, call `s.store.NewServicePrincipalScoped(acct.ID, name, app, role, req.Rungs, actor, time.Now())`, and extend the error mapping after the `ErrAccountScopedService` arm:
668
669```go
670 var ue *UnknownRungError
671 switch {
672 case errors.Is(err, ErrAccountScopedDeploy):
673 jsonError(w, http.StatusBadRequest, "<D7>")
674 return
675 case errors.Is(err, ErrDeployNeedsRungs):
676 jsonError(w, http.StatusBadRequest, "<D8>")
677 return
678 case errors.Is(err, ErrStableNotDelegable):
679 jsonError(w, http.StatusBadRequest, "<D9>")
680 return
681 case errors.As(err, &ue):
682 jsonError(w, http.StatusBadRequest, fmt.Sprintf("<D10>", ue.Rung))
683 return
684 case errors.Is(err, ErrRungsNeedDeploy):
685 jsonError(w, http.StatusBadRequest, "<D11>")
686 return
687 }
688```
689
690`<Dn>` means the approved text from the Copy table, as a Go string literal. After a successful mint, compute the canonical list for the response and the audit line:
691
692```go
693 canon, _ := canonicalRungs(req.Rungs) // already validated by the mint
694 rungs := []string{}
695 if canon != "" {
696 rungs = strings.Split(canon, ",")
697 }
698```
699
700Use D17 for the audit detail when `role == svcrole.Deploy` (verb `cli`), and keep today's detail for every other role. Add `"rungs": rungs` to the response map.
701
702In `serviceView` add `Rungs []string \`json:"rungs"\``, and in `handleAPIServiceList` fill it: `[]string{}` when `t.Rungs == ""`, else `strings.Split(t.Rungs, ",")`.
703
704- [ ] **Step 4: Browser door.** In `handleServiceCreatePage`, after the existing `role == svcrole.Service && app == ""` pre-check, add pre-checks that refuse before the passkey trip, each with `back("err", "<Dn>")`:
705
706```go
707 canon, rerr := canonicalRungs(r.Form["rungs"])
708 var ue *UnknownRungError
709 switch {
710 case errors.Is(rerr, ErrStableNotDelegable):
711 back("err", "<D14>")
712 return
713 case errors.As(rerr, &ue):
714 back("err", fmt.Sprintf("<D15>", ue.Rung))
715 return
716 case role == svcrole.Deploy && app == "":
717 back("err", "<D12>")
718 return
719 case role == svcrole.Deploy && canon == "":
720 back("err", "<D13>")
721 return
722 case role != svcrole.Deploy && canon != "":
723 back("err", "<D16>")
724 return
725 }
726```
727
728`r.Form` is populated because `FormValue` has already been called above. Mint with `s.store.NewServicePrincipalScoped(acct.ID, name, app, role, r.Form["rungs"], addr, time.Now())` and map the same sentinels to D12 to D16 in its error arm (the store is still the authority). Use D17 (verb `console`) for a deploy mint's audit detail. Add `Rungs string` to `ServiceSecretView` and set it to `strings.ReplaceAll(canon, ",", ", ")`.
729
730In `server.go` next to `ServiceRoles`, add:
731
732```go
733 ServiceRungs []string // the mint form's rung choices, deploy only
734```
735
736and where `svcpage.go:177` fills `ServiceRoles`, set `p.ServiceRungs = deployRungs`.
737
738- [ ] **Step 5: Template.** In `templates/account_settings.html`:
739 - Banner: in the not-rotated branch, render D20 when `.Rungs` is non-empty, else the existing line:
740
741```html
742 {{else}}
743 {{if .Rungs}}<p><strong>D20</strong></p>{{else}}<p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p>{{end}}
744 {{end}}
745```
746
747 (`D20` replaced by the approved template text.)
748 - Listing: add a header cell `<th>D19</th>` after `Scope`, and a cell `<td>{{emptyOrDash .RungList}}</td>` after the scope cell.
749 - Form: after the app `<select>`, add:
750
751```html
752 <span class="small">D18</span>
753 {{range .ServiceRungs}}<label class="small"><input type="checkbox" name="rungs" value="{{.}}"> {{.}}</label>{{end}}
754```
755
756- [ ] **Step 6: Run the tests, then the package.**
757
758Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1`
759Expected: PASS.
760
761- [ ] **Step 7: Commit** with message `console: mint and list deploy credentials (#229)`.
762
763---
764
765### Task 3: `carlos services create --rungs` and `services ls`
766
767**Files:**
768- Modify: `services.go` (`runServicesCreate`, `printServiceCreated`, `runServicesList`)
769- Modify: `apimode.go:4285-4330` (`apiServiceView`, `createService`)
770- Modify: `services_cli_test.go`
771
772**Interfaces:**
773- Consumes: Task 2's API fields `rungs` (request and response, listing).
774- Produces: `func (s *apiSession) createService(ctx context.Context, a apiAcct, name, role, app string, rungs []string) (token string, doors []string, gotRungs []string, err error)`; `func printServiceCreated(acctSeg, name, role, app string, rungs []string, token string, doors []string)`; `apiServiceView.Rungs []string`.
775
776- [ ] **Step 1: Write the failing tests.** In `services_cli_test.go`, add a test modelled on `TestServicesCreatePrintsTheSecretAndTheDoors` that calls `printServiceCreated("bab", "ci", "deploy", "hello", []string{"canary", "edge"}, "tok", nil)` and asserts the first line is D5 filled as `minted service:bab/ci (role deploy, app hello, rungs canary, edge)`. Add a test modelled on `TestAPISessionServiceCreateAndList` whose fake console records the create request body and answers `{"token":"t","doors":[],"rungs":["canary","edge"]}`; assert the request JSON carried `"rungs":["edge","canary"]` and `createService` returned `gotRungs == []string{"canary","edge"}`; and that a listing row with `"rungs":["edge"]` decodes into `apiServiceView.Rungs`.
777
778- [ ] **Step 2: Run and see them fail.**
779
780Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestServicesCreate|TestAPISessionServiceCreate' -count=1`
781Expected: FAIL to compile (signature).
782
783- [ ] **Step 3: Implement.**
784 - `apiServiceView`: add `Rungs []string \`json:"rungs"\``.
785 - `createService`: add the `rungs []string` parameter, send `Rungs []string \`json:"rungs,omitempty"\`` in the request struct, decode `Rungs []string \`json:"rungs"\`` from the response, return it as the third value.
786 - `runServicesCreate`: replace the `--role` help with D1 and `usageMsg` with D2. Add:
787
788```go
789 rungsRaw := fs.String("rungs", "", "<D3>")
790```
791
792 After the role parse:
793
794```go
795 var rungsV []string
796 for _, r := range strings.Split(*rungsRaw, ",") {
797 if r = strings.TrimSpace(r); r != "" {
798 rungsV = append(rungsV, r)
799 }
800 }
801 if len(rungsV) > 0 && roleV != string(svcrole.Deploy) {
802 log.Fatal("<D4>")
803 }
804```
805
806 Pass `rungsV` to `createService` and the returned rungs to `printServiceCreated`.
807 - `printServiceCreated`: new signature above; when `role == "deploy"` print D5 with `strings.Join(rungs, ", ")` instead of today's first line. Every other role prints exactly what it prints today.
808 - `runServicesList`: header gains `\tD6` after `APP`, and each row prints `strings.Join(r.Rungs, ",")` or `-` when empty.
809 - Update every other caller of the two changed functions: `grep -n 'createService(\|printServiceCreated(' *.go` and pass `nil` / ignore the extra return.
810
811- [ ] **Step 4: Run.**
812
813Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'Service' -count=1 && go vet .`
814Expected: PASS.
815
816- [ ] **Step 5: Commit** with message `cli: services create --rungs and a RUNGS column (#229)`.
817
818---
819
820### Task 4: The doors a deploy credential reaches
821
822**Files:**
823- Modify: `internal/console/authcli.go:476-544` (route registrations)
824- Create: `internal/console/deploydoors_test.go`
825
826**Interfaces:**
827- Consumes: `svcrole.Deploy`; Task 1's `NewServicePrincipalScoped`.
828- Produces: `deployDoors` (test-only) — the exact §6 list.
829
830- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploydoors_test.go`:
831
832```go
833package console
834
835import (
836 "net/http"
837 "reflect"
838 "sort"
839 "testing"
840 "time"
841
842 "github.com/carlosframework/platform/internal/svcrole"
843)
844
845// deployDoors is spec §6, verbatim. A door added later is refused to a
846// deploy credential unless someone adds it here on purpose.
847var deployDoors = []string{
848 "GET /api/cli/apps",
849 "GET /api/cli/apps/{account}/{app}/channels",
850 "POST /api/cli/apps/{account}/{app}/releases",
851 "GET /api/cli/apps/{account}/{app}/releases",
852 "POST /api/cli/apps/{account}/{app}/promote",
853 "POST /api/cli/apps/{account}/{app}/rollback",
854 "GET /api/cli/apps/{account}/{app}/tags",
855 "GET /api/cli/apps/{account}/{app}/tags/{version}",
856 "GET /api/cli/apps/{account}/{app}/version-target",
857 "GET /api/cli/apps/{account}/{app}/pipeline",
858 "GET /api/cli/apps/{account}/{app}/pending",
859 "GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy
860 "GET /api/cli/apps/{account}/{app}/regions", // R6: carlos deploy --region
861}
862
863func TestDeployReachesExactlyItsDoors(t *testing.T) {
864 s := newConsoleFixture(t).srv
865 var got []string
866 for pattern, set := range s.cliRoles {
867 if set.Permits(svcrole.Deploy) {
868 got = append(got, pattern)
869 }
870 }
871 sort.Strings(got)
872 want := append([]string(nil), deployDoors...)
873 sort.Strings(want)
874 if !reflect.DeepEqual(got, want) {
875 t.Fatalf("deploy reaches:\n got %q\nwant %q", got, want)
876 }
877}
878
879// Review Focus 2: the app binding holds for deploy like any service.
880func TestDeployIsBoundToItsApp(t *testing.T) {
881 fx := newConsoleFixture(t)
882 for _, app := range []string{"hello", "other"} {
883 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil {
884 t.Fatal(err)
885 }
886 }
887 tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now())
888 if err != nil {
889 t.Fatal(err)
890 }
891 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/other/promote",
892 map[string]string{"version": "v1", "channel": "edge"}, tok)
893 if status != http.StatusNotFound {
894 t.Fatalf("deploy on a sibling app = %d %v, want 404", status, out)
895 }
896 // And a door outside §6 is the role gate's 403, not a 404.
897 status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/delete", map[string]string{}, tok)
898 if status != http.StatusForbidden {
899 t.Fatalf("deploy on trash = %d %v, want 403", status, out)
900 }
901}
902```
903
904- [ ] **Step 2: Run and see the first fail.**
905
906Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployReachesExactlyItsDoors|TestDeployIsBoundToItsApp' -count=1`
907Expected: FAIL, `deploy reaches: got []`.
908
909- [ ] **Step 3: Register the doors.** In `internal/console/authcli.go`, change exactly these registrations:
910
911```go
912 s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps)
913 s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels)
914 s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote)
915 s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback)
916 s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip)
917 s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet)
918 s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList)
919 s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet)
920 s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList)
921 s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet)
922 s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending)
923 s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList)
924 s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions)
925```
926
927The 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.
928
929- [ ] **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.
930
931Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1`
932Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` and `TestNoRoleReachesAMintFleetOrPolicyDoor`.
933
934- [ ] **Step 5: Commit** with message `console: the doors a deploy credential reaches (#229)`.
935
936---
937
938### Task 5: The scope function and the handlers' early refusal
939
940**Files:**
941- Modify: `internal/console/deployscope.go`
942- Modify: `internal/console/apicli.go` (`handleAPIPromote` after the pipeline read near `:772-777`; `handleAPIRollback` after its pipeline read)
943- Create: `internal/console/deployscope_test.go`
944
945**Interfaces:**
946- Consumes: `Principal.Rungs`, `release.Rung`, `release.RungCanary`, `release.RungStable`.
947- Produces:
948 - `func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error` — nil for any non-deploy principal.
949 - `type deployRefusal struct{ msg string }` implementing `error`.
950 - `func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error)` — 403 `{"error": <message>}`.
951 - `func deployRefusalFor(app string, err error) error` — fills the app name for D24 (see Step 3).
952
953- [ ] **Step 1: Write the failing table test.** Create `internal/console/deployscope_test.go`:
954
955```go
956package console
957
958import (
959 "testing"
960
961 "github.com/carlosframework/platform/internal/svcrole"
962)
963
964func TestDeployScopeAllows(t *testing.T) {
965 ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}}
966 beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}}
967 pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"}
968 person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"}
969 for _, tc := range []struct {
970 name string
971 p Principal
972 pipelined bool
973 channel string
974 hotfix bool
975 ok bool
976 }{
977 {"edge in scope", ce, false, "edge", false, true},
978 {"canary branch in scope", ce, false, "canary/feature-x", false, true},
979 {"bare canary refused", ce, false, "canary", false, false},
980 {"bare canary refused on a pipelined app", ce, true, "canary", false, false},
981 {"beta outside rungs", ce, false, "beta", false, false},
982 {"stable refused", ce, false, "stable", false, false},
983 {"stable refused even when granted beta", beta, false, "stable", false, false},
984 {"not a rung", ce, false, "staging", false, false},
985 {"hotfix refused", ce, false, "edge", true, false},
986 {"pipelined: edge refused", ce, true, "edge", false, false},
987 {"pipelined: canary branch allowed", ce, true, "canary/x", false, true},
988 {"beta credential on beta", beta, false, "beta", false, true},
989 {"publish untouched", pub, true, "stable", true, true},
990 {"person untouched", person, true, "stable", true, true},
991 } {
992 err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix)
993 if (err == nil) != tc.ok {
994 t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
995 }
996 }
997}
998```
999
1000Then add handler tests to the same file using `newConsoleFixture`, an app `hello` created in `OpsAccountID`, a person token to ship `v1` (pattern: `TestOrdinaryAppShipIsNotArchChecked`, `internal/console/controlplane_test.go:772`) and a deploy token from `fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, ...)`. For each of these, POST `/api/cli/apps/bab/hello/promote` with the deploy token and assert 403, the approved message text, and that `release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", <channel>)` is unchanged (still absent, or still the person's version):
1001 - `{"version":"v1","channel":"beta"}` (D22)
1002 - `{"version":"v1","channel":"stable"}` (D22)
1003 - `{"version":"v1","channel":"canary"}` (D23)
1004 - `{"version":"v1","channel":"edge","hotfix":true}` (D21)
1005 - after `release.SetPipeline(ctx, fx.rel, fx.priv, "bab", "hello", []release.PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "paul@keymail.dev")`: `{"version":"v1","channel":"edge"}` (D24), and `{"version":"v1","channel":"stable"}` — a pipeline naming `stable` with no passkey rule still refuses deploy.
1006 - rollback: `{"channel":"beta"}` gives 403 D22.
1007
1008- [ ] **Step 2: Run and see it fail.**
1009
1010Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployScope' -count=1`
1011Expected: FAIL to compile, `undefined: deployScopeAllows`.
1012
1013- [ ] **Step 3: Implement the function.** Append to `internal/console/deployscope.go` (add imports `fmt`, `net/http`, `svcrole`):
1014
1015```go
1016// deployRefusal is a promote or rollback a deploy credential may not make.
1017// Its text is the 403's error, so a CI log says what to fix (spec §7.3).
1018type deployRefusal struct{ msg string }
1019
1020func (e *deployRefusal) Error() string { return e.msg }
1021
1022// deployScopeAllows is the whole scope decision for a deploy credential
1023// (spec §7.1). It is pure so it can be table-tested, and it is called three
1024// times per write: early in the handler for the message, and by the release
1025// primitives on their own pipeline reads (Task 7). For every principal that
1026// is not a deploy credential it returns nil and changes nothing.
1027func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error {
1028 if p.Kind != PrincipalService || p.Role != svcrole.Deploy {
1029 return nil
1030 }
1031 held := deployHeldWords(p.Rungs)
1032 if hotfix {
1033 return &deployRefusal{fmt.Sprintf("<D21>", p.Name)}
1034 }
1035 rung, ok := release.Rung(channel)
1036 if !ok || rung == release.RungStable {
1037 return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)}
1038 }
1039 if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") {
1040 return &deployRefusal{fmt.Sprintf("<D23>", p.Name)}
1041 }
1042 inScope := false
1043 for _, r := range p.Rungs {
1044 if r == rung {
1045 inScope = true
1046 }
1047 }
1048 if !inScope {
1049 return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)}
1050 }
1051 if pipelined && rung != release.RungCanary {
1052 return &deployRefusal{fmt.Sprintf("<D24>", p.App, p.Name)}
1053 }
1054 return nil
1055}
1056
1057// deployHeldWords is the rung list as the refusal names it: canary shown as
1058// the channels it covers.
1059func deployHeldWords(rungs []string) string {
1060 words := make([]string, len(rungs))
1061 for i, r := range rungs {
1062 words[i] = r
1063 if r == release.RungCanary {
1064 words[i] = release.RungCanary + "/<name>"
1065 }
1066 }
1067 return strings.Join(words, ", ")
1068}
1069
1070// writeDeployRefusal answers a scope refusal. Anything that is not a
1071// deployRefusal is a programming error and answers 500.
1072func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) {
1073 var dr *deployRefusal
1074 if errors.As(err, &dr) {
1075 jsonError(w, http.StatusForbidden, dr.msg)
1076 return
1077 }
1078 jsonError(w, http.StatusInternalServerError, "internal error")
1079}
1080```
1081
1082`p.App` is the app the credential is bound to, which the app binding already proved equals the path's app, so D24 names the right app without a parameter.
1083
1084- [ ] **Step 4: Call it early in both handlers.** In `handleAPIPromote`, immediately after the `GetPipeline` error block (the one ending `return` near `:777`):
1085
1086```go
1087 if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil {
1088 s.writeDeployRefusal(w, err)
1089 return
1090 }
1091```
1092
1093In `handleAPIRollback`, immediately after its `GetPipeline` error block:
1094
1095```go
1096 if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil {
1097 s.writeDeployRefusal(w, err)
1098 return
1099 }
1100```
1101
1102`p` is already in scope in both handlers (`principalFrom`).
1103
1104- [ ] **Step 5: Run.**
1105
1106Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1`
1107Expected: PASS.
1108
1109- [ ] **Step 6: Commit** with message `console: deploy scope check at promote and rollback (#229)`.
1110
1111---
1112
1113### Task 6: The Authorize hook in the release primitives
1114
1115**Files:**
1116- Create: `internal/release/authorize.go`
1117- Modify: `internal/release/release.go` (`Signer.Promote` `:694`, `PromoteOptions` `:765`, `Signer.PromoteOnLadder` `:849`, `Signer.Rollback` `:1031`)
1118- Create: `internal/release/authorize_test.go`
1119
1120**Interfaces:**
1121- Produces:
1122 - `PromoteOptions.Authorize func(p *Pipeline) error`
1123 - `type AuthorizationError struct{ Err error }` with `Error()` and `Unwrap()`
1124 - `func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error`
1125 - unexported `func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error`
1126
1127- [ ] **Step 1: Write the test store and the failing tests.** Create `internal/release/authorize_test.go`:
1128
1129```go
1130package release
1131
1132import (
1133 "context"
1134 "crypto/ed25519"
1135 "errors"
1136 "os"
1137 "testing"
1138)
1139
1140// storeCall is one call a request made through hookStore.
1141type storeCall struct {
1142 seq int
1143 op, key string
1144 ifMatch string // put only
1145}
1146
1147// hookStore runs before(call) ahead of every call it forwards, and after()
1148// once a get returns, so a test can make another actor's writes land at an
1149// exact point inside a request. The other actor writes to inner directly:
1150// the same handle, which is where G2 holds for a directory store (spec
1151// §7.2). seq counts only the calls made through hookStore.
1152type hookStore struct {
1153 inner Store
1154 before func(c storeCall)
1155 after func(c storeCall, body []byte, etag string, err error)
1156 seq int
1157}
1158
1159func (h *hookStore) call(op, key, ifMatch string) storeCall {
1160 h.seq++
1161 c := storeCall{seq: h.seq, op: op, key: key, ifMatch: ifMatch}
1162 if h.before != nil {
1163 h.before(c)
1164 }
1165 return c
1166}
1167
1168func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1169 c := h.call("get", key, "")
1170 body, etag, err := h.inner.Get(ctx, key)
1171 if h.after != nil {
1172 h.after(c, body, etag, err)
1173 }
1174 return body, etag, err
1175}
1176
1177func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
1178 h.call("put", key, ifMatch)
1179 return h.inner.Put(ctx, key, body, ifMatch)
1180}
1181
1182func (h *hookStore) List(ctx context.Context, prefix string) ([]string, error) {
1183 h.call("list", prefix, "")
1184 return h.inner.List(ctx, prefix)
1185}
1186
1187func (h *hookStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
1188 h.call("listdirs", prefix, "")
1189 return h.inner.ListDirs(ctx, prefix)
1190}
1191
1192func (h *hookStore) Delete(ctx context.Context, key string) error {
1193 h.call("delete", key, "")
1194 return h.inner.Delete(ctx, key)
1195}
1196
1197// onNth runs fn once, just before the nth (1-based) call matching op and key.
1198func onNth(op, key string, n int, fn func()) func(storeCall) {
1199 seen := 0
1200 return func(c storeCall) {
1201 if c.op == op && c.key == key {
1202 seen++
1203 if seen == n {
1204 fn()
1205 }
1206 }
1207 }
1208}
1209
1210// noPipelineOnly is R2's rule for a non-canary channel: refuse if the app
1211// has a pipeline.
1212func noPipelineOnly(p *Pipeline) error {
1213 if p != nil {
1214 return errors.New("pipelined")
1215 }
1216 return nil
1217}
1218
1219var edgeKey = ChannelKey("bab", "myapp", "edge")
1220
1221func setupEdge(t *testing.T) (context.Context, Store, ed25519.PrivateKey, ed25519.PublicKey) {
1222 t.Helper()
1223 ctx := context.Background()
1224 inner := NewDirStore(t.TempDir())
1225 priv, pub := testKeys(t)
1226 for _, v := range []string{"v1", "v2", "v3", "v4"} {
1227 shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v))
1228 }
1229 return ctx, inner, priv, pub
1230}
1231
1232func installPipeline(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, names ...string) {
1233 t.Helper()
1234 var chans []PipelineChannel
1235 for _, n := range names {
1236 chans = append(chans, PipelineChannel{Name: n})
1237 }
1238 if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", chans, "owner"); err != nil {
1239 t.Fatalf("SetPipeline(%v): %v", names, err)
1240 }
1241}
1242
1243func ownerPromote(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, v string) {
1244 t.Helper()
1245 if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil {
1246 t.Fatalf("owner promote %s: %v", v, err)
1247 }
1248}
1249
1250func edgeVersion(t *testing.T, ctx context.Context, s Store, pub ed25519.PublicKey) string {
1251 t.Helper()
1252 p, err := Channel(ctx, s, pub, "bab", "myapp", "edge")
1253 if errors.Is(err, os.ErrNotExist) {
1254 return ""
1255 }
1256 if err != nil {
1257 t.Fatal(err)
1258 }
1259 return p.Version
1260}
1261
1262func wantAuthorizationError(t *testing.T, err error) {
1263 t.Helper()
1264 var ae *AuthorizationError
1265 if !errors.As(err, &ae) {
1266 t.Fatalf("err = %v; want *AuthorizationError", err)
1267 }
1268}
1269
1270func TestAuthorizeSeesAPipelineInstalledBeforeTheRequest(t *testing.T) {
1271 ctx, inner, priv, pub := setupEdge(t)
1272 installPipeline(t, ctx, inner, priv, "edge", "stable")
1273 _, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
1274 PromoteOptions{Authorize: noPipelineOnly})
1275 wantAuthorizationError(t, err)
1276 if got := edgeVersion(t, ctx, inner, pub); got != "" {
1277 t.Fatalf("edge = %q after a refusal", got)
1278 }
1279}
1280
1281// The refusal is the hook's, not the ladder's order error, even when the
1282// pipeline would make the ladder refuse too (spec §7.2 point 1, codex R3-3).
1283func TestAuthorizeRefusesBeforeTheLadderDoes(t *testing.T) {
1284 ctx, inner, priv, _ := setupEdge(t)
1285 h := &hookStore{inner: inner}
1286 h.before = onNth("get", PipelineKey("bab", "myapp"), 1, func() {
1287 installPipeline(t, ctx, inner, priv, "preview")
1288 })
1289 _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
1290 PromoteOptions{Authorize: noPipelineOnly})
1291 wantAuthorizationError(t, err)
1292}
1293
1294// The ladder's read saw no pipeline; one installed before the authoritative
1295// read (after the pointer read) is still seen.
1296func TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead(t *testing.T) {
1297 ctx, inner, priv, pub := setupEdge(t)
1298 h := &hookStore{inner: inner}
1299 h.before = onNth("get", PipelineKey("bab", "myapp"), 2, func() {
1300 installPipeline(t, ctx, inner, priv, "edge", "stable")
1301 })
1302 _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
1303 PromoteOptions{Authorize: noPipelineOnly})
1304 wantAuthorizationError(t, err)
1305 if got := edgeVersion(t, ctx, inner, pub); got != "" {
1306 t.Fatalf("edge = %q after a refusal", got)
1307 }
1308}
1309
1310// Astra's round 2 counterexample: an owner promotes under a new pipeline
1311// after the deploy request read the pointer. The deploy write must lose.
1312func TestOwnerPromoteAfterInstallDefeatsTheDeployWrite(t *testing.T) {
1313 ctx, inner, priv, pub := setupEdge(t)
1314 ownerPromote(t, ctx, inner, priv, "v1")
1315 h := &hookStore{inner: inner}
1316 h.before = onNth("put", edgeKey, 1, func() {
1317 installPipeline(t, ctx, inner, priv, "edge", "stable")
1318 ownerPromote(t, ctx, inner, priv, "v3")
1319 })
1320 _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2",
1321 PromoteOptions{Authorize: noPipelineOnly})
1322 if !errors.Is(err, ErrCASConflict) {
1323 t.Fatalf("err = %v; want ErrCASConflict", err)
1324 }
1325 if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
1326 t.Fatalf("edge = %q; want the owner's v3", got)
1327 }
1328}
1329
1330// R5, accepted: a pipeline installed after the authorization read, and the
1331// pointer written and restored to identical bytes, before the write. G2
1332// holds (the bytes are what was read), so the write lands.
1333func TestRestoredBytesLandAsTheAcceptedInFlightCase(t *testing.T) {
1334 ctx, inner, priv, pub := setupEdge(t)
1335 ownerPromote(t, ctx, inner, priv, "v1")
1336 ownerPromote(t, ctx, inner, priv, "v2")
1337 h := &hookStore{inner: inner}
1338 h.before = onNth("put", edgeKey, 1, func() {
1339 orig, _, err := inner.Get(ctx, edgeKey)
1340 if err != nil {
1341 t.Fatal(err)
1342 }
1343 installPipeline(t, ctx, inner, priv, "edge", "stable")
1344 ownerPromote(t, ctx, inner, priv, "v4")
1345 _, cur, err := inner.Get(ctx, edgeKey)
1346 if err != nil {
1347 t.Fatal(err)
1348 }
1349 if _, err := inner.Put(ctx, edgeKey, orig, cur); err != nil {
1350 t.Fatal(err)
1351 }
1352 })
1353 if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3",
1354 PromoteOptions{Authorize: noPipelineOnly}); err != nil {
1355 t.Fatalf("err = %v; R5 accepts this write (spec §16)", err)
1356 }
1357 if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
1358 t.Fatalf("edge = %q; want v3", got)
1359 }
1360}
1361
1362// R5, accepted: a first promote onto an empty channel, with a pipeline
1363// installed after the authorization read.
1364func TestFirstPromoteOntoEmptyChannelLandsAsTheAcceptedInFlightCase(t *testing.T) {
1365 ctx, inner, priv, pub := setupEdge(t)
1366 h := &hookStore{inner: inner}
1367 h.before = onNth("put", edgeKey, 1, func() {
1368 installPipeline(t, ctx, inner, priv, "edge", "stable")
1369 })
1370 if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
1371 PromoteOptions{Authorize: noPipelineOnly}); err != nil {
1372 t.Fatalf("err = %v; R5 accepts this write (spec §16)", err)
1373 }
1374 if got := edgeVersion(t, ctx, inner, pub); got != "v1" {
1375 t.Fatalf("edge = %q; want v1", got)
1376 }
1377}
1378
1379func TestRepeatPromoteRunsTheHook(t *testing.T) {
1380 ctx, inner, priv, _ := setupEdge(t)
1381 ownerPromote(t, ctx, inner, priv, "v1")
1382 installPipeline(t, ctx, inner, priv, "edge", "stable")
1383 res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
1384 PromoteOptions{Authorize: noPipelineOnly})
1385 wantAuthorizationError(t, err)
1386 if res.NoOp {
1387 t.Fatal("a refused repeat reported NoOp")
1388 }
1389}
1390
1391func TestRepeatPromoteWithNoPipelineIsANoOp(t *testing.T) {
1392 ctx, inner, priv, _ := setupEdge(t)
1393 ownerPromote(t, ctx, inner, priv, "v1")
1394 var seen []bool
1395 hook := func(p *Pipeline) error { seen = append(seen, p != nil); return noPipelineOnly(p) }
1396 res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
1397 PromoteOptions{Authorize: hook})
1398 if err != nil || !res.NoOp {
1399 t.Fatalf("= %+v, %v; want a no-op", res, err)
1400 }
1401 if len(seen) != 1 || seen[0] {
1402 t.Fatalf("hook calls = %v; want one, with no pipeline", seen)
1403 }
1404}
1405
1406// A caller with no hook makes no extra pipeline reads (spec §7.2).
1407func TestNoHookNoExtraReads(t *testing.T) {
1408 ctx, inner, priv, _ := setupEdge(t)
1409 count := func(run func(Store) error) int {
1410 n := 0
1411 h := &hookStore{inner: inner, before: func(c storeCall) {
1412 if c.op == "get" && c.key == PipelineKey("bab", "myapp") {
1413 n++
1414 }
1415 }}
1416 if err := run(h); err != nil {
1417 t.Fatal(err)
1418 }
1419 return n
1420 }
1421 fresh := count(func(s Store) error {
1422 _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{})
1423 return err
1424 })
1425 repeat := count(func(s Store) error {
1426 _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{})
1427 return err
1428 })
1429 ownerPromote(t, ctx, inner, priv, "v2")
1430 rollback := count(func(s Store) error { return RootSigner(priv).Rollback(ctx, s, "bab", "myapp", "edge") })
1431 if fresh != 1 || repeat != 0 || rollback != 0 {
1432 t.Fatalf("pipeline reads: fresh %d, repeat %d, rollback %d; want 1, 0, 0", fresh, repeat, rollback)
1433 }
1434}
1435
1436func TestRollbackAuthorized(t *testing.T) {
1437 ctx, inner, priv, pub := setupEdge(t)
1438 ownerPromote(t, ctx, inner, priv, "v1")
1439 ownerPromote(t, ctx, inner, priv, "v2")
1440 installPipeline(t, ctx, inner, priv, "edge", "stable")
1441 err := RootSigner(priv).RollbackAuthorized(ctx, inner, "bab", "myapp", "edge", noPipelineOnly)
1442 wantAuthorizationError(t, err)
1443 if got := edgeVersion(t, ctx, inner, pub); got != "v2" {
1444 t.Fatalf("edge = %q after a refused rollback", got)
1445 }
1446 if err := inner.Delete(ctx, PipelineKey("bab", "myapp")); err != nil {
1447 t.Fatal(err)
1448 }
1449 h := &hookStore{inner: inner}
1450 h.before = onNth("put", edgeKey, 1, func() { ownerPromote(t, ctx, inner, priv, "v3") })
1451 err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", noPipelineOnly)
1452 if !errors.Is(err, ErrCASConflict) {
1453 t.Fatalf("err = %v; want ErrCASConflict", err)
1454 }
1455 if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
1456 t.Fatalf("edge = %q; want the owner's v3", got)
1457 }
1458}
1459```
1460
1461If `SetPipeline` refuses a one-channel pipeline named `preview`, or a channel named `stable`, read `validatePipeline` (`pipeline.go:255`) and use the nearest valid shape: the tests need "a pipeline that names edge" and "a pipeline that does not".
1462
1463- [ ] **Step 2: Run and see them fail.**
1464
1465Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -run 'TestAuthorize|TestOwnerPromote|TestRestoredBytes|TestFirstPromote|TestRepeatPromote|TestNoHook|TestRollbackAuthorized' -count=1`
1466Expected: FAIL to compile (`Authorize`, `AuthorizationError`, `RollbackAuthorized` undefined).
1467
1468- [ ] **Step 3: Create `internal/release/authorize.go`:**
1469
1470```go
1471package release
1472
1473// AuthorizationError is a refusal from a caller's Authorize hook. The
1474// primitives wrap the hook's own error in it so a caller can tell "you may
1475// not" from a store or ladder failure with errors.As.
1476//
1477// The hook runs on a pipeline the primitive has just read (spec
1478// 2026-10-05-deploy-credential-design.md §7.2): on the ladder's read, on the
1479// repeat path, and between the pointer read and the conditional write. A
1480// caller that passes no hook gets no extra reads.
1481type AuthorizationError struct{ Err error }
1482
1483func (e *AuthorizationError) Error() string { return e.Err.Error() }
1484func (e *AuthorizationError) Unwrap() error { return e.Err }
1485```
1486
1487- [ ] **Step 4: Thread the hook through `release.go`.**
1488
1489`PromoteOptions` gains:
1490
1491```go
1492 // Authorize, when set, is called with the app's pipeline as read at each
1493 // point a write could depend on it: the ladder's read, the repeat path,
1494 // and between the pointer read and the conditional write. A non-nil
1495 // error refuses the promotion as an *AuthorizationError. nil means
1496 // today's behaviour exactly, with no extra reads.
1497 Authorize func(p *Pipeline) error
1498```
1499
1500Rename the body of `Signer.Promote` into `func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error`, and make `Promote` call it with `nil`:
1501
1502```go
1503func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error {
1504 return sg.promote(ctx, s, acct, app, channel, version, nil, rec...)
1505}
1506```
1507
1508Inside `promote`, after the `switch` that sets `old` and `etag` from `GetQualified` (`:732-744`) and before `next := ...`, add:
1509
1510```go
1511 if authorize != nil {
1512 // Between the pointer read and the conditional write, so the write
1513 // lands only if the pointer is still what was authorized against
1514 // (spec §7.2, G1 and G2).
1515 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
1516 if perr != nil {
1517 return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
1518 }
1519 if aerr := authorize(pipe); aerr != nil {
1520 return &AuthorizationError{Err: aerr}
1521 }
1522 }
1523```
1524
1525In `Signer.PromoteOnLadder`:
1526- On the repeat path, inside `if p, verr := ...; verr == nil && p.Version == version {` and before `res.NoOp = true`:
1527
1528```go
1529 if opt.Authorize != nil {
1530 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
1531 if perr != nil {
1532 return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
1533 }
1534 if aerr := opt.Authorize(pipe); aerr != nil {
1535 return res, &AuthorizationError{Err: aerr}
1536 }
1537 }
1538```
1539
1540- Right after its own `pipe, perr := GetPipeline(...)` error check (`:930-933`):
1541
1542```go
1543 if opt.Authorize != nil {
1544 if aerr := opt.Authorize(pipe); aerr != nil {
1545 return res, &AuthorizationError{Err: aerr}
1546 }
1547 }
1548```
1549
1550- Replace the `sg.Promote(ctx, s, acct, app, channel, version, rec)` call with `sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec)`.
1551
1552Rename the body of `Signer.Rollback` into `RollbackAuthorized` with the extra parameter, make `Rollback` call it with `nil`, and inside it add, after the `len(old.History) == 0` check and before `next := ...`:
1553
1554```go
1555 if authorize != nil {
1556 pipe, perr := GetPipeline(ctx, s, pub, acct, app)
1557 if perr != nil {
1558 return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr)
1559 }
1560 if aerr := authorize(pipe); aerr != nil {
1561 return &AuthorizationError{Err: aerr}
1562 }
1563 }
1564```
1565
1566- [ ] **Step 5: Run the new tests, then the package.**
1567
1568Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -count=1`
1569Expected: PASS, including `golden_test.go` (root-signed bytes unchanged).
1570
1571- [ ] **Step 6: Commit** with message `release: an Authorize hook between pointer read and write (#229)`.
1572
1573---
1574
1575### Task 7: The request-local scoped signer and the handler wiring
1576
1577**Files:**
1578- Modify: `internal/console/deployscope.go` (`deploySigner`)
1579- Modify: `internal/console/apicli.go` (`handleAPIPromote` near `:880`, `handleAPIRollback` near the `release.Rollback` call)
1580- Create: `internal/console/deploysign_test.go`
1581
1582**Interfaces:**
1583- Consumes: Task 5's `deployScopeAllows`, `writeDeployRefusal`; Task 6's `PromoteOptions.Authorize`, `AuthorizationError`, `RollbackAuthorized`.
1584- Produces: `func (s *Server) deploySigner(p Principal) (release.Signer, error)`; `const deployGrantTTL = 5 * time.Minute`.
1585
1586- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploysign_test.go` with a fixture helper:
1587
1588```go
1589// deployFixture is a console with app hello in bab, v1 and v2 shipped by
1590// paul, and a deploy credential ci over hello with rungs canary and edge.
1591func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) {
1592 t.Helper()
1593 fx = newConsoleFixture(t)
1594 if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
1595 t.Fatal(err)
1596 }
1597 person = cliToken(t, fx, "paul@keymail.dev", "code-deploy-sign")
1598 liftShipBudget(fx)
1599 for _, v := range []string{"v1", "v2"} {
1600 status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person,
1601 []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}})
1602 if status != http.StatusCreated {
1603 t.Fatalf("ship %s = %d %v", v, status, out)
1604 }
1605 }
1606 tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now())
1607 if err != nil {
1608 t.Fatal(err)
1609 }
1610 return fx, person, tok
1611}
1612```
1613
1614Then these tests, each written out in full:
1615- `TestDeployPromoteIsSignedByAScopedGrant`: deploy promotes `v1` to `edge` → 200 `"result":"promoted"`; `release.ReadSerial` advanced by one; `fx.st.Events(OpsAccountID, "hello", 10)` holds a `promote` row; `release.Channel(ctx, fx.rel, fx.pub, "bab", "hello", "edge")` verifies and is `v1`; the raw pointer (`release.GetQualified(ctx, fx.rel, release.ChannelKey("bab","hello","edge"))`) has a `grant` object whose base64 `payload` decodes to JSON with `"rungs":["canary","edge"]` and `"name":"deploy.ci"`.
1616- `TestDeployRepeatIsANoOp` (Review Focus 4): the same promote twice; the second answers 200 `"result":"noop"`, serial unchanged.
1617- `TestDeploySignerRefusesWithoutTheScopeFunction`: `sg, err := fx.srv.deploySigner(Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"edge"}})`; then `sg.PromoteOnLadder(ctx, fx.rel, "bab", "hello", "beta", "v1", release.PromoteOptions{Hotfix: true})` errors, and so does `"stable"`. This is the signer refusing with no `Authorize` hook at all (spec §12 "Signer").
1618- `TestDeploySignerFailureFailsClosed`: set `fx.srv.cfg.ReleasePub` to a fresh, unrelated public key (`ed25519.GenerateKey(nil)`); deploy promote `v1` to `edge` → 503 with D26; `release.GetQualified` on the edge key returns `os.ErrNotExist`.
1619- `TestPersonPromoteUnchanged`: the person promotes `v1` to `edge` → 200; the raw pointer has no `grant` field.
1620- `TestDeployRollback`: person promotes `v1` then `v2` to `edge`; deploy rolls back `edge` → 200 `"result":"rolled back"`; edge is `v1`; its raw pointer carries a grant naming `deploy.ci`.
1621
1622- [ ] **Step 2: Run and see them fail.**
1623
1624Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeploy|TestPersonPromoteUnchanged' -count=1`
1625Expected: FAIL (`deploySigner` undefined; pointers carry no grant).
1626
1627- [ ] **Step 3: Add `deploySigner`** to `internal/console/deployscope.go` (imports `time`):
1628
1629```go
1630// deployGrantTTL is how long a request-local grant lives. It only has to
1631// outlast one request: a box checks a grant against the pointer's own
1632// promoted_at, so a pointer signed inside the window stays valid after the
1633// grant expires (spec §7.4).
1634const deployGrantTTL = 5 * time.Minute
1635
1636// deploySigner mints a scoped key and a root-signed grant for exactly the
1637// credential's rungs, for one request. Boxes then verify the rung limit as
1638// they do for a scoped CI key today. Nothing is stored. Any failure refuses
1639// the request: a deploy write never falls back to the root signer.
1640func (s *Server) deploySigner(p Principal) (release.Signer, error) {
1641 if len(p.Rungs) == 0 {
1642 return release.Signer{}, errors.New("deploy credential carries no rungs")
1643 }
1644 seed, grant, err := release.MintKeyGrant(s.cfg.Key, "deploy."+p.Name, p.Rungs, deployGrantTTL)
1645 if err != nil {
1646 return release.Signer{}, err
1647 }
1648 key, err := release.ParseSigningKey(seed, "deploy grant")
1649 if err != nil {
1650 return release.Signer{}, err
1651 }
1652 return release.ScopedSigner(key, grant, s.cfg.ReleasePub)
1653}
1654```
1655
1656- [ ] **Step 4: Wire `handleAPIPromote`.** Replace the single `release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, ...)` call and its error block with:
1657
1658```go
1659 signer := release.RootSigner(s.cfg.Key)
1660 var authorize func(*release.Pipeline) error
1661 if p.Kind == PrincipalService && p.Role == svcrole.Deploy {
1662 sg, serr := s.deploySigner(p)
1663 if serr != nil {
1664 log.Printf("<D25>", acct.Sqid, app, serr)
1665 jsonError(w, http.StatusServiceUnavailable, "<D26>")
1666 return
1667 }
1668 signer = sg
1669 authorize = func(pp *release.Pipeline) error {
1670 return deployScopeAllows(p, pp != nil, channel, req.Hotfix)
1671 }
1672 }
1673 res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version,
1674 release.PromoteOptions{Actor: addr, Hotfix: hotfix, Authorize: authorize})
1675 if err != nil {
1676 var ae *release.AuthorizationError
1677 if errors.As(err, &ae) {
1678 s.writeDeployRefusal(w, ae.Err)
1679 return
1680 }
1681 log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err)
1682 jsonError(w, http.StatusConflict, err.Error())
1683 return
1684 }
1685```
1686
1687`release.RootSigner(s.cfg.Key).PromoteOnLadder(...)` is exactly what `release.PromoteOnLadder(..., s.cfg.Key, ...)` does today, so a person's and a `publish` credential's promote are unchanged. Keep the existing comment above the old call.
1688
1689- [ ] **Step 5: Wire `handleAPIRollback`.** Replace `release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel)` with the same signer selection (no hotfix: pass `false`), then `signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, authorize)`, with the same `AuthorizationError` arm before the existing 409.
1690
1691- [ ] **Step 6: Run the package.**
1692
1693Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/release/ -count=1`
1694Expected: PASS.
1695
1696- [ ] **Step 7: Commit** with message `console: sign deploy writes with a request-local scoped grant (#229)`.
1697
1698---
1699
1700### Task 8: The randomized interleaving test and its two mutants
1701
1702**Files:**
1703- Create: `internal/release/authorize_sim_test.go`
1704
1705**Interfaces:**
1706- Consumes: Task 6's `hookStore`, `noPipelineOnly`, `installPipeline`, `PromoteOptions.Authorize`, `RollbackAuthorized`.
1707
1708- [ ] **Step 1: Write the simulation.** Create `internal/release/authorize_sim_test.go`:
1709
1710```go
1711package release
1712
1713import (
1714 "bytes"
1715 "context"
1716 "crypto/ed25519"
1717 "errors"
1718 "fmt"
1719 "math/rand"
1720 "os"
1721 "strconv"
1722 "strings"
1723 "testing"
1724)
1725
1726// TestAuthorizeInterleavings runs random schedules of one deploy request
1727// against an owner who installs and removes pipelines, promotes and rolls
1728// back, at random points inside the request. It checks G1 and G2 and
1729// nothing else (spec §7.2, §12):
1730//
1731// G1: the last hook call before the write came after the pointer read the
1732// write was conditioned on, and the pipeline it was given permits it.
1733// G2: the bytes the write replaced equal the bytes that read returned.
1734//
1735// Seeds: DEPLOY_SIM_SEEDS (default 400). A failure prints the seed and the
1736// event log; DEPLOY_SIM_SEED=<n> replays exactly that seed.
1737func TestAuthorizeInterleavings(t *testing.T) {
1738 first, last := 0, 400
1739 if v := os.Getenv("DEPLOY_SIM_SEEDS"); v != "" {
1740 n, err := strconv.Atoi(v)
1741 if err != nil {
1742 t.Fatal(err)
1743 }
1744 last = n
1745 }
1746 if v := os.Getenv("DEPLOY_SIM_SEED"); v != "" {
1747 n, err := strconv.Atoi(v)
1748 if err != nil {
1749 t.Fatal(err)
1750 }
1751 first, last = n, n+1
1752 }
1753 priv, _ := testKeys(t)
1754 for seed := first; seed < last; seed++ {
1755 simulateOnce(t, seed, priv)
1756 }
1757}
1758
1759type simRead struct {
1760 seq int
1761 body []byte
1762 etag string
1763 absent bool
1764}
1765
1766type simHook struct {
1767 seq int
1768 pipelined bool
1769}
1770
1771func simulateOnce(t *testing.T, seed int, priv ed25519.PrivateKey) {
1772 t.Helper()
1773 ctx := context.Background()
1774 rng := rand.New(rand.NewSource(int64(seed)))
1775 inner := NewDirStore(t.TempDir())
1776 for _, v := range []string{"v1", "v2", "v3", "v4"} {
1777 shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v))
1778 }
1779 for _, v := range []string{"v1", "v2"} {
1780 if _, err := PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil {
1781 t.Fatalf("seed %d setup: %v", seed, err)
1782 }
1783 }
1784 var (
1785 events []string
1786 reads []simRead
1787 hooks []simHook
1788 put *storeCall
1789 replaced []byte
1790 replacedAbsent bool
1791 )
1792 owner := func() {
1793 var err error
1794 var what string
1795 switch rng.Intn(4) {
1796 case 0:
1797 what = "install"
1798 _, err = SetPipeline(ctx, inner, priv, "bab", "myapp", []PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "owner")
1799 case 1:
1800 what = "remove pipeline"
1801 err = inner.Delete(ctx, PipelineKey("bab", "myapp"))
1802 case 2:
1803 v := fmt.Sprintf("v%d", 1+rng.Intn(4))
1804 what = "promote " + v
1805 _, err = PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true})
1806 case 3:
1807 what = "rollback"
1808 err = Rollback(ctx, inner, priv, "bab", "myapp", "edge")
1809 }
1810 events = append(events, fmt.Sprintf(" owner %s: %v", what, err))
1811 }
1812 h := &hookStore{inner: inner}
1813 h.before = func(c storeCall) {
1814 if rng.Float64() < 0.3 {
1815 for i := 0; i <= rng.Intn(2); i++ {
1816 owner()
1817 }
1818 }
1819 events = append(events, fmt.Sprintf("%d %s %s %s", c.seq, c.op, c.key, c.ifMatch))
1820 if c.op == "put" && c.key == edgeKey {
1821 cc := c
1822 put = &cc
1823 body, _, err := inner.Get(ctx, edgeKey)
1824 replaced, replacedAbsent = body, errors.Is(err, os.ErrNotExist)
1825 }
1826 }
1827 h.after = func(c storeCall, body []byte, etag string, err error) {
1828 if c.key == edgeKey {
1829 reads = append(reads, simRead{c.seq, body, etag, errors.Is(err, os.ErrNotExist)})
1830 }
1831 }
1832 hook := func(p *Pipeline) error {
1833 hooks = append(hooks, simHook{h.seq, p != nil})
1834 events = append(events, fmt.Sprintf("%d hook pipelined=%v", h.seq, p != nil))
1835 return noPipelineOnly(p)
1836 }
1837 var err error
1838 noop := false
1839 switch rng.Intn(3) {
1840 case 0:
1841 var res PromoteResult
1842 res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", PromoteOptions{Authorize: hook})
1843 noop = res.NoOp
1844 case 1:
1845 var res PromoteResult
1846 res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", PromoteOptions{Authorize: hook})
1847 noop = res.NoOp
1848 case 2:
1849 err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", hook)
1850 }
1851 fail := func(msg string) {
1852 t.Fatalf("seed %d: %s\nreplay with DEPLOY_SIM_SEED=%d\n%s", seed, msg, seed, strings.Join(events, "\n"))
1853 }
1854 if err != nil {
1855 return // refusals and conflicts write nothing
1856 }
1857 if noop {
1858 if len(hooks) == 0 || hooks[len(hooks)-1].pipelined {
1859 fail("a no-op succeeded without a clean hook call")
1860 }
1861 return
1862 }
1863 if put == nil {
1864 fail("succeeded without writing")
1865 }
1866 var r *simRead
1867 for i := len(reads) - 1; i >= 0; i-- {
1868 rd := reads[i]
1869 if rd.seq >= put.seq {
1870 continue
1871 }
1872 if (put.ifMatch == "" && rd.absent) || (put.ifMatch != "" && !rd.absent && rd.etag == put.ifMatch) {
1873 r = &rd
1874 break
1875 }
1876 }
1877 if r == nil {
1878 fail("no pointer read matches the write's condition")
1879 }
1880 var a *simHook
1881 for i := len(hooks) - 1; i >= 0; i-- {
1882 if hooks[i].seq < put.seq {
1883 a = &hooks[i]
1884 break
1885 }
1886 }
1887 if a == nil {
1888 fail("a write landed with no hook call")
1889 }
1890 if a.seq <= r.seq {
1891 fail(fmt.Sprintf("G1: the hook at %d is not after the pointer read at %d", a.seq, r.seq))
1892 }
1893 if a.pipelined {
1894 fail("G1: the hook saw a pipeline and the write landed")
1895 }
1896 if replacedAbsent != r.absent || !bytes.Equal(replaced, r.body) {
1897 fail("G2: the write replaced bytes other than the ones it read")
1898 }
1899}
1900```
1901
1902- [ ] **Step 2: Run it.**
1903
1904Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1`
1905Expected: PASS. If it fails, the printed log is a real fault in Task 6 or in the oracle; read it before changing anything, and do not loosen the oracle without re-reading spec §7.2.
1906
1907- [ ] **Step 3: Mutant 1, hook before the pointer read.** In a copy of the tree, move the `if authorize != nil { ... }` block in `promote` to just before `cur, etag, err := GetQualified(ctx, s, key)`, and run the simulation. It must fail on G1 on a printed seed.
1908
1909```bash
1910M=$TMPDIR/deploy-mutant1; rm -rf "$M"; mkdir -p "$M"
1911git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M"
1912cd "$M" && python3 - <<'EOF'
1913p="internal/release/release.go"; s=open(p).read()
1914start=s.index("\tif authorize != nil {\n\t\t// Between the pointer read")
1915end=s.index("\t\t}\n\t}\n", start)+len("\t\t}\n\t}\n")
1916block=s[start:end]; s=s[:start]+s[end:]
1917anchor="\tcur, etag, err := GetQualified(ctx, s, key)\n\tvar old *ChannelPointer"
1918assert s.count(anchor)==1; s=s.replace(anchor, block+anchor)
1919open(p,"w").write(s)
1920EOF
1921export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=400 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?"
1922```
1923
1924Expected: `exit=1`, a G1 failure with a seed. Replay that seed with `DEPLOY_SIM_SEED=<n>` and confirm it fails again.
1925
1926- [ ] **Step 4: Mutant 2, unconditional write.** In a fresh copy, make the directory store's compare-and-swap a no-op and run the simulation. It must fail on G2 on a printed seed.
1927
1928```bash
1929M=$TMPDIR/deploy-mutant2; rm -rf "$M"; mkdir -p "$M"
1930git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M"
1931cd "$M" && sed -i 's/if ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/if false \&\& ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/' internal/release/store.go
1932grep -c 'if false && ifMatch' internal/release/store.go
1933export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?"
1934```
1935
1936Expected: the grep prints `1`, then `exit=1` with a G2 failure and a seed that replays.
1937
1938- [ ] **Step 5: Clean up the copies.**
1939
1940```bash
1941rm -rf "$TMPDIR/deploy-mutant1" "$TMPDIR/deploy-mutant2"
1942```
1943
1944- [ ] **Step 6: Commit** the simulation with message `release: randomized G1/G2 check for the Authorize hook (#229)`, and put both mutants' failing seeds in the commit body.
1945
1946---
1947
1948### Task 9: The CLI reads `CARLOS_SERVICE_TOKEN`
1949
1950**Files:**
1951- Modify: `apimode.go:93-143` (`resolveMode`)
1952- Modify: `apimode_test.go` (`clearDeploymentEnv` and new tests)
1953
1954**Interfaces:**
1955- Consumes: `normalizeConsole(in string) (string, error)` (`auth.go:354`), `newConsoleClient(origin, token string)`.
1956- Produces: `const serviceSessionLabel = "<D29>"`.
1957
1958- [ ] **Step 1: Keep existing tests hermetic.** In `apimode_test.go`'s `clearDeploymentEnv`, add:
1959
1960```go
1961 t.Setenv("CARLOS_SERVICE_TOKEN", "")
1962```
1963
1964- [ ] **Step 2: Write the failing tests** in `apimode_test.go`:
1965
1966```go
1967func TestResolveModeServiceToken(t *testing.T) {
1968 useIsolatedConfig(t)
1969 clearDeploymentEnv(t)
1970 var gotAuth string
1971 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1972 gotAuth = r.Header.Get("Authorization")
1973 w.Header().Set("Content-Type", "application/json")
1974 w.Write([]byte(`{"accounts":[]}`))
1975 }))
1976 defer ts.Close()
1977 // A person's credential for another console is on disk too. The token
1978 // wins and the file is not consulted.
1979 f, _ := loadCredentials()
1980 f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)})
1981 if err := f.save(); err != nil {
1982 t.Fatal(err)
1983 }
1984 t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
1985 t.Setenv("CARLOS_CONSOLE", ts.URL)
1986 sess, err := resolveMode("")
1987 if err != nil || sess == nil {
1988 t.Fatalf("resolveMode = %v, %v", sess, err)
1989 }
1990 if sess.address != serviceSessionLabel {
1991 t.Fatalf("address = %q", sess.address)
1992 }
1993 if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil {
1994 t.Fatal(err)
1995 }
1996 if gotAuth != "Bearer svc-secret" {
1997 t.Fatalf("Authorization = %q, want the service token", gotAuth)
1998 }
1999}
2000
2001func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) {
2002 useIsolatedConfig(t)
2003 clearDeploymentEnv(t)
2004 t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
2005 if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") {
2006 t.Fatalf("err = %v, want D27", err)
2007 }
2008}
2009
2010func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) {
2011 for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} {
2012 t.Run(env, func(t *testing.T) {
2013 useIsolatedConfig(t)
2014 clearDeploymentEnv(t)
2015 t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
2016 t.Setenv("CARLOS_CONSOLE", "https://c.example")
2017 t.Setenv(env, t.TempDir())
2018 sess, err := resolveMode("")
2019 if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") {
2020 t.Fatalf("= %v, %v; want D28", sess, err)
2021 }
2022 })
2023 }
2024}
2025```
2026
2027Adjust 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.
2028
2029- [ ] **Step 3: Run and see them fail.**
2030
2031Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployRoutinePath' -count=1`
2032Expected: FAIL (`serviceSessionLabel` undefined).
2033
2034- [ ] **Step 4: Implement** in `resolveMode`, after the existing `--console` conflict check and before `if bucketSet || dirSet { return nil, nil }`:
2035
2036```go
2037 token := os.Getenv("CARLOS_SERVICE_TOKEN")
2038 if token != "" && (bucketSet || dirSet) {
2039 return nil, errors.New("<D28>")
2040 }
2041```
2042
2043and after `if bucketSet || dirSet { return nil, nil }`:
2044
2045```go
2046 if token != "" {
2047 // CI's path (spec §8): the console is named outright, never taken
2048 // from a config default or a stored credential, and the credentials
2049 // file is not read.
2050 console := consoleFlag
2051 if console == "" {
2052 console = os.Getenv("CARLOS_CONSOLE")
2053 }
2054 if console == "" {
2055 return nil, errors.New("<D27>")
2056 }
2057 origin, err := normalizeConsole(console)
2058 if err != nil {
2059 return nil, err
2060 }
2061 return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil
2062 }
2063```
2064
2065Add near `apiSession`:
2066
2067```go
2068// serviceSessionLabel stands in for the address in "using X as Y" lines
2069// when the session is a service token: the CLI has no door that tells it
2070// the credential's name, and a person's whoami does not answer for one.
2071const serviceSessionLabel = "<D29>"
2072```
2073
2074Update `resolveMode`'s doc comment to describe the token arm.
2075
2076- [ ] **Step 5: Run.**
2077
2078Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -count=1 && go vet .`
2079Expected: PASS.
2080
2081- [ ] **Step 6: Commit** with message `cli: CARLOS_SERVICE_TOKEN selects the console for CI (#229)`.
2082
2083---
2084
2085### Task 10: Mixed versions fail closed
2086
2087**Files:**
2088- Create: `internal/console/deploymixed_test.go`
2089
2090- [ ] **Step 1: Write the tests.**
2091
2092```go
2093// TestUnknownStoredRoleIsNotSignedInAtEveryDoor is spec §9's first check: a
2094// console that does not know a stored role (as v0.21.0 does not know
2095// deploy) answers its token exactly as it answers no token at all.
2096func TestUnknownStoredRoleIsNotSignedInAtEveryDoor(t *testing.T) {
2097 fx := newConsoleFixture(t)
2098 if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
2099 t.Fatal(err)
2100 }
2101 raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now())
2102 if err != nil {
2103 t.Fatal(err)
2104 }
2105 if _, err := fx.st.db.Exec(`UPDATE cli_tokens SET role = 'futurerole' WHERE token_hash = ?`, hashToken(raw)); err != nil {
2106 t.Fatal(err)
2107 }
2108 fill := regexp.MustCompile(`\{[a-z]+\}`)
2109 for pattern := range fx.srv.cliRoles {
2110 method, path, _ := strings.Cut(pattern, " ")
2111 path = strings.Replace(path, "{account}", "bab", 1)
2112 path = strings.Replace(path, "{app}", "hello", 1)
2113 path = fill.ReplaceAllString(path, "x")
2114 with := doRaw(t, method, fx.ts.URL+path, raw)
2115 without := doRaw(t, method, fx.ts.URL+path, "")
2116 if with.status != without.status || with.body != without.body {
2117 t.Errorf("%s: unknown role answered %d %q; no token answered %d %q",
2118 pattern, with.status, with.body, without.status, without.body)
2119 }
2120 }
2121}
2122```
2123
2124`doRaw` sends `{}` as the body for non-GET methods, sets `Authorization: Bearer <tok>` only when `tok != ""`, and returns `{status int; body string}`. The role-set exclusion half of §9 is already pinned by `TestDeployIsBoundToItsApp` (Task 4).
2125
2126- [ ] **Step 2: Run.**
2127
2128Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run TestUnknownStoredRole -count=1`
2129Expected: PASS. A failure names a door that treats an unknown role differently from no token; stop and report it, do not special-case the test.
2130
2131- [ ] **Step 3: Read v0.21.0.** Confirm both refusals exist in the release Tito's console runs:
2132
2133```bash
2134git show v0.21.0:internal/console/svcprincipal.go | grep -n -A3 'r, ok := svcrole.Parse(role)'
2135git show v0.21.0:internal/console/authcli.go | grep -n 'allow.Permits(p.Role)'
2136```
2137
2138Expected: the first prints the `if !ok { return Principal{}, false }` arm; the second prints one line. Put both outputs in the commit body.
2139
2140- [ ] **Step 4: Commit** with message `console: pin fail-closed handling of an unknown stored role (#229)`.
2141
2142---
2143
2144### Task 11: Docs
2145
2146**Files:**
2147- Modify: `README.md:170-180`
2148- Modify: `docs/site/cli.md` (the `carlos services` section, `:1409-1440`)
2149
2150- [ ] **Step 1: README.** Replace the paragraph beginning "CI and operators keep the existing path unchanged" (through "...the rung gates) is what decides.") with D30 as approved. The draft for Task 0:
2151
2152```markdown
2153**CI uses the console too.** Mint a deploy credential for the app, limited
2154to the rungs CI may move, and give CI the secret and the console's address:
2155
2156 carlos services create --role deploy --app blog --rungs canary,edge ci
2157 # in CI:
2158 export CARLOS_SERVICE_TOKEN=... CARLOS_CONSOLE=https://console.example.com
2159 carlos deploy --app blog --version "$GITHUB_SHA" --channel edge blog-linux-arm64
2160
2161`carlos deploy` ships, promotes and watches the app's hosts, the same as
2162from a laptop. The first deploy of a new app still needs a person: a deploy
2163credential cannot create the app or set up its first host. Every promote is
2164audited in the console and reaches the boxes within seconds. A deploy credential can ship, and promote or roll back only its
2165rungs. It cannot reach stable, hotfix, delete the app, or change retention
2166or domains.
2167
2168**Direct bucket mode is for operators.** With `CARLOS_DEPLOYMENT_BUCKET`
2169(or `_DIR`) set, every verb goes straight to the bucket and the console
2170never sees it. Use it to set up a deployment before its console exists, to
2171run one that has no console, to recover when the console is down, and for
2172the platform's own releases. A scoped `CARLOS_RELEASE_KEY` limits that path
2173only. The CLI refuses to run with both `CARLOS_SERVICE_TOKEN` and a bucket
2174variable set.
2175```
2176
2177Leave "**On a box, or in CI, don't log in.**" and what follows unchanged.
2178
2179- [ ] **Step 2: docs/site/cli.md.** In `### carlos services`, change the example to `carlos services create --role deploy --app hello --rungs canary,edge ci-shipper`, and add D31 as approved after "Pick the narrowest role that works." The draft for Task 0:
2180
2181```markdown
2182`deploy` is for a CI pipeline: it ships, and promotes or rolls back only
2183the rungs named with `--rungs` (`canary`, `edge`, `beta`; never `stable`).
2184It must name one app and cannot hotfix. If the app has a pipeline, it may
2185move `canary/<name>` channels only. CI reads it from
2186`CARLOS_SERVICE_TOKEN` with `CARLOS_CONSOLE`.
2187```
2188
2189and change "`publish` ships releases;" to "`publish` ships releases and manages the app;".
2190
2191- [ ] **Step 3: Check links and formatting.**
2192
2193Run: `grep -n "CARLOS_SERVICE_TOKEN" README.md docs/site/cli.md`
2194Expected: the new passages and nothing contradicting them.
2195
2196- [ ] **Step 4: Commit** with message `docs: CI promotes through the console with a deploy credential (#229)`.
2197
2198---
2199
2200### Task 12: The full gate, push and CI
2201
2202- [ ] **Step 1: Full gate, outside the sandbox**, using the `-exec` wrapper (activator tests need a short temp root; the root package needs one on disk):
2203
2204```bash
2205mkdir -p ~/.cache/deploy-cred/gotmp
2206cat > ~/.cache/deploy-cred/texec.sh <<'EOF'
2207#!/bin/sh
2208case "$1" in
2209*/activator.test) root=/tmp/k ;;
2210*) root=/var/tmp/ck ;;
2211esac
2212mkdir -p "$root"
2213export GOTMPDIR="$root" TMPDIR="$root"
2214exec "$@"
2215EOF
2216chmod +x ~/.cache/deploy-cred/texec.sh
2217cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential
2218go build ./... && GOTMPDIR=~/.cache/deploy-cred/gotmp CARLOS_REQUIRE_JS_PARITY=1 \
2219 GOFLAGS="-buildvcs=false -exec=$HOME/.cache/deploy-cred/texec.sh" make test
2220```
2221
2222Expected: build, vet, gofmt and every package's tests pass. A failure is read first, never just re-run.
2223
2224- [ ] **Step 2: Cross-compile** (the CLI links `internal/host`):
2225
2226```bash
2227for p in darwin/arm64 windows/amd64 linux/arm64; do GOOS=${p%/*} GOARCH=${p#*/} go build -o /dev/null . || echo "FAIL $p"; done
2228```
2229
2230Expected: no `FAIL` lines.
2231
2232- [ ] **Step 3: Clean up and push.**
2233
2234```bash
2235rm -rf /tmp/k /var/tmp/ck ~/.cache/deploy-cred
2236git push origin design-deploy-credential
2237amadan ci status carlos/platform -branch design-deploy-credential; echo "exit=$?"
2238```
2239
2240Expected: the push succeeds; CI status eventually exits 0. Do not merge: merging and rolling wait for Paul.
2241
2242---
2243
2244## Self-review notes
2245
2246- Spec coverage: §5 → Tasks 1 to 3; §6 → Task 4; §7.1 and §7.3 → Task 5; §7.2 → Tasks 6 and 8; §7.4 → Task 7; §8 → Task 9; §9 → Tasks 4 and 10; §10 → Task 2 (audit) and Task 7 (promote audit unchanged); §11 → Tasks 0 and 11; §12 → every task's tests; §13 rollout is Paul's and titogo's, after merge, and is not a task here.
2247- R5 is pinned by the two "lands" tests in Task 6 and the simulation's acceptance of restored bytes in Task 8.
2248