carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

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):
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.
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:
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:
	// 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:

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:
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):
// 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):

		`ALTER TABLE cli_tokens ADD COLUMN rungs TEXT NOT NULL DEFAULT ''`,

Add to CLIToken (after RotatedAt):

	// 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:

// 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:

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:

	// 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:

// 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:

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:

	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:

	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:

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

	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>"):
	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:

	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:
  {{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:

  <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:
	rungsRaw := fs.String("rungs", "", "<D3>")

After the role parse:

	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:
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:
	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:
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):
// 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):
	if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil {
		s.writeDeployRefusal(w, err)
		return
	}

In handleAPIRollback, immediately after its GetPipeline error block:

	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:
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:
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:

	// 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:

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:

	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:
			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):
	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 := ...:

	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:
// 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):
// 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:
	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:
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.
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.
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.
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:
	t.Setenv("CARLOS_SERVICE_TOKEN", "")
  • [ ] Step 2: Write the failing tests in apimode_test.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 }:
	token := os.Getenv("CARLOS_SERVICE_TOKEN")
	if token != "" && (bucketSet || dirSet) {
		return nil, errors.New("<D28>")
	}

and after if bucketSet || dirSet { return nil, nil }:

	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:

// 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.
// 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:
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:
**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:
`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):
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):
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.
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.