carlos / platform Public

plan: deploy credential implementation plan (#229)

Human

Prompt

yes go ahead.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Twelve tasks plus a copy-review Task 0, from spec rev 5.1: the role and
rung storage, mint doors, CLI verbs, the §6 door list, the scope function,
the release Authorize hook, the request-local scoped signer, a randomized
G1/G2 simulation with two mutants, CARLOS_SERVICE_TOKEN in the CLI, the
mixed-version check, docs, and the gate.
Agent notes
CARLOS_SERVICE_TOKEN was checked for collisions: nothing in the platform
or titogo reads it today. `carlos deploy` reads GET .../regions, which is
not a §6 door; Task 9 pins that as a known gap for Paul.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2248be9dd932f23288c2a07a86bfd5e43c9da467 parent 210194b
1 file changed, +2241 −0
  • docs/superpowers/plans/2026-10-05-deploy-credential.md +2241 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-05-deploy-credential.md b/docs/superpowers/plans/2026-10-05-deploy-credential.md
new file mode 100644
index 000000000..0f7c06723
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-05-deploy-credential.md
@@ -0,0 +1,2241 @@
+# Deploy Credential Implementation Plan
+
+> **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.
+
+**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.
+
+**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.
+
+**Tech Stack:** Go (stdlib, `net/http`, `database/sql` on SQLite), the repo's own `internal/release`, `internal/console`, `internal/svcrole` packages, and the root `carlos` CLI package.
+
+**Spec:** `docs/superpowers/specs/2026-10-05-deploy-credential-design.md` (rev 5.1, approved 2026-10-05). Read §3 (rulings R1 to R5), §5 to §9 and §12 before starting any task.
+
+## Global Constraints
+
+- Rungs a `deploy` credential may hold: `canary`, `edge`, `beta`. `stable` is refused at mint and at every door (spec §5.2).
+- `deploy` requires a non-empty app and at least one rung. Any other role with rungs is refused (spec §5.4).
+- A stored `deploy` row whose rungs are empty or do not parse resolves to no principal at all (spec §5.5).
+- A `deploy` credential is permitted on exactly the doors in spec §6, and on nothing else.
+- A caller that passes no `Authorize` hook gets exactly today's behaviour and today's store calls (spec §7.2).
+- A `deploy` request never falls back to the root signer. If the scoped signer cannot be built, the request is refused (spec §7.4).
+- Request-local grant lifetime: 5 minutes. Grant name: `deploy.` + the credential's name.
+- 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.
+- `carlos auth whoami` is unchanged (spec §8).
+- Existing `publish` credentials and existing roles are unchanged (R1).
+- 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.
+- 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.
+- 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>`.
+
+## Review Focus
+
+1. **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.
+2. **A deploy token aimed at a sibling app.** The app binding already 404s a service credential on another app's path. Expected: a `deploy` credential for `hello` gets 404 on `/api/cli/apps/bab/other/promote`, and nothing moves. Pinned in Task 4.
+3. **Rotating a deploy credential.** Expected: the new secret keeps the same rungs and promotes exactly as before; the old secret is refused. Pinned in Task 1.
+4. **A CI retry of the same promote.** Expected: the second call answers 200 with `"result": "noop"`, writes nothing and bumps nothing. Pinned in Task 7.
+5. **`carlos deploy` with a deploy token.** `carlos deploy` reads `GET .../regions`, which is not a §6 door. Expected: CI that uses `ship` then `promote` works; `deploy` is refused at that door with the role gate's message. This is a known limit to report to Paul, not to fix here. Pinned in Task 9 by a test that records it.
+
+---
+
+## File map
+
+| File | Responsibility | Tasks |
+| --- | --- | --- |
+| `internal/svcrole/svcrole.go` | the `Deploy` role in the catalogue | 1 |
+| `internal/console/deployscope.go` (new) | rung parsing, `deployScopeAllows`, the refusal type, `deploySigner` | 1, 5, 7 |
+| `internal/console/authstore.go` | `rungs` column, `CLIToken.Rungs`, row scanning | 1 |
+| `internal/console/svcprincipal.go` | `Principal.Rungs`, `NewServicePrincipalScoped`, read-back | 1 |
+| `internal/console/apiservices.go` | API mint door and listing | 2 |
+| `internal/console/svcpage.go`, `templates/account_settings.html`, `server.go` | browser mint form and listing | 2 |
+| `internal/console/authcli.go` | the §6 door list | 4 |
+| `internal/console/apicli.go` | promote and rollback handlers | 5, 7 |
+| `internal/release/authorize.go` (new) | `AuthorizationError` | 6 |
+| `internal/release/release.go` | the `Authorize` hook in promote and rollback | 6 |
+| `services.go`, `apimode.go` | `services create --rungs`, `services ls`, `resolveMode` | 3, 9 |
+| `README.md`, `docs/site/cli.md` | CI uses the console | 11 |
+| tests | one new `_test.go` per task, named in each task | all |
+
+---
+
+### Task 0: Copy review (orchestrator with Paul; no subagent)
+
+Every string below is a draft. This task replaces the drafts with Paul's approved text before any task writes them into a file.
+
+**Files:**
+- Modify: this plan's Copy table, in place.
+
+- [ ] **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):
+
+```bash
+cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential
+for 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
+```
+
+- [ ] **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.
+- [ ] **Step 3: Write the approved text into the Copy table verbatim**, replacing each draft. Mark the table header "approved 2026-MM-DD".
+- [ ] **Step 4: Commit.**
+
+```bash
+git add docs/superpowers/plans/2026-10-05-deploy-credential.md
+git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "plan: deploy credential copy approved
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+### Copy table (drafts until Task 0)
+
+`%s` and `%q` are Go format verbs filled at run time. `{{...}}` is template syntax.
+
+| ID | Where | Draft |
+| --- | --- | --- |
+| D1 | `services create` `--role` help | `role: publish, deploy, instance, operate, service or admin (required)` |
+| 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>` |
+| D3 | `services create` `--rungs` help | `rungs a deploy credential may move, comma-separated: canary, edge, beta (required with --role deploy)` |
+| D4 | CLI pre-check | `services create: --rungs is only for --role deploy` |
+| D5 | mint output, deploy | `minted service:%s/%s (role deploy, app %s, rungs %s)` |
+| D6 | `services ls` column header | `RUNGS` |
+| D7 | API mint refusal: deploy, no app | `a deploy credential must name one app (--app)` |
+| D8 | API mint refusal: deploy, no rungs | `a deploy credential must name its rungs (--rungs): canary, edge or beta` |
+| D9 | API mint refusal: stable | `stable cannot be delegated: moving it needs a passkey in the console` |
+| D10 | API mint refusal: unknown rung | `unknown rung %q: one of canary, edge, beta` |
+| D11 | API mint refusal: rungs on another role | `only a deploy credential takes rungs` |
+| D12 | form flash: deploy, no app | `A deploy credential must name one app. Nothing was minted.` |
+| D13 | form flash: deploy, no rungs | `A deploy credential must name at least one rung: canary, edge or beta. Nothing was minted.` |
+| D14 | form flash: stable | `Stable cannot be delegated. Moving it needs a passkey in the console. Nothing was minted.` |
+| D15 | form flash: unknown rung | `“%s” is not a rung. Choose canary, edge or beta. Nothing was minted.` |
+| D16 | form flash: rungs on another role | `Only a deploy credential takes rungs. Nothing was minted.` |
+| D17 | audit detail, deploy mint | `minted service credential %s with role deploy over app %s, rungs %s (%s)` (last verb is `cli` or `console`) |
+| D18 | form: rung checkboxes label | `Rungs, for a deploy credential:` |
+| D19 | listing column header (page) | `Rungs` |
+| D20 | secret banner, deploy | `Minted {{.Name}}: role {{.Role}}, over {{.Scope}}, rungs {{.Rungs}}:` |
+| D21 | 403: hotfix | `deploy credential %s cannot hotfix. Promote in ladder order, or ask a member to hotfix.` |
+| 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`) |
+| D23 | 403: bare canary | `deploy credential %s may move canary/<name> channels, not canary itself.` |
+| D24 | 403: pipelined app | `%s has a pipeline, so deploy credential %s may move canary/<name> channels only.` |
+| D25 | console log line, scoped signer failure | `console: deploy signer for %s/%s: %v` |
+| D26 | 503: scoped signer failure | `could not prepare a signing key for this deploy credential. Nothing was promoted.` |
+| D27 | CLI: token, no console | `CARLOS_SERVICE_TOKEN is set but no console is named: set CARLOS_CONSOLE or pass --console` |
+| 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.` |
+| D29 | CLI session label (`using X as …`) | `a service credential (CARLOS_SERVICE_TOKEN)` |
+| D30 | README.md replacing lines 170-180 | see Task 11 Step 1 |
+| D31 | docs/site/cli.md `carlos services` section | see Task 11 Step 2 |
+
+---
+
+### Task 1: The role, the rungs column, minting rules and read-back
+
+**Files:**
+- Modify: `internal/svcrole/svcrole.go` (catalogue, comments)
+- Modify: `internal/svcrole/svcrole_test.go`
+- Create: `internal/console/deployscope.go`
+- Modify: `internal/console/authstore.go:99-125` (`CLIToken`), `:209-216` (ALTER list), `scanCLITokenRows` and every SELECT that feeds it
+- Modify: `internal/console/svcprincipal.go` (`Principal`, `CLIPrincipal`, `NewServicePrincipal`)
+- Create: `internal/console/deploycred_test.go`
+
+**Interfaces:**
+- Produces:
+ - `svcrole.Deploy Role = "deploy"`, in `All()` right after `Publish`.
+ - `func canonicalRungs(in []string) (string, error)` returns `""` for no rungs, else `canary,edge,beta` order joined by `,`. Errors: `ErrStableNotDelegable`, `*UnknownRungError{Rung string}`.
+ - `func parseStoredRungs(s string) ([]string, bool)`.
+ - `Principal.Rungs []string` (nil for every role but `deploy`).
+ - `CLIToken.Rungs string` (canonical stored form) and `func (t CLIToken) RungList() string` (`"canary, edge"`).
+ - `func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error)`.
+ - Sentinels: `ErrAccountScopedDeploy`, `ErrDeployNeedsRungs`, `ErrRungsNeedDeploy`, `ErrStableNotDelegable`.
+
+- [ ] **Step 1: Write the failing svcrole test.** Append to `internal/svcrole/svcrole_test.go`:
+
+```go
+func TestDeployIsMintableAndNarrow(t *testing.T) {
+ got, ok := Parse("deploy")
+ if !ok || got != Deploy {
+ t.Fatalf("Parse(\"deploy\") = %q, %v", got, ok)
+ }
+ all := All()
+ if len(all) < 2 || all[0] != Publish || all[1] != Deploy {
+ t.Fatalf("All() = %v; want deploy right after publish", all)
+ }
+ // Existing doors do not admit it: adding a role must not widen anything.
+ for _, door := range []Set{Allow(Publish), Allow(Instance), Allow(Operate), Allow(Service)} {
+ if door.Permits(Deploy) {
+ t.Fatalf("%v admits deploy", door.Roles())
+ }
+ }
+ if !Allow(Deploy).Permits(Admin) {
+ t.Fatal("Allow adds Admin to every set; that changed")
+ }
+}
+```
+
+- [ ] **Step 2: Run it and see it fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -run TestDeployIsMintableAndNarrow -count=1`
+Expected: FAIL, `undefined: Deploy`.
+
+- [ ] **Step 3: Add the role.** In `internal/svcrole/svcrole.go`, after the `Publish` constant:
+
+```go
+ // Deploy is a deploy pipeline's credential: ship, and promote or roll
+ // back only the rungs the credential was minted with. It is always bound
+ // to one app, and it never reaches app lifecycle, retention or domains.
+ // The rungs live on the credential, not here: this package knows roles,
+ // not channels (spec 2026-10-05-deploy-credential-design.md §5).
+ Deploy Role = "deploy"
+```
+
+Change the catalogue line to:
+
+```go
+var catalogue = []Role{Publish, Deploy, Instance, Operate, Service, Admin}
+```
+
+Change the `Admin` comment's first line from "the union of the four above" to "the union of the five above".
+
+- [ ] **Step 4: Run the svcrole package.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/svcrole/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Write the failing console tests.** Create `internal/console/deploycred_test.go`:
+
+```go
+package console
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func TestCanonicalRungs(t *testing.T) {
+ for _, tc := range []struct {
+ in []string
+ want string
+ }{
+ {nil, ""},
+ {[]string{" ", ""}, ""},
+ {[]string{"edge"}, "edge"},
+ {[]string{"edge", " canary ", "edge", ""}, "canary,edge"},
+ {[]string{"beta", "canary", "edge"}, "canary,edge,beta"},
+ } {
+ got, err := canonicalRungs(tc.in)
+ if err != nil || got != tc.want {
+ t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
+ }
+ }
+ if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) {
+ t.Errorf("stable: err = %v, want ErrStableNotDelegable", err)
+ }
+ for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} {
+ var ue *UnknownRungError
+ if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad {
+ t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err)
+ }
+ }
+}
+
+func TestParseStoredRungs(t *testing.T) {
+ if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) {
+ t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok)
+ }
+ // Not canonical, empty, or naming stable: never a usable scope.
+ for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} {
+ if got, ok := parseStoredRungs(bad); ok {
+ t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got)
+ }
+ }
+}
+
+func TestMintDeployRules(t *testing.T) {
+ st := newDeployStore(t)
+ now := time.Now()
+ mint := func(app string, role svcrole.Role, rungs []string) error {
+ _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, "paul@example.test", now)
+ return err
+ }
+ if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) {
+ t.Errorf("deploy with no app: %v", err)
+ }
+ if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) {
+ t.Errorf("deploy with no rungs: %v", err)
+ }
+ if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) {
+ t.Errorf("deploy with stable: %v", err)
+ }
+ if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) {
+ t.Errorf("publish with rungs: %v", err)
+ }
+ // The old entry point cannot mint a deploy credential: it has no rungs.
+ if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) {
+ t.Errorf("NewServicePrincipal(deploy): %v", err)
+ }
+}
+
+func TestDeployPrincipalReadsBackItsRungs(t *testing.T) {
+ st := newDeployStore(t)
+ now := time.Now()
+ raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, "paul@example.test", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, ok := st.CLIPrincipal(raw, now)
+ if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) {
+ t.Fatalf("CLIPrincipal = %+v, %v", p, ok)
+ }
+ // Review Focus 3: rotation keeps the rungs, and the old secret dies.
+ fresh, err := st.RotateServicePrincipal(1, "ci", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := st.CLIPrincipal(raw, now); ok {
+ t.Fatal("the old secret still resolves after rotate")
+ }
+ p, ok = st.CLIPrincipal(fresh, now)
+ if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) {
+ t.Fatalf("after rotate: %+v, %v", p, ok)
+ }
+ rows, err := st.ServicePrincipals(1)
+ if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" {
+ t.Fatalf("ServicePrincipals = %+v, %v", rows, err)
+ }
+}
+
+func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) {
+ st := newDeployStore(t)
+ now := time.Now()
+ for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} {
+ raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, "paul@example.test", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil {
+ t.Fatal(err)
+ }
+ if p, ok := st.CLIPrincipal(raw, now); ok {
+ t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p)
+ }
+ }
+ // A non-deploy row that somehow carries rungs is refused too.
+ raw, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, "paul@example.test", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := st.CLIPrincipal(raw, now); ok {
+ t.Error("a publish row carrying rungs resolved")
+ }
+}
+
+// newDeployStore is a bare console store, enough for the store-level tests.
+func newDeployStore(t *testing.T) *Store {
+ t.Helper()
+ st, err := Open(t.TempDir() + "/console.db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ return st
+}
+```
+
+- [ ] **Step 6: Run them and see them fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1`
+Expected: FAIL to compile, `undefined: canonicalRungs` and the other new names.
+
+- [ ] **Step 7: Create `internal/console/deployscope.go`** with the rung helpers (Task 5 and Task 7 add to this file):
+
+```go
+// The deploy credential's scope: which rungs it may move, and the one
+// function that decides whether a promote or rollback by it is in scope.
+//
+// Spec: docs/superpowers/specs/2026-10-05-deploy-credential-design.md.
+package console
+
+import (
+ "errors"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// deployRungs is the order rungs are stored and shown in. stable is not in
+// it: no delegated credential may move stable (spec §5.2).
+var deployRungs = []string{release.RungCanary, release.RungEdge, release.RungBeta}
+
+// ErrStableNotDelegable is a mint that names stable.
+var ErrStableNotDelegable = errors.New("console: stable cannot be delegated")
+
+// UnknownRungError is a mint that names something that is not a rung. It
+// carries the name so both mint doors can say which one (D10, D15).
+type UnknownRungError struct{ Rung string }
+
+func (e *UnknownRungError) Error() string { return "console: unknown rung " + e.Rung }
+
+// canonicalRungs turns what a person typed into the stored form: spaces and
+// empty entries dropped, duplicates collapsed, ladder order, joined by ",".
+// It returns "" when nothing was named. It never lower-cases: "Edge" is not
+// a rung, and accepting it would make the stored form differ from the input
+// in a way the minter did not ask for.
+func canonicalRungs(in []string) (string, error) {
+ seen := map[string]bool{}
+ for _, raw := range in {
+ r := strings.TrimSpace(raw)
+ if r == "" {
+ continue
+ }
+ if r == release.RungStable {
+ return "", ErrStableNotDelegable
+ }
+ known := false
+ for _, d := range deployRungs {
+ if r == d {
+ known = true
+ }
+ }
+ if !known {
+ return "", &UnknownRungError{Rung: r}
+ }
+ seen[r] = true
+ }
+ var out []string
+ for _, d := range deployRungs {
+ if seen[d] {
+ out = append(out, d)
+ }
+ }
+ return strings.Join(out, ","), nil
+}
+
+// parseStoredRungs reads the column back. Only the exact canonical form of a
+// non-empty set is accepted: anything else is a row this console did not
+// write, and a deploy credential with an unreadable scope must not resolve
+// (spec §5.5).
+func parseStoredRungs(s string) ([]string, bool) {
+ if s == "" {
+ return nil, false
+ }
+ parts := strings.Split(s, ",")
+ canon, err := canonicalRungs(parts)
+ if err != nil || canon != s {
+ return nil, false
+ }
+ return parts, true
+}
+```
+
+- [ ] **Step 8: Add the column and the listing field** in `internal/console/authstore.go`.
+
+Add to the ALTER list (after the `rotated_at` line, `:216`):
+
+```go
+ `ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`,
+```
+
+Add to `CLIToken` (after `RotatedAt`):
+
+```go
+ // Rungs is a deploy credential's scope in its stored, canonical form
+ // ("canary,edge"); empty for every other kind and role.
+ Rungs string
+```
+
+And the display method, right after the struct:
+
+```go
+// RungList is Rungs for a person to read: "canary, edge".
+func (t CLIToken) RungList() string { return strings.ReplaceAll(t.Rungs, ",", ", ") }
+```
+
+In `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:
+
+```bash
+grep -n "scanCLITokenRows(" internal/console/*.go
+```
+
+Each 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.
+
+- [ ] **Step 9: Add `Rungs` to `Principal`, the scoped mint and the read-back** in `internal/console/svcprincipal.go`.
+
+In `Principal`, after `Name`:
+
+```go
+ // Rungs is a deploy credential's scope, in ladder order. nil for every
+ // other role, and never empty for a deploy principal: CLIPrincipal
+ // refuses a deploy row it cannot read a scope from.
+ Rungs []string
+```
+
+Add the sentinels next to `ErrAccountScopedService`:
+
+```go
+// ErrAccountScopedDeploy is a deploy credential minted without an app.
+var ErrAccountScopedDeploy = errors.New("console: a deploy credential must be bound to one app")
+
+// ErrDeployNeedsRungs is a deploy credential minted with no rungs. There is
+// no default set: the minter says what the credential may move.
+var ErrDeployNeedsRungs = errors.New("console: a deploy credential needs at least one rung")
+
+// ErrRungsNeedDeploy is rungs named for any other role, where they would
+// read as a limit and enforce nothing.
+var ErrRungsNeedDeploy = errors.New("console: only a deploy credential takes rungs")
+```
+
+Replace the body of `NewServicePrincipal` with a delegation, keeping its doc comment, and move the body into the new function:
+
+```go
+func (s *Store) NewServicePrincipal(accountID int64, name, app string, role svcrole.Role, mintedBy string, now time.Time) (string, error) {
+ return s.NewServicePrincipalScoped(accountID, name, app, role, nil, mintedBy, now)
+}
+
+// NewServicePrincipalScoped is NewServicePrincipal with a rung scope, which
+// only a deploy credential takes. The rules live here rather than in a
+// handler for the reason the Service rule does: there are two mint doors.
+func (s *Store) NewServicePrincipalScoped(accountID int64, name, app string, role svcrole.Role, rungs []string, mintedBy string, now time.Time) (string, error) {
+ if name == "" {
+ return "", errors.New("console: a service credential needs a name")
+ }
+ if _, ok := svcrole.Parse(string(role)); !ok {
+ return "", fmt.Errorf("console: unknown service role %q", role)
+ }
+ if role == svcrole.Service && app == "" {
+ return "", ErrAccountScopedService
+ }
+ canon, err := canonicalRungs(rungs)
+ if err != nil {
+ return "", err
+ }
+ if role == svcrole.Deploy {
+ if app == "" {
+ return "", ErrAccountScopedDeploy
+ }
+ if canon == "" {
+ return "", ErrDeployNeedsRungs
+ }
+ } else if canon != "" {
+ return "", ErrRungsNeedDeploy
+ }
+ // ... the existing transaction from NewServicePrincipal, unchanged except
+ // for the INSERT, which gains the rungs column:
+```
+
+with the INSERT becoming:
+
+```go
+ if _, err := tx.Exec(`INSERT INTO cli_tokens
+(token_hash, address, label, remote_addr, created_at, rotated_at, expires_at, kind, account_id, app, role, name, rungs)
+VALUES (?, ?, '', '', ?, ?, 0, ?, ?, ?, ?, ?, ?)`,
+ hashToken(raw), mintedBy, now.Unix(), now.Unix(), cliKindService, accountID, app, string(role), name, canon); err != nil {
+ return "", err
+ }
+```
+
+Keep the whole existing comment block about `role == svcrole.Service && app == ""` above that check.
+
+In `CLIPrincipal`, select the column and check it. The query becomes:
+
+```go
+ var storedHash, addr, kind, app, role, name, rungs string
+ var accountID, expires, revoked int64
+ err := s.db.QueryRow(`SELECT token_hash, address, kind, account_id, app, role, name, expires_at, revoked_at, rungs
+FROM cli_tokens WHERE token_hash = ?`, hash).
+ Scan(&storedHash, &addr, &kind, &accountID, &app, &role, &name, &expires, &revoked, &rungs)
+```
+
+and the `cliKindService` case becomes:
+
+```go
+ case cliKindService:
+ r, ok := svcrole.Parse(role)
+ if !ok {
+ return Principal{}, false
+ }
+ var scope []string
+ if r == svcrole.Deploy {
+ // Fail closed: a deploy row with no readable scope, or with no
+ // app, is not treated as unscoped (spec §5.5).
+ scope, ok = parseStoredRungs(rungs)
+ if !ok || app == "" {
+ return Principal{}, false
+ }
+ } else if rungs != "" {
+ return Principal{}, false
+ }
+ p = Principal{Kind: PrincipalService, Address: addr, AccountID: accountID, App: app, Role: r, Name: name, Rungs: scope}
+```
+
+- [ ] **Step 10: Run the new tests, then the whole console package.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestCanonicalRungs|TestParseStoredRungs|TestMintDeployRules|TestDeployPrincipalReadsBackItsRungs|TestDeployRowWithBadRungsDoesNotResolve' -count=1`
+Expected: PASS.
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/svcrole/ -count=1`
+Expected: 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.
+
+- [ ] **Step 11: Commit.**
+
+```bash
+gofmt -l internal/ ; git add internal/svcrole internal/console/deployscope.go internal/console/authstore.go internal/console/svcprincipal.go internal/console/deploycred_test.go
+git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "console: deploy role and rung-scoped service credentials (#229)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: The console's mint doors and listings
+
+**Files:**
+- Modify: `internal/console/apiservices.go` (`serviceView`, `handleAPIServiceCreate`, `handleAPIServiceList`)
+- Modify: `internal/console/svcpage.go` (`ServiceSecretView`, `handleServiceCreatePage`, the page fill near `:177`)
+- Modify: `internal/console/server.go:1723-1724` (page struct)
+- Modify: `internal/console/templates/account_settings.html` (banner, listing, form)
+- Create: `internal/console/deploymint_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `NewServicePrincipalScoped`, sentinels, `UnknownRungError`, `CLIToken.Rungs`, `RungList`.
+- Produces: API create accepts `"rungs": ["edge"]` and answers `"rungs": ["canary","edge"]`; listing rows carry `"rungs": [...]` (empty array, never null, for non-deploy rows).
+
+- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploymint_test.go`:
+
+```go
+package console
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestAPIMintDeploy(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-deploy-mint")
+ u := fx.ts.URL + "/api/cli/services/bab"
+ status, out := postJSON(t, u, map[string]any{"name": "ci", "role": "deploy", "app": "hello", "rungs": []string{"edge", "canary"}}, token)
+ if status != http.StatusCreated {
+ t.Fatalf("mint deploy = %d %v", status, out)
+ }
+ if got, _ := out["rungs"].([]any); len(got) != 2 || got[0] != "canary" || got[1] != "edge" {
+ t.Fatalf("rungs in response = %v", out["rungs"])
+ }
+ for _, tc := range []struct {
+ body map[string]any
+ want string // D-text fragment from the approved Copy table
+ }{
+ {map[string]any{"name": "a1", "role": "deploy", "rungs": []string{"edge"}}, "must name one app"},
+ {map[string]any{"name": "a2", "role": "deploy", "app": "hello"}, "must name its rungs"},
+ {map[string]any{"name": "a3", "role": "deploy", "app": "hello", "rungs": []string{"stable"}}, "stable cannot be delegated"},
+ {map[string]any{"name": "a4", "role": "deploy", "app": "hello", "rungs": []string{"prod"}}, `"prod"`},
+ {map[string]any{"name": "a5", "role": "publish", "app": "hello", "rungs": []string{"edge"}}, "only a deploy credential"},
+ } {
+ status, out := postJSON(t, u, tc.body, token)
+ if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) {
+ t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want)
+ }
+ }
+ status, out = getJSON(t, u, token)
+ if status != http.StatusOK {
+ t.Fatalf("list = %d %v", status, out)
+ }
+ rows, _ := out["services"].([]any)
+ if len(rows) != 1 {
+ t.Fatalf("listing = %v", rows)
+ }
+ if r, _ := rows[0].(map[string]any)["rungs"].([]any); len(r) != 2 {
+ t.Fatalf("listing rungs = %v", rows[0])
+ }
+}
+```
+
+The 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.
+
+Add 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.
+
+- [ ] **Step 2: Run them and see them fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestAPIMintDeploy|TestFormMintDeploy' -count=1`
+Expected: FAIL (`rungs` missing from the response; refusals answer 500 or mint).
+
+- [ ] **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:
+
+```go
+ var ue *UnknownRungError
+ switch {
+ case errors.Is(err, ErrAccountScopedDeploy):
+ jsonError(w, http.StatusBadRequest, "<D7>")
+ return
+ case errors.Is(err, ErrDeployNeedsRungs):
+ jsonError(w, http.StatusBadRequest, "<D8>")
+ return
+ case errors.Is(err, ErrStableNotDelegable):
+ jsonError(w, http.StatusBadRequest, "<D9>")
+ return
+ case errors.As(err, &ue):
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf("<D10>", ue.Rung))
+ return
+ case errors.Is(err, ErrRungsNeedDeploy):
+ jsonError(w, http.StatusBadRequest, "<D11>")
+ return
+ }
+```
+
+`<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:
+
+```go
+ canon, _ := canonicalRungs(req.Rungs) // already validated by the mint
+ rungs := []string{}
+ if canon != "" {
+ rungs = strings.Split(canon, ",")
+ }
+```
+
+Use 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.
+
+In `serviceView` add `Rungs []string \`json:"rungs"\``, and in `handleAPIServiceList` fill it: `[]string{}` when `t.Rungs == ""`, else `strings.Split(t.Rungs, ",")`.
+
+- [ ] **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>")`:
+
+```go
+ canon, rerr := canonicalRungs(r.Form["rungs"])
+ var ue *UnknownRungError
+ switch {
+ case errors.Is(rerr, ErrStableNotDelegable):
+ back("err", "<D14>")
+ return
+ case errors.As(rerr, &ue):
+ back("err", fmt.Sprintf("<D15>", ue.Rung))
+ return
+ case role == svcrole.Deploy && app == "":
+ back("err", "<D12>")
+ return
+ case role == svcrole.Deploy && canon == "":
+ back("err", "<D13>")
+ return
+ case role != svcrole.Deploy && canon != "":
+ back("err", "<D16>")
+ return
+ }
+```
+
+`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, ",", ", ")`.
+
+In `server.go` next to `ServiceRoles`, add:
+
+```go
+ ServiceRungs []string // the mint form's rung choices, deploy only
+```
+
+and where `svcpage.go:177` fills `ServiceRoles`, set `p.ServiceRungs = deployRungs`.
+
+- [ ] **Step 5: Template.** In `templates/account_settings.html`:
+ - Banner: in the not-rotated branch, render D20 when `.Rungs` is non-empty, else the existing line:
+
+```html
+ {{else}}
+ {{if .Rungs}}<p><strong>D20</strong></p>{{else}}<p><strong>Minted {{.Name}} — role {{.Role}}, over {{.Scope}}:</strong></p>{{end}}
+ {{end}}
+```
+
+ (`D20` replaced by the approved template text.)
+ - Listing: add a header cell `<th>D19</th>` after `Scope`, and a cell `<td>{{emptyOrDash .RungList}}</td>` after the scope cell.
+ - Form: after the app `<select>`, add:
+
+```html
+ <span class="small">D18</span>
+ {{range .ServiceRungs}}<label class="small"><input type="checkbox" name="rungs" value="{{.}}"> {{.}}</label>{{end}}
+```
+
+- [ ] **Step 6: Run the tests, then the package.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 7: Commit** with message `console: mint and list deploy credentials (#229)`.
+
+---
+
+### Task 3: `carlos services create --rungs` and `services ls`
+
+**Files:**
+- Modify: `services.go` (`runServicesCreate`, `printServiceCreated`, `runServicesList`)
+- Modify: `apimode.go:4285-4330` (`apiServiceView`, `createService`)
+- Modify: `services_cli_test.go`
+
+**Interfaces:**
+- Consumes: Task 2's API fields `rungs` (request and response, listing).
+- 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`.
+
+- [ ] **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`.
+
+- [ ] **Step 2: Run and see them fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestServicesCreate|TestAPISessionServiceCreate' -count=1`
+Expected: FAIL to compile (signature).
+
+- [ ] **Step 3: Implement.**
+ - `apiServiceView`: add `Rungs []string \`json:"rungs"\``.
+ - `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.
+ - `runServicesCreate`: replace the `--role` help with D1 and `usageMsg` with D2. Add:
+
+```go
+ rungsRaw := fs.String("rungs", "", "<D3>")
+```
+
+ After the role parse:
+
+```go
+ var rungsV []string
+ for _, r := range strings.Split(*rungsRaw, ",") {
+ if r = strings.TrimSpace(r); r != "" {
+ rungsV = append(rungsV, r)
+ }
+ }
+ if len(rungsV) > 0 && roleV != string(svcrole.Deploy) {
+ log.Fatal("<D4>")
+ }
+```
+
+ Pass `rungsV` to `createService` and the returned rungs to `printServiceCreated`.
+ - `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.
+ - `runServicesList`: header gains `\tD6` after `APP`, and each row prints `strings.Join(r.Rungs, ",")` or `-` when empty.
+ - Update every other caller of the two changed functions: `grep -n 'createService(\|printServiceCreated(' *.go` and pass `nil` / ignore the extra return.
+
+- [ ] **Step 4: Run.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'Service' -count=1 && go vet .`
+Expected: PASS.
+
+- [ ] **Step 5: Commit** with message `cli: services create --rungs and a RUNGS column (#229)`.
+
+---
+
+### Task 4: The doors a deploy credential reaches
+
+**Files:**
+- Modify: `internal/console/authcli.go:476-544` (route registrations)
+- Create: `internal/console/deploydoors_test.go`
+
+**Interfaces:**
+- Consumes: `svcrole.Deploy`; Task 1's `NewServicePrincipalScoped`.
+- Produces: `deployDoors` (test-only) — the exact §6 list.
+
+- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploydoors_test.go`:
+
+```go
+package console
+
+import (
+ "net/http"
+ "reflect"
+ "sort"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// deployDoors is spec §6, verbatim. A door added later is refused to a
+// deploy credential unless someone adds it here on purpose.
+var deployDoors = []string{
+ "GET /api/cli/apps",
+ "GET /api/cli/apps/{account}/{app}/channels",
+ "POST /api/cli/apps/{account}/{app}/releases",
+ "GET /api/cli/apps/{account}/{app}/releases",
+ "POST /api/cli/apps/{account}/{app}/promote",
+ "POST /api/cli/apps/{account}/{app}/rollback",
+ "GET /api/cli/apps/{account}/{app}/tags",
+ "GET /api/cli/apps/{account}/{app}/tags/{version}",
+ "GET /api/cli/apps/{account}/{app}/version-target",
+ "GET /api/cli/apps/{account}/{app}/pipeline",
+ "GET /api/cli/apps/{account}/{app}/pending",
+}
+
+func TestDeployReachesExactlyItsDoors(t *testing.T) {
+ s := newConsoleFixture(t).srv
+ var got []string
+ for pattern, set := range s.cliRoles {
+ if set.Permits(svcrole.Deploy) {
+ got = append(got, pattern)
+ }
+ }
+ sort.Strings(got)
+ want := append([]string(nil), deployDoors...)
+ sort.Strings(want)
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("deploy reaches:\n got %q\nwant %q", got, want)
+ }
+}
+
+// Review Focus 2: the app binding holds for deploy like any service.
+func TestDeployIsBoundToItsApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ for _, app := range []string{"hello", "other"} {
+ if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/other/promote",
+ map[string]string{"version": "v1", "channel": "edge"}, tok)
+ if status != http.StatusNotFound {
+ t.Fatalf("deploy on a sibling app = %d %v, want 404", status, out)
+ }
+ // And a door outside §6 is the role gate's 403, not a 404.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/delete", map[string]string{}, tok)
+ if status != http.StatusForbidden {
+ t.Fatalf("deploy on trash = %d %v, want 403", status, out)
+ }
+}
+```
+
+- [ ] **Step 2: Run and see the first fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployReachesExactlyItsDoors|TestDeployIsBoundToItsApp' -count=1`
+Expected: FAIL, `deploy reaches: got []`.
+
+- [ ] **Step 3: Register the doors.** In `internal/console/authcli.go`, change exactly these registrations:
+
+```go
+ s.cliRoute("GET /api/cli/apps", svcrole.Allow(svcrole.Publish, svcrole.Deploy, svcrole.Instance, svcrole.Operate), s.handleAPIApps)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/channels", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIChannels)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/promote", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPromote)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/rollback", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIRollback)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIShip)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/version-target", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIVersionTargetGet)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/tags", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagsList)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/tags/{version}", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPITagGet)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/releases", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIReleasesList)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPipelineGet)
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish, svcrole.Deploy), s.handleAPIPending)
+```
+
+Change nothing else in the table.
+
+- [ ] **Step 4: Run the package.** Promote and rollback by a deploy credential are now reachable with no scope check. That is closed in Task 5, and nothing is released between the two tasks.
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1`
+Expected: PASS, including `TestEveryCLIDoorNamesItsRoles` and `TestNoRoleReachesAMintFleetOrPolicyDoor`.
+
+- [ ] **Step 5: Commit** with message `console: the doors a deploy credential reaches (#229)`.
+
+---
+
+### Task 5: The scope function and the handlers' early refusal
+
+**Files:**
+- Modify: `internal/console/deployscope.go`
+- Modify: `internal/console/apicli.go` (`handleAPIPromote` after the pipeline read near `:772-777`; `handleAPIRollback` after its pipeline read)
+- Create: `internal/console/deployscope_test.go`
+
+**Interfaces:**
+- Consumes: `Principal.Rungs`, `release.Rung`, `release.RungCanary`, `release.RungStable`.
+- Produces:
+ - `func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error` — nil for any non-deploy principal.
+ - `type deployRefusal struct{ msg string }` implementing `error`.
+ - `func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error)` — 403 `{"error": <message>}`.
+ - `func deployRefusalFor(app string, err error) error` — fills the app name for D24 (see Step 3).
+
+- [ ] **Step 1: Write the failing table test.** Create `internal/console/deployscope_test.go`:
+
+```go
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func TestDeployScopeAllows(t *testing.T) {
+ ce := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"canary", "edge"}}
+ beta := Principal{Kind: PrincipalService, Role: svcrole.Deploy, Name: "ci", App: "hello", Rungs: []string{"beta"}}
+ pub := Principal{Kind: PrincipalService, Role: svcrole.Publish, Name: "ci", App: "hello"}
+ person := Principal{Kind: PrincipalUser, Address: "paul@keymail.dev"}
+ for _, tc := range []struct {
+ name string
+ p Principal
+ pipelined bool
+ channel string
+ hotfix bool
+ ok bool
+ }{
+ {"edge in scope", ce, false, "edge", false, true},
+ {"canary branch in scope", ce, false, "canary/feature-x", false, true},
+ {"bare canary refused", ce, false, "canary", false, false},
+ {"bare canary refused on a pipelined app", ce, true, "canary", false, false},
+ {"beta outside rungs", ce, false, "beta", false, false},
+ {"stable refused", ce, false, "stable", false, false},
+ {"stable refused even when granted beta", beta, false, "stable", false, false},
+ {"not a rung", ce, false, "staging", false, false},
+ {"hotfix refused", ce, false, "edge", true, false},
+ {"pipelined: edge refused", ce, true, "edge", false, false},
+ {"pipelined: canary branch allowed", ce, true, "canary/x", false, true},
+ {"beta credential on beta", beta, false, "beta", false, true},
+ {"publish untouched", pub, true, "stable", true, true},
+ {"person untouched", person, true, "stable", true, true},
+ } {
+ err := deployScopeAllows(tc.p, tc.pipelined, tc.channel, tc.hotfix)
+ if (err == nil) != tc.ok {
+ t.Errorf("%s: err = %v, want ok=%v", tc.name, err, tc.ok)
+ }
+ }
+}
+```
+
+Then 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):
+ - `{"version":"v1","channel":"beta"}` (D22)
+ - `{"version":"v1","channel":"stable"}` (D22)
+ - `{"version":"v1","channel":"canary"}` (D23)
+ - `{"version":"v1","channel":"edge","hotfix":true}` (D21)
+ - 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.
+ - rollback: `{"channel":"beta"}` gives 403 D22.
+
+- [ ] **Step 2: Run and see it fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeployScope' -count=1`
+Expected: FAIL to compile, `undefined: deployScopeAllows`.
+
+- [ ] **Step 3: Implement the function.** Append to `internal/console/deployscope.go` (add imports `fmt`, `net/http`, `svcrole`):
+
+```go
+// deployRefusal is a promote or rollback a deploy credential may not make.
+// Its text is the 403's error, so a CI log says what to fix (spec §7.3).
+type deployRefusal struct{ msg string }
+
+func (e *deployRefusal) Error() string { return e.msg }
+
+// deployScopeAllows is the whole scope decision for a deploy credential
+// (spec §7.1). It is pure so it can be table-tested, and it is called three
+// times per write: early in the handler for the message, and by the release
+// primitives on their own pipeline reads (Task 7). For every principal that
+// is not a deploy credential it returns nil and changes nothing.
+func deployScopeAllows(p Principal, pipelined bool, channel string, hotfix bool) error {
+ if p.Kind != PrincipalService || p.Role != svcrole.Deploy {
+ return nil
+ }
+ held := deployHeldWords(p.Rungs)
+ if hotfix {
+ return &deployRefusal{fmt.Sprintf("<D21>", p.Name)}
+ }
+ rung, ok := release.Rung(channel)
+ if !ok || rung == release.RungStable {
+ return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)}
+ }
+ if rung == release.RungCanary && !strings.HasPrefix(channel, release.RungCanary+"/") {
+ return &deployRefusal{fmt.Sprintf("<D23>", p.Name)}
+ }
+ inScope := false
+ for _, r := range p.Rungs {
+ if r == rung {
+ inScope = true
+ }
+ }
+ if !inScope {
+ return &deployRefusal{fmt.Sprintf("<D22>", p.Name, held, channel)}
+ }
+ if pipelined && rung != release.RungCanary {
+ return &deployRefusal{fmt.Sprintf("<D24>", p.App, p.Name)}
+ }
+ return nil
+}
+
+// deployHeldWords is the rung list as the refusal names it: canary shown as
+// the channels it covers.
+func deployHeldWords(rungs []string) string {
+ words := make([]string, len(rungs))
+ for i, r := range rungs {
+ words[i] = r
+ if r == release.RungCanary {
+ words[i] = release.RungCanary + "/<name>"
+ }
+ }
+ return strings.Join(words, ", ")
+}
+
+// writeDeployRefusal answers a scope refusal. Anything that is not a
+// deployRefusal is a programming error and answers 500.
+func (s *Server) writeDeployRefusal(w http.ResponseWriter, err error) {
+ var dr *deployRefusal
+ if errors.As(err, &dr) {
+ jsonError(w, http.StatusForbidden, dr.msg)
+ return
+ }
+ jsonError(w, http.StatusInternalServerError, "internal error")
+}
+```
+
+`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.
+
+- [ ] **Step 4: Call it early in both handlers.** In `handleAPIPromote`, immediately after the `GetPipeline` error block (the one ending `return` near `:777`):
+
+```go
+ if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil {
+ s.writeDeployRefusal(w, err)
+ return
+ }
+```
+
+In `handleAPIRollback`, immediately after its `GetPipeline` error block:
+
+```go
+ if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil {
+ s.writeDeployRefusal(w, err)
+ return
+ }
+```
+
+`p` is already in scope in both handlers (`principalFrom`).
+
+- [ ] **Step 5: Run.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 6: Commit** with message `console: deploy scope check at promote and rollback (#229)`.
+
+---
+
+### Task 6: The Authorize hook in the release primitives
+
+**Files:**
+- Create: `internal/release/authorize.go`
+- Modify: `internal/release/release.go` (`Signer.Promote` `:694`, `PromoteOptions` `:765`, `Signer.PromoteOnLadder` `:849`, `Signer.Rollback` `:1031`)
+- Create: `internal/release/authorize_test.go`
+
+**Interfaces:**
+- Produces:
+ - `PromoteOptions.Authorize func(p *Pipeline) error`
+ - `type AuthorizationError struct{ Err error }` with `Error()` and `Unwrap()`
+ - `func (sg Signer) RollbackAuthorized(ctx context.Context, s Store, acct, app, channel string, authorize func(*Pipeline) error) error`
+ - unexported `func (sg Signer) promote(ctx context.Context, s Store, acct, app, channel, version string, authorize func(*Pipeline) error, rec ...Promotion) error`
+
+- [ ] **Step 1: Write the test store and the failing tests.** Create `internal/release/authorize_test.go`:
+
+```go
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "os"
+ "testing"
+)
+
+// storeCall is one call a request made through hookStore.
+type storeCall struct {
+ seq int
+ op, key string
+ ifMatch string // put only
+}
+
+// hookStore runs before(call) ahead of every call it forwards, and after()
+// once a get returns, so a test can make another actor's writes land at an
+// exact point inside a request. The other actor writes to inner directly:
+// the same handle, which is where G2 holds for a directory store (spec
+// §7.2). seq counts only the calls made through hookStore.
+type hookStore struct {
+ inner Store
+ before func(c storeCall)
+ after func(c storeCall, body []byte, etag string, err error)
+ seq int
+}
+
+func (h *hookStore) call(op, key, ifMatch string) storeCall {
+ h.seq++
+ c := storeCall{seq: h.seq, op: op, key: key, ifMatch: ifMatch}
+ if h.before != nil {
+ h.before(c)
+ }
+ return c
+}
+
+func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c := h.call("get", key, "")
+ body, etag, err := h.inner.Get(ctx, key)
+ if h.after != nil {
+ h.after(c, body, etag, err)
+ }
+ return body, etag, err
+}
+
+func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ h.call("put", key, ifMatch)
+ return h.inner.Put(ctx, key, body, ifMatch)
+}
+
+func (h *hookStore) List(ctx context.Context, prefix string) ([]string, error) {
+ h.call("list", prefix, "")
+ return h.inner.List(ctx, prefix)
+}
+
+func (h *hookStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ h.call("listdirs", prefix, "")
+ return h.inner.ListDirs(ctx, prefix)
+}
+
+func (h *hookStore) Delete(ctx context.Context, key string) error {
+ h.call("delete", key, "")
+ return h.inner.Delete(ctx, key)
+}
+
+// onNth runs fn once, just before the nth (1-based) call matching op and key.
+func onNth(op, key string, n int, fn func()) func(storeCall) {
+ seen := 0
+ return func(c storeCall) {
+ if c.op == op && c.key == key {
+ seen++
+ if seen == n {
+ fn()
+ }
+ }
+ }
+}
+
+// noPipelineOnly is R2's rule for a non-canary channel: refuse if the app
+// has a pipeline.
+func noPipelineOnly(p *Pipeline) error {
+ if p != nil {
+ return errors.New("pipelined")
+ }
+ return nil
+}
+
+var edgeKey = ChannelKey("bab", "myapp", "edge")
+
+func setupEdge(t *testing.T) (context.Context, Store, ed25519.PrivateKey, ed25519.PublicKey) {
+ t.Helper()
+ ctx := context.Background()
+ inner := NewDirStore(t.TempDir())
+ priv, pub := testKeys(t)
+ for _, v := range []string{"v1", "v2", "v3", "v4"} {
+ shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v))
+ }
+ return ctx, inner, priv, pub
+}
+
+func installPipeline(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, names ...string) {
+ t.Helper()
+ var chans []PipelineChannel
+ for _, n := range names {
+ chans = append(chans, PipelineChannel{Name: n})
+ }
+ if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", chans, "owner"); err != nil {
+ t.Fatalf("SetPipeline(%v): %v", names, err)
+ }
+}
+
+func ownerPromote(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, v string) {
+ t.Helper()
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil {
+ t.Fatalf("owner promote %s: %v", v, err)
+ }
+}
+
+func edgeVersion(t *testing.T, ctx context.Context, s Store, pub ed25519.PublicKey) string {
+ t.Helper()
+ p, err := Channel(ctx, s, pub, "bab", "myapp", "edge")
+ if errors.Is(err, os.ErrNotExist) {
+ return ""
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p.Version
+}
+
+func wantAuthorizationError(t *testing.T, err error) {
+ t.Helper()
+ var ae *AuthorizationError
+ if !errors.As(err, &ae) {
+ t.Fatalf("err = %v; want *AuthorizationError", err)
+ }
+}
+
+func TestAuthorizeSeesAPipelineInstalledBeforeTheRequest(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+ if got := edgeVersion(t, ctx, inner, pub); got != "" {
+ t.Fatalf("edge = %q after a refusal", got)
+ }
+}
+
+// The refusal is the hook's, not the ladder's order error, even when the
+// pipeline would make the ladder refuse too (spec §7.2 point 1, codex R3-3).
+func TestAuthorizeRefusesBeforeTheLadderDoes(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ h := &hookStore{inner: inner}
+ h.before = onNth("get", PipelineKey("bab", "myapp"), 1, func() {
+ installPipeline(t, ctx, inner, priv, "preview")
+ })
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+}
+
+// The ladder's read saw no pipeline; one installed before the authoritative
+// read (after the pointer read) is still seen.
+func TestAuthorizeSeesAPipelineInstalledBeforeTheAuthoritativeRead(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ h := &hookStore{inner: inner}
+ h.before = onNth("get", PipelineKey("bab", "myapp"), 2, func() {
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ })
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+ if got := edgeVersion(t, ctx, inner, pub); got != "" {
+ t.Fatalf("edge = %q after a refusal", got)
+ }
+}
+
+// Astra's round 2 counterexample: an owner promotes under a new pipeline
+// after the deploy request read the pointer. The deploy write must lose.
+func TestOwnerPromoteAfterInstallDefeatsTheDeployWrite(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() {
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ ownerPromote(t, ctx, inner, priv, "v3")
+ })
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2",
+ PromoteOptions{Authorize: noPipelineOnly})
+ if !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("err = %v; want ErrCASConflict", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
+ t.Fatalf("edge = %q; want the owner's v3", got)
+ }
+}
+
+// R5, accepted: a pipeline installed after the authorization read, and the
+// pointer written and restored to identical bytes, before the write. G2
+// holds (the bytes are what was read), so the write lands.
+func TestRestoredBytesLandAsTheAcceptedInFlightCase(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ ownerPromote(t, ctx, inner, priv, "v2")
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() {
+ orig, _, err := inner.Get(ctx, edgeKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ ownerPromote(t, ctx, inner, priv, "v4")
+ _, cur, err := inner.Get(ctx, edgeKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := inner.Put(ctx, edgeKey, orig, cur); err != nil {
+ t.Fatal(err)
+ }
+ })
+ if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3",
+ PromoteOptions{Authorize: noPipelineOnly}); err != nil {
+ t.Fatalf("err = %v; R5 accepts this write (spec §16)", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
+ t.Fatalf("edge = %q; want v3", got)
+ }
+}
+
+// R5, accepted: a first promote onto an empty channel, with a pipeline
+// installed after the authorization read.
+func TestFirstPromoteOntoEmptyChannelLandsAsTheAcceptedInFlightCase(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() {
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ })
+ if _, err := RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly}); err != nil {
+ t.Fatalf("err = %v; R5 accepts this write (spec §16)", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v1" {
+ t.Fatalf("edge = %q; want v1", got)
+ }
+}
+
+func TestRepeatPromoteRunsTheHook(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: noPipelineOnly})
+ wantAuthorizationError(t, err)
+ if res.NoOp {
+ t.Fatal("a refused repeat reported NoOp")
+ }
+}
+
+func TestRepeatPromoteWithNoPipelineIsANoOp(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ var seen []bool
+ hook := func(p *Pipeline) error { seen = append(seen, p != nil); return noPipelineOnly(p) }
+ res, err := RootSigner(priv).PromoteOnLadder(ctx, inner, "bab", "myapp", "edge", "v1",
+ PromoteOptions{Authorize: hook})
+ if err != nil || !res.NoOp {
+ t.Fatalf("= %+v, %v; want a no-op", res, err)
+ }
+ if len(seen) != 1 || seen[0] {
+ t.Fatalf("hook calls = %v; want one, with no pipeline", seen)
+ }
+}
+
+// A caller with no hook makes no extra pipeline reads (spec §7.2).
+func TestNoHookNoExtraReads(t *testing.T) {
+ ctx, inner, priv, _ := setupEdge(t)
+ count := func(run func(Store) error) int {
+ n := 0
+ h := &hookStore{inner: inner, before: func(c storeCall) {
+ if c.op == "get" && c.key == PipelineKey("bab", "myapp") {
+ n++
+ }
+ }}
+ if err := run(h); err != nil {
+ t.Fatal(err)
+ }
+ return n
+ }
+ fresh := count(func(s Store) error {
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{})
+ return err
+ })
+ repeat := count(func(s Store) error {
+ _, err := RootSigner(priv).PromoteOnLadder(ctx, s, "bab", "myapp", "edge", "v1", PromoteOptions{})
+ return err
+ })
+ ownerPromote(t, ctx, inner, priv, "v2")
+ rollback := count(func(s Store) error { return RootSigner(priv).Rollback(ctx, s, "bab", "myapp", "edge") })
+ if fresh != 1 || repeat != 0 || rollback != 0 {
+ t.Fatalf("pipeline reads: fresh %d, repeat %d, rollback %d; want 1, 0, 0", fresh, repeat, rollback)
+ }
+}
+
+func TestRollbackAuthorized(t *testing.T) {
+ ctx, inner, priv, pub := setupEdge(t)
+ ownerPromote(t, ctx, inner, priv, "v1")
+ ownerPromote(t, ctx, inner, priv, "v2")
+ installPipeline(t, ctx, inner, priv, "edge", "stable")
+ err := RootSigner(priv).RollbackAuthorized(ctx, inner, "bab", "myapp", "edge", noPipelineOnly)
+ wantAuthorizationError(t, err)
+ if got := edgeVersion(t, ctx, inner, pub); got != "v2" {
+ t.Fatalf("edge = %q after a refused rollback", got)
+ }
+ if err := inner.Delete(ctx, PipelineKey("bab", "myapp")); err != nil {
+ t.Fatal(err)
+ }
+ h := &hookStore{inner: inner}
+ h.before = onNth("put", edgeKey, 1, func() { ownerPromote(t, ctx, inner, priv, "v3") })
+ err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", noPipelineOnly)
+ if !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("err = %v; want ErrCASConflict", err)
+ }
+ if got := edgeVersion(t, ctx, inner, pub); got != "v3" {
+ t.Fatalf("edge = %q; want the owner's v3", got)
+ }
+}
+```
+
+If `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".
+
+- [ ] **Step 2: Run and see them fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -run 'TestAuthorize|TestOwnerPromote|TestRestoredBytes|TestFirstPromote|TestRepeatPromote|TestNoHook|TestRollbackAuthorized' -count=1`
+Expected: FAIL to compile (`Authorize`, `AuthorizationError`, `RollbackAuthorized` undefined).
+
+- [ ] **Step 3: Create `internal/release/authorize.go`:**
+
+```go
+package release
+
+// AuthorizationError is a refusal from a caller's Authorize hook. The
+// primitives wrap the hook's own error in it so a caller can tell "you may
+// not" from a store or ladder failure with errors.As.
+//
+// The hook runs on a pipeline the primitive has just read (spec
+// 2026-10-05-deploy-credential-design.md §7.2): on the ladder's read, on the
+// repeat path, and between the pointer read and the conditional write. A
+// caller that passes no hook gets no extra reads.
+type AuthorizationError struct{ Err error }
+
+func (e *AuthorizationError) Error() string { return e.Err.Error() }
+func (e *AuthorizationError) Unwrap() error { return e.Err }
+```
+
+- [ ] **Step 4: Thread the hook through `release.go`.**
+
+`PromoteOptions` gains:
+
+```go
+ // Authorize, when set, is called with the app's pipeline as read at each
+ // point a write could depend on it: the ladder's read, the repeat path,
+ // and between the pointer read and the conditional write. A non-nil
+ // error refuses the promotion as an *AuthorizationError. nil means
+ // today's behaviour exactly, with no extra reads.
+ Authorize func(p *Pipeline) error
+```
+
+Rename 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`:
+
+```go
+func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error {
+ return sg.promote(ctx, s, acct, app, channel, version, nil, rec...)
+}
+```
+
+Inside `promote`, after the `switch` that sets `old` and `etag` from `GetQualified` (`:732-744`) and before `next := ...`, add:
+
+```go
+ if authorize != nil {
+ // Between the pointer read and the conditional write, so the write
+ // lands only if the pointer is still what was authorized against
+ // (spec §7.2, G1 and G2).
+ pipe, perr := GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ return fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ if aerr := authorize(pipe); aerr != nil {
+ return &AuthorizationError{Err: aerr}
+ }
+ }
+```
+
+In `Signer.PromoteOnLadder`:
+- On the repeat path, inside `if p, verr := ...; verr == nil && p.Version == version {` and before `res.NoOp = true`:
+
+```go
+ if opt.Authorize != nil {
+ pipe, perr := GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ return res, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ if aerr := opt.Authorize(pipe); aerr != nil {
+ return res, &AuthorizationError{Err: aerr}
+ }
+ }
+```
+
+- Right after its own `pipe, perr := GetPipeline(...)` error check (`:930-933`):
+
+```go
+ if opt.Authorize != nil {
+ if aerr := opt.Authorize(pipe); aerr != nil {
+ return res, &AuthorizationError{Err: aerr}
+ }
+ }
+```
+
+- Replace the `sg.Promote(ctx, s, acct, app, channel, version, rec)` call with `sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec)`.
+
+Rename 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 := ...`:
+
+```go
+ if authorize != nil {
+ pipe, perr := GetPipeline(ctx, s, pub, acct, app)
+ if perr != nil {
+ return fmt.Errorf("rollback: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ if aerr := authorize(pipe); aerr != nil {
+ return &AuthorizationError{Err: aerr}
+ }
+ }
+```
+
+- [ ] **Step 5: Run the new tests, then the package.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/release/ -count=1`
+Expected: PASS, including `golden_test.go` (root-signed bytes unchanged).
+
+- [ ] **Step 6: Commit** with message `release: an Authorize hook between pointer read and write (#229)`.
+
+---
+
+### Task 7: The request-local scoped signer and the handler wiring
+
+**Files:**
+- Modify: `internal/console/deployscope.go` (`deploySigner`)
+- Modify: `internal/console/apicli.go` (`handleAPIPromote` near `:880`, `handleAPIRollback` near the `release.Rollback` call)
+- Create: `internal/console/deploysign_test.go`
+
+**Interfaces:**
+- Consumes: Task 5's `deployScopeAllows`, `writeDeployRefusal`; Task 6's `PromoteOptions.Authorize`, `AuthorizationError`, `RollbackAuthorized`.
+- Produces: `func (s *Server) deploySigner(p Principal) (release.Signer, error)`; `const deployGrantTTL = 5 * time.Minute`.
+
+- [ ] **Step 1: Write the failing tests.** Create `internal/console/deploysign_test.go` with a fixture helper:
+
+```go
+// deployFixture is a console with app hello in bab, v1 and v2 shipped by
+// paul, and a deploy credential ci over hello with rungs canary and edge.
+func deployFixture(t *testing.T) (fx *consoleFixture, person, deploy string) {
+ t.Helper()
+ fx = newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ person = cliToken(t, fx, "paul@keymail.dev", "code-deploy-sign")
+ liftShipBudget(fx)
+ for _, v := range []string{"v1", "v2"} {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/hello/releases", person,
+ []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("bin " + v)}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship %s = %d %v", v, status, out)
+ }
+ }
+ tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"canary", "edge"}, "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fx, person, tok
+}
+```
+
+Then these tests, each written out in full:
+- `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"`.
+- `TestDeployRepeatIsANoOp` (Review Focus 4): the same promote twice; the second answers 200 `"result":"noop"`, serial unchanged.
+- `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").
+- `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`.
+- `TestPersonPromoteUnchanged`: the person promotes `v1` to `edge` → 200; the raw pointer has no `grant` field.
+- `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`.
+
+- [ ] **Step 2: Run and see them fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run 'TestDeploy|TestPersonPromoteUnchanged' -count=1`
+Expected: FAIL (`deploySigner` undefined; pointers carry no grant).
+
+- [ ] **Step 3: Add `deploySigner`** to `internal/console/deployscope.go` (imports `time`):
+
+```go
+// deployGrantTTL is how long a request-local grant lives. It only has to
+// outlast one request: a box checks a grant against the pointer's own
+// promoted_at, so a pointer signed inside the window stays valid after the
+// grant expires (spec §7.4).
+const deployGrantTTL = 5 * time.Minute
+
+// deploySigner mints a scoped key and a root-signed grant for exactly the
+// credential's rungs, for one request. Boxes then verify the rung limit as
+// they do for a scoped CI key today. Nothing is stored. Any failure refuses
+// the request: a deploy write never falls back to the root signer.
+func (s *Server) deploySigner(p Principal) (release.Signer, error) {
+ if len(p.Rungs) == 0 {
+ return release.Signer{}, errors.New("deploy credential carries no rungs")
+ }
+ seed, grant, err := release.MintKeyGrant(s.cfg.Key, "deploy."+p.Name, p.Rungs, deployGrantTTL)
+ if err != nil {
+ return release.Signer{}, err
+ }
+ key, err := release.ParseSigningKey(seed, "deploy grant")
+ if err != nil {
+ return release.Signer{}, err
+ }
+ return release.ScopedSigner(key, grant, s.cfg.ReleasePub)
+}
+```
+
+- [ ] **Step 4: Wire `handleAPIPromote`.** Replace the single `release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, ...)` call and its error block with:
+
+```go
+ signer := release.RootSigner(s.cfg.Key)
+ var authorize func(*release.Pipeline) error
+ if p.Kind == PrincipalService && p.Role == svcrole.Deploy {
+ sg, serr := s.deploySigner(p)
+ if serr != nil {
+ log.Printf("<D25>", acct.Sqid, app, serr)
+ jsonError(w, http.StatusServiceUnavailable, "<D26>")
+ return
+ }
+ signer = sg
+ authorize = func(pp *release.Pipeline) error {
+ return deployScopeAllows(p, pp != nil, channel, req.Hotfix)
+ }
+ }
+ res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version,
+ release.PromoteOptions{Actor: addr, Hotfix: hotfix, Authorize: authorize})
+ if err != nil {
+ var ae *release.AuthorizationError
+ if errors.As(err, &ae) {
+ s.writeDeployRefusal(w, ae.Err)
+ return
+ }
+ log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err)
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+```
+
+`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.
+
+- [ ] **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.
+
+- [ ] **Step 6: Run the package.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ ./internal/release/ -count=1`
+Expected: PASS.
+
+- [ ] **Step 7: Commit** with message `console: sign deploy writes with a request-local scoped grant (#229)`.
+
+---
+
+### Task 8: The randomized interleaving test and its two mutants
+
+**Files:**
+- Create: `internal/release/authorize_sim_test.go`
+
+**Interfaces:**
+- Consumes: Task 6's `hookStore`, `noPipelineOnly`, `installPipeline`, `PromoteOptions.Authorize`, `RollbackAuthorized`.
+
+- [ ] **Step 1: Write the simulation.** Create `internal/release/authorize_sim_test.go`:
+
+```go
+package release
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "fmt"
+ "math/rand"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// TestAuthorizeInterleavings runs random schedules of one deploy request
+// against an owner who installs and removes pipelines, promotes and rolls
+// back, at random points inside the request. It checks G1 and G2 and
+// nothing else (spec §7.2, §12):
+//
+// G1: the last hook call before the write came after the pointer read the
+// write was conditioned on, and the pipeline it was given permits it.
+// G2: the bytes the write replaced equal the bytes that read returned.
+//
+// Seeds: DEPLOY_SIM_SEEDS (default 400). A failure prints the seed and the
+// event log; DEPLOY_SIM_SEED=<n> replays exactly that seed.
+func TestAuthorizeInterleavings(t *testing.T) {
+ first, last := 0, 400
+ if v := os.Getenv("DEPLOY_SIM_SEEDS"); v != "" {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ last = n
+ }
+ if v := os.Getenv("DEPLOY_SIM_SEED"); v != "" {
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, last = n, n+1
+ }
+ priv, _ := testKeys(t)
+ for seed := first; seed < last; seed++ {
+ simulateOnce(t, seed, priv)
+ }
+}
+
+type simRead struct {
+ seq int
+ body []byte
+ etag string
+ absent bool
+}
+
+type simHook struct {
+ seq int
+ pipelined bool
+}
+
+func simulateOnce(t *testing.T, seed int, priv ed25519.PrivateKey) {
+ t.Helper()
+ ctx := context.Background()
+ rng := rand.New(rand.NewSource(int64(seed)))
+ inner := NewDirStore(t.TempDir())
+ for _, v := range []string{"v1", "v2", "v3", "v4"} {
+ shipRelease(t, inner, "bab", "myapp", v, "linux-arm64", []byte("bin "+v))
+ }
+ for _, v := range []string{"v1", "v2"} {
+ if _, err := PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true}); err != nil {
+ t.Fatalf("seed %d setup: %v", seed, err)
+ }
+ }
+ var (
+ events []string
+ reads []simRead
+ hooks []simHook
+ put *storeCall
+ replaced []byte
+ replacedAbsent bool
+ )
+ owner := func() {
+ var err error
+ var what string
+ switch rng.Intn(4) {
+ case 0:
+ what = "install"
+ _, err = SetPipeline(ctx, inner, priv, "bab", "myapp", []PipelineChannel{{Name: "edge"}, {Name: "stable"}}, "owner")
+ case 1:
+ what = "remove pipeline"
+ err = inner.Delete(ctx, PipelineKey("bab", "myapp"))
+ case 2:
+ v := fmt.Sprintf("v%d", 1+rng.Intn(4))
+ what = "promote " + v
+ _, err = PromoteOnLadder(ctx, inner, priv, "bab", "myapp", "edge", v, PromoteOptions{Hotfix: true})
+ case 3:
+ what = "rollback"
+ err = Rollback(ctx, inner, priv, "bab", "myapp", "edge")
+ }
+ events = append(events, fmt.Sprintf(" owner %s: %v", what, err))
+ }
+ h := &hookStore{inner: inner}
+ h.before = func(c storeCall) {
+ if rng.Float64() < 0.3 {
+ for i := 0; i <= rng.Intn(2); i++ {
+ owner()
+ }
+ }
+ events = append(events, fmt.Sprintf("%d %s %s %s", c.seq, c.op, c.key, c.ifMatch))
+ if c.op == "put" && c.key == edgeKey {
+ cc := c
+ put = &cc
+ body, _, err := inner.Get(ctx, edgeKey)
+ replaced, replacedAbsent = body, errors.Is(err, os.ErrNotExist)
+ }
+ }
+ h.after = func(c storeCall, body []byte, etag string, err error) {
+ if c.key == edgeKey {
+ reads = append(reads, simRead{c.seq, body, etag, errors.Is(err, os.ErrNotExist)})
+ }
+ }
+ hook := func(p *Pipeline) error {
+ hooks = append(hooks, simHook{h.seq, p != nil})
+ events = append(events, fmt.Sprintf("%d hook pipelined=%v", h.seq, p != nil))
+ return noPipelineOnly(p)
+ }
+ var err error
+ noop := false
+ switch rng.Intn(3) {
+ case 0:
+ var res PromoteResult
+ res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v3", PromoteOptions{Authorize: hook})
+ noop = res.NoOp
+ case 1:
+ var res PromoteResult
+ res, err = RootSigner(priv).PromoteOnLadder(ctx, h, "bab", "myapp", "edge", "v2", PromoteOptions{Authorize: hook})
+ noop = res.NoOp
+ case 2:
+ err = RootSigner(priv).RollbackAuthorized(ctx, h, "bab", "myapp", "edge", hook)
+ }
+ fail := func(msg string) {
+ t.Fatalf("seed %d: %s\nreplay with DEPLOY_SIM_SEED=%d\n%s", seed, msg, seed, strings.Join(events, "\n"))
+ }
+ if err != nil {
+ return // refusals and conflicts write nothing
+ }
+ if noop {
+ if len(hooks) == 0 || hooks[len(hooks)-1].pipelined {
+ fail("a no-op succeeded without a clean hook call")
+ }
+ return
+ }
+ if put == nil {
+ fail("succeeded without writing")
+ }
+ var r *simRead
+ for i := len(reads) - 1; i >= 0; i-- {
+ rd := reads[i]
+ if rd.seq >= put.seq {
+ continue
+ }
+ if (put.ifMatch == "" && rd.absent) || (put.ifMatch != "" && !rd.absent && rd.etag == put.ifMatch) {
+ r = &rd
+ break
+ }
+ }
+ if r == nil {
+ fail("no pointer read matches the write's condition")
+ }
+ var a *simHook
+ for i := len(hooks) - 1; i >= 0; i-- {
+ if hooks[i].seq < put.seq {
+ a = &hooks[i]
+ break
+ }
+ }
+ if a == nil {
+ fail("a write landed with no hook call")
+ }
+ if a.seq <= r.seq {
+ fail(fmt.Sprintf("G1: the hook at %d is not after the pointer read at %d", a.seq, r.seq))
+ }
+ if a.pipelined {
+ fail("G1: the hook saw a pipeline and the write landed")
+ }
+ if replacedAbsent != r.absent || !bytes.Equal(replaced, r.body) {
+ fail("G2: the write replaced bytes other than the ones it read")
+ }
+}
+```
+
+- [ ] **Step 2: Run it.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1`
+Expected: 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.
+
+- [ ] **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.
+
+```bash
+M=$TMPDIR/deploy-mutant1; rm -rf "$M"; mkdir -p "$M"
+git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M"
+cd "$M" && python3 - <<'EOF'
+p="internal/release/release.go"; s=open(p).read()
+start=s.index("\tif authorize != nil {\n\t\t// Between the pointer read")
+end=s.index("\t\t}\n\t}\n", start)+len("\t\t}\n\t}\n")
+block=s[start:end]; s=s[:start]+s[end:]
+anchor="\tcur, etag, err := GetQualified(ctx, s, key)\n\tvar old *ChannelPointer"
+assert s.count(anchor)==1; s=s.replace(anchor, block+anchor)
+open(p,"w").write(s)
+EOF
+export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=400 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?"
+```
+
+Expected: `exit=1`, a G1 failure with a seed. Replay that seed with `DEPLOY_SIM_SEED=<n>` and confirm it fails again.
+
+- [ ] **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.
+
+```bash
+M=$TMPDIR/deploy-mutant2; rm -rf "$M"; mkdir -p "$M"
+git -C /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential archive HEAD | tar -x -C "$M"
+cd "$M" && sed -i 's/if ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/if false \&\& ifMatch != "" \&\& (!exists || etagOf(cur) != ifMatch) {/' internal/release/store.go
+grep -c 'if false && ifMatch' internal/release/store.go
+export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; DEPLOY_SIM_SEEDS=2000 go test ./internal/release/ -run TestAuthorizeInterleavings -count=1; echo "exit=$?"
+```
+
+Expected: the grep prints `1`, then `exit=1` with a G2 failure and a seed that replays.
+
+- [ ] **Step 5: Clean up the copies.**
+
+```bash
+rm -rf "$TMPDIR/deploy-mutant1" "$TMPDIR/deploy-mutant2"
+```
+
+- [ ] **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.
+
+---
+
+### Task 9: The CLI reads `CARLOS_SERVICE_TOKEN`
+
+**Files:**
+- Modify: `apimode.go:93-143` (`resolveMode`)
+- Modify: `apimode_test.go` (`clearDeploymentEnv` and new tests)
+
+**Interfaces:**
+- Consumes: `normalizeConsole(in string) (string, error)` (`auth.go:354`), `newConsoleClient(origin, token string)`.
+- Produces: `const serviceSessionLabel = "<D29>"`.
+
+- [ ] **Step 1: Keep existing tests hermetic.** In `apimode_test.go`'s `clearDeploymentEnv`, add:
+
+```go
+ t.Setenv("CARLOS_SERVICE_TOKEN", "")
+```
+
+- [ ] **Step 2: Write the failing tests** in `apimode_test.go`:
+
+```go
+func TestResolveModeServiceToken(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ var gotAuth string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"accounts":[]}`))
+ }))
+ defer ts.Close()
+ // A person's credential for another console is on disk too. The token
+ // wins and the file is not consulted.
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "person", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
+ t.Setenv("CARLOS_CONSOLE", ts.URL)
+ sess, err := resolveMode("")
+ if err != nil || sess == nil {
+ t.Fatalf("resolveMode = %v, %v", sess, err)
+ }
+ if sess.address != serviceSessionLabel {
+ t.Fatalf("address = %q", sess.address)
+ }
+ if _, _, err := sess.getJSON(context.Background(), "/api/cli/apps"); err != nil {
+ t.Fatal(err)
+ }
+ if gotAuth != "Bearer svc-secret" {
+ t.Fatalf("Authorization = %q, want the service token", gotAuth)
+ }
+}
+
+func TestResolveModeServiceTokenNeedsAConsole(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
+ if _, err := resolveMode(""); err == nil || !strings.Contains(err.Error(), "CARLOS_CONSOLE") {
+ t.Fatalf("err = %v, want D27", err)
+ }
+}
+
+func TestResolveModeServiceTokenRefusedWithStoreEnv(t *testing.T) {
+ for _, env := range []string{"CARLOS_DEPLOYMENT_BUCKET", "CARLOS_DEPLOYMENT_DIR"} {
+ t.Run(env, func(t *testing.T) {
+ useIsolatedConfig(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_SERVICE_TOKEN", "svc-secret")
+ t.Setenv("CARLOS_CONSOLE", "https://c.example")
+ t.Setenv(env, t.TempDir())
+ sess, err := resolveMode("")
+ if err == nil || sess != nil || !strings.Contains(err.Error(), "CARLOS_SERVICE_TOKEN") {
+ t.Fatalf("= %v, %v; want D28", sess, err)
+ }
+ })
+ }
+}
+```
+
+Adjust imports (`net/http`, `net/http/httptest`, `context`, `strings`). If `normalizeConsole` refuses the `http://127.0.0.1` origin, read how `TestAPISessionShipMultipart` points a session at an httptest server and do the same. Also add a door-coverage test (Review Focus 5): a fake console that records `METHOD path` for every request and answers the shapes `TestAPISessionShipMultipart` and `TestAPISessionStableRefusal` already use; drive `sess.ship`, `sess.promote`, `sess.rollback` and the channels read with `--account-id bab` resolution (`apiAcct{Seg: "bab", Exact: true}` or whatever `resolvedAccount` builds for an exact sqid; read `apiAccount` at `apimode.go:399`). Map each recorded path back to a route pattern and assert each is in Task 4's §6 list. Then call `sess.appRegions` and assert its path is `.../regions`, which is not in the list; name the test `TestCarlosDeployNeedsADoorDeployDoesNotHave` so the gap is visible.
+
+- [ ] **Step 3: Run and see them fail.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployNeedsADoor' -count=1`
+Expected: FAIL (`serviceSessionLabel` undefined).
+
+- [ ] **Step 4: Implement** in `resolveMode`, after the existing `--console` conflict check and before `if bucketSet || dirSet { return nil, nil }`:
+
+```go
+ token := os.Getenv("CARLOS_SERVICE_TOKEN")
+ if token != "" && (bucketSet || dirSet) {
+ return nil, errors.New("<D28>")
+ }
+```
+
+and after `if bucketSet || dirSet { return nil, nil }`:
+
+```go
+ if token != "" {
+ // CI's path (spec §8): the console is named outright, never taken
+ // from a config default or a stored credential, and the credentials
+ // file is not read.
+ console := consoleFlag
+ if console == "" {
+ console = os.Getenv("CARLOS_CONSOLE")
+ }
+ if console == "" {
+ return nil, errors.New("<D27>")
+ }
+ origin, err := normalizeConsole(console)
+ if err != nil {
+ return nil, err
+ }
+ return &apiSession{c: newConsoleClient(origin, token), console: origin, address: serviceSessionLabel}, nil
+ }
+```
+
+Add near `apiSession`:
+
+```go
+// serviceSessionLabel stands in for the address in "using X as Y" lines
+// when the session is a service token: the CLI has no door that tells it
+// the credential's name, and a person's whoami does not answer for one.
+const serviceSessionLabel = "<D29>"
+```
+
+Update `resolveMode`'s doc comment to describe the token arm.
+
+- [ ] **Step 5: Run.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -count=1 && go vet .`
+Expected: PASS.
+
+- [ ] **Step 6: Commit** with message `cli: CARLOS_SERVICE_TOKEN selects the console for CI (#229)`.
+
+---
+
+### Task 10: Mixed versions fail closed
+
+**Files:**
+- Create: `internal/console/deploymixed_test.go`
+
+- [ ] **Step 1: Write the tests.**
+
+```go
+// TestUnknownStoredRoleIsNotSignedInAtEveryDoor is spec §9's first check: a
+// console that does not know a stored role (as v0.21.0 does not know
+// deploy) answers its token exactly as it answers no token at all.
+func TestUnknownStoredRoleIsNotSignedInAtEveryDoor(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, "paul@keymail.dev", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE cli_tokens SET role = 'futurerole' WHERE token_hash = ?`, hashToken(raw)); err != nil {
+ t.Fatal(err)
+ }
+ fill := regexp.MustCompile(`\{[a-z]+\}`)
+ for pattern := range fx.srv.cliRoles {
+ method, path, _ := strings.Cut(pattern, " ")
+ path = strings.Replace(path, "{account}", "bab", 1)
+ path = strings.Replace(path, "{app}", "hello", 1)
+ path = fill.ReplaceAllString(path, "x")
+ with := doRaw(t, method, fx.ts.URL+path, raw)
+ without := doRaw(t, method, fx.ts.URL+path, "")
+ if with.status != without.status || with.body != without.body {
+ t.Errorf("%s: unknown role answered %d %q; no token answered %d %q",
+ pattern, with.status, with.body, without.status, without.body)
+ }
+ }
+}
+```
+
+`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).
+
+- [ ] **Step 2: Run.**
+
+Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test ./internal/console/ -run TestUnknownStoredRole -count=1`
+Expected: 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.
+
+- [ ] **Step 3: Read v0.21.0.** Confirm both refusals exist in the release Tito's console runs:
+
+```bash
+git show v0.21.0:internal/console/svcprincipal.go | grep -n -A3 'r, ok := svcrole.Parse(role)'
+git show v0.21.0:internal/console/authcli.go | grep -n 'allow.Permits(p.Role)'
+```
+
+Expected: the first prints the `if !ok { return Principal{}, false }` arm; the second prints one line. Put both outputs in the commit body.
+
+- [ ] **Step 4: Commit** with message `console: pin fail-closed handling of an unknown stored role (#229)`.
+
+---
+
+### Task 11: Docs
+
+**Files:**
+- Modify: `README.md:170-180`
+- Modify: `docs/site/cli.md` (the `carlos services` section, `:1409-1440`)
+
+- [ ] **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:
+
+```markdown
+**CI uses the console too.** Mint a deploy credential for the app, limited
+to the rungs CI may move, and give CI the secret and the console's address:
+
+ carlos services create --role deploy --app blog --rungs canary,edge ci
+ # in CI:
+ export CARLOS_SERVICE_TOKEN=... CARLOS_CONSOLE=https://console.example.com
+ carlos ship --app blog --version "$GITHUB_SHA" --target linux-arm64 blog-linux-arm64
+ carlos promote --app blog "$GITHUB_SHA" edge
+
+Every promote is audited in the console and reaches the boxes within
+seconds. A deploy credential can ship, and promote or roll back only its
+rungs. It cannot reach stable, hotfix, delete the app, or change retention
+or domains.
+
+**Direct bucket mode is for operators.** With `CARLOS_DEPLOYMENT_BUCKET`
+(or `_DIR`) set, every verb goes straight to the bucket and the console
+never sees it. Use it to set up a deployment before its console exists, to
+run one that has no console, to recover when the console is down, and for
+the platform's own releases. A scoped `CARLOS_RELEASE_KEY` limits that path
+only. The CLI refuses to run with both `CARLOS_SERVICE_TOKEN` and a bucket
+variable set.
+```
+
+Leave "**On a box, or in CI, don't log in.**" and what follows unchanged.
+
+- [ ] **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:
+
+```markdown
+`deploy` is for a CI pipeline: it ships, and promotes or rolls back only
+the rungs named with `--rungs` (`canary`, `edge`, `beta`; never `stable`).
+It must name one app and cannot hotfix. If the app has a pipeline, it may
+move `canary/<name>` channels only. CI reads it from
+`CARLOS_SERVICE_TOKEN` with `CARLOS_CONSOLE`.
+```
+
+and change "`publish` ships releases;" to "`publish` ships releases and manages the app;".
+
+- [ ] **Step 3: Check links and formatting.**
+
+Run: `grep -n "CARLOS_SERVICE_TOKEN" README.md docs/site/cli.md`
+Expected: the new passages and nothing contradicting them.
+
+- [ ] **Step 4: Commit** with message `docs: CI promotes through the console with a deploy credential (#229)`.
+
+---
+
+### Task 12: The full gate, push and CI
+
+- [ ] **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):
+
+```bash
+mkdir -p ~/.cache/deploy-cred/gotmp
+cat > ~/.cache/deploy-cred/texec.sh <<'EOF'
+#!/bin/sh
+case "$1" in
+*/activator.test) root=/tmp/k ;;
+*) root=/var/tmp/ck ;;
+esac
+mkdir -p "$root"
+export GOTMPDIR="$root" TMPDIR="$root"
+exec "$@"
+EOF
+chmod +x ~/.cache/deploy-cred/texec.sh
+cd /home/paulca/amadan.net/carlos/platform/.claude/worktrees/design-deploy-credential
+go build ./... && GOTMPDIR=~/.cache/deploy-cred/gotmp CARLOS_REQUIRE_JS_PARITY=1 \
+ GOFLAGS="-buildvcs=false -exec=$HOME/.cache/deploy-cred/texec.sh" make test
+```
+
+Expected: build, vet, gofmt and every package's tests pass. A failure is read first, never just re-run.
+
+- [ ] **Step 2: Cross-compile** (the CLI links `internal/host`):
+
+```bash
+for p in darwin/arm64 windows/amd64 linux/arm64; do GOOS=${p%/*} GOARCH=${p#*/} go build -o /dev/null . || echo "FAIL $p"; done
+```
+
+Expected: no `FAIL` lines.
+
+- [ ] **Step 3: Clean up and push.**
+
+```bash
+rm -rf /tmp/k /var/tmp/ck ~/.cache/deploy-cred
+git push origin design-deploy-credential
+amadan ci status carlos/platform -branch design-deploy-credential; echo "exit=$?"
+```
+
+Expected: the push succeeds; CI status eventually exits 0. Do not merge: merging and rolling wait for Paul.
+
+---
+
+## Self-review notes
+
+- 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.
+- R5 is pinned by the two "lands" tests in Task 6 and the simulation's acceptance of restored bytes in Task 8.