# 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.2, approved 2026-10-05). Read §3 (rulings R1 to R6), §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 (R6).** Expected: the routine path on an app that is already set up (instance list, channels, ship, promote, and `--region`'s region read) uses only §6 doors; first-deploy setup and `--host` are refused at the role gate; the watch's log read is refused and the deploy still reports its result. Pinned in Task 9.

---

## 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 (approved 2026-10-05, all 32 strings as drafted)

`%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 |
| D31b | docs/site/cli.md, the role list | `` `publish` ships releases and manages the app; `` (replaces "`publish` ships releases;") |

---

### 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",
	"GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy
	"GET /api/cli/apps/{account}/{app}/regions",   // R6: carlos deploy --region
}

func TestDeployReachesExactlyItsDoors(t *testing.T) {
	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)
	s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIInstanceList)
	s.cliRoute("GET /api/cli/apps/{account}/{app}/regions", svcrole.Allow(svcrole.Instance, svcrole.Deploy), s.handleAPIRegions)
```

The last two are R6. Both handlers go through `instanceGate`, which has no role logic of its own, so adding the role to the set is the whole change. If a test pins the exact role set of either door (search `grep -n '/instances"\|/regions"' internal/console/*_test.go`), add `deploy` to its expectation. Change nothing else in the table.

- [ ] **Step 4: Run the package.** Promote and rollback by a deploy credential are now reachable with no scope check. That is closed in Task 5, and nothing is released between the two tasks.

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`). Also drive `sess.instanceList` and `sess.appRegions`, the two reads `carlos deploy` adds (R6). Map each recorded path back to a route pattern and assert each is in Task 4's §6 list; name the test `TestCarlosDeployRoutinePathUsesOnlyDeployDoors`. Separately assert that `sess.logsGet`'s path (`.../logs`) is not in the list, so the watch's diagnosis stays a best-effort read a deploy credential is refused.

- [ ] **Step 3: Run and see them fail.**

Run: `export GOCACHE=$TMPDIR/gocache GOFLAGS=-mod=mod GOPROXY=off; go test . -run 'TestResolveMode|TestCarlosDeployRoutinePath' -count=1`
Expected: FAIL (`serviceSessionLabel` undefined).

- [ ] **Step 4: Implement** in `resolveMode`, after the existing `--console` conflict check and before `if bucketSet || dirSet { return nil, nil }`:

```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 deploy --app blog --version "$GITHUB_SHA" --channel edge blog-linux-arm64

`carlos deploy` ships, promotes and watches the app's hosts, the same as
from a laptop. The first deploy of a new app still needs a person: a deploy
credential cannot create the app or set up its first host. Every promote is
audited in the console and reaches the boxes within seconds. A deploy credential can ship, and promote or roll back only its
rungs. It cannot reach stable, hotfix, delete the app, or change retention
or domains.

**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.
