| new file mode 100644 |
| index 000000000..480b55ebb |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-04-console-bake-controls.md |
| @@ -0,0 +1,1761 @@ |
| +# Console Bake Controls 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:** Per-app production flag (default off) gating the supply-chain bake window, a ratcheted off-switch, a root-key fast-track waiver on channel pointers, console controls behind the existing passkey step-up, and the `edge: 0` hold fix. |
| + |
| +**Architecture:** Spec is `docs/superpowers/specs/2026-08-04-console-bake-controls-design.md` — read it first. The release package gains a signed per-app flag object (`apps/<acct>/<app>/production.json`), a `fast_track` field on channel pointers, box-side flag memory in the bake clock, and adopt wiring that finally passes `Hold`'s `ignore` parameter. The console gains two handlers, view plumbing, and ladder-section controls behind `requireFreshAuth`. |
| + |
| +**Tech Stack:** Go stdlib only; Ed25519 via the existing `signedWrapper` discipline; html/template. |
| + |
| +## Global Constraints |
| + |
| +- Working directory is the worktree root (`.claude/worktrees/console-bake-controls-spec`). Never `cd` to the main checkout. |
| +- Go commands need: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off` (and may need the sandbox disabled — "read-only file system" from the toolchain is the tell). |
| +- All wire changes are ADDITIVE: pointers/flags written before this change must still parse, and pointers written by this change must parse in old struct shapes (fields dropped, boxes bake — the safe direction). |
| +- `internal/release/laddercheck_test.go`'s pinned production channels must stay green untouched. |
| +- `gofmt -l` clean for every file you touch. |
| +- Commit messages end with: |
| + `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` and |
| + `Claude-Session: https://claude.ai/code/session_01VioXzogWf1Xi4Rswz2B6EK` |
| +- Test style: package-internal tests (`package release` / `package console`), table-driven where natural, comments explaining WHY in the repo's voice. |
| + |
| +**Delegation (CLAUDE.md policy):** Task 1 → Haiku. Tasks 2, 3, 4, 6, 8 → Sonnet. Tasks 5, 7, 9 → Opus (adopt is the production install path; handlers are security gates; 9 is the reviewer pass). |
| + |
| +--- |
| + |
| +### Task 1: `edge` bakes zero (spec §8) |
| + |
| +**Files:** |
| +- Modify: `internal/release/bake.go` (the `DefaultHolds` map, ~line 36) |
| +- Test: `internal/release/bake_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `HoldFor(holds map[string]time.Duration, channel string) time.Duration` (bake.go) |
| +- Produces: `DefaultHolds["edge"] == 0` — Tasks 5/6 rely on edge (and canary) being hold-free. |
| + |
| +- [ ] **Step 1: Write the failing test** (append to `internal/release/bake_test.go`) |
| + |
| +```go |
| +// The pipelines spec defines edge as "picked up as it lands — no human in |
| +// the loop, by definition". Without its own entry, HoldFor fail-closes edge |
| +// to 72h — longer than beta, and a spec violation (2026-08-04 console bake |
| +// controls spec, §1/§8). |
| +func TestHoldForEdgeIsZero(t *testing.T) { |
| + if d := HoldFor(DefaultHolds, "edge"); d != 0 { |
| + t.Fatalf("edge hold = %v, want 0", d) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run it, verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/release/ -run TestHoldForEdgeIsZero -count=1` |
| +Expected: FAIL, `edge hold = 72h0m0s, want 0` |
| + |
| +- [ ] **Step 3: Add the entry** — in `DefaultHolds`, add `"edge": 0,` between `"canary/*"` and `"beta"`, and extend the map's doc comment block (bake.go lines 12–16) with one sentence: `edge has no window either — merged to main is picked up as it lands (pipelines spec §3).` |
| + |
| +- [ ] **Step 4: Run the package tests** — `go test ./internal/release/ -count=1` (with the env above). Expected: PASS (no existing test pins edge's 72h). |
| + |
| +- [ ] **Step 5: Commit** — `git add internal/release/bake.go internal/release/bake_test.go && git commit -m "release: edge bakes zero — merged to main is picked up as it lands"` (with the trailer lines from Global Constraints). |
| + |
| +--- |
| + |
| +### Task 2: the signed production flag object (spec §2, §3) |
| + |
| +**Files:** |
| +- Create: `internal/release/production.go` |
| +- Test: `internal/release/production_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `signedWrapper` (release.go), `ValidAccount`, `ValidApp`, `appPrefix`, `Store`, `ErrCASConflict`. |
| +- Produces (later tasks call these exactly): |
| + - `type ProductionFlag struct { Schema int; Account, App string; Production bool; Actor string; SetAt time.Time }` |
| + - `func ProductionKey(acct, app string) string` |
| + - `func SetProduction(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, production bool, actor string) error` |
| + - `func GetProduction(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) (*ProductionFlag, error)` — missing object is `(nil, nil)`; any other failure is the error, which callers treat as production=true (fail toward baking). |
| + |
| +- [ ] **Step 1: Write the failing tests** (`internal/release/production_test.go`) |
| + |
| +```go |
| +package release |
| + |
| +// The production flag (2026-08-04 console bake controls spec §2–§3): a |
| +// root-signed per-app object deciding whether the supply-chain bake applies. |
| +// The properties tested here, in the order they'd hurt if lost: missing is |
| +// the default (nil, nil); a set flag round-trips; a scoped key's wrapper is |
| +// refused even with a valid root grant; a tampered payload is refused; the |
| +// key sits OUTSIDE channels/ so pointer listings never try to parse it. |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestProductionFlagDefaultIsMissing(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + _, pub := testKeys(t) |
| + f, err := GetProduction(context.Background(), s, pub, "bab", "kass") |
| + if err != nil || f != nil { |
| + t.Fatalf("GetProduction on empty store = %v, %v; want nil, nil (the default: not production)", f, err) |
| + } |
| +} |
| + |
| +func TestProductionFlagRoundTrips(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + if err := SetProduction(ctx, s, priv, "bab", "kass", true, "paul@keymail.dev"); err != nil { |
| + t.Fatalf("SetProduction: %v", err) |
| + } |
| + f, err := GetProduction(ctx, s, pub, "bab", "kass") |
| + if err != nil { |
| + t.Fatalf("GetProduction: %v", err) |
| + } |
| + if f == nil || !f.Production || f.Actor != "paul@keymail.dev" || f.SetAt.IsZero() { |
| + t.Fatalf("flag = %+v, want production on, actor recorded, set_at stamped", f) |
| + } |
| + // Flip it off over the existing object — the CAS path. |
| + if err := SetProduction(ctx, s, priv, "bab", "kass", false, "paul@keymail.dev"); err != nil { |
| + t.Fatalf("SetProduction off: %v", err) |
| + } |
| + if f, _ = GetProduction(ctx, s, pub, "bab", "kass"); f == nil || f.Production { |
| + t.Fatalf("flag = %+v after flipping off, want production false", f) |
| + } |
| +} |
| + |
| +// The flag key lives beside channels/, never inside it: ChannelsPrefix |
| +// listings are parsed as pointers by the console and CLI. |
| +func TestProductionKeyIsOutsideChannels(t *testing.T) { |
| + if k := ProductionKey("bab", "kass"); k != "apps/bab/kass/production.json" { |
| + t.Fatalf("ProductionKey = %q", k) |
| + } |
| + if strings.HasPrefix(ProductionKey("bab", "kass"), ChannelsPrefix("bab", "kass")) { |
| + t.Fatal("production flag key sits under channels/ — pointer listings would try to parse it") |
| + } |
| +} |
| + |
| +// A scoped key must not be able to mint the flag: a wrapper carrying ANY |
| +// grant is refused before signature checking, and a wrapper signed by a key |
| +// other than the root fails signature checking. Together: root only. |
| +func TestProductionFlagRefusesScopedAndForeignKeys(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + // Build a validly-shaped flag payload signed by a DIFFERENT key. |
| + otherPub, otherPriv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _ = otherPub |
| + payload, _ := json.Marshal(ProductionFlag{Schema: 1, Account: "bab", App: "kass", |
| + Production: false, SetAt: timeNowTestHelper()}) |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + wrapper, _ := json.Marshal(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(otherPriv, []byte(b64))), |
| + }) |
| + if _, err := s.Put(ctx, ProductionKey("bab", "kass"), wrapper, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := GetProduction(ctx, s, pub, "bab", "kass"); err == nil { |
| + t.Fatal("a flag signed by a non-root key verified") |
| + } |
| + |
| + // A wrapper WITH a grant is refused outright — even a grant the root |
| + // really signed. Mint a real scoped key and have it sign the flag. |
| + seed, grantB64, err := MintKeyGrant(priv, "builder", []string{RungEdge}, maxGrantTTL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + scopedKey, err := ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sg, err := parseGrantBlob(grantB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wrapper, _ = json.Marshal(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(scopedKey, []byte(b64))), |
| + Grant: sg, |
| + }) |
| + if _, err := s.Put(ctx, ProductionKey("bab", "kass"), wrapper, etagOfKey(t, s, "bab", "kass")); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = GetProduction(ctx, s, pub, "bab", "kass") |
| + if err == nil || !strings.Contains(err.Error(), "root") { |
| + t.Fatalf("a grant-bearing flag wrapper verified (err=%v), want a root-only refusal", err) |
| + } |
| +} |
| + |
| +// etagOfKey reads the current etag of an app's flag so a test can CAS over it. |
| +func etagOfKey(t *testing.T, s Store, acct, app string) string { |
| + t.Helper() |
| + _, etag, err := s.Get(context.Background(), ProductionKey(acct, app)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return etag |
| +} |
| + |
| +func TestProductionFlagRefusesWrongApp(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + if err := SetProduction(ctx, s, priv, "bab", "kass", true, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Copy kass's flag to keymail's key — a cross-app replay must be refused |
| + // because the SIGNED payload names the app. |
| + raw, _, err := s.Get(ctx, ProductionKey("bab", "kass")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ProductionKey("bab", "keymail"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := GetProduction(ctx, s, pub, "bab", "keymail"); err == nil { |
| + t.Fatal("another app's flag verified under keymail's key") |
| + } |
| +} |
| +``` |
| + |
| +Add this small helper at the bottom of the test file (tests must not depend on wall-clock ordering): |
| + |
| +```go |
| +// timeNowTestHelper is a stamped, truncated now — what signProductionFlag |
| +// itself writes — for tests that hand-build payloads. |
| +func timeNowTestHelper() time.Time { return time.Now().UTC().Truncate(time.Second) } |
| +``` |
| + |
| +(and add `"time"` to the imports.) |
| + |
| +- [ ] **Step 2: Run tests, verify they fail** — `go test ./internal/release/ -run TestProductionFlag -count=1` (env from Global Constraints). Expected: compile errors (`undefined: ProductionKey` etc.). |
| + |
| +- [ ] **Step 3: Implement** (`internal/release/production.go`) |
| + |
| +```go |
| +// The per-app production flag — the console bake controls spec |
| +// (docs/superpowers/specs/2026-08-04-console-bake-controls-design.md, §2–§3). |
| +// |
| +// production: true means the supply-chain bake window (bake.go) applies to |
| +// this app's held channels; false — and MISSING, the default — means its |
| +// boxes adopt immediately. The object is root-signed in the pointer's own |
| +// signedWrapper envelope, and root-ONLY: a wrapper carrying a key grant is |
| +// refused before its signature is even checked, because a scoped key that |
| +// could flip the flag could skip the very window it bakes under. |
| +// |
| +// What verification here does NOT decide: whether a missing or unreadable |
| +// flag downgrades a box that once saw production: true. It never does — |
| +// that memory, the ratchet and the replay floor are the box's own |
| +// (bake.go's production memory), because anyone with bucket write but no |
| +// key can delete an object, and deletion must not be a downgrade (spec §4). |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| +) |
| + |
| +// ProductionFlag is the signed payload: one app's declaration that it is (or |
| +// is not) in production, and therefore whether its releases bake. |
| +type ProductionFlag struct { |
| + Schema int `json:"schema"` // 1 |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Production bool `json:"production"` |
| + // Actor is who flipped it — a console member's address. Recorded for the |
| + // same reason the pointer's audit trail exists: an invisible bypass is a |
| + // broken gate. |
| + Actor string `json:"actor,omitempty"` |
| + SetAt time.Time `json:"set_at"` |
| +} |
| + |
| +// ProductionKey is where an app's flag lives: beside channels/ and |
| +// releases/, deliberately NEVER under ChannelsPrefix — those listings are |
| +// parsed as channel pointers by the console and the CLI. |
| +func ProductionKey(acct, app string) string { |
| + return appPrefix(acct, app) + "production.json" |
| +} |
| + |
| +// SetProduction writes the flag, root key only, CAS over whatever is there |
| +// (a concurrent flip surfaces as ErrCASConflict — re-ask, never overwrite |
| +// blind). SetAt is stamped here; boxes use their OWN sighting for the |
| +// ratchet and this stamp only as a replay floor. |
| +func SetProduction(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, production bool, actor string) error { |
| + if len(priv) != ed25519.PrivateKeySize { |
| + return fmt.Errorf("set production: no usable release signing key") |
| + } |
| + if !ValidAccount(acct) { |
| + return fmt.Errorf("set production: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) { |
| + return fmt.Errorf("set production: invalid app %q", app) |
| + } |
| + key := ProductionKey(acct, app) |
| + _, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + etag = "" // first write: create-only |
| + } else if err != nil { |
| + return fmt.Errorf("set production: read %s: %w", key, err) |
| + } |
| + f := &ProductionFlag{ |
| + Schema: 1, Account: acct, App: app, Production: production, |
| + Actor: actor, SetAt: time.Now().UTC().Truncate(time.Second), |
| + } |
| + if err := validateProductionFlag(f, acct, app); err != nil { |
| + return fmt.Errorf("set production: %w", err) |
| + } |
| + payload, err := json.MarshalIndent(f, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + wrapper, err := json.MarshalIndent(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64))), |
| + }, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, wrapper, etag) |
| + return err |
| +} |
| + |
| +// GetProduction resolves an app's flag. A MISSING object is (nil, nil): the |
| +// default, not production — but never let a nil collapse the distinction the |
| +// spec draws: any OTHER failure (unreadable, unverifiable, misnamed) is the |
| +// error, and callers on the adopt path treat it as production=true, failing |
| +// toward baking. |
| +func GetProduction(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) (*ProductionFlag, error) { |
| + raw, _, err := s.Get(ctx, ProductionKey(acct, app)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return verifyProductionFlag(pub, raw, acct, app) |
| +} |
| + |
| +// verifyProductionFlag is verifyPointer's discipline for the flag: refuse a |
| +// grant outright, check the signature against the ROOT key alone, decode |
| +// after, validate the payload names the account/app whose key it sits at. |
| +func verifyProductionFlag(pub ed25519.PublicKey, wrapper []byte, wantAcct, wantApp string) (*ProductionFlag, error) { |
| + var sw signedWrapper |
| + if err := json.Unmarshal(wrapper, &sw); err != nil { |
| + return nil, fmt.Errorf("production flag wrapper: %v", err) |
| + } |
| + if sw.Grant != nil { |
| + return nil, fmt.Errorf("production flag %s/%s: signed by a scoped key — the flag takes the root key, because a delegate that could flip it could skip the window it bakes under", wantAcct, wantApp) |
| + } |
| + if len(pub) != ed25519.PublicKeySize { |
| + return nil, fmt.Errorf("production flag: no usable release public key") |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| + if err != nil { |
| + return nil, fmt.Errorf("production flag sig: %v", err) |
| + } |
| + if !ed25519.Verify(pub, []byte(sw.Payload), sig) { |
| + return nil, fmt.Errorf("production flag signature verification failed") |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| + if err != nil { |
| + return nil, fmt.Errorf("production flag payload: %v", err) |
| + } |
| + var f ProductionFlag |
| + if err := json.Unmarshal(payload, &f); err != nil { |
| + return nil, fmt.Errorf("production flag payload: %v", err) |
| + } |
| + if err := validateProductionFlag(&f, wantAcct, wantApp); err != nil { |
| + return nil, err |
| + } |
| + return &f, nil |
| +} |
| + |
| +func validateProductionFlag(f *ProductionFlag, wantAcct, wantApp string) error { |
| + if f.Schema != 1 { |
| + return fmt.Errorf("unsupported production flag schema %d", f.Schema) |
| + } |
| + if f.Account != wantAcct || f.App != wantApp { |
| + return fmt.Errorf("production flag names %s/%s, expected %s/%s", f.Account, f.App, wantAcct, wantApp) |
| + } |
| + if !ValidAccount(f.Account) || !ValidApp(f.App) { |
| + return fmt.Errorf("production flag has invalid account/app %s/%s", f.Account, f.App) |
| + } |
| + if f.SetAt.IsZero() { |
| + return fmt.Errorf("production flag has no set_at") |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests, verify pass** — `go test ./internal/release/ -run TestProduction -count=1`. Expected: PASS. Then the whole package: `go test ./internal/release/ -count=1`. |
| + |
| +- [ ] **Step 5: Commit** — `git add internal/release/production.go internal/release/production_test.go && git commit -m "release: the per-app production flag — root-signed, missing means not production"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 3: fast_track on the pointer, and the FastTrack verb (spec §3, §5) |
| + |
| +**Files:** |
| +- Modify: `internal/release/release.go` (ChannelPointer ~line 68, Promotion ~line 90, verifyPointer ~line 344, signPointer ~line 263, new verb after Rollback ~line 850) |
| +- Test: `internal/release/release_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `signedWrapper`, `signPointer(sg Signer, p *ChannelPointer)`, `verifyPointer`, `GetQualified`, `prependCapped`, `Rung`, `RootSigner`. |
| +- Produces: |
| + - `ChannelPointer.FastTrack bool` (json `fast_track,omitempty`) — Task 5's adopt and Task 6's views read it. |
| + - `Promotion.FastTrack bool` (json `fast_track,omitempty`). |
| + - `func FastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, actor string) (string, error)` — returns the version waived; no-op success if already fast-tracked. Task 7's handler calls it. |
| + |
| +- [ ] **Step 1: Write the failing tests** (append to `internal/release/release_test.go`; reuse its existing helpers — read the file's top for `testKeys` and whatever ship/promote helpers exist and follow suit) |
| + |
| +```go |
| +// Fast-track (2026-08-04 console bake controls spec §5): re-sign the head |
| +// with the bake waiver — same version, same history, one new audit entry — |
| +// and never from a scoped key, at mint or at verification. |
| +func TestFastTrackWaivesTheHeadInPlace(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "kass", "v1", "linux-amd64", []byte("b1")) |
| + shipRelease(t, s, "bab", "kass", "v2", "linux-amd64", []byte("b2")) |
| + if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + ver, err := FastTrack(ctx, s, priv, "bab", "kass", "stable", "paul@keymail.dev") |
| + if err != nil { |
| + t.Fatalf("FastTrack: %v", err) |
| + } |
| + if ver != "v2" { |
| + t.Fatalf("FastTrack waived %q, want the head v2", ver) |
| + } |
| + p, err := Channel(ctx, s, pub, "bab", "kass", "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !p.FastTrack || p.Version != "v2" { |
| + t.Fatalf("pointer = version %s fast_track %v, want v2 waived", p.Version, p.FastTrack) |
| + } |
| + // History survives — a fast-tracked head must stay rollbackable. |
| + if len(p.History) == 0 || p.History[0] != "v1" { |
| + t.Fatalf("history = %v, want v1 preserved", p.History) |
| + } |
| + // The waiver is recorded on the trail it waives. |
| + if len(p.Audit) == 0 || !p.Audit[0].FastTrack || p.Audit[0].Actor != "paul@keymail.dev" { |
| + t.Fatalf("audit[0] = %+v, want a fast_track entry naming the actor", p.Audit) |
| + } |
| + // Idempotent: a second fast-track is a no-op success. |
| + if _, err := FastTrack(ctx, s, priv, "bab", "kass", "stable", "paul@keymail.dev"); err != nil { |
| + t.Fatalf("repeated FastTrack: %v", err) |
| + } |
| + // The NEXT promotion starts clean — fast_track is per write, never |
| + // inherited (spec §5). |
| + shipRelease(t, s, "bab", "kass", "v3", "linux-amd64", []byte("b3")) |
| + if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v3"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, _ := Channel(ctx, s, pub, "bab", "kass", "stable"); p.FastTrack { |
| + t.Fatal("fast_track survived onto the next promotion") |
| + } |
| +} |
| + |
| +// Root only, both directions (mint AND verification, like the grant cap): |
| +// a scoped signer cannot SIGN a fast-tracked pointer, and a hand-built |
| +// grant-bearing wrapper whose payload carries fast_track does not VERIFY. |
| +func TestFastTrackIsRootOnly(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "kass", "v1", "linux-amd64", []byte("b1")) |
| + if err := Promote(ctx, s, priv, "bab", "kass", "edge", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + seed, grantB64, err := MintKeyGrant(priv, "builder", []string{RungEdge}, maxGrantTTL) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + scoped, err := ScopedSigner(key, grantB64, pub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Mint side: the scoped signer refuses the payload. |
| + if _, err := signPointer(scoped, &ChannelPointer{ |
| + App: "kass", Channel: "edge", Version: "v1", FastTrack: true, |
| + }); err == nil || !strings.Contains(err.Error(), "root") { |
| + t.Fatalf("a scoped signer signed a fast-tracked pointer (err=%v)", err) |
| + } |
| + // Verify side: build the wrapper by hand, exactly as a patched-out mint |
| + // check would, and watch verification refuse it. |
| + payload, _ := json.Marshal(ChannelPointer{Schema: 1, App: "kass", Channel: "edge", |
| + Version: "v1", FastTrack: true, PromotedAt: timeNowTestHelper()}) |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + sg, err := parseGrantBlob(grantB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wrapper, _ := json.Marshal(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b64))), |
| + Grant: sg, |
| + }) |
| + if _, err := verifyPointer(pub, wrapper, "kass", "edge"); err == nil || |
| + !strings.Contains(err.Error(), "root") { |
| + t.Fatalf("a scoped-signed fast_track pointer verified (err=%v)", err) |
| + } |
| +} |
| + |
| +// The wire change is additive both ways: a pre-change pointer still parses |
| +// (no fast_track key at all), and a fast-tracked pointer parses in a struct |
| +// that predates the field — the old binary simply keeps baking. |
| +func TestFastTrackIsAdditiveOnTheWire(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "bab", "kass", "v1", "linux-amd64", []byte("b1")) |
| + if err := Promote(ctx, s, priv, "bab", "kass", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := FastTrack(ctx, s, priv, "bab", "kass", "stable", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := GetQualified(ctx, s, ChannelKey("bab", "kass", "stable")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var sw signedWrapper |
| + if err := json.Unmarshal(raw, &sw); err != nil { |
| + t.Fatal(err) |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The old binary's shape: every pre-fast_track field, nothing else. |
| + var old struct { |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + } |
| + if err := json.Unmarshal(payload, &old); err != nil { |
| + t.Fatalf("an old struct shape failed to parse a fast-tracked payload: %v", err) |
| + } |
| + if old.Version != "v1" { |
| + t.Fatalf("old shape read version %q", old.Version) |
| + } |
| +} |
| +``` |
| + |
| +(If `release_test.go` lacks any imports these use — `strings`, `encoding/base64`, `encoding/json`, `crypto/ed25519` — add them. If `timeNowTestHelper` from Task 2 isn't visible because Task 2 hasn't merged, define the same helper locally; delete the duplicate at integration.) |
| + |
| +- [ ] **Step 2: Run, verify failure** — `go test ./internal/release/ -run TestFastTrack -count=1`. Expected: compile error (`unknown field FastTrack`). |
| + |
| +- [ ] **Step 3: Implement.** Four edits in `internal/release/release.go`: |
| + |
| +(a) `ChannelPointer` — after the `Audit` field: |
| + |
| +```go |
| + // FastTrack waives the supply-chain bake window for THIS version on THIS |
| + // channel (2026-08-04 console bake controls spec §5). Root-key-only at |
| + // mint AND at verification — a scoped key must never skip the window it |
| + // bakes under. Set per write and never inherited: the next promotion |
| + // starts clean. ADDITIVE: old pointers have no key; old binaries drop the |
| + // field and keep baking, the safe direction. |
| + FastTrack bool `json:"fast_track,omitempty"` |
| +``` |
| + |
| +(b) `Promotion` — after the `Hotfix` field: |
| + |
| +```go |
| + // FastTrack records that this write waived the bake window — same |
| + // reasoning as Hotfix: a bypass nobody can see is a broken gate. |
| + FastTrack bool `json:"fast_track,omitempty"` |
| +``` |
| + |
| +(c) `signPointer` — after the `sg.allows(...)` check: |
| + |
| +```go |
| + if p.FastTrack && sg.scope != nil { |
| + return nil, fmt.Errorf("fast-track is root-key-only — key grant %q cannot waive the bake window", sg.scope.Name) |
| + } |
| +``` |
| + |
| +(d) `verifyPointer` — after the `validatePointer` call and before the `if scope != nil` block: |
| + |
| +```go |
| + if p.FastTrack && sw.Grant != nil { |
| + return nil, fmt.Errorf("channel pointer %s/%s: fast_track is root-key-only — a scoped key cannot waive the bake window", p.App, p.Channel) |
| + } |
| +``` |
| + |
| +(e) The verb — after `Rollback` (follow its shape exactly): |
| + |
| +```go |
| +// FastTrack re-signs a channel's current pointer with the bake-window |
| +// waiver set — same version, same history, one new audit entry (the |
| +// Rollback precedent: a write that is not a promotion stays out of the |
| +// ladder). Root key only: the window defends subscribers against exactly |
| +// the keys that are not the root, so this takes priv directly and never a |
| +// Signer. A head already waived is a no-op success. Returns the version |
| +// waived either way. |
| +// |
| +// signPointer re-stamps PromotedAt, which is correct here: the bake clock |
| +// keys on the box's own first sighting of the VERSION (bake.go), which this |
| +// write does not change, and the console suppresses its bakes-until |
| +// estimate for a waived head. |
| +func FastTrack(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, actor string) (string, error) { |
| + sg := RootSigner(priv) |
| + if !ValidAccount(acct) { |
| + return "", fmt.Errorf("fast-track: invalid account %q", acct) |
| + } |
| + if !ValidApp(app) || !ValidChannel(channel) { |
| + return "", fmt.Errorf("fast-track: invalid app/channel %s/%s", app, channel) |
| + } |
| + key := ChannelKey(acct, app, channel) |
| + cur, etag, err := GetQualified(ctx, s, key) |
| + if err != nil { |
| + return "", fmt.Errorf("fast-track: %w", err) |
| + } |
| + old, err := verifyPointer(sg.verifyPub(), cur, app, channel) |
| + if err != nil { |
| + return "", fmt.Errorf("fast-track: existing pointer at %s: %w", key, err) |
| + } |
| + if old.FastTrack { |
| + return old.Version, nil |
| + } |
| + rung, _ := Rung(channel) |
| + next := &ChannelPointer{ |
| + App: app, Channel: channel, Version: old.Version, |
| + History: old.History, FastTrack: true, |
| + Audit: prependCapped(Promotion{ |
| + Version: old.Version, From: rung, To: rung, Actor: actor, |
| + FastTrack: true, At: time.Now().UTC().Truncate(time.Second), |
| + }, old.Audit, auditMax), |
| + } |
| + wrapper, err := signPointer(sg, next) |
| + if err != nil { |
| + return "", err |
| + } |
| + if _, err := s.Put(ctx, key, wrapper, etag); err != nil { |
| + return "", err |
| + } |
| + return old.Version, nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the package** — `go test ./internal/release/ -count=1`. PASS, including `laddercheck_test.go` and `golden_test.go` (the golden test pins root-signed bytes for pointers WITHOUT fast_track — `omitempty` keeps those byte-identical; if it fails, the field placement is wrong, not the golden file). |
| + |
| +- [ ] **Step 5: Commit** — `git add internal/release/release.go internal/release/release_test.go && git commit -m "release: fast_track waives the bake window — root-only at mint and at verification"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 4: the box's production memory and the ratchet (spec §4) |
| + |
| +**Files:** |
| +- Modify: `internal/release/bake.go` (FirstSeen struct ~line 70, LoadFirstSeen ~line 118; new section at the bottom) |
| +- Test: `internal/release/bake_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `ProductionFlag` (Task 2), `HoldFor`, `RungStable`, `clockSep`, `FirstSeen.Jitter`. |
| +- Produces (Task 5 calls these exactly): |
| + - `func (st *FirstSeen) ObserveProduction(acct, app string, flag *ProductionFlag, now time.Time) bool` — folds a VERIFIED flag into memory; nil flag is a no-op; returns changed (caller Saves). |
| + - `func (st *FirstSeen) ProductionBake(acct, app string, flagErr error, hasBakeableHead bool, holds map[string]time.Duration, now time.Time) bool` — whether the window applies to this app on this box right now. Call ObserveProduction FIRST. |
| + |
| +- [ ] **Step 1: Write the failing tests** (append to `internal/release/bake_test.go`) |
| + |
| +```go |
| +// The box-side production memory (2026-08-04 console bake controls spec §4). |
| +// Scenario table, because the security content IS the state machine: the |
| +// default is off; on is instant; off ratchets on the box's own clock; a |
| +// DELETED flag never downgrades a box that saw true; a stale replayed off |
| +// cannot roll back a newer on. |
| +func TestProductionMemoryRatchet(t *testing.T) { |
| + base := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) |
| + flag := func(prod bool, setAt time.Time) *ProductionFlag { |
| + return &ProductionFlag{Schema: 1, Account: "bab", App: "kass", |
| + Production: prod, SetAt: setAt} |
| + } |
| + newClock := func() *FirstSeen { |
| + st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json")) |
| + st.Jitter = 0 // determinism: eligibility is exactly firstSeen + hold |
| + return st |
| + } |
| + |
| + t.Run("default is off", func(t *testing.T) { |
| + st := newClock() |
| + if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("an app with no flag ever seen bakes") |
| + } |
| + }) |
| + |
| + t.Run("unverifiable flag fails toward baking", func(t *testing.T) { |
| + st := newClock() |
| + if !st.ProductionBake("bab", "kass", fmt.Errorf("sig"), true, DefaultHolds, base) { |
| + t.Fatal("a corrupt flag read as the default instead of production") |
| + } |
| + }) |
| + |
| + t.Run("on is instant, deletion is not a downgrade", func(t *testing.T) { |
| + st := newClock() |
| + if !st.ObserveProduction("bab", "kass", flag(true, base), base) { |
| + t.Fatal("first observation did not change the clock") |
| + } |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("production: true did not apply immediately") |
| + } |
| + // The object vanishes from the bucket (flag == nil every later pass): |
| + // the box holds its verdict. |
| + st.ObserveProduction("bab", "kass", nil, base.Add(time.Hour)) |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour)) { |
| + t.Fatal("deleting the flag object downgraded a box that saw true") |
| + } |
| + }) |
| + |
| + t.Run("off ratchets for the stable window", func(t *testing.T) { |
| + st := newClock() |
| + st.ObserveProduction("bab", "kass", flag(true, base), base) |
| + off := flag(false, base.Add(time.Hour)) |
| + st.ObserveProduction("bab", "kass", off, base.Add(2*time.Hour)) // box sees it an hour late |
| + hold := HoldFor(DefaultHolds, RungStable) |
| + // Still baking just inside the window, measured from the box's OWN |
| + // sighting (base+2h), not the flag's set_at (base+1h). |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold-time.Minute)) { |
| + t.Fatal("off was honored before the ratchet elapsed") |
| + } |
| + if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold+time.Minute)) { |
| + t.Fatal("off was not honored after the ratchet elapsed") |
| + } |
| + }) |
| + |
| + t.Run("no bakeable head skips the ratchet", func(t *testing.T) { |
| + st := newClock() |
| + st.ObserveProduction("bab", "kass", flag(true, base), base) |
| + st.ObserveProduction("bab", "kass", flag(false, base.Add(time.Hour)), base.Add(time.Hour)) |
| + if st.ProductionBake("bab", "kass", nil, false, DefaultHolds, base.Add(time.Hour)) { |
| + t.Fatal("carve-out: nothing at a held head, yet off still ratcheted") |
| + } |
| + }) |
| + |
| + t.Run("a stale off cannot be replayed over a newer on", func(t *testing.T) { |
| + st := newClock() |
| + staleOff := flag(false, base) |
| + st.ObserveProduction("bab", "kass", staleOff, base) |
| + st.ObserveProduction("bab", "kass", flag(true, base.Add(time.Hour)), base.Add(time.Hour)) |
| + // Attacker re-writes the old signed off object; the box refuses to |
| + // regress below the newest set_at it has honored. |
| + if st.ObserveProduction("bab", "kass", staleOff, base.Add(2*time.Hour)) { |
| + t.Fatal("a replayed stale flag changed the clock") |
| + } |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(100*time.Hour)) { |
| + t.Fatal("a replayed stale off eventually turned the bake off") |
| + } |
| + }) |
| + |
| + t.Run("memory survives a save/load round trip", func(t *testing.T) { |
| + st := newClock() |
| + st.ObserveProduction("bab", "kass", flag(true, base), base) |
| + if err := st.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st2 := LoadFirstSeen(st.path) |
| + if !st2.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("production memory did not survive persistence") |
| + } |
| + }) |
| +} |
| +``` |
| + |
| +(`filepath`, `fmt` may need importing; `st.path` is unexported but the test is in-package.) |
| + |
| +- [ ] **Step 2: Run, verify failure** — `go test ./internal/release/ -run TestProductionMemoryRatchet -count=1`. Expected: compile errors. |
| + |
| +- [ ] **Step 3: Implement** — three edits in `internal/release/bake.go`: |
| + |
| +(a) `FirstSeen` struct, after `Channels`: |
| + |
| +```go |
| + // Production is the per-app production-flag memory (2026-08-04 console |
| + // bake controls spec §4), keyed "<acct>|<app>" with clockSep — the same |
| + // collision argument as channelClockKey. ADDITIVE: old clock files have |
| + // no key, old binaries drop this one. |
| + Production map[string]*prodMemory `json:"production,omitempty"` |
| +``` |
| + |
| +(b) `LoadFirstSeen`, next to the `st.Channels == nil` guard: |
| + |
| +```go |
| + if st.Production == nil { |
| + st.Production = map[string]*prodMemory{} |
| + } |
| +``` |
| + |
| +(c) New section at the bottom of the file: |
| + |
| +```go |
| +// ---- production memory ------------------------------------------------------ |
| + |
| +// prodMemory is one app's production-flag memory on this box. It exists to |
| +// hold two properties no bucket read can: DELETION IS NOT A DOWNGRADE (once |
| +// a verified production: true is seen, absence never clears it — anyone |
| +// with bucket write but no key can delete an object), and A STALE FLAG |
| +// CANNOT BE REPLAYED (an off from a past era, re-written by someone with |
| +// bucket access alone, must not roll back a newer on). |
| +type prodMemory struct { |
| + // WasProduction latches on the first verified production: true. |
| + WasProduction bool `json:"was_production,omitempty"` |
| + // NewestSetAt is the replay floor: the newest set_at this box has |
| + // honored. Anything at or below it is old news. |
| + NewestSetAt time.Time `json:"newest_set_at,omitempty"` |
| + // OffSetAt identifies the current off-flag edition; OffFirstSeen is when |
| + // THIS BOX first saw it — the ratchet clock, the box's own observation, |
| + // never the flag's timestamp (same argument as the bake clock itself). |
| + OffSetAt time.Time `json:"off_set_at,omitempty"` |
| + OffFirstSeen time.Time `json:"off_first_seen,omitempty"` |
| +} |
| + |
| +func prodKey(acct, app string) string { return acct + clockSep + app } |
| + |
| +// ObserveProduction folds one VERIFIED flag read into this box's memory. |
| +// nil (no object in the bucket) changes nothing — absence is not evidence. |
| +// Returns whether anything changed; the caller persists via Save. Never |
| +// call it with a flag that failed verification: like Observe, the memory |
| +// only ever ticks for signed documents. |
| +func (st *FirstSeen) ObserveProduction(acct, app string, flag *ProductionFlag, now time.Time) bool { |
| + if flag == nil { |
| + return false |
| + } |
| + key := prodKey(acct, app) |
| + mem := st.Production[key] |
| + if mem == nil { |
| + mem = &prodMemory{} |
| + } |
| + // The replay floor. Equal set_at is the object already recorded — the |
| + // every-pass re-read — so nothing changes; older is a replay and is |
| + // ignored outright. |
| + if !flag.SetAt.After(mem.NewestSetAt) { |
| + return false |
| + } |
| + mem.NewestSetAt = flag.SetAt |
| + if flag.Production { |
| + mem.WasProduction = true |
| + mem.OffSetAt, mem.OffFirstSeen = time.Time{}, time.Time{} |
| + } else { |
| + mem.OffSetAt = flag.SetAt |
| + mem.OffFirstSeen = now.UTC().Truncate(time.Second) |
| + } |
| + st.Production[key] = mem |
| + return true |
| +} |
| + |
| +// ProductionBake is the adopt pass's one question: does the supply-chain |
| +// window apply to this app on this box right now? Call ObserveProduction |
| +// first — this reads memory, plus flagErr, which is non-nil when a flag |
| +// object EXISTS but would not read or verify: that fails toward baking (an |
| +// attacker who can corrupt but not sign must not shorten anything). |
| +// hasBakeableHead is the spec §4 carve-out: with no held channel head |
| +// resolved this pass, an off-flag needs no ratchet — there is nothing at a |
| +// baked head to protect. |
| +func (st *FirstSeen) ProductionBake(acct, app string, flagErr error, hasBakeableHead bool, holds map[string]time.Duration, now time.Time) bool { |
| + if flagErr != nil { |
| + return true |
| + } |
| + mem := st.Production[prodKey(acct, app)] |
| + if mem == nil { |
| + return false // never declared production — the default |
| + } |
| + if mem.OffSetAt.IsZero() { |
| + // The newest verified word is production: true — or the flag has |
| + // vanished since, which never downgrades (WasProduction latches). |
| + return mem.WasProduction |
| + } |
| + // The newest verified word is production: false. |
| + if !mem.WasProduction { |
| + return false // off, and never was on: nothing to ratchet |
| + } |
| + if !hasBakeableHead { |
| + return false // the carve-out |
| + } |
| + // The ratchet: off is honored only after the stable window has elapsed |
| + // on this box's own sighting of the off-flag, jittered like any hold — |
| + // the flag defends itself with the mechanism it controls. |
| + d := HoldFor(holds, RungStable) |
| + eligible := mem.OffFirstSeen.Add(time.Duration(float64(d) * (1 + st.Jitter))) |
| + return now.Before(eligible) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the package** — `go test ./internal/release/ -count=1`. PASS. |
| + |
| +- [ ] **Step 5: Commit** — `git add internal/release/bake.go internal/release/bake_test.go && git commit -m "release: production memory on the bake clock — off ratchets, deletion never downgrades, replays refused"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 5: adopt wiring — the `ignore` parameter finally gets its caller (spec §6) — OPUS |
| + |
| +**Files:** |
| +- Modify: `internal/release/adopt.go` (PassFilter ~lines 113–253, adoptHost ~lines 303–376) |
| +- Test: `internal/release/adopt_test.go` (append; also UPDATE `TestPassStableHeldByBakeWindow` ~line 187) |
| + |
| +**Interfaces:** |
| +- Consumes: `GetProduction`, `ObserveProduction`, `ProductionBake` (Tasks 2/4), `ChannelPointer.FastTrack` (Task 3), `HoldFor`, `Hold`. |
| +- Produces: `adoptHost` gains a trailing `ignore bool` parameter. No exported surface changes. |
| + |
| +**Behavior change to own explicitly:** with the flag defaulting to off, a beta/stable head with no production flag installs immediately. `TestPassStableHeldByBakeWindow` currently pins the old behavior and MUST be updated (not deleted): it becomes the production-on case. |
| + |
| +- [ ] **Step 1: Update the existing test and add the new ones** (in `internal/release/adopt_test.go`) |
| + |
| +Rewrite `TestPassStableHeldByBakeWindow` (keep the name — the property survives, it just needs the flag on now): |
| + |
| +```go |
| +func TestPassStableHeldByBakeWindow(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + // The app is DECLARED production (2026-08-04 spec §2): the window is no |
| + // longer unconditional — it is what the flag buys. |
| + if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil { |
| + t.Fatalf("SetProduction: %v", err) |
| + } |
| + addRoute(t, reg, "h.example.com", "myapp", "", false) // "" = stable |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + // A brand-new stable head is inside its 72h supply-chain window: the |
| + // clock started this pass, nothing installs. |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "" { |
| + t.Fatalf("live = %q, want unmoved (bake hold)", v) |
| + } |
| + // The pass persisted the first sighting so the window is measured from |
| + // the box's own clock. |
| + st := LoadFirstSeen(FirstSeenPath(opt)) |
| + if _, ok := st.EligibleAt("bab", "myapp", "stable", "v1", DefaultHolds); !ok { |
| + t.Fatalf("Pass did not tick the first-seen clock") |
| + } |
| +} |
| +``` |
| + |
| +Append the new tests: |
| + |
| +```go |
| +// The default: no flag, no window (2026-08-04 spec §2). The whole fleet |
| +// runs on canary/* today, so nothing live loses protection — and a |
| +// rapid-iteration app's stable is just another channel. |
| +func TestPassNoProductionFlagInstallsImmediately(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, "h.example.com", "myapp", "", false) |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want v1 — no production flag means no window", v) |
| + } |
| +} |
| + |
| +// Fast-track: a production app's held head installs the moment its pointer |
| +// carries the root-signed waiver (spec §5). |
| +func TestPassFastTrackSkipsTheWindow(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, "h.example.com", "myapp", "", false) |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "" { |
| + t.Fatalf("live = %q, want held (production, no waiver yet)", v) |
| + } |
| + if _, err := FastTrack(ctx, s, priv, "bab", "myapp", "stable", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want v1 — the waiver did not skip the window", v) |
| + } |
| +} |
| + |
| +// A production app whose flag OBJECT rots (exists, won't verify) keeps |
| +// baking — an attacker who can corrupt but not sign shortens nothing. |
| +func TestPassCorruptProductionFlagFailsTowardBaking(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ProductionKey("bab", "myapp"), []byte("not a wrapper"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, "h.example.com", "myapp", "", false) |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "" { |
| + t.Fatalf("live = %q, want held — a corrupt flag must read as production", v) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify the new tests fail** — `go test ./internal/release/ -run 'TestPassNoProductionFlag|TestPassFastTrack|TestPassCorruptProduction' -count=1`. Expected: FAIL (`TestPassNoProductionFlag` fails because today the head is held; the others fail or pass vacuously — confirm at least one real failure before implementing). |
| + |
| +- [ ] **Step 3: Implement in `PassFilter`.** After the pointer-resolution loop and BEFORE the bake-clock tick block, insert: |
| + |
| +```go |
| + // Resolve each app's production flag once per pass — but only for apps |
| + // with a HELD head resolved this pass (spec §6). The 2-3s canary fast |
| + // loop filters to canary/* routes, which have no hold, so it never pays |
| + // this read; the hourly pass pays one read per held app. |
| + type acctApp2 struct{ acct, app string } |
| + type prodState struct { |
| + flag *ProductionFlag |
| + err error // non-nil: object exists but would not read or verify |
| + } |
| + bakeable := map[acctApp2]bool{} |
| + for k, ptr := range pointers { |
| + if ptr != nil && HoldFor(DefaultHolds, k.channel) > 0 { |
| + bakeable[acctApp2{k.acct, k.app}] = true |
| + } |
| + } |
| + prod := map[acctApp2]prodState{} |
| + for ak := range bakeable { |
| + f, err := GetProduction(ctx, s, pub, ak.acct, ak.app) |
| + prod[ak] = prodState{flag: f, err: err} |
| + } |
| +``` |
| + |
| +NOTE: `PassFilter` already declares a local `type acctApp struct{ acct, app string }` for the migration cache — REUSE that type instead of declaring `acctApp2` (the snippet names it `acctApp2` only because the plan cannot see your merge; in the file, use the existing `acctApp`, moving its declaration up if needed so both uses see it). |
| + |
| +In the bake-clock tick block (`for k, ptr := range pointers { ... }`), add after that loop, before `if changed {`: |
| + |
| +```go |
| + for ak, ps := range prod { |
| + if ps.err == nil && bake.ObserveProduction(ak.acct, ak.app, ps.flag, now) { |
| + changed = true |
| + } |
| + } |
| +``` |
| + |
| +Then compute `ignore` at both `adoptHost` call sites (awake and hibernating). Replace each call with: |
| + |
| +```go |
| + ak := acctApp{RouteAccount(rt), rt.App} |
| + ignore := !bake.ProductionBake(ak.acct, ak.app, prod[ak].err, bakeable[ak], DefaultHolds, now) |
| + adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg.nag, ignore) |
| +``` |
| + |
| +(hibernating site passes `nil` for restart, as today.) |
| + |
| +- [ ] **Step 4: Implement in `adoptHost`.** Change the signature's tail from `now time.Time, nag io.Writer)` to `now time.Time, nag io.Writer, ignore bool)`, document the parameter in the function comment (`ignore is the app-level bake verdict from ProductionBake — the production flag's doing, never a publisher field`), and change the Hold call to: |
| + |
| +```go |
| + if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, DefaultHolds, ignore || ptr.FastTrack, now); hold != "" { |
| +``` |
| + |
| +- [ ] **Step 5: Run the whole package** — `go test ./internal/release/ -count=1`. PASS, including the updated `TestPassStableHeldByBakeWindow` and everything in `laddercheck_test.go`. |
| + |
| +- [ ] **Step 6: Commit** — `git add internal/release/adopt.go internal/release/adopt_test.go && git commit -m "release: adopt honors the production flag and fast_track — Hold's ignore finally has its caller"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 6: console view plumbing (spec §7 data) |
| + |
| +**Files:** |
| +- Modify: `internal/console/view.go` (AppView ~line 60, ChannelView ~line 126, Assemble ~lines 184–258, assembleChannels ~lines 290–335), `internal/console/ladder.go` (LadderChannel ~line 51, assembleLadder ~line 124) |
| +- Test: `internal/console/view_test.go` if it exists, else assert through `internal/console/ladder_test.go` in Task 8's page tests (view fields are exercised there; this task still needs `go build` + existing tests green) |
| + |
| +**Interfaces:** |
| +- Consumes: `release.GetProduction`, `release.HoldFor`, `release.DefaultHolds`, `release.RungStable`, `release.Rung`, `prodWiredCanaryHosts` (ladder.go), `ChannelView`, `AppView`. |
| +- Produces (Task 7/8 read these exactly): |
| + - `type ProductionView struct { On, TurningOff bool; EffectiveAt, SetAt time.Time; Nudge bool; Err string }` in view.go |
| + - `AppView.Production ProductionView` |
| + - `ChannelView.FastTrack bool` |
| + - `LadderChannel.FastTrack bool` and `LadderChannel.BakedUntil time.Time` |
| + |
| +- [ ] **Step 1: Add the types.** In `view.go`, add to `ChannelView` after `Hotfix`: |
| + |
| +```go |
| + // FastTrack is the pointer's bake waiver (release.ChannelPointer) — the |
| + // badge is permanent, like the hotfix badge. |
| + FastTrack bool |
| +``` |
| + |
| +Add after the `ChannelView` type: |
| + |
| +```go |
| +// ProductionView is the app's production-flag state as the console shows it |
| +// (2026-08-04 console bake controls spec §7). |
| +type ProductionView struct { |
| + // On is the flag as the bucket has it; the default (no object) is off. |
| + On bool |
| + // TurningOff marks an off-flag younger than the stable window while the |
| + // app still has held channels: hosts may still be ratcheting. |
| + // EffectiveAt is the ~estimate shown (set_at + the stable hold) — each |
| + // box's own clock and jitter govern, exactly as BakedUntil is only ever |
| + // an approximation. |
| + TurningOff bool |
| + EffectiveAt time.Time |
| + SetAt time.Time |
| + // Nudge: the app looks production-wired while bake is off. Detection |
| + // recommends; it never flips the flag. |
| + Nudge bool |
| + // Err is a flag object that exists but will not verify — shown, and |
| + // boxes treat it as production (they keep baking). |
| + Err string |
| +} |
| +``` |
| + |
| +Add `Production ProductionView` to `AppView` (after `Ladder`). |
| + |
| +In `ladder.go`, add to `LadderChannel` after `Hotfix`: |
| + |
| +```go |
| + // FastTrack and BakedUntil carry the channel's waiver and its ~hold |
| + // estimate into the rung row, where the fast-track control lives. |
| + FastTrack bool |
| + BakedUntil time.Time |
| +``` |
| + |
| +- [ ] **Step 2: Wire assembly.** In `assembleChannels`, in the ChannelView build, add `FastTrack: p.FastTrack` to the `cv := ChannelView{...}` literal. |
| + |
| +In `Assemble`'s per-app loop, replace the `out = append(out, AppView{...})` block with: |
| + |
| +```go |
| + pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now) |
| + if pv.Err == "" && !pv.On { |
| + // No production flag (or off): nothing bakes, so no estimate. An |
| + // Err keeps the estimate — boxes fail toward baking. |
| + for i := range channels { |
| + channels[i].BakedUntil = time.Time{} |
| + } |
| + } |
| + for i := range channels { |
| + if channels[i].FastTrack { |
| + channels[i].BakedUntil = time.Time{} // waived — nothing to wait out |
| + } |
| + } |
| + out = append(out, AppView{ |
| + App: app, |
| + Routes: byApp[app], // List orders by host already |
| + Domains: domainsByApp[app], |
| + Channels: channels, |
| + Canaries: assembleCanaries(channels, byApp[app]), |
| + Ladder: assembleLadder(channels, byApp[app], now), |
| + Production: pv, |
| + RouteAccount: mismatch, |
| + }) |
| +``` |
| + |
| +Add the helper (bottom of view.go): |
| + |
| +```go |
| +// assembleProduction reads the app's flag and derives the console's view of |
| +// it: on/off, the turning-off ratchet estimate, and the prod-wiring nudge. |
| +func assembleProduction(ctx context.Context, s release.Store, pub ed25519.PublicKey, acct, app string, channels []ChannelView, routes []RouteView, now time.Time) ProductionView { |
| + pv := ProductionView{} |
| + flag, err := release.GetProduction(ctx, s, pub, acct, app) |
| + switch { |
| + case err != nil: |
| + pv.Err = err.Error() |
| + pv.On = true // what the boxes will do: fail toward baking |
| + return pv |
| + case flag != nil: |
| + pv.On = flag.Production |
| + pv.SetAt = flag.SetAt |
| + } |
| + held := false |
| + for _, cv := range channels { |
| + if cv.Err == "" && release.HoldFor(release.DefaultHolds, cv.Name) > 0 { |
| + held = true |
| + break |
| + } |
| + } |
| + if flag != nil && !flag.Production && held { |
| + hold := release.HoldFor(release.DefaultHolds, release.RungStable) |
| + if until := flag.SetAt.Add(hold); now.Before(until) { |
| + pv.TurningOff, pv.EffectiveAt = true, until |
| + } |
| + } |
| + if !pv.On { |
| + stableRoute := false |
| + for _, rt := range routes { |
| + if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable { |
| + stableRoute = true |
| + break |
| + } |
| + } |
| + pv.Nudge = stableRoute || len(prodWiredCanaryHosts(routes)) > 0 |
| + } |
| + return pv |
| +} |
| +``` |
| + |
| +In `assembleLadder` (ladder.go), the `entries[rung] = append(...)` literal for readable pointers gains `FastTrack: cv.FastTrack, BakedUntil: cv.BakedUntil,`. |
| + |
| +- [ ] **Step 3: Build and run existing console tests** — `go build ./... && go test ./internal/console/ -count=1` (env from Global Constraints). PASS — existing pages must render unchanged except: any existing test asserting `BakedUntil`/"bakes until" text for an app with NO production flag will now see it empty. `grep -rn "BakedUntil\|bakes until" internal/console/` — update any such assertion to seed the flag on first (`release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, app, true, "test")`) and note in the test why. |
| + |
| +- [ ] **Step 4: Commit** — `git add internal/console/view.go internal/console/ladder.go internal/console/*_test.go && git commit -m "console: production flag, fast-track and bake state plumbed into the app view"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 7: console handlers — flag toggle, fast-track, promote checkbox (spec §7) — OPUS |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (route table ~line 231, handlePromote ~line 1305, new handlers after handleRollback ~line 1440) |
| +- Test: create `internal/console/bakecontrols_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `s.accountApp`, `s.requireFreshAuth`, `s.audit`, `s.setFlash`, `RoleOwner`, `release.SetProduction`, `release.FastTrack`, `release.GetProduction`, `release.ValidChannel`; test harness: `newConsoleFixture`, `signInPaul`, `sessionToken`, `postForm`, `postGated`, `completeStepUp`, `get`, `fx.u`, `fx.rel`, `fx.priv`, `fx.st`, `fx.setUser`, `newBrowser`, `signUp`, `OpsAccountID`, `RoleMember` (see ladder_test.go for usage). |
| +- Produces: `POST /a/{sqid}/apps/{app}/production` (form: `csrf`, `production` = `"on"`/`"off"`), `POST /a/{sqid}/apps/{app}/fasttrack` (form: `csrf`, `channel`), and `enable_bake=1` honored on `POST .../promote`. Task 8's templates post to these. |
| + |
| +**Gate order is load-bearing and reviewed against CLAUDE.md:** session → CSRF → owner (byte-identical `http.NotFound`) → `requireFreshAuth` LAST. Turning ON and the promote checkbox take any member (the safe direction is cheap); turning OFF and fast-track take owner + passkey. |
| + |
| +- [ ] **Step 1: Write the failing tests** (`internal/console/bakecontrols_test.go`) |
| + |
| +```go |
| +package console |
| + |
| +// The bake controls (2026-08-04 console bake controls spec §7). Properties |
| +// in the order they'd hurt if lost: OFF cannot happen without owner + |
| +// passkey (and a member probing it gets the byte-identical 404); ON is any |
| +// member's, instantly; fast-track is owner + passkey and lands on the |
| +// pointer; the stable-promote checkbox flips the flag in the same confirmed |
| +// action. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func productionFlag(t *testing.T, fx *consoleFixture, app string) *release.ProductionFlag { |
| + t.Helper() |
| + f, err := release.GetProduction(context.Background(), fx.rel, fx.priv.Public().(ed25519.PublicKey), release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return f |
| +} |
| + |
| +// NOTE for the implementer: if the fixture exposes the public key directly |
| +// (check consoleFixture's fields — many tests use fx.pub or similar), use |
| +// that instead of deriving from fx.priv; adjust the import of crypto/ed25519 |
| +// accordingly. |
| + |
| +func TestProductionOnIsAnyMembers(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-bake") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"on"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("member turning bake ON = %d, want a plain redirect (no step-up)", status) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatalf("flag = %+v, want production on", f) |
| + } |
| +} |
| + |
| +func TestProductionOffIsOwnerPasskeyGated(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + // Seed on, as any member could. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Without the passkey the flag does not move. |
| + status, loc, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"off"}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("un-stepped-up off = %d -> %q, want a redirect into Keymail", status, loc) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatal("the flag flipped off with no passkey confirmation") |
| + } |
| + // With it, the held action resumes. |
| + if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| + t.Fatalf("resumed off = %d:\n%s", status, body) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || f.Production { |
| + t.Fatal("the flag did not flip off after confirmation") |
| + } |
| +} |
| + |
| +// A MEMBER probing the off direction meets the byte-identical 404 — before |
| +// any step-up redirect could confirm the account exists. |
| +func TestProductionOffMemberGets404(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-bake2") |
| + csrf := sessionToken(t, fx, c) |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"off"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member turning bake OFF = %d, want 404", status) |
| + } |
| +} |
| + |
| +func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + // The fixture's stable already points at v1; waive it as the owner with |
| + // the full passkey round trip. |
| + postGated(t, fx, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + p := fx.channelVersion(t, "kass", "stable") |
| + if !p.FastTrack { |
| + t.Fatalf("pointer = %+v, want fast_track set", p) |
| + } |
| + if len(p.Audit) == 0 || !p.Audit[0].FastTrack || p.Audit[0].Actor != "paul@keymail.dev" { |
| + t.Fatalf("audit[0] = %+v, want the waiver recorded with its actor", p.Audit) |
| + } |
| +} |
| + |
| +// The stable-promote prompt (spec §7): enable_bake rides the same |
| +// passkey-confirmed promote, and flips the flag on only when it is off. |
| +func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "beta", "v3") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}}) |
| + if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" { |
| + t.Fatalf("stable = %s, want v3", p.Version) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatalf("flag = %+v after enable_bake promote, want production on", f) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify failure** — `go test ./internal/console/ -run 'TestProduction|TestFastTrackIs|TestPromoteEnableBake' -count=1`. Expected: 404s where routes don't exist yet (real failures, not compile errors, once imports are fixed against the fixture's actual field names — check `consoleFixture` in the harness file, e.g. `authcli_test.go` or wherever `newConsoleFixture` lives, and adjust `fx.fk.srv.URL` / pub-key access to match the fixture). |
| + |
| +- [ ] **Step 3: Register routes** (server.go route table, after the rollback line): |
| + |
| +```go |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| +``` |
| + |
| +- [ ] **Step 4: Implement the handlers** (after `handleRollback`): |
| + |
| +```go |
| +// handleProduction flips the app's production flag (2026-08-04 console bake |
| +// controls spec §7). ON is any member's to do, instantly — the safe |
| +// direction stays cheap. OFF removes a supply-chain guard from every |
| +// subscriber of the app, so it is the owner's, behind the passkey, and the |
| +// fleet ratchets it in on each box's own clock. Gate order is the mandated |
| +// one: session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. |
| +func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + } |
| + var on bool |
| + switch r.FormValue("production") { |
| + case "on": |
| + on = true |
| + case "off": |
| + on = false |
| + default: |
| + back("err", "Invalid production value.") |
| + return |
| + } |
| + if !on { |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, on, addr); err != nil { |
| + log.Printf("console: production %s -> %v: %v", app, on, err) |
| + back("err", "Could not update the production flag: "+err.Error()) |
| + return |
| + } |
| + if on { |
| + s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on)") |
| + back("ok", "Supply-chain bake enabled — beta and stable now hold before hosts adopt.") |
| + } else { |
| + s.audit(acct, app, addr, "production", "disabled the supply-chain bake (production: off)") |
| + back("ok", "Supply-chain bake turning off — each host honors it after the stable window (~72h) on its own clock.") |
| + } |
| +} |
| + |
| +// handleFastTrack waives the bake window for one channel's CURRENT head — |
| +// owner + passkey, always: this is the loud instant path past the window |
| +// (spec §5, §9), and the pointer wears the badge forever. |
| +func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + } |
| + channel := strings.TrimSpace(r.FormValue("channel")) |
| + if !release.ValidChannel(channel) { |
| + back("err", "Invalid channel name.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr) |
| + if err != nil { |
| + log.Printf("console: fast-track %s %s: %v", app, channel, err) |
| + back("err", "Fast-track failed: "+err.Error()) |
| + return |
| + } |
| + s.audit(acct, app, addr, "fast-track", "waived the bake window for "+ver+" on "+channel) |
| + back("ok", "Fast-tracked "+ver+" on "+channel+" — hosts adopt on their next pass.") |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: The promote checkbox.** In `handlePromote`, after the `s.audit(...)` line and the `msg := ...` construction, insert (before `back("ok", msg)`): |
| + |
| +```go |
| + // The spec §7 prompt: promote-to-stable asked "is this app in production |
| + // now?" and the human said yes — in the same passkey-confirmed action. |
| + // Turning ON is any member's anyway, so no extra gate; flips only when |
| + // the flag is actually off, so a stray field on a repeat is a no-op. |
| + if r.FormValue("enable_bake") == "1" { |
| + if flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app); ferr == nil && (flag == nil || !flag.Production) { |
| + if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr); err != nil { |
| + log.Printf("console: enable_bake %s: %v", app, err) |
| + } else { |
| + s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on, at stable promote)") |
| + msg += " Supply-chain bake enabled." |
| + } |
| + } |
| + } |
| +``` |
| + |
| +- [ ] **Step 6: Run** — `go test ./internal/console/ -count=1`. PASS. |
| + |
| +- [ ] **Step 7: Commit** — `git add internal/console/server.go internal/console/bakecontrols_test.go && git commit -m "console: bake controls — on for members, off and fast-track behind owner + passkey"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 8: templates — status line, controls, badges, checkbox, nudge (spec §7 UI) |
| + |
| +**Files:** |
| +- Modify: `internal/console/templates/ladder.html` |
| +- Test: `internal/console/ladder_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `AppView.Production` (`.Detail.Production`), `LadderChannel.FastTrack`/`.BakedUntil`, page fields `$.CanPromote` (signing key present), `$.IsOwner`, `$.CSRF`, `$.Base`, template helpers `fmtTime`, `icon` (vendored Lucide names only — check `internal/console/icons*.go` for which names exist; `shield`, `hourglass`, `triangle-alert`, `zap` are safe bets, verify before use and pick existing ones). |
| +- Produces: markup the Task 7 handlers receive. |
| + |
| +- [ ] **Step 1: Write the failing tests** (append to `ladder_test.go`) |
| + |
| +```go |
| +// The bake status line and its controls (spec §7): off-by-default says so; |
| +// an owner gets the turn-on/turn-off controls; a held channel row offers |
| +// the owner fast-track; the stable promote form carries the enable_bake |
| +// prompt while the flag is off. |
| +func TestLadderShowsBakeStateAndControls(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "beta", "v3") |
| + c := signInPaul(t, fx) |
| + |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "Supply-chain bake") { |
| + t.Fatalf("no bake status line:\n%s", body) |
| + } |
| + if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) { |
| + t.Fatal("owner is offered no production toggle") |
| + } |
| + // Off by default, and the stable promote form asks the question. |
| + if !strings.Contains(body, `name="enable_bake"`) { |
| + t.Fatal("the stable promote form does not carry the enable_bake prompt") |
| + } |
| + |
| + // Flip on; the held beta head now shows its window and the fast-track |
| + // control appears on it. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, `action="/a/bab/apps/kass/fasttrack"`) { |
| + t.Fatalf("no fast-track control on a held head:\n%s", rungBlock(t, body, "beta")) |
| + } |
| + if strings.Contains(body, `name="enable_bake"`) { |
| + t.Fatal("the enable_bake prompt survived the flag turning on") |
| + } |
| + |
| + // Waive it; the badge is permanent and the control is gone. |
| + if _, err := release.FastTrack(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", "beta", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/kass")) |
| + b := rungBlock(t, body, "beta") |
| + if !strings.Contains(b, "fast-tracked") { |
| + t.Errorf("no permanent fast-tracked badge:\n%s", b) |
| + } |
| + if strings.Contains(b, `action="/a/bab/apps/kass/fasttrack"`) { |
| + t.Error("a waived head still offers the fast-track control") |
| + } |
| +} |
| + |
| +// A member sees state, never the owner-only controls (off / fast-track) — |
| +// but IS offered turn-on, the safe direction. |
| +func TestLadderBakeControlsMemberView(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-baketpl") |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "Supply-chain bake") { |
| + t.Fatal("member sees no bake status") |
| + } |
| + if !strings.Contains(body, `name="production" value="on"`) { |
| + t.Error("member is not offered the turn-on control") |
| + } |
| + if strings.Contains(body, `name="production" value="off"`) { |
| + t.Error("member is offered the owner-only turn-off control") |
| + } |
| + if strings.Contains(body, `/fasttrack"`) { |
| + t.Error("member is offered the owner-only fast-track control") |
| + } |
| +} |
| + |
| +// The prod-wiring nudge: a stable route with bake off gets the persistent |
| +// recommendation line. |
| +func TestLadderNudgesProdWiredBakeOff(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + // The fixture's kass.carlosapp.dev is on stable and the flag is off (the |
| + // default): that IS the nudge condition. |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "adopts releases immediately") { |
| + t.Fatalf("no prod-wiring nudge:\n%s", body) |
| + } |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "adopts releases immediately") { |
| + t.Fatal("the nudge survived the flag turning on") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify failure** — `go test ./internal/console/ -run TestLadder -count=1`. New tests FAIL; old ladder tests still PASS. |
| + |
| +- [ ] **Step 3: Edit `ladder.html`.** Insert after the intro `<p class="small">` (line 5), a bake block: |
| + |
| +```html |
| +{{with .Detail.Production}} |
| +<div class="bake"> |
| + {{if .Err}} |
| + <p class="error">Production flag unreadable: {{.Err}} — hosts treat this app as in production and keep baking.</p> |
| + {{else if .On}} |
| + <p class="small">Supply-chain bake: <strong>on</strong> — beta holds 24h and stable 72h on each host before it adopts. |
| + {{if and $.CanPromote $.IsOwner}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="production" value="off"> |
| + <button type="submit" class="linkish">Turn off</button> |
| + </form> |
| + <span class="small">— confirms with your passkey; each host honors it after the stable window (~72h) on its own clock</span> |
| + {{end}} |
| + </p> |
| + {{else if .TurningOff}} |
| + <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}, each on its own clock. |
| + {{if $.CanPromote}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="production" value="on"> |
| + <button type="submit" class="linkish">Turn back on</button> |
| + </form> |
| + {{end}} |
| + </p> |
| + {{else}} |
| + <p class="small">Supply-chain bake: <strong>off</strong> (default) — hosts adopt releases immediately. |
| + {{if $.CanPromote}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="production" value="on"> |
| + <button type="submit" class="linkish">Turn on</button> |
| + </form> |
| + {{end}} |
| + </p> |
| + {{end}} |
| + {{if .Nudge}} |
| + <p class="small nudge">{{icon "triangle-alert"}} This app looks production-wired but adopts releases immediately — consider enabling the supply-chain bake.</p> |
| + {{end}} |
| +</div> |
| +{{end}} |
| +``` |
| + |
| +In the channel row (after the Hotfix span, line 19): |
| + |
| +```html |
| + {{if .FastTrack}}· fast-tracked past the bake window{{end}}</span></p> |
| + {{if and (not .BakedUntil.IsZero) $.CanPromote $.IsOwner}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/fasttrack" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="channel" value="{{.Channel}}"> |
| + <button type="submit" class="linkish">Fast-track {{.Version}}</button> |
| + </form> |
| + <span class="small">— in its bake window until ~{{fmtTime .BakedUntil}}; confirms with your passkey</span> |
| + {{end}} |
| +``` |
| + |
| +(the first line replaces the existing `{{if .Hotfix}}...{{end}}</span></p>` tail — keep the Hotfix clause, append the FastTrack clause inside the same `<span class="small">`.) |
| + |
| +In the promote step form (inside the `{{if and .Up.Version $.CanPromote $.IsOwner}}` form, before the button): |
| + |
| +```html |
| + {{if and (eq .Up.To "stable") (not $.Detail.Production.On)}} |
| + <label class="small"><input type="checkbox" name="enable_bake" value="1" checked> |
| + This app is in production — enable the supply-chain bake (recommended)</label> |
| + {{end}} |
| +``` |
| + |
| +Also `grep -n "BakedUntil" internal/console/templates/*.html` — if `app.html`'s channels table renders `BakedUntil` or a bake note, add the same `fast-tracked` clause there for consistency; if it doesn't, touch nothing. |
| + |
| +- [ ] **Step 4: Run the console package** — `go test ./internal/console/ -count=1`. PASS. |
| + |
| +- [ ] **Step 5: Commit** — `git add internal/console/templates/ladder.html internal/console/ladder_test.go && git commit -m "console: bake status line, production toggle, fast-track control and the stable-promote prompt"` (+ trailers). |
| + |
| +--- |
| + |
| +### Task 9: full verification, docs sync, review — OPUS |
| + |
| +**Files:** |
| +- Verify all; no planned source edits (fix what verification finds). |
| + |
| +- [ ] **Step 1: Full gate** — from the worktree root: |
| + |
| +``` |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 |
| +``` |
| + |
| +All 14+ packages PASS. Then `gofmt -l internal/ *.go` — none of the files this branch touched may appear (the three pre-existing unformatted files elsewhere are left alone). |
| + |
| +- [ ] **Step 2: Spec cross-check** — reread `docs/superpowers/specs/2026-08-04-console-bake-controls-design.md` section by section and point at the code satisfying each of §2–§8. Confirm specifically: (a) a box with `WasProduction` latched never downgrades on a missing object; (b) `verifyPointer` refuses grant-bearing `fast_track` wrappers; (c) the gate order in both new handlers is session → CSRF → owner 404 → step-up; (d) the canary fast loop pays no production-flag reads (only held-channel apps are fetched); (e) `laddercheck_test.go` untouched. |
| + |
| +- [ ] **Step 3: Commit anything the check fixed**, then run the repo's review skill (superpowers:requesting-code-review) over the branch diff with Opus as reviewer — this branch grants authority and touches production paths, which is exactly the CLAUDE.md case for an Opus review. |