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