carlos / platform Public

plan: release pipelines v2 — 12 tasks, spec amended (fast-track stays immediate, CLI proposals start at zero confirms)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 09f820eca6ae410548a9cf042ca892a4f662bb19 parent 7659241
2 files changed, +994 −4
  • docs/superpowers/plans/2026-08-20-release-pipelines-v2.md +974 −0
  • docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md +20 −4
diff --git a/docs/superpowers/plans/2026-08-20-release-pipelines-v2.md b/docs/superpowers/plans/2026-08-20-release-pipelines-v2.md
new file mode 100644
index 000000000..9e182a027
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-20-release-pipelines-v2.md
@@ -0,0 +1,974 @@
+# Release Pipelines v2 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:** App-defined release channels with per-channel opt-in policy (bake, passkey, N-member approvals), one `edge` channel by default, legacy ladder preserved byte-for-byte when no pipeline exists.
+
+**Architecture:** A new root-signed `apps/<acct>/<app>/pipeline.json` (modeled on `production.json`) carries an ordered channel list with per-channel rules. Boxes consult it for bake windows through a loosening ratchet added to `FirstSeen`; the console enforces rules (passkey step-up, pending-change quorum) at its existing gates; the CLI proposes, the console confirms.
+
+**Tech Stack:** Go 1.26, modernc.org/sqlite (console DB), ed25519 signed wrappers, stdlib `flag` CLI, html/template console UI.
+
+**Spec:** `docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md` — read it first; every task argues from it.
+
+## Global Constraints
+
+- Build/test env (sandbox): `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled ("read-only file system" is the tell).
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1`, plus `gofmt -l` clean on touched files. CI also cross-compiles `GOOS=darwin` — no Linux-only assumptions.
+- **Wire-additive only**: absent `pipeline.json` must reproduce today's behaviour byte-for-byte. `internal/release/laddercheck_test.go` must pass UNTOUCHED — production runs on `canary/*` channels.
+- All per-channel policy defaults permissive: bake 0, no passkey, no approvals. Opt-in throughout.
+- `canary` is a reserved name; `canary/*` machinery (TTL, unconditional promotion) is untouched by every task.
+- Console gate order is law: session → CSRF → owner (byte-identical 404) → `requireFreshAuth` LAST.
+- Tests that shell the carlos binary must use `carlosTestEnv` + `cmd.Dir` scratch dir (see `scopedkeys_e2e_test.go:29`).
+- Commit after every task (branch `edge-beta-stable`); nothing merges to main outside a PR.
+
+---
+
+### Task 1: The Pipeline object — types, validation, signed Get/Set
+
+**Files:**
+- Create: `internal/release/pipeline.go`
+- Test: `internal/release/pipeline_test.go`
+
+**Interfaces:**
+- Consumes: `signedWrapper`, `appPrefix`, `Store`, `ValidAccount`, `ValidApp`, `ReadAppTombstone`, `ErrCASConflict` — all existing in `internal/release`. Copy the shape of `internal/release/production.go` (`SetProduction`/`GetProduction`) closely; it is the reference implementation for a root-signed per-app object.
+- Produces (later tasks rely on these exact names):
+ - `type ChannelRules struct { Passkey bool; PromoteApprovals int; ChangeApprovals int }`
+ - `type PipelineChannel struct { Name string; Bake string; Rules ChannelRules }` with `func (c PipelineChannel) BakeWindow() (time.Duration, error)`
+ - `type Pipeline struct { Schema int; Account, App string; Channels []PipelineChannel; Actor string; SetAt time.Time }` with methods `Entry() string`, `Index(channel string) int`, `ChannelNamed(name string) (PipelineChannel, bool)`, `MaxApprovals() int`
+ - `func PipelineKey(acct, app string) string`
+ - `func GetPipeline(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) (*Pipeline, error)` — missing → `(nil, nil)`; unverifiable/misnamed → error.
+ - `func SetPipeline(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, channels []PipelineChannel, actor string) (*Pipeline, error)`
+ - `func DefaultPipelineChannels() []PipelineChannel` → `[]PipelineChannel{{Name: "edge"}}`
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/release/pipeline_test.go` (same package `release`, matching `production_test.go` style — use the in-memory store the existing tests use; find it with `grep -n "memStore\|newMemStore\|fakeStore" internal/release/*_test.go` and reuse it):
+
+```go
+func TestPipelineValidation(t *testing.T) {
+ good := []PipelineChannel{{Name: "edge"}, {Name: "production", Bake: "24h",
+ Rules: ChannelRules{Passkey: true, PromoteApprovals: 2, ChangeApprovals: 2}}}
+ for _, tc := range []struct {
+ name string
+ channels []PipelineChannel
+ wantErr string // "" = valid
+ }{
+ {"default single edge", DefaultPipelineChannels(), ""},
+ {"two channels with rules", good, ""},
+ {"empty list", nil, "at least one channel"},
+ {"reserved canary", []PipelineChannel{{Name: "canary"}}, "reserved"},
+ {"slash in name", []PipelineChannel{{Name: "canary/x"}}, "may not contain"},
+ {"duplicate names", []PipelineChannel{{Name: "edge"}, {Name: "edge"}}, "duplicate"},
+ {"bad duration", []PipelineChannel{{Name: "edge", Bake: "soon"}}, "bake"},
+ {"negative bake", []PipelineChannel{{Name: "edge", Bake: "-1h"}}, "bake"},
+ {"negative approvals", []PipelineChannel{{Name: "edge",
+ Rules: ChannelRules{PromoteApprovals: -1}}}, "approvals"},
+ } {
+ p := &Pipeline{Schema: 1, Account: "bae", App: "web", Channels: tc.channels, SetAt: time.Now()}
+ err := validatePipeline(p, "bae", "web")
+ if tc.wantErr == "" && err != nil {
+ t.Errorf("%s: unexpected error %v", tc.name, err)
+ }
+ if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
+ t.Errorf("%s: err = %v, want mention of %q", tc.name, err, tc.wantErr)
+ }
+ }
+}
+
+func TestPipelineAccessors(t *testing.T) {
+ p := &Pipeline{Schema: 1, Channels: []PipelineChannel{
+ {Name: "edge"}, {Name: "staging", Bake: "1h"}, {Name: "production", Bake: "24h"}}}
+ if p.Entry() != "edge" {
+ t.Errorf("Entry() = %q, want edge", p.Entry())
+ }
+ if i := p.Index("staging"); i != 1 {
+ t.Errorf("Index(staging) = %d, want 1", i)
+ }
+ if i := p.Index("canary/x"); i != -1 {
+ t.Errorf("Index(canary/x) = %d, want -1 (canary is never a pipeline channel)", i)
+ }
+ if i := p.Index("nope"); i != -1 {
+ t.Errorf("Index(nope) = %d, want -1", i)
+ }
+ c, ok := p.ChannelNamed("production")
+ if !ok || c.Bake != "24h" {
+ t.Errorf("ChannelNamed(production) = %+v, %v", c, ok)
+ }
+ if d, err := c.BakeWindow(); err != nil || d != 24*time.Hour {
+ t.Errorf("BakeWindow() = %v, %v", d, err)
+ }
+ var zero PipelineChannel
+ if d, err := zero.BakeWindow(); err != nil || d != 0 {
+ t.Errorf("zero BakeWindow() = %v, %v; want 0, nil", d, err)
+ }
+}
+
+func TestSetGetPipelineRoundTrip(t *testing.T) {
+ // Mirror TestSetProductionMonotonicSetAtOnRapidFlip's harness:
+ // mem store + root key. Missing object reads as (nil, nil).
+ s, priv, pub := pipelineTestHarness(t) // helper below, copying production_test.go setup
+ ctx := context.Background()
+ if p, err := GetPipeline(ctx, s, pub, "bae", "web"); p != nil || err != nil {
+ t.Fatalf("missing pipeline = %v, %v; want nil, nil", p, err)
+ }
+ set, err := SetPipeline(ctx, s, priv, "bae", "web", DefaultPipelineChannels(), "paul@rslw.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := GetPipeline(ctx, s, pub, "bae", "web")
+ if err != nil || got == nil || got.Entry() != "edge" || got.Actor != "paul@rslw.com" {
+ t.Fatalf("round trip = %+v, %v", got, err)
+ }
+ // Monotonic SetAt: an immediate rewrite must stamp strictly later.
+ set2, err := SetPipeline(ctx, s, priv, "bae", "web",
+ []PipelineChannel{{Name: "edge"}, {Name: "production", Bake: "24h"}}, "paul@rslw.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !set2.SetAt.After(set.SetAt) {
+ t.Fatalf("SetAt not monotonic: %v then %v", set.SetAt, set2.SetAt)
+ }
+}
+
+func TestGetPipelineRefusesTamperAndWrongApp(t *testing.T) {
+ s, priv, pub := pipelineTestHarness(t)
+ ctx := context.Background()
+ if _, err := SetPipeline(ctx, s, priv, "bae", "web", DefaultPipelineChannels(), ""); err != nil {
+ t.Fatal(err)
+ }
+ // Copy the object to another app's key: signed payload names bae/web,
+ // so it must refuse at bae/other (same trap production.go documents).
+ raw, _, err := s.Get(ctx, PipelineKey("bae", "web"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, PipelineKey("bae", "other"), raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := GetPipeline(ctx, s, pub, "bae", "other"); err == nil {
+ t.Fatal("copied pipeline verified at the wrong app; want refusal")
+ }
+}
+```
+
+Add the harness helper at the bottom of the test file, copying exactly how `production_test.go` builds its store and key pair (read that file first; reuse its helpers if they are package-visible rather than redefining).
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/release/ -run 'TestPipeline|TestSetGetPipeline|TestGetPipelineRefuses' -count=1`
+Expected: FAIL — undefined: `PipelineChannel`, `validatePipeline`, etc.
+
+- [ ] **Step 3: Implement `internal/release/pipeline.go`**
+
+Model every mechanism on `production.go` (root-only signed wrapper — refuse a wrapper carrying a key grant BEFORE checking its signature; CAS read-modify-write; monotonic `SetAt` bumped strictly past the existing object's; tombstone refusal in Set). File-top comment states the spec path. Core:
+
+```go
+// ChannelRules is a channel's opt-in policy. The zero value is the default:
+// no rule. Enforcement is the console's job (spec §4-§5); boxes only ever
+// read Bake — rules travel here so one signed object is the whole policy.
+type ChannelRules struct {
+ Passkey bool `json:"passkey,omitempty"`
+ PromoteApprovals int `json:"promote_approvals,omitempty"`
+ ChangeApprovals int `json:"change_approvals,omitempty"`
+}
+
+type PipelineChannel struct {
+ Name string `json:"name"`
+ Bake string `json:"bake,omitempty"` // Go duration string; "" means 0
+ Rules ChannelRules `json:"rules,omitzero"`
+}
+
+func (c PipelineChannel) BakeWindow() (time.Duration, error) {
+ if c.Bake == "" {
+ return 0, nil
+ }
+ d, err := time.ParseDuration(c.Bake)
+ if err != nil || d < 0 {
+ return 0, fmt.Errorf("channel %s: bake %q is not a non-negative duration", c.Name, c.Bake)
+ }
+ return d, nil
+}
+
+type Pipeline struct {
+ Schema int `json:"schema"` // 1
+ Account string `json:"account"`
+ App string `json:"app"`
+ Channels []PipelineChannel `json:"channels"`
+ Actor string `json:"actor,omitempty"`
+ SetAt time.Time `json:"set_at"`
+}
+
+func (p *Pipeline) Entry() string { return p.Channels[0].Name }
+
+// Index is a channel's position in the promotion order, -1 for anything
+// outside it — including every canary/* name, which is never a pipeline
+// channel (spec §2).
+func (p *Pipeline) Index(channel string) int {
+ if r, ok := Rung(channel); ok && r == RungCanary {
+ return -1
+ }
+ for i, c := range p.Channels {
+ if c.Name == channel {
+ return i
+ }
+ }
+ return -1
+}
+
+func (p *Pipeline) ChannelNamed(name string) (PipelineChannel, bool) {
+ if i := p.Index(name); i >= 0 {
+ return p.Channels[i], true
+ }
+ return PipelineChannel{}, false
+}
+
+// MaxApprovals is the largest quorum any channel demands — the console
+// refuses to store a pipeline whose MaxApprovals exceeds the account's
+// member count (spec §4).
+func (p *Pipeline) MaxApprovals() int {
+ m := 0
+ for _, c := range p.Channels {
+ if c.Rules.PromoteApprovals > m {
+ m = c.Rules.PromoteApprovals
+ }
+ if c.Rules.ChangeApprovals > m {
+ m = c.Rules.ChangeApprovals
+ }
+ }
+ return m
+}
+
+func PipelineKey(acct, app string) string { return appPrefix(acct, app) + "pipeline.json" }
+
+func DefaultPipelineChannels() []PipelineChannel { return []PipelineChannel{{Name: "edge"}} }
+```
+
+`validatePipeline(p *Pipeline, acct, app string) error` checks, in order: schema == 1; account/app match the arguments (the copied-object trap); 1–16 channels ("at least one channel" / "at most 16 channels"); per channel: name passes `ValidChannel`, contains no `/` ("channel names may not contain \"/\" — only canary nests"), is not `"canary"` ("\"canary\" is reserved — canary/* lives outside the pipeline"), is unique ("duplicate channel %q"); `BakeWindow()` parses; both approvals fields >= 0 ("approvals must be >= 0"). `GetPipeline`/`SetPipeline`: transliterate `GetProduction`/`SetProduction` (production.go:61-160) swapping the payload type, key, and validator; `SetPipeline` returns the stored `*Pipeline` so callers see the stamped `SetAt`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/release/ -count=1`
+Expected: PASS (whole package — nothing existing may break).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/pipeline.go internal/release/pipeline_test.go
+git commit -m "release: Pipeline object — signed per-app channel list with per-channel rules"
+```
+
+---
+
+### Task 2: Pipeline promotion order — `PipelinePromotionAllowed`
+
+**Files:**
+- Modify: `internal/release/pipeline.go`
+- Test: `internal/release/pipeline_test.go`
+
+**Interfaces:**
+- Consumes: `Pipeline.Index`, `Rung`, `RungCanary` (Task 1 / existing).
+- Produces: `func PipelinePromotionAllowed(p *Pipeline, from, to string) error` — nil means go ahead; every refusal names `-hotfix` (matching `PromotionAllowed`'s contract at `ladder.go:78`).
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestPipelinePromotionAllowed(t *testing.T) {
+ p := &Pipeline{Schema: 1, Channels: []PipelineChannel{
+ {Name: "edge"}, {Name: "staging"}, {Name: "production"}}}
+ for _, tc := range []struct {
+ name, from, to string
+ wantErr string // substring; "" = allowed
+ }{
+ {"canary always", "production", "canary/x", ""},
+ {"fresh build enters at entry", "", "edge", ""},
+ {"nothing else enters at entry", "staging", "edge", "-hotfix"},
+ {"edge to staging", "edge", "staging", ""},
+ {"staging to production", "staging", "production", ""},
+ {"skip refused", "edge", "production", "staging"},
+ {"unknown target names the list", "edge", "beta", "edge, staging, production"},
+ } {
+ err := PipelinePromotionAllowed(p, tc.from, tc.to)
+ if tc.wantErr == "" && err != nil {
+ t.Errorf("%s: unexpected refusal %v", tc.name, err)
+ }
+ if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
+ t.Errorf("%s: err = %v, want mention of %q", tc.name, err, tc.wantErr)
+ }
+ }
+}
+
+func TestPipelineSingleChannelPromoteNamesSetup(t *testing.T) {
+ p := &Pipeline{Schema: 1, Channels: DefaultPipelineChannels()}
+ err := PipelinePromotionAllowed(p, "edge", "production")
+ if err == nil || !strings.Contains(err.Error(), "carlos pipeline") {
+ t.Fatalf("one-channel refusal = %v; want it to point at carlos pipeline setup (spec §5)", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/release/ -run TestPipeline -count=1` → FAIL, undefined `PipelinePromotionAllowed`.
+
+- [ ] **Step 3: Implement** (append to pipeline.go):
+
+```go
+// PipelinePromotionAllowed is PromotionAllowed for an app-defined pipeline:
+// order comes from the channel list, canary is unconditional, and every
+// refusal names -hotfix — which bypasses ORDER only, never a channel's
+// rules; those are the console's gates (spec §5).
+func PipelinePromotionAllowed(p *Pipeline, from, to string) error {
+ if r, ok := Rung(to); ok && r == RungCanary {
+ return nil
+ }
+ names := make([]string, len(p.Channels))
+ for i, c := range p.Channels {
+ names[i] = c.Name
+ }
+ i := p.Index(to)
+ if i < 0 {
+ if len(p.Channels) == 1 {
+ return fmt.Errorf("%q is not one of this app's channels — it has one channel (%s); `carlos pipeline` sets up more, or -hotfix points outside the pipeline",
+ to, p.Entry())
+ }
+ return fmt.Errorf("%q is not one of this app's channels (%s) — use -hotfix to point a channel outside the pipeline",
+ to, strings.Join(names, ", "))
+ }
+ if i == 0 {
+ if from == "" {
+ return nil // a fresh build entering the pipeline — deploys land here
+ }
+ return fmt.Errorf("nothing is promoted onto %s — deploys land there, and this build is already on %s; use -hotfix to override",
+ to, from)
+ }
+ if from == p.Channels[i-1].Name {
+ return nil
+ }
+ return fmt.Errorf("%s is entered from %s only, and this build is on %s — use -hotfix to bypass the order",
+ to, p.Channels[i-1].Name, describeRung(from))
+}
+```
+
+(`describeRung` at `ladder.go:105` already renders `""` as "no rung yet" — reuse it.)
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/release/ -count=1` → PASS.
+- [ ] **Step 5: Commit** — `git add -u internal/release && git commit -m "release: pipeline promotion order — entry channel, predecessors, hotfix names"`
+
+---
+
+### Task 3: `VersionStage` + pipeline-aware `PromoteOnLadder`
+
+**Files:**
+- Modify: `internal/release/pipeline.go`, `internal/release/release.go` (the `Signer.PromoteOnLadder` body at `release.go:809-895`)
+- Test: `internal/release/pipeline_test.go`
+
+**Interfaces:**
+- Consumes: `VersionRung` (`release.go:898`) as the structural model; `ChannelsPrefix`, `Channel`, `GetPipeline`, `PipelinePromotionAllowed`.
+- Produces:
+ - `func VersionStage(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string, p *Pipeline) (string, error)` — the highest-Index pipeline channel currently pointing at `version`; `""` when on none. Unreadable pointers are skipped (fail-closed, same as `VersionRung`).
+ - `Signer.PromoteOnLadder` behaviour change: it loads the pipeline itself. `GetPipeline` error → refuse the promotion (fail closed, "cannot read the pipeline"). Pipeline present → `VersionStage` + `PipelinePromotionAllowed` replace `VersionRung` + `PromotionAllowed`; `opt.Hotfix` skips the order gate in both worlds; the grant gate (`sg.allows`) is untouched either way.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestVersionStageAndPipelinePromote(t *testing.T) {
+ // Harness: reuse how release_test.go:394 sets up store+signer for
+ // VersionRung tests (read that test first; mirror its ship/promote calls).
+ s, priv, pub := pipelineTestHarness(t)
+ ctx := context.Background()
+ chans := []PipelineChannel{{Name: "edge"}, {Name: "production"}}
+ if _, err := SetPipeline(ctx, s, priv, "bae", "web", chans, ""); err != nil {
+ t.Fatal(err)
+ }
+ shipTestRelease(t, s, priv, "bae", "web", "v1") // mirror release_test.go's ship helper name
+ // Fresh build promotes onto the entry channel.
+ if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "edge", "v1", PromoteOptions{}); err != nil {
+ t.Fatalf("promote v1 onto entry: %v", err)
+ }
+ if st, err := VersionStage(ctx, s, pub, "bae", "web", "v1", mustGetPipeline(t, s, pub)); err != nil || st != "edge" {
+ t.Fatalf("VersionStage = %q, %v; want edge", st, err)
+ }
+ // edge -> production allowed; a second fresh promote onto production refused.
+ if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v1", PromoteOptions{}); err != nil {
+ t.Fatalf("edge->production: %v", err)
+ }
+ shipTestRelease(t, s, priv, "bae", "web", "v2")
+ if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v2", PromoteOptions{}); err == nil {
+ t.Fatal("v2 (on no channel) promoted straight onto production; want order refusal")
+ }
+ // Hotfix bypasses order.
+ if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "production", "v2", PromoteOptions{Hotfix: true}); err != nil {
+ t.Fatalf("hotfix: %v", err)
+ }
+ // Rung names outside the pipeline are refused for a pipelined app.
+ if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "stable", "v2", PromoteOptions{}); err == nil {
+ t.Fatal("stable promoted on a pipelined app whose pipeline has no stable")
+ }
+ // Canary stays unconditional.
+ if _, err := PromoteOnLadder(ctx, s, priv, "bae", "web", "canary/x", "v2", PromoteOptions{}); err != nil {
+ t.Fatalf("canary on pipelined app: %v", err)
+ }
+}
+
+func TestScopedKeyCannotSignPipelineChannel(t *testing.T) {
+ // A grant names frozen rungs only (grant.go), so "production" is
+ // uncoverable: the scoped signer must refuse (spec §6). Mirror the
+ // scoped-signer construction in grant_test.go / release_test.go.
+ s, priv, _ := pipelineTestHarness(t)
+ ctx := context.Background()
+ if _, err := SetPipeline(ctx, s, priv, "bae", "web",
+ []PipelineChannel{{Name: "edge"}, {Name: "production"}}, ""); err != nil {
+ t.Fatal(err)
+ }
+ seed, grant, err := MintKeyGrant(priv, "builder", []string{RungEdge}, time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sg, err := scopedSignerForTest(t, seed, grant) // mirror existing grant test helper
+ shipTestRelease(t, s, priv, "bae", "web", "v1")
+ if _, err := sg.PromoteOnLadder(ctx, s, "bae", "web", "production", "v1", PromoteOptions{Hotfix: true}); err == nil {
+ t.Fatal("scoped key signed a pipeline channel pointer; want refusal even with -hotfix")
+ }
+ _ = err
+}
+```
+
+Before writing, read `release_test.go:380-420` and `grant.go`'s test file to get the real helper names (`shipTestRelease`, scoped signer construction) — adapt these tests to the names that exist; the assertions stay.
+
+- [ ] **Step 2: Run to verify failure** — undefined `VersionStage`; pipelined promote follows legacy rules.
+
+- [ ] **Step 3: Implement.** `VersionStage` transliterates `VersionRung` (`release.go:898-940`), keeping channels where `p.Index(name) >= 0` and returning the max by `Index`. In `Signer.PromoteOnLadder` (release.go:~860-880), replace the fixed `VersionRung`+`PromotionAllowed` block with:
+
+```go
+ pipe, perr := GetPipeline(ctx, s, sg.verifyPub(), acct, app)
+ if perr != nil {
+ return PromoteResult{}, fmt.Errorf("promote: cannot read the pipeline for %s/%s: %w", acct, app, perr)
+ }
+ var from string
+ var err error
+ if pipe != nil {
+ from, err = VersionStage(ctx, s, sg.verifyPub(), acct, app, version, pipe)
+ } else {
+ from, err = VersionRung(ctx, s, sg.verifyPub(), acct, app, version)
+ }
+ if err != nil {
+ return PromoteResult{}, err
+ }
+ if !opt.Hotfix {
+ if pipe != nil {
+ err = PipelinePromotionAllowed(pipe, from, channel)
+ } else {
+ err = PromotionAllowed(from, channel)
+ }
+ if err != nil {
+ return PromoteResult{}, err
+ }
+ }
+```
+
+Adapt to the function's actual local variable names and keep the grant gate (`sg.allows`) exactly where it is. Check how `sg.verifyPub()` is named/used elsewhere in the file and match it.
+
+- [ ] **Step 4: Run** — `go test ./internal/release/ -count=1` → PASS, including `laddercheck_test.go` untouched.
+- [ ] **Step 5: Commit** — `git add -u internal/release && git commit -m "release: pipeline-aware promotion — VersionStage + order gate in PromoteOnLadder"`
+
+---
+
+### Task 4: The bake ratchet — `FirstSeen.Holds`, `ObservePipeline`, `HoldsForApp`
+
+**Files:**
+- Modify: `internal/release/bake.go`
+- Test: `internal/release/bake_test.go`
+
+**Interfaces:**
+- Consumes: `FirstSeen` (bake.go:80), `channelClockKey` (bake.go:147), `HoldFor`, `DefaultHolds`, `Pipeline` (Task 1). Read `ObserveProduction` (bake.go:406) and `prodMemory` first — this task is the same pattern for windows.
+- Produces:
+ - New additive `FirstSeen` field: `Holds map[string]*holdMemory \`json:"holds,omitempty"\`` keyed by `channelClockKey(acct, app, channel)`.
+ - `type holdMemory struct { Window time.Duration; SetAt time.Time; Pending time.Duration; PendingAt time.Time }` (json tags: `window`, `set_at`, `pending,omitempty`, `pending_at,omitzero`).
+ - `func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time) bool` — folds one verified read into memory; reports changed (caller saves). `p == nil` still runs pending-promotion checks for the app's remembered channels but never loosens from absence.
+ - `func (f *FirstSeen) HoldsForApp(acct, app string, p *Pipeline) map[string]time.Duration` — the ENFORCED per-channel window map for a pipelined app (memory value per remembered/declared channel), `nil` when `p == nil` (caller then uses `DefaultHolds`). Channels absent from the returned map fall to `HoldFor`'s 72h fail-closed default, which is correct.
+
+Ratchet semantics (spec §3, encode exactly):
+- First sighting of a channel: baseline `Window = HoldFor(DefaultHolds, name)` (72h for a non-rung name — a brand-new channel's narrower window ratchets in, never applies instantly).
+- Replay floor: `p.SetAt` not after `mem.SetAt` → no policy change recorded (pending promotion still checked).
+- Declared window `w >= mem.Window` → tighten immediately: `Window = w`, clear pending.
+- `w < mem.Window` → `Pending = w`; `PendingAt = now` only if `Pending` changed (a re-read must not restart the clock).
+- Pending promotion (checked every call): `now >= PendingAt + Window*(1+f.Jitter)` → `Window = Pending`, clear pending. The elapsed test uses the box's OWN sighting (`PendingAt`), never `p.SetAt`.
+
+- [ ] **Step 1: Write the failing tests** (append to bake_test.go, mirroring `TestProductionMemoryRatchet` at line 376 for style):
+
+```go
+func TestHoldMemoryRatchet(t *testing.T) {
+ f := &FirstSeen{Jitter: 0}
+ now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
+ pipe := func(bake string, setAt time.Time) *Pipeline {
+ return &Pipeline{Schema: 1, Channels: []PipelineChannel{
+ {Name: "edge"}, {Name: "production", Bake: bake}}, SetAt: setAt}
+ }
+ // First sighting: production baselines at the fail-closed 72h, NOT its declared 24h.
+ f.ObservePipeline("bae", "web", pipe("24h", now), now)
+ holds := f.HoldsForApp("bae", "web", pipe("24h", now))
+ if holds["production"] != 72*time.Hour {
+ t.Fatalf("first-sighting window = %v; want fail-closed 72h baseline", holds["production"])
+ }
+ if holds["edge"] != 0 {
+ t.Fatalf("edge window = %v; want 0 (legacy baseline)", holds["edge"])
+ }
+ // After the old window elapses on the box's own clock, the pending 24h lands.
+ later := now.Add(72*time.Hour + time.Second)
+ f.ObservePipeline("bae", "web", pipe("24h", now), later)
+ if h := f.HoldsForApp("bae", "web", pipe("24h", now)); h["production"] != 24*time.Hour {
+ t.Fatalf("post-ratchet window = %v; want 24h", h["production"])
+ }
+ // Tightening is immediate.
+ f.ObservePipeline("bae", "web", pipe("96h", now.Add(80*time.Hour)), later.Add(time.Minute))
+ if h := f.HoldsForApp("bae", "web", nil); h != nil {
+ t.Fatalf("HoldsForApp(nil pipeline) = %v; want nil", h)
+ }
+ if h := f.HoldsForApp("bae", "web", pipe("96h", now)); h["production"] != 96*time.Hour {
+ t.Fatalf("tighten = %v; want 96h immediately", h["production"])
+ }
+}
+
+func TestHoldMemoryReplayFloorAndReReadKeepsClock(t *testing.T) {
+ f := &FirstSeen{Jitter: 0}
+ now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
+ p1 := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "1h"}}, SetAt: now}
+ f.ObservePipeline("bae", "web", p1, now)
+ // Re-reading the same object an hour later must NOT restart the pending clock…
+ f.ObservePipeline("bae", "web", p1, now.Add(time.Hour))
+ // …so the loosening lands 72h after the FIRST sighting, not the second.
+ f.ObservePipeline("bae", "web", p1, now.Add(72*time.Hour+time.Second))
+ if h := f.HoldsForApp("bae", "web", p1); h["stable"] != time.Hour {
+ t.Fatalf("window = %v; want 1h landed 72h after first sighting", h["stable"])
+ }
+ // A replayed OLDER SetAt can never loosen further.
+ old := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "0s"}}, SetAt: now.Add(-time.Hour)}
+ f.ObservePipeline("bae", "web", old, now.Add(73*time.Hour))
+ if h := f.HoldsForApp("bae", "web", p1); h["stable"] != time.Hour {
+ t.Fatalf("replayed old object loosened the window to %v", h["stable"])
+ }
+}
+
+func TestHoldMemorySurvivesSaveLoadAndOldBinaryDrop(t *testing.T) {
+ // Additive-field discipline: marshal a FirstSeen with Holds, unmarshal,
+ // memory intact; unmarshal a PRE-CHANGE JSON (no holds key) into the new
+ // struct — no error, nil map, and ObservePipeline re-latches from scratch
+ // (the closed direction: a restarted clock only ever delays loosening).
+ f := &FirstSeen{Jitter: 0, Holds: map[string]*holdMemory{
+ channelClockKey("bae", "web", "production"): {Window: 24 * time.Hour, SetAt: time.Now().UTC()}}}
+ b, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back FirstSeen
+ if err := json.Unmarshal(b, &back); err != nil {
+ t.Fatal(err)
+ }
+ if back.Holds[channelClockKey("bae", "web", "production")].Window != 24*time.Hour {
+ t.Fatal("holds memory lost across marshal round trip")
+ }
+ var legacy FirstSeen
+ if err := json.Unmarshal([]byte(`{"jitter":0,"channels":{}}`), &legacy); err != nil || legacy.Holds != nil {
+ t.Fatalf("legacy clock parse = %v, holds %v", err, legacy.Holds)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/release/ -run TestHoldMemory -count=1` → FAIL.
+- [ ] **Step 3: Implement** in bake.go: the `holdMemory` type + field beside `Production` (copy the ADDITIVE-caveat comment style from bake.go:99-108 — an old binary's Save drops the field; re-latching restarts clocks in the closed direction), `ObservePipeline`, `HoldsForApp` per the semantics above. `ObservePipeline` iterates `p.Channels`, skips a channel whose `BakeWindow()` errors (keep old memory — fail closed), and returns true when any memory changed so `PassFilter`'s save logic runs.
+- [ ] **Step 4: Run** — `go test ./internal/release/ -count=1` → PASS.
+- [ ] **Step 5: Commit** — `git add -u internal/release && git commit -m "release: bake ratchet memory — pipeline windows loosen only after the old window runs"`
+
+---
+
+### Task 5: Adoption wiring — boxes enforce pipeline windows
+
+**Files:**
+- Modify: `internal/release/adopt.go` (anchors: the `bakeable` precheck ~349-353, production-flag reads ~355-374, `ObserveProduction` fold ~407-411, `bakeIgnore` closure ~461-464, the `Hold` call in `adoptHost` at ~641)
+- Test: `internal/release/adopt_test.go` (or the file where `PassFilter` is tested — find with `grep -rn "PassFilter" internal/release/*_test.go`)
+
+**Interfaces:**
+- Consumes: `GetPipeline`, `ObservePipeline`, `HoldsForApp` (Tasks 1, 4); the existing `acctApp` key type and `prod` map in `PassFilter`.
+- Produces: behavioural only —
+ 1. `PassFilter` reads `pipeline.json` once per app appearing in the pass (same loop shape as the production-flag reads; a read ERROR is logged and treated as "no pipeline verdict → fail toward legacy holds + production-flag path", never as "no pipeline, adopt free").
+ 2. Every read is folded via `bake.ObservePipeline` BEFORE any verdict (same ordering rule as `ObserveProduction` at 407).
+ 3. The `bakeable` precheck and the `Hold` call resolve per-app holds: pipelined app → `bake.HoldsForApp(...)`; else `DefaultHolds`.
+ 4. `bakeIgnore` returns `false` (never ignore) for a pipelined app — the production flag is legacy-only there (spec §3); a 0-window channel holds nothing anyway.
+ 5. `ptr.FastTrack` keeps bypassing the hold unchanged (spec §3 amendment).
+
+- [ ] **Step 1: Read `PassFilter` end-to-end** (`internal/release/adopt.go:300-480` roughly) and the existing test that drives a pass with a held channel. This task is converge-grade: the ordering comments in the file are load-bearing — keep every one true.
+
+- [ ] **Step 2: Write the failing test** (adapt harness names to the existing PassFilter tests — the assertions are what matter):
+
+```go
+func TestPassEnforcesPipelineWindows(t *testing.T) {
+ // An app with pipeline {edge(0), production(24h)}:
+ // - a release at head of "production" is HELD even though the app's
+ // production flag is absent (pipelined apps never consult the flag),
+ // - the same release at head of "edge" adopts immediately,
+ // - after the ratchet has landed 24h (advance the fake clock past the
+ // 72h baseline, re-observe, then 24h more), the held release adopts,
+ // - a FastTrack-re-signed pointer adopts immediately while held.
+ // Build via the same fixture the existing hold tests use.
+}
+```
+
+Write it fully against the real harness (the plan cannot know its constructor names; the four assertions above are the required behaviour, each as its own sub-step of the test).
+
+- [ ] **Step 3: Run to verify failure**, **Step 4: Implement** per the Produces list, **Step 5: Run full package**, then:
+
+Run: `go test ./internal/release/ -count=1` → PASS including laddercheck.
+
+- [ ] **Step 6: Commit** — `git add -u internal/release && git commit -m "release: adoption enforces pipeline bake windows through the ratchet"`
+
+---
+
+### Task 6: Console — pipeline API (GET/PUT), default at create, production side-write
+
+**Files:**
+- Create: `internal/console/apipipeline.go`
+- Modify: `internal/console/authcli.go` (route block at :414-416), `internal/console/apicli.go` (`handleAPICreateApp` at :330)
+- Test: `internal/console/apipipeline_test.go`
+
+**Interfaces:**
+- Consumes: `apiAccountApp` (apicli.go:40), `writeJSON`, `s.cfg.Releases`, `s.cfg.Key`, `s.bumpSerial` (serialbump.go:31), `s.audit` (mirror the call beside bumpSerial at server.go:2712), `s.st.Members` (store.go:853), `release.GetPipeline/SetPipeline/DefaultPipelineChannels`, `svcrole.Allow`.
+- Produces:
+ - `GET /api/cli/apps/{account}/{app}/pipeline` → `svcrole.Allow(svcrole.Publish)` → `{"pipeline": {...}}` or `{"pipeline": null}`.
+ - `PUT /api/cli/apps/{account}/{app}/pipeline` → `svcrole.Allow()` (admin-only for service principals) → body `{"channels": [...]}` → validates, member-count check, `change_approvals` interim refusal (Task 8 replaces it with the propose flow), production side-write, `SetPipeline`, `bumpSerial`, audit → `{"pipeline": {...}}`.
+ - `handleAPICreateApp` writes the default pipeline after a successful create.
+ - Helper for later tasks: `func holdWindow(p *release.Pipeline, channel string) time.Duration` (package-level in apipipeline.go) — pipeline channel's window when pipelined, else `release.HoldFor(release.DefaultHolds, channel)`.
+
+- [ ] **Step 1: Write the failing tests** (use `newConsoleFixture` (server_test.go:43) + the CLI-token path the existing apicli tests use — read `apicli_test.go:1-120` and `TestAPIPromoteStableRefused` at :502 for the harness):
+
+```go
+func TestAPIPipelineRoundTripAndDefaults(t *testing.T) {
+ // 1. Create an app through the CLI door -> GET pipeline returns the
+ // one-channel edge default (spec §2), not null.
+ // 2. PUT {"channels":[{"name":"edge"},{"name":"production","bake":"24h"}]}
+ // -> 200; GET reflects it; control/serial bumped (fixture exposes the
+ // store — read the serial key before/after).
+ // 3. The app's production flag is now TRUE (side-write on nonzero bake,
+ // spec §3); a PUT with all-zero bakes must NOT flip it back.
+ // 4. PUT with rules {"promote_approvals": 2} on a 1-member account ->
+ // 422, body names the member count.
+ // 5. PUT with a channel named "canary" -> 422.
+}
+
+func TestAPIPipelinePutGuardedChannelInterimRefusal(t *testing.T) {
+ // Until Task 8: PUT that edits/removes/reorders a channel whose CURRENT
+ // stored rules carry change_approvals >= 1 -> 403 with
+ // {"error": "... needs N-member confirmation — finish in the console", "console_url": ...}.
+ // (Adding approvals where there were none is allowed — tightening.)
+}
+```
+
+Write both fully against the fixture's real helpers.
+
+- [ ] **Step 2: Run to verify failure.**
+- [ ] **Step 3: Implement.** Handler skeleton for the PUT:
+
+```go
+func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) {
+ acct, app, addr, ok := s.apiAccountApp(w, r) // adapt to apiAccountApp's real signature (apicli.go:40)
+ if !ok {
+ return
+ }
+ var in struct {
+ Channels []release.PipelineChannel `json:"channels"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad pipeline body"})
+ return
+ }
+ ctx := r.Context()
+ cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.releasePub(), acct.Sqid, app) // adapt pub accessor to how GetProduction call sites do it
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "cannot read the current pipeline"})
+ return
+ }
+ proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: in.Channels}
+ members, err := s.st.Members(acct.ID)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "members lookup failed"})
+ return
+ }
+ if n := proposed.MaxApprovals(); n > len(members) {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{
+ "error": fmt.Sprintf("approvals of %d exceed the account's %d member(s) — add members first", n, len(members))})
+ return
+ }
+ if ch, n := guardedEdit(cur, in.Channels); n > 0 {
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": fmt.Sprintf("channel %s needs %d-member confirmation to change — finish in the console", ch, n),
+ "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)})
+ return
+ }
+ set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, in.Channels, addr)
+ if err != nil {
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
+ return
+ }
+ s.ensureProductionForBakes(ctx, acct.Sqid, app, set, addr) // side-write, below
+ s.bumpSerial(ctx)
+ // audit: mirror the s.audit call shape beside bumpSerial at server.go:2712
+ writeJSON(w, http.StatusOK, map[string]any{"pipeline": set})
+}
+```
+
+`guardedEdit(cur *release.Pipeline, next []release.PipelineChannel) (string, int)` returns the first guarded channel whose entry the edit touches and its quorum: for each channel in `cur` with `Rules.ChangeApprovals >= 1`, the edit touches it if the channel is absent from `next`, at a different index, or its `PipelineChannel` differs (`!=` on the struct). `ensureProductionForBakes`: if any `BakeWindow() > 0` and `release.GetProduction` reports not-production (nil flag or false), call `release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct, app, true, actor)`; never flip toward false. GET handler and route registrations follow `handleAPIChannels` (apicli.go:514, authcli.go:415). In `handleAPICreateApp`, after the success path stores the app, add `release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, release.DefaultPipelineChannels(), addr)` — log-only on failure (an app without a pipeline is the legacy world, which is safe).
+
+- [ ] **Step 4: Run** — `go test ./internal/console/ -count=1` → PASS.
+- [ ] **Step 5: Commit** — `git add internal/console && git commit -m "console: pipeline API — GET/PUT, edge default at create, production side-write"`
+
+---
+
+### Task 7: Pending-change store
+
+**Files:**
+- Create: `internal/console/pending.go`
+- Test: `internal/console/pending_test.go`
+
+**Interfaces:**
+- Consumes: `*Store` (store.go), the migrate pattern (store.go:81 — feature-scoped `migrateX()` in its own file, called from the tail of `migrate()`; add the call there), `crypto/rand` for ids.
+- Produces (Task 8 relies on these exact signatures):
+
+```go
+const PendingChangeTTL = 7 * 24 * time.Hour
+
+type PendingChange struct {
+ ID string
+ AccountID int64
+ App string
+ Kind string // "promote" | "policy" | "fasttrack"
+ Payload []byte // canonical JSON, pinned at propose time
+ Quorum int
+ Proposer string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+ Confirms []string // addresses, proposer included when passkey-backed
+}
+
+// ProposeChange inserts a change; proposerConfirmed records the proposer's
+// confirmation row (true only for console/step-up proposals — spec §4:
+// a bearer token never counts toward a passkey quorum).
+func (s *Store) ProposeChange(accountID int64, app, kind string, payload []byte, quorum int, proposer string, proposerConfirmed bool, now time.Time) (*PendingChange, error)
+
+// ConfirmChange records one member's confirmation (idempotent per address)
+// and reports whether quorum is now met. Expired or executed changes refuse.
+func (s *Store) ConfirmChange(id, address string, now time.Time) (*PendingChange, bool, error)
+
+func (s *Store) PendingChangesFor(accountID int64, app string, now time.Time) ([]PendingChange, error) // unexpired, unexecuted
+func (s *Store) PendingChange(id string, now time.Time) (*PendingChange, error)
+func (s *Store) MarkExecuted(id string, now time.Time) error
+func (s *Store) DeleteChange(id string) error // reject/cancel
+```
+
+Tables (in `migratePendingChanges`, verbatim):
+
+```sql
+CREATE TABLE IF NOT EXISTS pending_changes (
+ id TEXT PRIMARY KEY,
+ account_id INTEGER NOT NULL,
+ app TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ quorum INTEGER NOT NULL,
+ proposer TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ executed_at INTEGER NOT NULL DEFAULT 0
+);
+CREATE TABLE IF NOT EXISTS pending_confirms (
+ change_id TEXT NOT NULL,
+ address TEXT NOT NULL,
+ confirmed_at INTEGER NOT NULL,
+ PRIMARY KEY (change_id, address)
+);
+```
+
+- [ ] **Step 1: Write the failing tests** — open a `Store` the way `store_test.go` does (temp file + `Open`), then:
+
+```go
+func TestPendingChangeQuorum(t *testing.T) {
+ // quorum 2, console-proposed (proposerConfirmed=true): propose -> not met;
+ // ConfirmChange(same proposer) -> still 1 confirm (idempotent), not met;
+ // ConfirmChange(second member) -> met=true, Confirms has both addresses.
+}
+func TestPendingChangeCLIProposalStartsAtZero(t *testing.T) {
+ // proposerConfirmed=false, quorum 1: not met at propose; met after ONE
+ // console confirmation — even by the proposer themself.
+}
+func TestPendingChangeExpiryAndExecution(t *testing.T) {
+ // Confirm past ExpiresAt -> error mentioning "expired";
+ // MarkExecuted then Confirm -> error; PendingChangesFor excludes both.
+}
+```
+
+Write the bodies fully.
+
+- [ ] **Step 2: Run to verify failure**, **Step 3: Implement** (id = 16 bytes `crypto/rand` hex; every method a single transaction; `ConfirmChange` uses `INSERT OR IGNORE` then recounts), **Step 4: Run** `go test ./internal/console/ -run TestPendingChange -count=1` then the whole package, **Step 5: Commit**:
+
+```bash
+git add internal/console/pending.go internal/console/pending_test.go internal/console/store.go
+git commit -m "console: pending-change store — payload-pinned quorum with 7-day expiry"
+```
+
+---
+
+### Task 8: Console enforcement — rules gate promote, fast-track, and policy edits
+
+**Files:**
+- Modify: `internal/console/apicli.go` (`handleAPIPromote` :568-690, the stable refusal at :623-631, the `held` computation at :682), `internal/console/server.go` (`handlePromote` :2586 / `promoteGate` :2719, `handleFastTrack` :2875, route table ~:742), `internal/console/apipipeline.go` (replace Task 6's interim 403 with the propose flow)
+- Create: `internal/console/pendinghandlers.go`
+- Test: `internal/console/pendingflow_test.go`
+
+**Interfaces:**
+- Consumes: Tasks 1, 6, 7; `requireFreshAuth` (stepup.go), `completeStepUp`/`postGated`/`memberFixture` (stepup_test.go) for tests; `release.PromoteOnLadder`, `release.FastTrack` (release.go:1005), `release.VersionStage`.
+- Produces:
+ - `handleAPIPromote`: loads the pipeline once. `nil` → today's legacy stable refusal verbatim. Pipelined → rules for the target channel decide: no rules → promote as now; `Passkey` only → 403 `{"error": "<channel> needs a passkey — finish in the console", "console_url": ...}`; `PromoteApprovals >= 1` → `ProposeChange(kind "promote", payload {"version","channel"}, quorum, proposer, false, now)` → 202 `{"result":"proposed","pending_id","needed":N,"confirmed":0,"console_url"}`. The `held` field in the success response uses `holdWindow` (Task 6).
+ - New request field `{"hotfix": true}` in the promote body, passed to `PromoteOptions{Hotfix:}` — rules are checked BEFORE the hotfix flag is even read (order-only bypass, spec §5).
+ - Browser `handlePromote`: same decision; passkey rule → `requireFreshAuth` (existing placement, LAST); approvals → `ProposeChange(..., proposerConfirmed=true, ...)` after `requireFreshAuth`, then if quorum met immediately (N==1) execute inline, else redirect to the app page where the pending card shows.
+ - `handleFastTrack`: after its existing owner+step-up gates, a pipelined channel with `ChangeApprovals >= 1` proposes (kind "fasttrack", payload {"channel","version"}) instead of executing; its `HoldFor == 0` refusal switches to `holdWindow == 0`.
+ - Policy PUT (browser POST in Task 9 + API PUT): guarded edits propose (kind "policy", payload = full new channel list + the current pipeline's `SetAt` pinned as `"observed_set_at"`).
+ - New CLI-door route `GET /api/cli/apps/{account}/{app}/pending` → `svcrole.Allow(svcrole.Publish)` → `{"pending": [{"id","kind","summary","confirmed","needed"}]}` (Task 10's `pendingList` consumes this).
+ - `pendinghandlers.go`: `POST /a/{sqid}/apps/{app}/pending/{id}/confirm` and `.../reject`. Confirm gate order: session → CSRF → member of account (404) → `requireFreshAuth` LAST → `ConfirmChange` → if met, `executePending`. Reject: proposer or owner only.
+ - `executePending(ctx, acct, app, change)`: by kind — `promote`: recheck `VersionStage` still allows the pinned move (else refuse "the pipeline moved underneath this proposal — re-propose"), then `PromoteOnLadder`; `policy`: refuse if current pipeline `SetAt` differs from pinned `observed_set_at`, then `SetPipeline`; `fasttrack`: `release.FastTrack`. All three: `bumpSerial` + audit + `MarkExecuted`.
+
+- [ ] **Step 1: Write the failing tests** in `pendingflow_test.go`, driving handlers exactly as `stepup_test.go` does (fixture + `postGated`):
+
+```go
+func TestPipelinePromoteRulesLadder(t *testing.T) {
+ // App with pipeline edge -> production {passkey:true}. CLI-token promote
+ // to production -> 403 naming passkey + console_url. Same app,
+ // production {promote_approvals:2}: CLI promote -> 202 with pending_id,
+ // needed 2, confirmed 0; pointer NOT moved.
+}
+func TestPipelineQuorumExecutesPromotion(t *testing.T) {
+ // 2-member account (memberFixture), promote_approvals 2. Owner proposes
+ // in the browser (postGated walks the step-up) -> 1 confirm. Second
+ // member confirms (their own step-up) -> pointer moved, serial bumped,
+ // change marked executed. Confirming again -> refusal.
+}
+func TestPipelineLegacyAppsUntouched(t *testing.T) {
+ // App with NO pipeline: stable promote refusal is byte-identical to
+ // today's ("stable needs a passkey — finish in the console") and
+ // TestAPIPromoteStableRefused's assertions still hold.
+}
+func TestPolicyEditPinsSetAt(t *testing.T) {
+ // Propose a guarded policy edit; then land a DIFFERENT policy write
+ // (unguarded channel tweak) bumping SetAt; confirming the original to
+ // quorum -> execution refuses, change stays unexecuted.
+}
+func TestHotfixBypassesOrderNotRules(t *testing.T) {
+ // production {passkey:true}: promote {"hotfix":true} via CLI token ->
+ // still the 403 passkey refusal. An UNguarded out-of-order target with
+ // {"hotfix":true} -> succeeds.
+}
+```
+
+Write bodies fully against the fixture.
+
+- [ ] **Step 2: Run to verify failure.** **Step 3: Implement** per Produces. Keep `promoteGate`'s documented order comment updated but the ORDER itself unchanged. **Step 4:** `go test ./internal/console/ -count=1` → PASS (existing stable/step-up tests must all still pass). **Step 5: Commit** — `git add internal/console && git commit -m "console: per-channel rules gate promote, fast-track and policy edits through pending changes"`
+
+---
+
+### Task 9: Console UI — hidden vocabulary, pipeline card, settings + templates, pending card
+
+**Files:**
+- Modify: `internal/console/ladder.go` (`assembleLadder` + `gates` map :119), `internal/console/server.go` (route table, `promoteChannelOptions` :3206), templates `internal/console/templates/ladder.html`, `app_overview.html`
+- Create: `internal/console/templates/pipeline.html` (settings page), `internal/console/templates/pending.html` (pending-changes card), `internal/console/pipelinepage.go`
+- Test: `internal/console/pipelinepage_test.go`
+
+**Interfaces:**
+- Consumes: `assembleLadder` (read it first), `holdWindow`, `PendingChangesFor`, template embedding (flat namespace — names must be globally unique, server.go:42-45).
+- Produces:
+ - **One-channel apps hide the vocabulary** (spec §5): when the app's pipeline exists and has exactly one channel, the app page renders NO ladder/channel card and no promote form — releases list + live marker only.
+ - Multi-channel pipelined apps: the ladder card renders the pipeline's channels in order, each with version at head, bake window, and a gate sentence derived from rules (`"Anyone on the team promotes here."` / `"Needs a passkey."` / `"Needs N members to confirm."` / combinations) — the legacy `gates` map stays for legacy apps only.
+ - `promoteChannelOptions` offers pipeline channel names (not rungs) for pipelined apps.
+ - Settings page `GET/POST /a/{sqid}/apps/{app}/pipeline`: current channels as a form (per-row name/bake/passkey/approvals inputs, indexed field names `ch.0.name`, `ch.0.bake`, …), plus — when the pipeline is the one-channel default — two template buttons that prefill: **Edge → Production** (`production`: bake 24h, passkey) and **Full ladder** (edge / beta bake 24h / stable bake 72h + passkey), matching spec §5 exactly. POST goes through the same guarded path as the API PUT (propose when guarded).
+ - Pending-changes card on the app page: each unexpired change with kind, payload summary, confirms `k/N`, Confirm and Reject buttons (CSRF forms posting to Task 8's handlers).
+
+- [ ] **Step 1: Write the failing tests** (server_test.go browser style — `signInPaul`, GET the app page, assert on HTML):
+
+```go
+func TestOneChannelAppHidesChannelVocabulary(t *testing.T) {
+ // Freshly created app (default pipeline): app page body contains the
+ // releases list but NOT the strings "Promote", "edge", "ladder".
+}
+func TestPipelineCardAndSettingsTemplates(t *testing.T) {
+ // After PUT edge+production{passkey}: app page shows both channels in
+ // order with "Needs a passkey." on production; settings page shows the
+ // form; on a default pipeline the two template buttons render.
+}
+func TestPendingCardShowsQuorumProgress(t *testing.T) {
+ // A proposed promote renders "1/2 confirmed" with Confirm + Reject.
+}
+```
+
+Write bodies fully.
+
+- [ ] **Step 2–4: red → implement → green** (`go test ./internal/console/ -count=1`). Template snippets: follow ladder.html's existing structure and classes; icons are vendored Lucide in-code SVG constants only.
+- [ ] **Step 5: Commit** — `git add internal/console && git commit -m "console: pipeline UI — hidden one-channel vocabulary, settings templates, pending card"`
+
+---
+
+### Task 10: CLI — `carlos pipeline`, promote proposals, `-hotfix` over the API
+
+**Files:**
+- Modify: `main.go` (dispatch switch :29-124 + `usage` :138-204), `cli.go` (`runPromote` :2038, drop the API-mode `-hotfix` refusal at :2056-2064), `apimode.go` (new session methods; extend `stableRefusal` :781 usage)
+- Create: `pipeline.go` (repo root), `pipeline_test.go`
+- Test: `apimode_test.go` additions (in-process pattern of `TestPromoteVerbAPIMode` :1027)
+
+**Interfaces:**
+- Consumes: `apiSession` methods `getJSON`/`postJSON`/`putJSON` (apimode.go:710/772/1480), `apiPath` (:383), `resolveMode` (apimode.go:88), `newTable` (color.go:194), `accountFlags` (cli.go:1625). Current flag style: plain `fs.String("app", ...)` — the flag-conventions spec is not yet implemented; follow the current pattern.
+- Produces:
+ - `func runPipeline(args []string)` — subcommands: `show` (default; renders channels with ORDER, BAKE, RULES columns via `newTable`, plus pending changes beneath), `init -template edge-production|full-ladder`, `set -channel <name> [-bake 24h] [-passkey] [-promote-approvals N] [-change-approvals N]`, `add -channel <name> [-after <name>] [...same policy flags]`, `remove -channel <name>`. All compose the full channel list client-side from a fresh GET and PUT it back — the server is the validator and the gate.
+ - Session methods: `func (s *apiSession) pipelineGet(ctx context.Context, a apiAcct, app string) (*apiPipeline, error)`, `func (s *apiSession) pipelinePut(ctx context.Context, a apiAcct, app string, channels []apiPipelineChannel) (*apiPipeline, error)`, `func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([]apiPending, error)` with client types `apiPipeline{Channels []apiPipelineChannel}`, `apiPipelineChannel{Name, Bake string; Passkey bool; PromoteApprovals, ChangeApprovals int}`, `apiPending{ID, Kind, Summary string; Confirmed, Needed int}` (server adds `GET .../pending` in Task 8).
+ - `runPromote`: sends `{"version","channel","hotfix"}`; handles the 202 proposed response by printing `proposed — needs N confirmations; finish at <console_url>`; the generalized 403 `{error, console_url}` path reuses `stableRefusal` (rename it `consoleRefusal` and update both call sites).
+ - `main.go`: `case "pipeline": runPipeline(args)` + one usage line under "Your apps": `pipeline show or shape an app's release channels`.
+
+- [ ] **Step 1: Write the failing tests** (in-process, stub console server exactly as `TestPromoteVerbAPIMode` does):
+
+```go
+func TestPipelineShowVerbAPIMode(t *testing.T) // GET stubbed -> table lists channels in order with rules
+func TestPipelineInitTemplateAPIMode(t *testing.T) // init -template edge-production PUTs the exact two-channel body
+func TestPipelineSetComposesFullList(t *testing.T) // set -channel production -bake 48h GETs, mutates one entry, PUTs all
+func TestPromoteProposedPrintsConsoleURL(t *testing.T) // stub 202 -> output names N and the URL
+func TestPromoteHotfixSentToAPI(t *testing.T) // -hotfix now allowed in API mode; body carries hotfix:true
+```
+
+Write bodies fully, asserting the stub-observed request bodies.
+
+- [ ] **Step 2–4: red → implement → green** (`go test . -count=1`).
+- [ ] **Step 5: Commit** — `git add pipeline.go pipeline_test.go main.go cli.go apimode.go apimode_test.go && git commit -m "cli: carlos pipeline verb family; promote proposes and carries -hotfix over the API"`
+
+---
+
+### Task 11: Deploy entry channel + channels API envelope
+
+**Files:**
+- Modify: `internal/console/apicli.go` (`handleAPIChannels` :514-542), `deploy.go` (`deployTargets` :488 + its callers :179-188), `apimode.go` (`channels` :988)
+- Test: `deploy_test.go`, `internal/console/apicli_test.go`
+
+**Interfaces:**
+- Consumes: Task 6's GET handler logic (share the pipeline load), `errAmbiguousChannel` (deploy.go:651).
+- Produces:
+ - `handleAPIChannels` envelope gains an ADDITIVE `"pipeline"` key: `{"channels": [...], "pipeline": {"entry": "edge", "channels": ["edge","production"]} }` (null when legacy). Existing rows unchanged — old CLIs ignore the new key.
+ - Client: `channels` returns a new `apiChannelsResult{Channels []apiChannel; PipelineEntry string; PipelineChannels []string}`; `deployTargets`'s `channels func()` parameter takes the new type.
+ - `deployTargets`: when the app has no instances and no resolvable channel, a pipelined app resolves to `PipelineEntry` instead of erroring — `carlos deploy` on a brand-new one-channel app just works (spec §2: the entry channel is where deploy lands). The ambiguity error and `-channel` override behaviour are unchanged otherwise.
+
+- [ ] **Step 1: Write the failing tests** — extend the existing `deployTargets` unit tests (find them in deploy_test.go) with: no instances + pipeline entry `edge` → resolves `edge`, static and non-static both; no instances + NO pipeline → today's error text unchanged. Console side: `handleAPIChannels` response contains the pipeline key for a pipelined app and `"pipeline": null` for a legacy one.
+- [ ] **Step 2–4: red → implement → green** (`go test . ./internal/console/ -count=1`).
+- [ ] **Step 5: Commit** — `git add -u && git commit -m "deploy: resolve the pipeline entry channel; channels API carries the pipeline envelope"`
+
+---
+
+### Task 12: Full-gate sweep, spec conformance, docs
+
+**Files:**
+- Modify: `CLAUDE.md` (Deploy reality: one line noting pipelines exist and absence = legacy ladder), spec status line
+- Test: everything
+
+- [ ] **Step 1: Spec conformance pass.** Reread `docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md` section by section against the code; each §-claim must have an implementing test. Known deliberate deferrals (spec §9) need nothing. Verify specifically: `laddercheck_test.go` has zero diffs (`git diff main -- internal/release/laddercheck_test.go` is empty); canary TTL files untouched; the legacy stable refusal string is byte-identical. Then sweep every remaining `release.HoldFor(release.DefaultHolds, …)` call site in `internal/console` (`flow.go:206`, `server.go:2713`, `view.go:586/612/626`, `confirm.go:136` — grep for the current list) and route each through `holdWindow` where the app may be pipelined, so console-rendered hold information never disagrees with what boxes enforce.
+- [ ] **Step 2: Full gate.** `go build ./... && go vet ./... && go test ./... -count=1` and `GOOS=darwin go build ./...`; `gofmt -l` on all touched files.
+- [ ] **Step 3: Docs.** Add to CLAUDE.md's rules section: "**Absence of `apps/<acct>/<app>/pipeline.json` = the legacy ladder**, byte-for-byte; new apps get a one-channel `edge` pipeline at create; per-channel rules are console-enforced, bake windows box-enforced through the `FirstSeen.Holds` ratchet." Update the spec's Status line to note implementation landed on this branch.
+- [ ] **Step 4: Commit** — `git add -u && git commit -m "pipelines v2: conformance sweep + docs"`
+- [ ] **Step 5:** Push the branch and open the PR (never a local merge): `git push -u origin edge-beta-stable && gh pr create` with a body walking the spec's sections, ending with the standard generation line. Note in the PR body: website/docs batch and the fleet's own migration off `canary/*` are follow-ups (spec §9).
diff --git a/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md b/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md
index 3e9e136f9..2d123db82 100644
--- a/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md
+++ b/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md
@@ -85,9 +85,19 @@ channel with no pipeline entry, or an app with no pipeline on a channel name
the box doesn't recognise, keeps today's fail-closed 72h.
**The loosening ratchet** (same pattern as the production-flag memory in
-`FirstSeen.Production`): a box honours a *shorter* bake — or a fast-track of
-a held release — only after the *previously-known* window has elapsed since
-the box first observed the change. A *longer* bake is honoured immediately.
+`FirstSeen.Production`): a box honours a *shorter* bake window only after
+the *previously-known* window has elapsed since the box first observed the
+change. A *longer* bake is honoured immediately. A channel the box has no
+memory of starts from its fail-closed legacy window (`HoldFor` semantics:
+the rung's default, 72h for an unknown name), so a brand-new channel's
+narrower window also ratchets in rather than applying instantly.
+
+*(Amended 2026-08-20 during planning:)* a **fast-track of one specific held
+release** stays honoured immediately, exactly as shipped today
+(`ptr.FastTrack` in the root-key-re-signed pointer) — ratcheting it box-side
+would make it wait out the very window it exists to skip, a no-op. Its
+protection is console-side (`change_approvals` + passkey, §4) plus root-key
+possession; the ratchet covers policy changes.
The clock is the box's own observation, never a manifest timestamp. So a
fully compromised console session cannot collapse a 72h window and ship in
the same hour: the old window still runs once, locally, on clocks the
@@ -124,7 +134,13 @@ fast-track:
- **Confirming requires the member's passkey, in the console.** The CLI can
propose and list, never confirm — the existing "finish in the console"
shape, now uniform across every gated action.
-- **Quorum is N distinct members; the proposer counts as the first.**
+- **Quorum is N distinct members; the proposer counts as the first** — but
+ *(amended 2026-08-20 during planning)* only when the proposal itself was
+ made through the console's passkey step-up. A CLI-proposed change starts
+ at zero confirmations: a bearer token must never count toward a quorum
+ whose whole point is passkeys. With `approvals: 1`, a console proposal
+ therefore executes immediately (it degenerates to a passkey rule) while a
+ CLI proposal still requires one console confirmation.
Setting any approvals value above the account's member count is refused at
write time with an error that names the member count.
- **Pending changes expire after 7 days.** A promotion confirmed against a