merge: console bake controls — the production flag and fast-track (#47)
Console bake controls — the production flag and fast-track
21 files changed,
+4599
−32
accounts.go+46 −11docs/superpowers/plans/2026-08-04-console-bake-controls.md+1761 −0docs/superpowers/specs/2026-08-04-console-bake-controls-design.md+277 −0e2e_test.go+94 −0internal/console/bakecontrols_test.go+354 −0internal/console/ladder.go+5 −0internal/console/ladder_test.go+206 −0internal/console/server.go+171 −1internal/console/server_test.go+33 −5internal/console/templates/app.html+1 −1internal/console/templates/ladder.html+65 −1internal/console/view.go+91 −0internal/console/view_test.go+8 −1internal/release/adopt.go+109 −5internal/release/adopt_test.go+320 −0internal/release/bake.go+178 −5internal/release/bake_test.go+251 −0internal/release/production.go+177 −0internal/release/production_test.go+240 −0internal/release/release.go+69 −2internal/release/release_test.go+143 −0
diff --git a/accounts.go b/accounts.go| index 3b3d9911f..a703033c4 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -275,7 +275,7 @@ func runAccountsMigrate(args []string) { |
| // Where the app's objects are RIGHT NOW. This is the whole hazard of the |
| // verb, so it is resolved the way the runtime resolves them and not by |
| // assuming a prefix. |
| - plan, srcAcct := migrationPlan(ctx, store, *app, *from, *to, dst) |
| + plan, srcAcct, hadProduction := migrationPlan(ctx, store, *app, *from, *to, dst) |
| if len(plan) == 0 { |
| fmt.Printf("nothing under %s — nothing to copy\n", release.AppPrefix(srcAcct, *app)) |
| } |
| @@ -349,6 +349,14 @@ func runAccountsMigrate(args []string) { |
| } |
| fmt.Printf("copied %s -> %s\n", c.from, c.to) |
| } |
| + // The one object that does not travel. Loud, because nothing downstream |
| + // will notice: a destination with no flag simply defaults to "not |
| + // production", so the app stops baking silently rather than failing. |
| + if hadProduction { |
| + fmt.Println("\nNOT COPIED:") |
| + fmt.Printf(" %s: carried a production declaration — re-declare it at the destination "+ |
| + "(the flag is signed per-account and cannot be copied)\n", *app) |
| + } |
| if !*dryRun { |
| verifyMigratedPointers(ctx, store, *app, srcAcct, *to, keptPointers) |
| } |
| @@ -401,6 +409,26 @@ type copyOp struct { |
| // pointer rather than an immutable release object. |
| func isChannelPointer(rel string) bool { return strings.HasPrefix(rel, "channels/") } |
| +// productionFlagRel is the production flag's key relative to an app prefix — |
| +// the ONE object this verb must not carry across. Its signed payload NAMES |
| +// the source account, so a byte copy fails validateProductionFlag at the |
| +// destination forever: the console shows an unreadable flag, and every box |
| +// fails toward baking, holding the app's beta and stable heads with no expiry |
| +// and nothing that reads like an error (verifyMigratedPointers checks channel |
| +// pointers only). It is re-declared at the destination instead — one POST on |
| +// the app page, or the same by hand. |
| +const productionFlagRel = "production.json" |
| + |
| +// planCopy adds one object to the migration plan, dropping the production |
| +// flag and recording that the source had one so the verb can say so. |
| +func planCopy(out []copyOp, hadProduction *bool, from, rel, dst string) []copyOp { |
| + if rel == productionFlagRel { |
| + *hadProduction = true |
| + return out |
| + } |
| + return append(out, copyOp{from, dst + rel, isChannelPointer(rel)}) |
| +} |
| + |
| // putOverwrite writes body at key whatever is there now: read the etag, put |
| // with it, one retry on a lost race (the CAS-guarded read-then-write the |
| // fleet publisher and the console's manifest writer both use). Reports |
| @@ -449,9 +477,13 @@ func putOverwrite(ctx context.Context, s release.Store, key string, body []byte) |
| // under both.) `-from` overrides it with a single explicit prefix, for moving |
| // between two non-ops accounts. |
| // |
| -// Returns the copies to make and the account the app is being moved FROM (for |
| -// the post-copy verification). |
| -func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst string) ([]copyOp, string) { |
| +// The production flag is the one object deliberately NOT copied — see |
| +// productionFlagRel. The third return says the source carried one, so the |
| +// verb can tell the operator to re-declare it. |
| +// |
| +// Returns the copies to make, the account the app is being moved FROM (for |
| +// the post-copy verification), and whether a production flag was skipped. |
| +func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst string) ([]copyOp, string, bool) { |
| // The account-less keys, named explicitly. Checked BEFORE ValidAccount: |
| // acctRe is [a-z]{3,16}, which "legacy" satisfies, so the order is what |
| // keeps the sentinel from being read as an account sqid. |
| @@ -466,11 +498,12 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst |
| } |
| fmt.Printf("source: %s (the legacy, account-less keys)\n", src) |
| var out []copyOp |
| + hadProduction := false |
| for _, k := range keys { |
| rel := strings.TrimPrefix(k, src) |
| - out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)}) |
| + out = planCopy(out, &hadProduction, k, rel, dst) |
| } |
| - return out, release.LegacySource |
| + return out, release.LegacySource, hadProduction |
| } |
| if from != "" { |
| if !release.ValidAccount(from) { |
| @@ -485,11 +518,12 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst |
| log.Fatalf("accounts migrate: list %s: %v", src, err) |
| } |
| var out []copyOp |
| + hadProduction := false |
| for _, k := range keys { |
| rel := strings.TrimPrefix(k, src) |
| - out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)}) |
| + out = planCopy(out, &hadProduction, k, rel, dst) |
| } |
| - return out, from |
| + return out, from, hadProduction |
| } |
| if to == release.OpsAccount { |
| @@ -523,19 +557,20 @@ func migrationPlan(ctx context.Context, store release.Store, app, from, to, dst |
| // per-key precedence described above. |
| seen := map[string]bool{} |
| var out []copyOp |
| + hadProduction := false |
| for _, k := range opsKeys { |
| rel := strings.TrimPrefix(k, opsPrefix) |
| seen[rel] = true |
| - out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)}) |
| + out = planCopy(out, &hadProduction, k, rel, dst) |
| } |
| for _, k := range legacyKeys { |
| rel := strings.TrimPrefix(k, legacyPrefix) |
| if seen[rel] { |
| continue // the qualified copy is the live one |
| } |
| - out = append(out, copyOp{k, dst + rel, isChannelPointer(rel)}) |
| + out = planCopy(out, &hadProduction, k, rel, dst) |
| } |
| - return out, release.OpsAccount |
| + return out, release.OpsAccount, hadProduction |
| } |
| // verifyMigratedPointers re-reads every channel pointer after the copy and |
diff --git a/docs/superpowers/plans/2026-08-04-console-bake-controls.md b/docs/superpowers/plans/2026-08-04-console-bake-controls.md| 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. |
diff --git a/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md b/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md| new file mode 100644 |
| index 000000000..592b20e7b |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md |
| @@ -0,0 +1,277 @@ |
| +# Console bake controls — the production flag and fast-track |
| + |
| +2026-08-04. Extends the pipelines spec (2026-08-02-pipelines-design.md §3, §5) |
| +and the scoped signing keys spec (2026-08-03). Touches `internal/release` |
| +(bake.go, adopt.go, release.go) and `internal/console` (server.go, ladder.go, |
| +view.go, templates). |
| + |
| +## 1. Problem |
| + |
| +The supply-chain bake window (beta 24h, stable 72h) protects subscribers of an |
| +app that is actually in production. On a rapid-iteration deployment, edge, beta |
| +and stable are effectively the same audience, and the hold is pure friction — |
| +today the only escape is root on each box, and even that is unwired (the |
| +`ignore` parameter of `Hold` has no caller that passes true). |
| + |
| +Two adjacent defects surfaced while designing this: |
| + |
| +- `DefaultHolds` has no `edge` entry, so `HoldFor` fail-closes edge to 72 |
| + hours. The pipelines spec defines edge as "picked up as it lands — no human |
| + in the loop, by definition". This spec fixes it to 0. |
| +- The "per-deployment overrides come from host config" promised in bake.go's |
| + comment was never wired; that stays unwired (non-goal, §10). |
| + |
| +## 2. The production flag |
| + |
| +Each app carries a **production flag**. `production: true` means the |
| +supply-chain bake applies to the app's beta and stable channels; |
| +`production: false` (the default) means its boxes adopt immediately on every |
| +channel. |
| + |
| +**Default is off.** A new app is not in production, and the flag should mean |
| +"someone declared this production" — a real audit fact — rather than "nobody |
| +got around to opting out". Nothing on either current deployment loses |
| +protection: the entire fleet runs on `canary/*` channels, which never bake. |
| +The silent-failure risk of default-off (a production app quietly missing its |
| +window) is answered structurally in §7: promote-to-stable asks the question at |
| +the exact moment it matters, and prod-wiring detection nags persistently. |
| + |
| +The flag gates ONLY the bake window. The ladder, its sign-off gates, the |
| +passkey step-up on stable, and route wiring are all untouched — a |
| +non-production app climbs edge → beta → stable with the same recorded |
| +promotions; its boxes just don't sit out the hold. |
| + |
| +## 3. Wire shapes — both additive |
| + |
| +Live pointers and manifests exist in production buckets; everything written |
| +before this change must still parse (repo rule). Both shapes here are |
| +additive, and both fail SAFE on old binaries: a box that predates this feature |
| +keeps baking. The same additive rule applies to the box's own bake-clock |
| +file: an old binary that Saves it drops the production key it doesn't know |
| +about until that box is restarted onto the new binary; the loss self-heals |
| +in the closed direction (§4). |
| + |
| +**The flag object** is a new signed bucket object per app, in the same Ed25519 |
| +envelope as channel pointers, root-key-signed: |
| + |
| +```json |
| +{"schema": 1, "account": "<sqid>", "app": "<app>", "production": true, |
| + "actor": "<address>", "set_at": "<rfc3339>"} |
| +``` |
| + |
| +Stored at `apps/<acct>/<app>/production.json` (the `appPrefix` shape) — |
| +deliberately NOT under `channels/`, which ChannelsPrefix lists and whose |
| +entries the console and CLI parse as pointers. A missing object means the |
| +default: not production — but see §4 for why absence never *downgrades* a box |
| +that has seen `true`. A scoped key must not be able to mint it — enforced at |
| +mint and at verification, like everything else the root key reserves. |
| + |
| +**Fast-track** is a new optional field on the channel pointer itself, |
| +mirroring `Hotfix`: |
| + |
| +``` |
| +FastTrack bool `json:"fast_track,omitempty"` |
| +``` |
| + |
| +No second object to fetch during adopt; inherently scoped to one |
| +(account, app, channel, version) so it cannot be replayed onto a later |
| +release; recorded on the very artifact it waives, next to the `Actor` trail. |
| + |
| +Consumer list for the pointer shape (everything that PARSES it, per the repo |
| +rule): `verifyPointer` and the adopt pass, console `ChannelView`/`view.go`, |
| +CLI status output, `laddercheck`. All are additive parses; old parsers ignore |
| +the field, which means old boxes bake — the safe direction. |
| + |
| +## 4. The ratchet on turning bake off |
| + |
| +Flipping an app to `production: false` is honored by each box only after the |
| +flag object has sat unchanged for the **stable window (72h) on that box's own |
| +first-seen clock** — the flag defends itself with the mechanism it controls. |
| +A stolen root key cannot insta-disable baking; it can only start a loud 72h |
| +timer that the console displays for the whole window (§7). |
| + |
| +Mechanics: the adopt pass runs the flag object through the same `FirstSeen` |
| +clock, under a synthetic key that cannot collide with channel keys (the |
| +`clockSep` argument from bake.go applies). First sighting of a given |
| +`production: false` object starts the clock; `Hold`-style eligibility uses the |
| +stable hold with the box's jitter. |
| + |
| +Flipping to `production: true` is honored **immediately** — the fail-safe |
| +direction always wins, and a re-enable must never itself bake. |
| + |
| +Two tamper cases the ratchet must survive, both handled with small local |
| +state in the box's bake-clock file: |
| + |
| +- **Deletion is not a downgrade.** Once a box has seen a verified |
| + `production: true` for an app, it holds that verdict until a verified |
| + `production: false` passes the ratchet. A missing, unreadable or |
| + unverifiable object never downgrades a box that once saw `true` — else |
| + anyone with bucket write but no key could delete the flag and skip the |
| + ratchet entirely. The box persists "this app was production" alongside its |
| + first-seen clock. |
| +- **A stale flag cannot be replayed.** The box records the newest `set_at` it |
| + has honored per app and refuses an object older than that — a signed |
| + `production: false` from a past era, re-written to the bucket by someone |
| + with bucket access alone, cannot roll back a newer `true`. (It would still |
| + cost the replayer the full 72h ratchet even without this; the monotonicity |
| + check just closes the loop.) |
| + |
| +Carve-out: if the app has no beta or stable pointer at the moment a box |
| +evaluates the flag, `production: false` takes effect immediately — there is |
| +nothing at a baked head to protect. With default-off this is rarely exercised |
| +(it matters only for an app flipped on and then off while young), but it keeps |
| +the ratchet honest about what it protects. |
| + |
| +## 5. Fast-track a release |
| + |
| +For a production app whose beta or stable head is inside its window, the |
| +console offers **fast-track**: a dedicated `FastTrack` verb re-signs the |
| +channel's current pointer in place — same version, same history, the waiver |
| +set, one new audit entry (the `Rollback` precedent: a write that is not a |
| +promotion stays out of the ladder). Boxes then skip the hold for exactly |
| +that version on that channel. |
| + |
| +- An already-fast-tracked head is a no-op success, like a repeated promote. |
| +- **Root key only, enforced at verification, not just at mint** — a |
| + scoped-key-signed pointer bearing `fast_track` is refused by verifiers, |
| + matching the grant model's mint-AND-verify rule. Otherwise one leaked |
| + scoped stable key would defeat the window it bakes under. |
| +- `fast_track` is set per write and never inherited: a later promotion of a |
| + new version onto the channel starts clean, and a rollback writes whatever |
| + the rollback path writes today (rollback never bakes anyway). |
| + |
| +Rollback needs none of this: a pull-back to a previously-seen version is |
| +already immediately eligible (bake.go keeps past heads for exactly this). |
| + |
| +## 6. Adopt changes (box side) |
| + |
| +In `adoptHost`, before the existing `Hold` check: resolve the app's flag |
| +(verified read, with §4's deletion and replay rules), and pass `ignore=true` |
| +to `Hold` when either |
| + |
| +- the app is non-production and past its ratchet (§4), or |
| +- the channel pointer carries a root-signed `fast_track` for the version |
| + being considered. |
| + |
| +Fail-safe detail: for a box with no memory of `true`, a flag object that |
| +exists but will not read or verify is treated as `production: true` — an |
| +attacker who can corrupt but not sign must not be able to shorten anything. |
| +The bake clock still ticks regardless of the flag (`Observe` runs as today): |
| +observations are cheap, and flipping the flag back on must not restart |
| +anyone's 72h from zero. |
| + |
| +The wired `ignore` finally gives the box-local override a caller; a |
| +`carlos adopt` flag for root-on-box use remains a non-goal (§10). |
| + |
| +## 7. Console surface |
| + |
| +All on the app page's ladder section, all through the existing gates in the |
| +repo's mandated order — session → CSRF → owner (byte-identical 404) → |
| +`requireFreshAuth` LAST. No new auth machinery: these are three more callers |
| +of the existing step-up gate. |
| + |
| +- **Bake status line**: "Supply-chain bake: off (default)" / "on" / |
| + "turning off — hosts honor this from ~<date>" during the ratchet, the date |
| + computed from the flag's `set_at` plus the stable hold (an estimate — each |
| + box's own clock and jitter govern, as the adopt nag already words it). |
| +- **Turn on**: any member, instant, no step-up. The safe direction is the |
| + cheap one, deliberately frictionless. |
| +- **Turn off**: owner-only + passkey step-up, audited, and the ratchet |
| + message shown on confirmation. |
| +- **Fast-track**: shown on a beta/stable channel row currently inside its |
| + window (the row already knows PromotedAt + `HoldFor`); owner-only + |
| + step-up; audited; the row carries a permanent "fast-tracked" badge, like |
| + the hotfix badge. |
| +- **Promote-to-stable prompt** (the structural mitigation for default-off): |
| + when the flag is off, the stable promote flow — already owner + passkey — |
| + asks inline: "This puts a build in front of every user of this app. Is this |
| + app in production now? Enabling the supply-chain bake is recommended." One |
| + checkbox, recorded through the same confirmed action. It asks; it never |
| + flips the flag on its own. |
| +- **Prod-wiring nudge**: the signals `prodWiredCanaryHosts` already computes |
| + (a route bound to the same environment as a stable route, or sitting on the |
| + app's apex) plus "has a stable route at all": an app tripping them while |
| + bake is off gets a persistent line on its ladder section — "this app looks |
| + production-wired but adopts releases immediately". Detection recommends; |
| + it never flips the flag (implicit gates are the thing this platform keeps |
| + refusing). The fleet page renders the published status snapshot, not |
| + console-assembled state, so the nudge lives on the app page; a fleet-page |
| + line can follow whenever the snapshot shape next changes. |
| + |
| +## 8. The edge fix |
| + |
| +`DefaultHolds` gains `"edge": 0`, restoring the pipelines spec's definition of |
| +edge. Independent of everything above and safe to land first. |
| + |
| +## 9. Security posture — what changed and what held |
| + |
| +bake.go's ported doctrine was "deliberately NO publisher-side bypass". This |
| +spec knowingly relaxes it, the way ladder.go already did for the ladder: an |
| +explicit, recorded bypass beats an invisible one, and beats the workaround |
| +culture that grows around a gate with no legitimate exit. |
| + |
| +What an attacker holding a **scoped** key can do: nothing new. Fast-track and |
| +the flag are root-reserved at mint and at verification. |
| + |
| +What an attacker holding the **root** key gains: an instant path past the |
| +window — fast-track — that is loud by construction: the pointer names the |
| +actor and wears the badge forever. The standing flag gains them nothing |
| +instant: flipping it off starts a 72h ratchet the console displays the whole |
| +time, which preserves the discovery window the bake existed to buy. The |
| +honest loss is exactly fast-track on production apps: before this spec a |
| +root-key thief had no publisher-side way past the hold; now they have one |
| +that cannot be used quietly. |
| + |
| +Console compromise short of the root key gets nothing: every control sits |
| +behind owner + fresh passkey, server-side, fail-closed when Keymail is |
| +unreachable (stepup.go's existing properties). |
| + |
| +The member-race asymmetry is accepted, not overlooked. Turning the flag ON is |
| +any member's and takes effect immediately; turning it OFF is owner + passkey |
| +and then a 72h ratchet on every box. So a member who wants the window kept can |
| +re-impose it the instant an owner removes it, and can keep doing so — each |
| +flip costing them one click and the owner a passkey confirmation plus another |
| +three days. That asymmetry is the same fail-safe direction the rest of the |
| +design leans on (the cheap, instant move is always the protective one), and |
| +the remedies are the ordinary ones: remove the member, and fast-track the |
| +individual releases that need to ship meanwhile — fast-track is per release |
| +and unaffected by the standing flag. Nothing here is quiet: every flip in |
| +either direction is audited with its actor and displayed on the app page, so a |
| +member re-imposing the window against an owner is visible in the trail rather |
| +than a mystery about why the fleet will not adopt. |
| + |
| +## 10. Non-goals |
| + |
| +- CLI fast-track flag (a root-key holder at a terminal has stronger powers |
| + already; add later if wanted). |
| +- Per-deployment hold overrides via host config — bake.go's comment keeps |
| + promising it; still unwired. |
| +- A `carlos adopt -ignore-bake` root-on-box flag (the `ignore` parameter now |
| + has its console-driven caller; the local flag can follow if ever needed). |
| +- Any canary behavior change. Canary remains bake-free and outside all of |
| + this. |
| + |
| +## 11. Testing |
| + |
| +- `bake_test.go`: ratchet timing (off honored only after stable hold + |
| + jitter), on honored immediately, the no-beta/stable carve-out, corrupt |
| + flag object treated as production, deletion-is-not-a-downgrade, stale |
| + `set_at` replay refused. |
| +- Wire tests: pre-change pointer parses; pointer with `fast_track` parses in |
| + the old struct shape (field ignored). |
| +- Grant/verify tests: scoped-key-signed `fast_track` refused; scoped key |
| + cannot write the flag object. |
| +- Promote tests: same-version-gaining-fast_track is a write, not a no-op. |
| +- Console tests: gate ordering for all three controls (non-member gets the |
| + byte-identical 404 before any step-up redirect), promote-to-stable prompt |
| + shown only when the flag is off, nudge appears on prod-wired signals. |
| +- `laddercheck_test.go`'s pinned production channels: untouched and still |
| + green — the fleet runs on `canary/*` and nothing here may refuse them. |
| + |
| +## 12. Operational follow-up (not part of the build) |
| + |
| +Once shipped: flip the flag ON for flagship's four apps (console, kass, |
| +keymail, platform-site) and tito's production apps — these are the apps the |
| +flag is for. Until their routes follow beta/stable channels the flag changes |
| +nothing day-to-day, but the declaration should exist before the migration off |
| +`canary/*` ever happens, not after. |
diff --git a/e2e_test.go b/e2e_test.go| index d7dcc6277..4e992452a 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -1025,6 +1025,100 @@ func TestAccountsMigrateTakesTheLiveObjects(t *testing.T) { |
| } |
| } |
| +// The production flag does NOT travel. Its signed payload names the account, |
| +// so a byte copy is unverifiable at the destination forever — and an |
| +// unverifiable flag is exactly what every box reads as "keep baking", so the |
| +// migrated app's held heads would sit in a window with no expiry, silently |
| +// (verifyMigratedPointers checks channel pointers, not this). The verb skips |
| +// it and says so, loudly, because the alternative failure is invisible. |
| +func TestAccountsMigrateDoesNotCopyTheProductionFlag(t *testing.T) { |
| + goBin, err := exec.LookPath("go") |
| + if err != nil { |
| + t.Skip("go toolchain not found") |
| + } |
| + tmp := t.TempDir() |
| + carlosBin := filepath.Join(tmp, "carlos") |
| + build := exec.Command(goBin, "build", "-o", carlosBin, ".") |
| + build.Env = os.Environ() |
| + if out, err := build.CombinedOutput(); err != nil { |
| + t.Fatalf("build carlos: %v\n%s", err, out) |
| + } |
| + storeDir := filepath.Join(tmp, "store") |
| + regPath := filepath.Join(tmp, "registry.db") |
| + store := release.NewDirStore(storeDir) |
| + ctx := context.Background() |
| + |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + env := append(os.Environ(), |
| + "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| + "CARLOS_RELEASE_PUBKEY="+pubB64) |
| + run := func(args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(carlosBin, args...) |
| + cmd.Env = env |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + m := release.Manifest{Schema: 1, App: "demo", Version: "v1", Kind: "binary", |
| + Artifacts: map[string]release.Artifact{"linux-amd64": {File: "demo"}}} |
| + if err := release.Ship(ctx, store, release.OpsAccount, m, map[string][]byte{"linux-amd64": []byte("v1 bytes")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.Promote(ctx, store, priv, release.OpsAccount, "demo", "stable", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.SetProduction(ctx, store, priv, release.OpsAccount, "demo", true, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + out, err := run("accounts", "create", "-registry", regPath, "-name", "Kass", "-owner", "paul@keymail.dev") |
| + if err != nil { |
| + t.Fatalf("accounts create: %v\n%s", err, out) |
| + } |
| + sqid := regexp.MustCompile(`(?m)^\s*sqid:\s*(\S+)`).FindStringSubmatch(out)[1] |
| + |
| + out, err = run("accounts", "migrate", "-registry", regPath, "-app", "demo", "-to", sqid) |
| + if err != nil { |
| + t.Fatalf("migrate: %v\n%s", err, out) |
| + } |
| + // No flag object at the destination at all — a copied one would be worse |
| + // than none: none defaults to "not production", a copy bakes forever. |
| + if _, _, err := store.Get(ctx, release.ProductionKey(sqid, "demo")); err == nil { |
| + t.Fatal("the migration copied production.json — the copy names the source account and can never verify") |
| + } |
| + if f, err := release.GetProduction(ctx, store, pub, sqid, "demo"); err != nil || f != nil { |
| + t.Fatalf("destination flag = %+v, %v — want the clean default (no object, no error)", f, err) |
| + } |
| + // And the operator is TOLD, because nothing downstream will notice. |
| + want := "demo: carried a production declaration — re-declare it at the destination" |
| + if !strings.Contains(out, want) { |
| + t.Fatalf("migrate output does not name the flag it left behind (%q):\n%s", want, out) |
| + } |
| + // The source is untouched, as with every other object this verb moves. |
| + if f, err := release.GetProduction(ctx, store, pub, release.OpsAccount, "demo"); err != nil || f == nil || !f.Production { |
| + t.Fatalf("source flag = %+v, %v after the move, want it untouched and still on", f, err) |
| + } |
| + // The rest of the app still came across. |
| + if _, err := release.GetManifest(ctx, store, sqid, "demo", "v1"); err != nil { |
| + t.Errorf("manifest missing after the move: %v", err) |
| + } |
| + if p, err := release.Channel(ctx, store, pub, sqid, "demo", "stable"); err != nil || p.Version != "v1" { |
| + t.Errorf("migrated stable = %+v, %v, want v1", p, err) |
| + } |
| +} |
| + |
| // A destination channel pointer that disagrees with the source must CONVERGE, |
| // not strand the operator. Channel pointers are the one mutable thing in the |
| // bucket — they move on every promote — so a stale or half-written one at the |
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go| new file mode 100644 |
| index 000000000..d62dc69e0 |
| --- /dev/null |
| +++ b/internal/console/bakecontrols_test.go |
| @@ -0,0 +1,354 @@ |
| +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" |
| +) |
| + |
| +// productionFlag reads the app's flag straight from the bucket, through the |
| +// same verification the adopt path uses. The fixture carries the release |
| +// public key, so there is nothing to derive. |
| +func productionFlag(t *testing.T, fx *consoleFixture, app string) *release.ProductionFlag { |
| + t.Helper() |
| + f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return f |
| +} |
| + |
| +// strangerRefusal is what someone with no membership at all gets from one of |
| +// this account's app routes: a signed-in browser belonging to nobody here. |
| +// It is the yardstick the member's refusal must match byte for byte. |
| +func strangerRefusal(t *testing.T, fx *consoleFixture, code, u string, form url.Values) string { |
| + t.Helper() |
| + fx.fk.mu.Lock() |
| + prev := fx.fk.address |
| + fx.fk.mu.Unlock() |
| + fx.setUser("zoe@keymail.dev") // signs up into an account of her own |
| + defer fx.setUser(prev) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, code) |
| + form.Set("csrf", sessionToken(t, fx, c)) |
| + status, _, body := postForm(t, c, u, form) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("non-member posting %s = %d, want 404", u, status) |
| + } |
| + return body |
| +} |
| + |
| +// auditedBy asserts the console's OWN activity trail (store events, which is |
| +// what the app page renders) recorded this action for this actor. The |
| +// pointer's audit trail is a different record with a different purpose; a |
| +// console action that leaves no console event is an invisible one. |
| +func auditedBy(t *testing.T, fx *consoleFixture, app, action, actor, detailPart string) { |
| + t.Helper() |
| + events, err := fx.st.Events(OpsAccountID, app, 20) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range events { |
| + if e.Action == action && e.Address == actor && strings.Contains(e.Detail, detailPart) { |
| + return |
| + } |
| + } |
| + t.Fatalf("events = %+v, want a %q row by %s mentioning %q", events, action, actor, detailPart) |
| +} |
| + |
| +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) |
| + } |
| + auditedBy(t, fx, "kass", "production", "sam@keymail.dev", "production: on") |
| +} |
| + |
| +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") |
| + } |
| + auditedBy(t, fx, "kass", "production", "paul@keymail.dev", "production: off") |
| +} |
| + |
| +// 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) |
| + // Seeded ON, so "the flag stayed put" is an assertion about a value that |
| + // could have moved rather than about an absent object. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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) |
| + form := url.Values{"csrf": {csrf}, "production": {"off"}} |
| + status, _, memberBody := postForm(t, c, fx.u("/apps/kass/production"), form) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member turning bake OFF = %d, want 404", status) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatalf("flag = %+v after a member's refused post, want it untouched", f) |
| + } |
| + // Byte-identical, not merely same-status: the difference between "not |
| + // allowed" and "no such account" is not information this hands out. |
| + strangerBody := strangerRefusal(t, fx, "code-zoe-bake2", fx.u("/apps/kass/production"), |
| + url.Values{"production": {"off"}}) |
| + if memberBody != strangerBody { |
| + t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| + } |
| + // http.NotFound's own, well-known output — pinned so a future change to |
| + // either path is caught even if it stays self-consistent. |
| + if memberBody != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody) |
| + } |
| +} |
| + |
| +// Fast-track is owner + passkey, ALWAYS. The un-stepped-up post is what |
| +// proves the second half: postGated alone would stay green with the step-up |
| +// call deleted, because it only walks the round trip when one is demanded. |
| +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. Without the passkey the |
| + // waiver does not happen. |
| + status, loc, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("un-stepped-up fast-track = %d -> %q, want a redirect into Keymail", status, loc) |
| + } |
| + if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| + t.Fatal("the bake window was waived with no passkey confirmation") |
| + } |
| + |
| + // With it, the held action resumes and lands on the pointer. |
| + if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| + t.Fatalf("resumed fast-track = %d:\n%s", status, body) |
| + } |
| + 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) |
| + } |
| + auditedBy(t, fx, "kass", "fast-track", "paul@keymail.dev", "waived the bake window for v1 on stable") |
| +} |
| + |
| +// Fast-tracking a channel that never bakes is refused. It looks inert — |
| +// adopt ignores the waiver on a zero-hold channel — but the re-sign stamps a |
| +// fresh PromotedAt, which is exactly what the canary TTL lease is measured |
| +// from: the post would quietly renew the lease and leave a permanent |
| +// "fast-tracked past the bake window" badge on a channel that has no window. |
| +func TestFastTrackRefusesAChannelWithNoBakeWindow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "canary/x", "v3") |
| + before := fx.channelVersion(t, "kass", "canary/x") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, _ := postGated(t, fx, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"canary/x"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("fast-track of a bake-free channel = %d, want the flash redirect", status) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, |
| + "Channel canary/x has no bake window — there is nothing to fast-track.") { |
| + t.Fatalf("no refusal flash:\n%s", body) |
| + } |
| + after := fx.channelVersion(t, "kass", "canary/x") |
| + if after.FastTrack { |
| + t.Fatal("a bake-free channel wears the waiver badge") |
| + } |
| + if !after.PromotedAt.Equal(before.PromotedAt) { |
| + t.Fatalf("PromotedAt moved %s -> %s — the refused post renewed the canary's TTL lease", |
| + before.PromotedAt, after.PromotedAt) |
| + } |
| + if len(after.Audit) != len(before.Audit) { |
| + t.Fatalf("audit trail grew from %d to %d on a refused fast-track", len(before.Audit), len(after.Audit)) |
| + } |
| +} |
| + |
| +// A MEMBER probing fast-track meets the byte-identical 404 — the loud |
| +// instant path past the window is the owner's alone. |
| +func TestFastTrackMemberGets404(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-bake3") |
| + csrf := sessionToken(t, fx, c) |
| + status, _, memberBody := postForm(t, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member fast-tracking = %d, want 404", status) |
| + } |
| + if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| + t.Fatal("a member's post waived the bake window") |
| + } |
| + // Byte-identical, not merely same-status: the difference between "not |
| + // allowed" and "no such account" is not information this hands out. |
| + strangerBody := strangerRefusal(t, fx, "code-zoe-bake3", fx.u("/apps/kass/fasttrack"), |
| + url.Values{"channel": {"stable"}}) |
| + if memberBody != strangerBody { |
| + t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| + } |
| + // http.NotFound's own, well-known output — pinned so a future change to |
| + // either path is caught even if it stays self-consistent. |
| + if memberBody != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| + // The flash tells the truth about both halves of what just happened: the |
| + // flag went on in this action, so the bake note is now accurate. |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Supply-chain bake enabled.") || |
| + !strings.Contains(body, "Adopts after the bake window") { |
| + t.Fatalf("enable_bake flash missing its two halves:\n%s", body) |
| + } |
| +} |
| + |
| +// A checkbox that could not be honored SAYS so. When the flag will not read, |
| +// the flip is never attempted — and the promote flash used to carry no trace |
| +// of that, leaving the human who ticked the box believing the app now bakes. |
| +func TestPromoteEnableBakeSaysWhenItCouldNotRead(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) |
| + if _, err := fx.rel.Put(context.Background(), |
| + release.ProductionKey(release.OpsAccount, "kass"), []byte("{not a wrapper"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + 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 — the promote itself must not depend on the flag", p.Version) |
| + } |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "could not be read, so the bake was not enabled") { |
| + t.Fatalf("the flash is silent about the unhonored checkbox:\n%s", body) |
| + } |
| + if strings.Contains(body, "Supply-chain bake enabled.") { |
| + t.Fatal("the flash claims the bake was enabled over an unreadable flag") |
| + } |
| +} |
| + |
| +// The bake note is a claim about this app, not about the channel: a |
| +// non-production app adopts a held channel immediately (spec §2), so |
| +// promising a window would be a lie. |
| +func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // No flag at all: beta holds, but this app does not bake. (edge first — |
| + // the ladder promotes a build one rung at a time.) |
| + seedChannel(t, fx, "kass", "edge", "v3") |
| + // Checked, not fired and forgotten: a refused promote would make the |
| + // absence assertion below true for the wrong reason. |
| + if status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}}); status != http.StatusSeeOther || |
| + loc != fx.path("/apps/kass") { |
| + t.Fatalf("beta promote = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| + t.Fatalf("beta = %+v, want v3", p) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Adopts after the bake window") { |
| + t.Fatalf("a flagless app was promised a bake window:\n%s", body) |
| + } |
| + |
| + // Flag on: the same rung now says so. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "kass", "v4") |
| + seedChannel(t, fx, "kass", "edge", "v4") |
| + if status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}}); status != http.StatusSeeOther { |
| + t.Fatalf("beta promote of v4 = %d:\n%s", status, body) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Adopts after the bake window") { |
| + t.Fatalf("a production app's held promotion did not mention the window:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index ceadd0ab7..a96c45bb9 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -57,6 +57,10 @@ type LadderChannel struct { |
| Actor string |
| // Hotfix records that this promotion bypassed the ladder. |
| Hotfix bool |
| + // 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 |
| // Err carries a pointer that would not read or verify, and a channel that |
| // hosts follow but that nothing has ever been published to. Both are one |
| // line of bad news on one rung, never a failed page. |
| @@ -146,6 +150,7 @@ func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) [ |
| entries[rung] = append(entries[rung], LadderChannel{ |
| Channel: cv.Name, Version: cv.Version, PromotedAt: cv.PromotedAt, |
| Actor: cv.Actor, Hotfix: cv.Hotfix, Err: cv.Err, |
| + FastTrack: cv.FastTrack, BakedUntil: cv.BakedUntil, |
| Routes: byChannel[cv.Name], |
| }) |
| delete(byChannel, cv.Name) |
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go| index 2194c2768..62734d3e4 100644 |
| --- a/internal/console/ladder_test.go |
| +++ b/internal/console/ladder_test.go |
| @@ -536,3 +536,209 @@ func TestLadderPromotesOntoAnExpiredCanary(t *testing.T) { |
| t.Fatalf("expired canary still holds %s — expiry has become a gate", p.Version) |
| } |
| } |
| + |
| +// 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="production" value="off"`) { |
| + t.Fatal("owner is offered no turn-off control once bake is on") |
| + } |
| + 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. Off-state alone would make |
| +// the turn-off/fast-track assertions tautological (neither control is |
| +// eligible to render for ANYONE — owner included — when the flag is off |
| +// and no channel is held): so this also flips the flag on and confirms |
| +// through the OWNER's own browser, first, that the controls DO render in |
| +// that state, before checking the member does not get them in the same |
| +// state. That ordering is what keeps the member-side absence checks from |
| +// passing vacuously. |
| +func TestLadderBakeControlsMemberView(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v9") |
| + seedChannel(t, fx, "kass", "beta", "v9") |
| + c := signInPaul(t, fx) |
| + |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + cm := newBrowser(t) |
| + signUp(t, fx, cm, "code-sam-baketpl") |
| + |
| + _, _, mbody := get(t, cm, fx.u("/apps/kass")) |
| + if !strings.Contains(mbody, "Supply-chain bake") { |
| + t.Fatal("member sees no bake status") |
| + } |
| + if !strings.Contains(mbody, `name="production" value="on"`) { |
| + t.Error("member is not offered the turn-on control") |
| + } |
| + if strings.Contains(mbody, `name="production" value="off"`) { |
| + t.Error("member is offered the owner-only turn-off control") |
| + } |
| + if strings.Contains(mbody, `/fasttrack"`) { |
| + t.Error("member is offered the owner-only fast-track control") |
| + } |
| + |
| + // Put the page into the state where turn-off and fast-track are |
| + // ELIGIBLE to render: flag on, with a held beta head. Confirm the |
| + // OWNER gets both controls in this state... |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, obody := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(obody, `name="production" value="off"`) { |
| + t.Fatal("owner is not offered the turn-off control once bake is eligible — the member-only checks below would be vacuous") |
| + } |
| + if !strings.Contains(obody, `action="/a/bab/apps/kass/fasttrack"`) { |
| + t.Fatalf("owner is not offered the fast-track control on a held head — the member-only checks below would be vacuous:\n%s", rungBlock(t, obody, "beta")) |
| + } |
| + |
| + // ...then confirm the member does NOT, in that same eligible state. |
| + _, _, mbody = get(t, cm, fx.u("/apps/kass")) |
| + if strings.Contains(mbody, `name="production" value="off"`) { |
| + t.Error("member is offered the owner-only turn-off control once bake is on") |
| + } |
| + if strings.Contains(mbody, `action="/a/bab/apps/kass/fasttrack"`) { |
| + t.Error("member is offered the owner-only fast-track control on a held head") |
| + } |
| +} |
| + |
| +// An UNREADABLE flag must not strand the app. The boxes fail toward baking |
| +// (spec §4), so an app whose flag object will not verify — a root key |
| +// rotation makes this every production app at once — bakes forever, and the |
| +// page's error text alone offers no way out. Re-declaring is the repair: |
| +// SetProduction CASes over whatever unverifiable bytes are there. |
| +func TestLadderUnreadableFlagOffersRepair(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if _, err := fx.rel.Put(context.Background(), |
| + release.ProductionKey(release.OpsAccount, "kass"), []byte("{not a wrapper"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "Production flag unreadable") { |
| + t.Fatalf("no error line for an unverifiable flag:\n%s", body) |
| + } |
| + if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) || |
| + !strings.Contains(body, `name="production" value="on"`) { |
| + t.Fatalf("an unreadable flag offers no repair form — the app bakes forever:\n%s", body) |
| + } |
| + if !strings.Contains(body, "Re-declare production") { |
| + t.Fatalf("the repair control is not named as a re-declaration:\n%s", body) |
| + } |
| + // The safe direction only: turning OFF over an unverifiable flag is still |
| + // the owner's, through the ordinary control, and is not offered here. |
| + if strings.Contains(body, `name="production" value="off"`) { |
| + t.Error("the error branch offers turn-off; only the safe direction belongs here") |
| + } |
| + // And the repair actually lands: a member's plain POST replaces the |
| + // garbage with a verifiable flag. |
| + csrf := sessionToken(t, fx, c) |
| + if status, _, b := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"on"}}); status != http.StatusSeeOther { |
| + t.Fatalf("re-declare = %d, want 303:\n%s", status, b) |
| + } |
| + if f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, |
| + release.OpsAccount, "kass"); err != nil || f == nil || !f.Production { |
| + t.Fatalf("flag = %+v, %v after the re-declaration, want a verifiable production: true", f, err) |
| + } |
| +} |
| + |
| +// The turning-off branch, through the HTML: an off-flag younger than the |
| +// ratchet on an app with a held channel says hosts are still ratcheting, and |
| +// offers the instant way back on (any member's, the safe direction). |
| +func TestLadderTurningOffOffersTurnBackOn(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "beta", "v3") // a held channel: beta bakes |
| + c := signInPaul(t, fx) |
| + ctx := context.Background() |
| + if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", false, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "turning off") { |
| + t.Fatalf("a fresh off-flag on a held app does not say hosts are still ratcheting:\n%s", body) |
| + } |
| + if !strings.Contains(body, `name="production" value="on"`) || |
| + !strings.Contains(body, "Turn back on") { |
| + t.Fatalf("the turning-off state offers no way back on:\n%s", body) |
| + } |
| + // Not the default-off line: this app has an off-flag mid-ratchet, and |
| + // telling the operator hosts adopt immediately would be false for ~72h. |
| + if strings.Contains(body, "hosts adopt releases immediately") { |
| + t.Error("mid-ratchet, the page claims hosts already adopt immediately") |
| + } |
| +} |
| + |
| +// 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") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 7f65c1a44..e0031150a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -230,6 +230,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| @@ -1410,7 +1412,48 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| } |
| s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel) |
| msg := "Promoted " + version + " to " + channel + "." |
| - if release.HoldFor(release.DefaultHolds, channel) > 0 { |
| + // Whether this app bakes at all is the production flag's business (bake |
| + // controls spec §2): a non-production app's boxes adopt a held channel |
| + // immediately, so the window note below would be a lie for them. Read the |
| + // flag before the checkbox, which may still turn it on in this same |
| + // request. |
| + flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if ferr != nil { |
| + log.Printf("console: production flag %s: %v", app, ferr) |
| + } |
| + // An unreadable flag fails TOWARD baking, exactly as the adopt path does |
| + // (spec §4) — so the note is still the truth about what the boxes will do. |
| + baking := ferr != nil || (flag != nil && flag.Production) |
| + // 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. |
| + // Both failure paths SAY so. The human ticked a box in a passkey-confirmed |
| + // action; a log line they will never read is not an answer, and a silent |
| + // skip leaves them believing the app bakes when it does not. |
| + if r.FormValue("enable_bake") == "1" { |
| + switch { |
| + case ferr != nil: |
| + // The read failed, so the flip was never attempted: writing over a |
| + // flag nobody could read is the repair path's job (the app page's |
| + // re-declare control), not a promote's side effect. baking is true |
| + // here only because an unreadable flag fails TOWARD baking — which |
| + // settles nothing about what this app has declared. |
| + msg += " The production flag could not be read, so the bake was not enabled — use the toggle on the app page." |
| + case baking: |
| + // Already on: a stray checkbox on a repeat is a no-op, as before. |
| + default: |
| + 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) |
| + msg += " Enabling the supply-chain bake failed — use the toggle on the app page." |
| + } else { |
| + s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on, at stable promote)") |
| + msg += " Supply-chain bake enabled." |
| + baking = true |
| + } |
| + } |
| + } |
| + if baking && release.HoldFor(release.DefaultHolds, channel) > 0 { |
| msg += " Adopts after the bake window on each host — rollback never bakes." |
| } |
| back("ok", msg) |
| @@ -1483,6 +1526,133 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Rolled back "+channel+" to "+target+".") |
| } |
| +// 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, and the two owner-side gates apply to the |
| +// OFF direction ONLY: session/membership (accountApp) → CSRF → signing-key |
| +// availability → value parse → for OFF: owner (byte-identical 404) → |
| +// requireFreshAuth LAST. The refusals that cost nothing and reveal nothing |
| +// come first; the 404 comes before any step-up redirect, which would |
| +// otherwise confirm to a member-probing-as-stranger that the account exists. |
| +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 |
| + } |
| + // A channel with no bake window has nothing to waive, and the waiver is |
| + // not inert on it: FastTrack re-signs the pointer, which re-stamps |
| + // PromotedAt — and PromotedAt is what CanaryExpired reads, so waiving a |
| + // canary would silently renew its TTL lease and badge a channel that |
| + // never bakes as "fast-tracked past the bake window", forever. |
| + // |
| + // Placed HERE, after both owner gates, and not with the cheap refusals |
| + // above: HoldFor is pure vocabulary and leaks nothing on its own, but a |
| + // refusal that fires before the owner check would make this endpoint's |
| + // response vary with the channel name for a NON-OWNER, who must see the |
| + // byte-identical 404 and nothing else. The mandated order (session → CSRF |
| + // → owner 404 → requireFreshAuth LAST) then leaves only this spot. The |
| + // cost is a passkey tap spent on a refusal; the alternative is an oracle. |
| + if release.HoldFor(release.DefaultHolds, channel) == 0 { |
| + back("err", "Channel "+channel+" has no bake window — there is nothing to fast-track.") |
| + 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.") |
| +} |
| + |
| // ---- canaries --------------------------------------------------------------- |
| // canaryNameRe pins canary names to the same boring slugs as app names — the |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index a2d5efe5a..99a94cb4d 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -234,6 +234,14 @@ func TestConsoleE2E(t *testing.T) { |
| t.Fatal("welcome flash survived a second page view") |
| } |
| + // Bake estimates only render for an app the production flag marks on |
| + // (2026-08-04 console bake controls spec §7) — seed it so the "bakes |
| + // until ~" assertion below still exercises the baking path rather than |
| + // the new "nothing bakes without the flag" zeroing. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| // Detail page: routes and channels, with the bake approximation labeled. |
| status, _, body := get(t, c, fx.u("/apps/kass")) |
| if status != 200 { |
| @@ -489,9 +497,11 @@ func TestPromoteFlow(t *testing.T) { |
| t.Fatalf("edge pointer = %+v, want v3", p) |
| } |
| _, _, body = get(t, c, fx.u("/apps/kass")) |
| - if !strings.Contains(body, "Promoted v3 to edge.") || |
| - !strings.Contains(body, "bake window") || !strings.Contains(body, "rollback never bakes") { |
| - t.Fatalf("promote flash missing bake note:\n%s", body) |
| + // edge holds zero — pipelines spec: merged to main is picked up as it lands. |
| + // The flash for an edge promotion contains only "Promoted v3 to edge." without |
| + // the "bake window" text that a held channel gets on a PRODUCTION app. |
| + if !strings.Contains(body, `<p class="banner">Promoted v3 to edge.</p>`) { |
| + t.Fatalf("edge promotion flash incorrect:\n%s", body) |
| } |
| // One rung at a time, up to stable — which is the rung that asks for a |
| @@ -501,8 +511,12 @@ func TestPromoteFlow(t *testing.T) { |
| if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| t.Fatalf("beta pointer = %+v, want v3", p) |
| } |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Promoted v3 to beta.") { |
| - t.Fatal("no beta promote flash") |
| + // beta HOLDS, but this app has no production flag — the default is not |
| + // production (bake controls spec §2), so its boxes adopt beta immediately |
| + // and the flash must not promise a window. The exact banner is asserted, |
| + // not a substring: "no bake note here" is the property under test. |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `<p class="banner">Promoted v3 to beta.</p>`) { |
| + t.Fatalf("beta promotion flash incorrect (a flagless app must not be promised a bake window):\n%s", body) |
| } |
| postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}}) |
| @@ -868,6 +882,20 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| url.Values{"csrf": {csrf}, "name": {"x"}}); status != http.StatusServiceUnavailable { |
| t.Fatalf("keyless canary retire = %d, want 503", status) |
| } |
| + // The bake controls sign too — the flag is a root-signed object and |
| + // fast-track re-signs a pointer, so both are mutations a keyless console |
| + // must refuse rather than fail halfway through. |
| + if status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"on"}}); status != http.StatusServiceUnavailable { |
| + t.Fatalf("keyless production flag = %d, want 503", status) |
| + } |
| + if status, _, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable { |
| + t.Fatalf("keyless fast-track = %d, want 503", status) |
| + } |
| + if f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, release.OpsAccount, "kass"); err != nil || f != nil { |
| + t.Fatalf("keyless console wrote a production flag: %+v (%v)", f, err) |
| + } |
| if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { |
| t.Fatalf("keyless console moved a pointer to %s", p.Version) |
| } |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index d25b31297..8684587a9 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -95,7 +95,7 @@ |
| {{else}} |
| <td><code>{{.Version}}</code></td> |
| <td>{{fmtTime .PromotedAt}}</td> |
| - <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}</td> |
| + <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td> |
| {{end}} |
| <td>{{if and $.CanPromote .HasHistory (not .Err)}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback"> |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index 72448ae20..f176e9c34 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -3,6 +3,57 @@ |
| <p class="small">A channel is maturity, not a place: the same build climbs |
| canary → edge → beta → stable, and a promotion moves a signed pointer without |
| touching what any host is wired to.</p> |
| +{{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. |
| + {{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">Re-declare production</button> |
| + </form> |
| + <span class="small">— writes a fresh signed flag over the unreadable one. Without this the app |
| + bakes forever; turning it off afterwards is the owner's, through the ordinary control.</span> |
| + {{end}} |
| + </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}} |
| <ol class="ladder"> |
| {{range .Detail.Ladder}} |
| <li class="rung" id="rung-{{.Rung}}"> |
| @@ -16,7 +67,16 @@ touching what any host is wired to.</p> |
| <p><code>{{.Channel}}</code> holds <code>{{.Version}}</code> |
| <span class="small">promoted {{fmtTime .PromotedAt}} |
| {{if .Actor}}by {{.Actor}}{{else}}— by whom is not recorded, on a pointer written before the trail existed{{end}} |
| - {{if .Hotfix}}· ladder bypassed (hotfix){{end}}</span></p> |
| + {{if .Hotfix}}· ladder bypassed (hotfix){{end}} |
| + {{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}} |
| {{end}} |
| {{if .Routes}} |
| <ul class="routes"> |
| @@ -46,6 +106,10 @@ touching what any host is wired to.</p> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="channel" value="{{.Up.To}}"> |
| <input type="hidden" name="version" value="{{.Up.Version}}"> |
| + {{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}} |
| <button type="submit" class="linkish">Promote {{.Up.Version}} to {{.Up.To}}</button> |
| </form> |
| {{if .Up.StepUp}}<span class="small">— confirms with your passkey</span>{{end}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index 0e9c7054c..2f34e51ed 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -31,6 +31,9 @@ type AppView struct { |
| // channels and routes as the fields above, arranged as the pipeline they |
| // actually are rather than as two alphabetical lists. |
| Ladder []LadderRung |
| + // Production is the app's production-flag state (release.ProductionFlag) |
| + // as the console shows it — see ProductionView. |
| + Production ProductionView |
| // RouteAccount is the account the app's ROUTES say it belongs to, when |
| // that disagrees with the account whose page this is; "" when they agree |
| // or there are no routes. It is the half-migrated state — see |
| @@ -142,11 +145,36 @@ type ChannelView struct { |
| // missing information, not an error, and the page says so. |
| Actor string |
| Hotfix bool |
| + // FastTrack is the pointer's bake waiver (release.ChannelPointer) — the |
| + // badge is permanent, like the hotfix badge. |
| + FastTrack bool |
| // Err degrades a missing or invalid channel pointer to one row's error |
| // text instead of a failed page. |
| Err string |
| } |
| +// 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 at the |
| + // MAXIMUM jitter, 1.25x) — each box's own clock and jitter govern, |
| + // exactly as BakedUntil is only ever an approximation, and this one errs |
| + // late on purpose (see assembleProduction). |
| + 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 |
| +} |
| + |
| // VersionView is one shipped release on the app page. |
| type VersionView struct { |
| Version, Kind, BuiltAt string |
| @@ -244,6 +272,19 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin |
| if ra := routeAccount[app]; ra != "" && ra != acct { |
| mismatch = ra |
| } |
| + 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 |
| @@ -251,6 +292,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin |
| Channels: channels, |
| Canaries: assembleCanaries(channels, byApp[app]), |
| Ladder: assembleLadder(channels, byApp[app], now), |
| + Production: pv, |
| RouteAccount: mismatch, |
| }) |
| } |
| @@ -312,6 +354,7 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe |
| cv := ChannelView{ |
| Name: name, Version: p.Version, PromotedAt: p.PromotedAt, |
| HasHistory: len(p.History) > 0, |
| + FastTrack: p.FastTrack, |
| } |
| // The trail is newest first, but a rollback carries it forward |
| // without appending: the entry that explains the CURRENT version is |
| @@ -334,6 +377,54 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe |
| return out |
| } |
| +// 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 { |
| + // The estimate errs LATE, deliberately. A box ratchets from its OWN |
| + // first sighting of the off-flag, not from set_at, and it waits |
| + // hold*(1+jitter) with jitter in [0, 0.25) — so set_at + the bare hold |
| + // is the earliest any box could possibly honor it, and the console |
| + // would go on to claim "off — hosts adopt immediately" for up to |
| + // another 19 hours while the slowest boxes were still baking. Quoting |
| + // the maximum (1.25x) means the line is late rather than wrong; each |
| + // box's own clock still governs, as the wording says. |
| + hold := release.HoldFor(release.DefaultHolds, release.RungStable) |
| + if until := flag.SetAt.Add(time.Duration(float64(hold) * 1.25)); 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 |
| +} |
| + |
| // FleetView reads status/fleet.json off the console's release.Store handle |
| // and decodes it into status.Fleet — the exact shape internal/status's |
| // public page renders, so the operator view and the public page never |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 4fea7b3b4..c93b651b2 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -107,7 +107,14 @@ func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView { |
| } |
| func TestAssemble(t *testing.T) { |
| - regPath, store, _, pub := buildFixtures(t) |
| + regPath, store, priv, pub := buildFixtures(t) |
| + // Bake estimates only render for an app the flag marks as production |
| + // (2026-08-04 console bake controls spec §7) — seed it so the |
| + // BakedUntil assertions below still exercise the "still baking" path |
| + // rather than the new "nothing bakes without the flag" zeroing. |
| + if err := release.SetProduction(context.Background(), store, priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| if err != nil { |
| t.Fatal(err) |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index c654bb1da..8816459ba 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -199,6 +199,45 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| pointers[k] = ptr |
| } |
| + // Resolve each app's production flag once per pass — but ONLY for apps |
| + // with a HELD head resolved this pass (spec §6). That filter is a cost |
| + // property, not a nicety: the canary fast loop (PassFilter with a |
| + // canary/*-only extra, every 2-3 SECONDS) resolves nothing but channels |
| + // whose HoldFor is 0, so it never pays a production-flag bucket read at |
| + // all; the hourly pass pays exactly one read per (account, app) with a |
| + // held head, not one per channel and not one per route. |
| + type prodState struct { |
| + flag *ProductionFlag |
| + err error // non-nil: the object exists but would not read or verify |
| + } |
| + bakeable := map[acctApp]bool{} |
| + for k, ptr := range pointers { |
| + if ptr != nil && HoldFor(DefaultHolds, k.channel) > 0 { |
| + bakeable[acctApp{k.acct, k.app}] = true |
| + } |
| + } |
| + prod := map[acctApp]prodState{} |
| + for ak := range bakeable { |
| + // A missing flag is (nil, nil) — the default, not production. Any |
| + // OTHER failure is kept as an error and fails toward baking in |
| + // ProductionBake: an attacker who can corrupt but not sign must not |
| + // be able to shorten a window by breaking the object. |
| + f, err := GetProduction(ctx, s, pub, ak.acct, ak.app) |
| + prod[ak] = prodState{flag: f, err: err} |
| + if err != nil { |
| + // SAY SO. This failure re-imposes the full window on every held |
| + // head of the app, and the most likely cause is not tampering at |
| + // all: on a GetObject-without-ListBucket fleet (the same IAM shape |
| + // the missing-pointer branch above already handles) a GET of an |
| + // ABSENT key answers 403, not 404 — so the spec's default-OFF |
| + // silently inverts to production for every app with no flag. A |
| + // 72h hold nobody can see the reason for is the worst version of |
| + // this feature; log it like every other per-app problem here and |
| + // carry on. |
| + fmt.Printf(" %s/%s: production flag unreadable (%v) — treating as production, holds apply\n", ak.acct, ak.app, err) |
| + } |
| + } |
| + |
| // Tick the bake clock: record every channel head this box hasn't seen at |
| // that head before, so the supply-chain window is measured from the box's |
| // own first sighting, never a feed timestamp (bake.go). The clock only |
| @@ -219,7 +258,51 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| changed = true |
| } |
| } |
| - if changed { |
| + // ORDER IS LOAD-BEARING: fold this pass's verified flag reads into the |
| + // box's production memory HERE, before any bake verdict below is taken. |
| + // That is what makes prodMemory's "a mixed-version rollout costs at most |
| + // one pass of this memory" true — a binary that wiped the latch re-latches |
| + // it from the bucket and honors it in the SAME pass, never a pass later. |
| + // |
| + // The err == nil guard is the second lock on a door ObserveProduction's |
| + // own nil-check already holds (GetProduction returns a nil flag on every |
| + // error path, and a nil flag is a no-op there). It stays because the rule |
| + // it states — the memory only ever ticks for VERIFIED documents — is |
| + // worth being true at the call site too, not only at the callee. |
| + for ak, ps := range prod { |
| + if ps.err == nil && bake.ObserveProduction(ak.acct, ak.app, ps.flag, now) { |
| + changed = true |
| + } |
| + } |
| + // bake.tainted forces a Save for a different reason than migrated: the |
| + // clock file existed but did not read or parse, so every held head on |
| + // this box is failing toward baking until that file is replaced |
| + // (ProductionBake). Left alone, the corrupt file would survive every |
| + // changed-gated pass and the box would bake forever. |
| + // |
| + // But ONLY a full pass may perform that repair. A NARROWED pass |
| + // (extra != nil — the canary fast loop, every 2-3 seconds) resolves only |
| + // channels with no hold, so it fetched no production flags at all: its |
| + // in-memory Production map is EMPTY, not repaired. Letting it write would |
| + // overwrite the corrupt file with that emptiness AND clear the taint that |
| + // is currently failing closed, all before anything re-observed the flags |
| + // — and if the bucket flag had also been deleted, the latch would be gone |
| + // for good. So a tainted clock on a narrowed pass skips the Save entirely, |
| + // forced or ordinary: a pass that fetched no flags cannot repair what it |
| + // would overwrite. Nothing is lost — the canary observations it would |
| + // have recorded re-occur within seconds, and the hourly pass does the |
| + // repair with the flags actually in hand. |
| + // |
| + // Residual, accepted: even the full pass only re-observes flags for apps |
| + // with a currently-resolved HELD head, so an app whose channel pointer is |
| + // simultaneously unreadable keeps its latch out of the repaired file |
| + // until that pointer heals. That is triple-tamper territory (corrupt |
| + // clock file AND unreadable pointer AND deleted flag) and sits inside the |
| + // box-file trust boundary LoadFirstSeen already documents as accepted. |
| + switch { |
| + case bake.tainted && extra != nil: |
| + // skip: see above. |
| + case changed || bake.tainted: |
| if err := bake.Save(); err != nil { |
| return fmt.Errorf("record bake clock: %v", err) |
| } |
| @@ -229,6 +312,18 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // this pass — one bad build shouldn't bounce every host in turn. |
| poisoned := map[string]bool{} |
| + // One definition of "does the window apply here", shared by the awake and |
| + // hibernating loops below — the same question must not be able to be |
| + // answered two ways for the same app depending on whether it sleeps. |
| + // |
| + // RouteAccount(rt), never the raw rt.Account column: the group and |
| + // bakeable keys were built from RouteAccount output, and an unmigrated |
| + // row can still carry an account NAME there, which resolves to ops. |
| + bakeIgnore := func(rt registry.Route) bool { |
| + ak := acctApp{RouteAccount(rt), rt.App} |
| + return !bake.ProductionBake(ak.acct, ak.app, prod[ak].err, bakeable[ak], DefaultHolds, now) |
| + } |
| + |
| // Awake hosts first, hibernating second: repoints have no health gate, so |
| // let an awake host discover (and poison) a broken build before any |
| // sleeper is moved onto it. |
| @@ -242,12 +337,12 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| continue |
| } |
| ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}] |
| - adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg.nag) |
| + adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg.nag, bakeIgnore(rt)) |
| } |
| for _, rt := range hibernating { |
| ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}] |
| // restart=nil: hibernating hosts get the pure repoint — never woken. |
| - adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg.nag) |
| + adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg.nag, bakeIgnore(rt)) |
| } |
| return nil |
| } |
| @@ -300,7 +395,13 @@ func RouteAccount(rt registry.Route) string { |
| // nag is passConfig.nag from the call's PassOptions — nil (the ordinary |
| // case) means the canary-TTL nag below never fires, never even builds its |
| // string; see NagExpiredCanaries for who sets it and why. |
| -func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time, nag io.Writer) { |
| +// |
| +// ignore is the app-level bake verdict from ProductionBake — the production |
| +// flag's doing, never a publisher field. True means the supply-chain window |
| +// does not CURRENTLY apply to this app on this box (not declared production, |
| +// or declared off and past the ratchet); the caller computes it once per |
| +// route from the flag it read this pass. |
| +func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time, nag io.Writer, ignore bool) { |
| if ptr == nil { |
| return |
| } |
| @@ -336,7 +437,10 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri |
| if cur == ver { |
| return // up to date — stay quiet on the periodic tick |
| } |
| - if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, DefaultHolds, false, now); hold != "" { |
| + // Two independent waivers, both root-signed in origin: ignore is the |
| + // app's production verdict (§2/§4), ptr.FastTrack is this ONE pointer's |
| + // per-promotion waiver (§5). Either one alone releases the head. |
| + if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, DefaultHolds, ignore || ptr.FastTrack, now); hold != "" { |
| fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, ver, channel, hold) |
| return |
| } |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index f7ffb98e1..962ab16f3 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -195,6 +195,11 @@ func TestPassStableHeldByBakeWindow(t *testing.T) { |
| 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 { |
| @@ -689,3 +694,318 @@ func withCapturedStdout(t *testing.T, buf *bytes.Buffer, fn func()) { |
| w.Close() |
| <-done |
| } |
| + |
| +// ---- the production flag on the adopt path (2026-08-04 spec §6) ------------- |
| + |
| +// 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.Fatalf("Pass: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "" { |
| + t.Fatalf("live = %q, want held — a corrupt flag must read as production", v) |
| + } |
| +} |
| + |
| +// ORDERING, pinned: ObserveProduction runs BEFORE this pass's bake verdicts |
| +// are taken, so the bucket flag re-latches a wiped memory within ONE pass — |
| +// the whole basis of prodMemory's "a mixed-version rollout costs at most one |
| +// pass of this memory" comment. Simulates the old binary that re-marshals a |
| +// struct with no Production field and thereby deletes the latch. |
| +func TestPassReLatchesProductionMemoryInTheSamePass(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) |
| + } |
| + st := LoadFirstSeen(FirstSeenPath(opt)) |
| + if len(st.Production) == 0 { |
| + t.Fatalf("first pass did not latch the production memory") |
| + } |
| + |
| + // The old binary's write: every channel observation survives, the |
| + // production memory (and with it the replay floor) is gone. |
| + st.Production = map[string]*prodMemory{} |
| + if err := st.Save(); err != nil { |
| + t.Fatalf("Save wiped clock: %v", 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 != "" { |
| + t.Fatalf("live = %q, want still held — the bucket flag must re-latch BEFORE this pass's bake verdict, not after it", v) |
| + } |
| + if st := LoadFirstSeen(FirstSeenPath(opt)); len(st.Production) == 0 { |
| + t.Fatalf("the re-latch was not persisted") |
| + } |
| +} |
| + |
| +// A clock file that exists but does not parse is rewritten within ONE pass, |
| +// even when the pass observes nothing new — the taint-forced Save. Without |
| +// it a corrupt file would sit there forever, and every held head on the box |
| +// would fail toward baking pass after pass (ProductionBake's st.tainted). |
| +func TestPassRewritesATaintedClockInOnePass(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + _, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) // no routes: nothing new is observed this pass |
| + |
| + if err := os.WriteFile(FirstSeenPath(opt), []byte("{not json"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st := LoadFirstSeen(FirstSeenPath(opt)); !st.tainted { |
| + t.Fatalf("setup: the corrupt clock did not read as tainted") |
| + } |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if st := LoadFirstSeen(FirstSeenPath(opt)); st.tainted { |
| + t.Fatalf("the pass left the clock file corrupt — a tainted clock must force its own Save") |
| + } |
| +} |
| + |
| +// prodReadStore counts Get calls for one exact key. |
| +type prodReadStore struct { |
| + Store |
| + key string |
| + n int |
| +} |
| + |
| +func (p *prodReadStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == p.key { |
| + p.n++ |
| + } |
| + return p.Store.Get(ctx, key) |
| +} |
| + |
| +// The 2-3 SECOND canary fast loop must never pay a production-flag bucket |
| +// read: canary/* has no window (DefaultHolds), so no canary-only pass ever |
| +// has a HELD head to ask about. The hourly pass pays exactly one read per |
| +// (account, app) with a held head — not one per channel, not one per route. |
| +func TestCanaryFastLoopPaysNoProductionRead(t *testing.T) { |
| + dir := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, dir, "bab", "myapp", "v1", adoptPlatform, []byte("bin")) |
| + for _, ch := range []string{"canary/x", "canary/y", "stable"} { |
| + if err := Promote(ctx, dir, priv, "bab", "myapp", ch, "v1"); err != nil { |
| + t.Fatalf("Promote %s: %v", ch, err) |
| + } |
| + } |
| + if err := SetProduction(ctx, dir, priv, "bab", "myapp", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, "cx.example.com", "myapp", "canary/x", false) |
| + addRoute(t, reg, "cy.example.com", "myapp", "canary/y", false) |
| + addRoute(t, reg, "s.example.com", "myapp", "stable", false) |
| + |
| + s := &prodReadStore{Store: dir, key: ProductionKey("bab", "myapp")} |
| + if err := PassFilter(ctx, reg, s, pub, opt, func(string) error { return nil }, isCanaryTestRoute); err != nil { |
| + t.Fatalf("PassFilter: %v", err) |
| + } |
| + if s.n != 0 { |
| + t.Fatalf("canary-only pass read the production flag %d times, want 0 — the fast loop must not pay bucket reads for a window canary/* does not have", s.n) |
| + } |
| + |
| + // Not vacuous: the full pass, which DOES resolve a held head, reads it — |
| + // once, for two canary channels, a stable one and three routes. |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if s.n != 1 { |
| + t.Fatalf("full pass read the production flag %d times, want exactly 1 per (account, app)", s.n) |
| + } |
| +} |
| + |
| +// A NARROWED pass (the canary fast loop) must never repair a tainted clock: |
| +// it fetched no production flags, so its Production map is empty, and writing |
| +// it would replace the corrupt file with that emptiness AND clear the taint |
| +// that is currently failing closed — before anything re-observed the flags. |
| +// The full pass, which holds the flags, does the repair. |
| +func TestNarrowedPassNeverRepairsATaintedClock(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")) |
| + for _, ch := range []string{"canary/x", "stable"} { |
| + if err := Promote(ctx, s, priv, "bab", "myapp", ch, "v1"); err != nil { |
| + t.Fatalf("Promote %s: %v", ch, err) |
| + } |
| + } |
| + if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, "cx.example.com", "myapp", "canary/x", false) |
| + addRoute(t, reg, "s.example.com", "myapp", "stable", false) |
| + |
| + if err := os.WriteFile(FirstSeenPath(opt), []byte("{not json"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if err := PassFilter(ctx, reg, s, pub, opt, func(string) error { return nil }, isCanaryTestRoute); err != nil { |
| + t.Fatalf("PassFilter: %v", err) |
| + } |
| + if raw, err := os.ReadFile(FirstSeenPath(opt)); err != nil { |
| + t.Fatal(err) |
| + } else if json.Valid(raw) { |
| + t.Fatalf("the canary fast loop overwrote a tainted clock — a pass that fetched no flags cannot repair what it would overwrite; file is now %q", raw) |
| + } |
| + |
| + // The hourly pass has the flags in hand, so it repairs — and the app's |
| + // production state survives, re-latched from the bucket in that same pass. |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + st := LoadFirstSeen(FirstSeenPath(opt)) |
| + if st.tainted { |
| + t.Fatalf("the full pass did not repair the tainted clock") |
| + } |
| + if mem := st.Production[prodKey("bab", "myapp")]; mem == nil || !mem.WasProduction { |
| + t.Fatalf("production memory = %+v, want the latch re-established by the repairing pass", mem) |
| + } |
| +} |
| + |
| +// The off-ratchet, end to end on the adopt path: flipping production OFF does |
| +// not release a held head immediately — the off is honored only after the |
| +// stable window has elapsed on this box's own sighting of it (spec §4). This |
| +// is the dangerous direction, so it is pinned here and not only in bake_test: |
| +// it is also what fails if ProductionBake's hasBakeableHead carve-out is ever |
| +// passed a constant false from this call site. |
| +func TestPassProductionOffStillRatchets(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 (declared production)", v) |
| + } |
| + |
| + // Flip it off. An operator who can flip the flag must not thereby have a |
| + // same-minute bypass of the window their held head is sitting in. |
| + if err := SetProduction(ctx, s, priv, "bab", "myapp", false, "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 != "" { |
| + t.Fatalf("live = %q, want STILL held — production: false must ratchet through the stable window, never release a held head on sight", v) |
| + } |
| + // And the box recorded the off-sighting, which is what the ratchet is |
| + // measured from — its own clock, never the flag's timestamp. |
| + mem := LoadFirstSeen(FirstSeenPath(opt)).Production[prodKey("bab", "myapp")] |
| + if mem == nil || mem.OffFirstSeen.IsZero() || !mem.WasProduction { |
| + t.Fatalf("production memory = %+v, want a latched app with an off-sighting recorded", mem) |
| + } |
| +} |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index ed87fbc43..8b8d0c311 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -13,7 +13,8 @@ |
| // promote`). beta's 24h absorbs the caught-within-a-day mass of real |
| // supply-chain incidents; stable's 72h additionally spans a weekend, so a |
| // Friday-night malicious publish is seen by a human on Monday with margin to |
| -// pull it back. |
| +// pull it back. edge has no window either — merged to main is picked up as it |
| +// lands (pipelines spec §3). |
| // |
| // There is deliberately NO publisher-side bypass ("critical fix, skip the |
| // hold") — that flag is the first thing an attacker sets. The bypass is |
| @@ -23,6 +24,7 @@ package release |
| import ( |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "math/rand" |
| "os" |
| @@ -35,6 +37,7 @@ import ( |
| // overrides come from host config as a replacement map passed by the caller. |
| var DefaultHolds = map[string]time.Duration{ |
| "canary/*": 0, |
| + "edge": 0, |
| "beta": 24 * time.Hour, |
| "stable": 72 * time.Hour, |
| } |
| @@ -84,6 +87,27 @@ type FirstSeen struct { |
| // channelClockKey. |
| Channels map[string]map[string]time.Time `json:"channels"` |
| + // Production is the per-app production-flag memory (2026-08-04 console |
| + // bake controls spec §4), keyed with clockSep — the same SEPARATOR |
| + // argument as channelClockKey (a channel segment may contain "/"; an |
| + // account or app name never contains clockSep). prodKey folds "" and |
| + // OpsAccount to the same key — there is no legacy bare shape to |
| + // preserve here, this memory is new, so callers can pass whatever |
| + // RouteAccount(rt) (adopt.go) returns and land on the right memory |
| + // either way. |
| + // |
| + // ADDITIVE, but only one pass deep: an OLD binary that Saves this file |
| + // re-marshals the struct IT knows about, which has no Production field — |
| + // its write DELETES every app's production memory from the shared file. |
| + // That is not silently unsafe: the latch re-latches from the bucket flag |
| + // the very next pass a new binary reads it (true stays true), and a lost |
| + // ratchet clock simply restarts from that pass's sighting (an |
| + // in-progress off-ratchet gets pushed out, never skipped) — both are the |
| + // closed direction on their own. A mixed-version rollout only ever costs |
| + // up to one pass of this memory; it never lets an off-flag through |
| + // early, and never lets a deleted true downgrade anything either. |
| + Production map[string]*prodMemory `json:"production,omitempty"` |
| + |
| path string // where Save persists; set by LoadFirstSeen |
| // migrated marks a clock LoadFirstSeenMigrating built from its |
| @@ -92,6 +116,12 @@ type FirstSeen struct { |
| // change this pass — see LoadFirstSeenMigrating's doc comment for why |
| // a changed-gated Save alone isn't enough to make the migration durable. |
| migrated bool |
| + |
| + // tainted marks a clock whose file read succeeded but did NOT parse as |
| + // JSON — distinct from a missing file, which is just a fresh, empty |
| + // clock. Never serialized (unexported); see LoadFirstSeen and |
| + // ProductionBake for why the two failure modes must be told apart. |
| + tainted bool |
| } |
| // channelClockKey identifies one channel head's clock. The ACCOUNT is part of |
| @@ -126,17 +156,45 @@ func channelClockKey(acct, app, channel string) string { |
| // name (appRe) or a channel segment (channelSegRe). |
| const clockSep = "|" |
| -// LoadFirstSeen reads the bake clock from path. A missing or corrupt file is |
| -// an empty clock — every head then bakes from "now", which fails closed (a |
| -// wiped clock delays updates; it never lets one through early). |
| +// LoadFirstSeen reads the bake clock from path. For the CHANNEL clock, a |
| +// missing OR corrupt file is simply an empty clock — every head then bakes |
| +// from "now", which fails closed (a wiped clock delays updates; it never |
| +// lets one through early either way). |
| +// |
| +// For the PRODUCTION memory (§4), those two failure modes must be told |
| +// apart. A file that reads but does not PARSE marks the clock tainted, and |
| +// ProductionBake fails toward baking for every held head on a tainted |
| +// clock — the same closed direction, reached differently, because an |
| +// unmarshal error can leave Production nil or half-written rather than |
| +// usably empty. A file that is simply ABSENT, though, is indistinguishable |
| +// from a fresh box: the same read that lets the channel clock safely |
| +// restart "from now" also silently loses the production latch and the |
| +// off-ratchet's replay floor, because there is nothing on disk to say a |
| +// latch ever existed. That gap is accepted: the latch defends against |
| +// BUCKET-side tampering (someone with bucket write but no key deleting the |
| +// flag object) — a box's own /data is a different trust boundary. Whatever |
| +// already has write access there already holds adopt authority, i.e. |
| +// already controls what this box installs; deleting its bake-clock file |
| +// buys it nothing a corrupt bucket object would. |
| func LoadFirstSeen(path string) *FirstSeen { |
| st := &FirstSeen{path: path} |
| if raw, err := os.ReadFile(path); err == nil { |
| - _ = json.Unmarshal(raw, st) |
| + if err := json.Unmarshal(raw, st); err != nil { |
| + st.tainted = true |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + // The file EXISTS but this process could not read it (EACCES, |
| + // EISDIR, an I/O error) — a different failure than "no file", and |
| + // one this box cannot tell apart from a bucket object it can't |
| + // trust either. Same taint, same fail-toward-baking direction. |
| + st.tainted = true |
| } |
| if st.Channels == nil { |
| st.Channels = map[string]map[string]time.Time{} |
| } |
| + if st.Production == nil { |
| + st.Production = map[string]*prodMemory{} |
| + } |
| if st.Jitter < 0 || st.Jitter >= 0.25 { |
| st.Jitter = 0 |
| } |
| @@ -302,3 +360,118 @@ func Hold(st *FirstSeen, acct, app, channel, version string, holds map[string]ti |
| } |
| return "" |
| } |
| + |
| +// ---- 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,omitzero"` |
| + // OffSetAt is the discriminator: non-zero means the newest verified word |
| + // for this app is off (only ever read via IsZero — its VALUE isn't |
| + // otherwise consulted; NewestSetAt already carries the replay floor). |
| + // OffFirstSeen is when THIS BOX first saw that off-flag edition — the |
| + // ratchet clock, the box's own observation, never the flag's own |
| + // timestamp (same argument as the bake clock itself). |
| + OffSetAt time.Time `json:"off_set_at,omitzero"` |
| + OffFirstSeen time.Time `json:"off_first_seen,omitzero"` |
| +} |
| + |
| +// prodKey identifies one app's production memory. Joined with clockSep for |
| +// the same reason channelClockKey is (an app or account name can never |
| +// contain it, unlike a channel segment), and it folds "" and OpsAccount to |
| +// the same key — unlike channelClockKey there's no legacy bare shape to |
| +// keep separate, this memory is new, so RouteAccount(rt)'s either form |
| +// always resolves to the one record. |
| +func prodKey(acct, app string) string { |
| + if acct == "" || acct == OpsAccount { |
| + acct = OpsAccount |
| + } |
| + 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 st.tainted { |
| + // This box's own clock file existed but couldn't be read or didn't |
| + // parse — untrustworthy in a way absence never is. Fail toward |
| + // baking for every held head, same direction as flagErr, for the |
| + // lifetime of THIS load: tainted is set once, in LoadFirstSeen, and |
| + // nothing clears it in memory afterward. Save rewrites the file |
| + // from this struct's current (now-valid, in-memory) content, so the |
| + // NEXT LoadFirstSeen of that path starts clean. |
| + return true |
| + } |
| + 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) |
| +} |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index 90077ebf6..7465509cb 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| + "fmt" |
| "os" |
| "path/filepath" |
| "testing" |
| @@ -356,3 +357,253 @@ func TestFirstSeenSaveReplacesFileItCannotWriteInPlace(t *testing.T) { |
| t.Fatal("observation missing after Save") |
| } |
| } |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +// 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") |
| + } |
| + }) |
| + |
| + // Task-4-review fix 2: prodKey must fold the ops account the same way |
| + // channelClockKey does, or an app addressed as "" (the pre-accounts |
| + // bare shape some callers still use) and the same app addressed as |
| + // OpsAccount silently keep SEPARATE production memories. |
| + t.Run("prodKey folds the ops account like channelClockKey", func(t *testing.T) { |
| + st := newClock() |
| + st.ObserveProduction(OpsAccount, "kass", flag(true, base), base) |
| + if !st.ProductionBake("", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal(`observed under OpsAccount, not found evaluating under ""`) |
| + } |
| + |
| + st2 := newClock() |
| + st2.ObserveProduction("", "kass", flag(true, base), base) |
| + if !st2.ProductionBake(OpsAccount, "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal(`observed under "", not found evaluating under OpsAccount`) |
| + } |
| + // No free ride for an unrelated account: "bab"/"kass" (the ops |
| + // account) must not satisfy "zzz"/"kass". |
| + if st2.ProductionBake("zzz", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("an unrelated account satisfied the ops account's production memory") |
| + } |
| + }) |
| + |
| + // Task-4-review fix 3a: re-reading the SAME off object (the every-pass |
| + // re-read, not a fresh sighting) must not change anything — in |
| + // particular must not push the ratchet clock forward from the box's |
| + // FIRST sighting to whatever pass happens to re-read it. |
| + t.Run("equal set_at re-read does not reset the ratchet clock", 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)) // first sighting |
| + if st.ObserveProduction("bab", "kass", off, base.Add(3*time.Hour)) { |
| + t.Fatal("re-reading the identical off object reported a change") |
| + } |
| + hold := HoldFor(DefaultHolds, RungStable) |
| + // Still measured from the FIRST sighting (base+2h), not the re-read |
| + // (base+3h) — a re-read one hour later must not buy the flag one |
| + // more hour of ratchet. |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold-time.Minute)) { |
| + t.Fatal("off honored before the ratchet from its first sighting elapsed") |
| + } |
| + if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold+time.Minute)) { |
| + t.Fatal("re-reading the same object pushed the ratchet clock forward") |
| + } |
| + }) |
| + |
| + // Task-4-review fix 3b: an in-progress off-ratchet (not just a bare |
| + // "on" latch) must itself survive Save/LoadFirstSeen. |
| + t.Run("off-state ratchet survives a save/load round trip", 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 err := st.Save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st2 := LoadFirstSeen(st.path) |
| + hold := HoldFor(DefaultHolds, RungStable) |
| + if !st2.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(hold-time.Minute)) { |
| + t.Fatal("off-state ratchet lost on reload: should still be baking mid-window") |
| + } |
| + if st2.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(hold+time.Minute)) { |
| + t.Fatal("off-state ratchet lost on reload: should be off past the window") |
| + } |
| + }) |
| + |
| + // Task-4-review fix 3c: the ratchet is jittered exactly like any other |
| + // hold (HoldFor's formula), not a bare, un-jittered 72h. |
| + t.Run("the ratchet is jittered like any other hold", func(t *testing.T) { |
| + st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json")) |
| + st.Jitter = 0.2 |
| + st.ObserveProduction("bab", "kass", flag(true, base), base) |
| + st.ObserveProduction("bab", "kass", flag(false, base.Add(time.Hour)), base.Add(time.Hour)) |
| + hold := HoldFor(DefaultHolds, RungStable) // 72h, unjittered |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(hold).Add(time.Minute)) { |
| + t.Fatal("jitter not applied: off honored right at the un-jittered window") |
| + } |
| + jittered := time.Duration(float64(hold) * 1.2) |
| + if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(jittered).Add(time.Minute)) { |
| + t.Fatal("jitter not applied: off still honored past the jittered window") |
| + } |
| + }) |
| + |
| + // Task-4-review fix 3d: an app declared off and NEVER on has nothing to |
| + // ratchet — it must never bake, immediately and forever. |
| + t.Run("an app declared off and never on never bakes", func(t *testing.T) { |
| + st := newClock() |
| + st.ObserveProduction("bab", "kass", flag(false, base), base) |
| + if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("an app declared off, never on, baked immediately") |
| + } |
| + if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(1000*time.Hour)) { |
| + t.Fatal("an app declared off, never on, baked eventually") |
| + } |
| + }) |
| +} |
| + |
| +// Task-4-review fix 1 (Critical): LoadFirstSeen must distinguish a file that |
| +// read but did not PARSE from a file that is simply absent. Before this fix |
| +// both collapsed to the same "empty clock" result, which for the CHANNEL |
| +// clock is fine (fails closed either way — everything re-bakes from now), |
| +// but for the PRODUCTION memory silently failed OPEN: a corrupt clock file |
| +// (mem == nil for every app) made ProductionBake return false, instantly |
| +// un-baking every production app on the box. A corrupt file must instead |
| +// fail TOWARD baking (st.tainted), while an absent file — a genuinely fresh |
| +// box — still gets the ordinary false default. |
| +func TestCorruptClockFailsTowardBakingNotAwayFromIt(t *testing.T) { |
| + base := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) |
| + |
| + path := filepath.Join(t.TempDir(), "clock.json") |
| + if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + st := LoadFirstSeen(path) |
| + if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("a corrupt clock file did not fail toward baking") |
| + } |
| + |
| + // A genuinely fresh box (no file at all) is a different case entirely — |
| + // still the ordinary false default, not tainted. |
| + fresh := LoadFirstSeen(filepath.Join(t.TempDir(), "absent.json")) |
| + if fresh.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("a fresh box with no clock file baked by default") |
| + } |
| + |
| + // A file that EXISTS but can't be read at all (not "did not parse") is |
| + // a different os.ReadFile failure mode than os.ErrNotExist — a |
| + // directory sitting at the clock path is the easiest way to reproduce |
| + // it portably (EISDIR), and review found the first cut of this fix only |
| + // tainted the "read OK, parse failed" branch, leaving this one to |
| + // silently fail open. |
| + dirPath := filepath.Join(t.TempDir(), "clock.json") |
| + if err := os.Mkdir(dirPath, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + unreadable := LoadFirstSeen(dirPath) |
| + if !unreadable.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) { |
| + t.Fatal("an unreadable (directory-at-path) clock file did not fail toward baking") |
| + } |
| +} |
diff --git a/internal/release/production.go b/internal/release/production.go| new file mode 100644 |
| index 000000000..29383b989 |
| --- /dev/null |
| +++ b/internal/release/production.go |
| @@ -0,0 +1,177 @@ |
| +// 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. |
| +// |
| +// WARNING to anything that copies objects under an app prefix (`carlos |
| +// accounts migrate` is the one such thing today): this object must be |
| +// RE-DECLARED at a new account, never copied. Its signed payload names the |
| +// account, so a byte-identical copy fails validateProductionFlag at the |
| +// destination forever — which every box reads as "unreadable, keep baking", |
| +// holding the app's held heads with no expiry and no error anyone sees. |
| +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) |
| + raw, 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) |
| + } |
| + setAt := time.Now().UTC().Truncate(time.Second) |
| + if err == nil { |
| + // SetAt is truncated to the second; two flips inside one wall-clock |
| + // second would otherwise share a value, and the box-side replay |
| + // floor (bake.go's NewestSetAt) treats an equal set_at as nothing |
| + // new — silently swallowing the second flip on every box. Bump |
| + // strictly past whatever this key's own last signed write recorded. |
| + if existing, verr := verifyProductionFlag(priv.Public().(ed25519.PublicKey), raw, acct, app); verr == nil && !existing.SetAt.Before(setAt) { |
| + setAt = existing.SetAt.Add(time.Second) |
| + } |
| + } |
| + f := &ProductionFlag{ |
| + Schema: 1, Account: acct, App: app, Production: production, |
| + Actor: actor, SetAt: setAt, |
| + } |
| + 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 |
| +} |
diff --git a/internal/release/production_test.go b/internal/release/production_test.go| new file mode 100644 |
| index 000000000..2ae0866fc |
| --- /dev/null |
| +++ b/internal/release/production_test.go |
| @@ -0,0 +1,240 @@ |
| +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" |
| + "time" |
| +) |
| + |
| +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") |
| + } |
| +} |
| + |
| +// The signature binds the exact base64 payload STRING, not the decoded JSON |
| +// meaning — so a payload that is decoded, edited, and re-encoded (even |
| +// leaving the original Sig in place) must fail verification. A verifier that |
| +// decoded first and checked a signature over re-marshalled bytes would let |
| +// this through if the re-marshalling happened to reproduce the tampered |
| +// bytes; this test pins the "no canonicalisation" wire rule directly against |
| +// the field an attacker would actually want to flip. |
| +func TestProductionFlagRefusesTamperedPayload(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.Fatal(err) |
| + } |
| + raw, etag, err := s.Get(ctx, ProductionKey("bab", "kass")) |
| + 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) |
| + } |
| + var f ProductionFlag |
| + if err := json.Unmarshal(payload, &f); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !f.Production { |
| + t.Fatal("expected production=true before tampering") |
| + } |
| + f.Production = false // flip the bit an attacker would want to flip |
| + tampered, err := json.Marshal(&f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Keep the ORIGINAL Sig — it covers the ORIGINAL payload string. A |
| + // re-encoded payload, even with identical decoded meaning, must fail |
| + // verification without a fresh signature. |
| + sw.Payload = base64.StdEncoding.EncodeToString(tampered) |
| + wrapper, err := json.Marshal(sw) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, ProductionKey("bab", "kass"), wrapper, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := GetProduction(ctx, s, pub, "bab", "kass"); err == nil { |
| + t.Fatal("a tampered payload (re-encoded, original sig) verified") |
| + } |
| +} |
| + |
| +// 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) } |
| + |
| +// Task-4-review fix 5 (Minor, promoted), tightened by re-review: SetAt is |
| +// truncated to the second, so two flips within the same wall-clock second |
| +// would otherwise share a set_at — and the box-side replay floor (bake.go's |
| +// NewestSetAt) treats an equal set_at as nothing new, silently swallowing |
| +// the second flip on every box that already saw the first. A single |
| +// before/after pair is vacuous if the two calls happen to straddle a |
| +// second boundary (it would pass with or without the bump). Looping several |
| +// rapid flips and requiring EVERY consecutive pair to be strictly |
| +// increasing removes that escape: the bump guarantees strict monotonicity |
| +// regardless of whether any given pair lands in the same truncated second |
| +// or crosses one, so this holds deterministically with the fix in place. |
| +func TestSetProductionMonotonicSetAtOnRapidFlip(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + var prev time.Time |
| + for i := 0; i < 3; i++ { |
| + if err := SetProduction(ctx, s, priv, "bab", "kass", i%2 == 0, "paul@keymail.dev"); err != nil { |
| + t.Fatalf("SetProduction flip %d: %v", i, err) |
| + } |
| + f, err := GetProduction(ctx, s, pub, "bab", "kass") |
| + if err != nil || f == nil { |
| + t.Fatalf("GetProduction after flip %d: %v, %v", i, f, err) |
| + } |
| + if i > 0 && !f.SetAt.After(prev) { |
| + t.Fatalf("flip %d's set_at %v not strictly after flip %d's %v (same-second collision not bumped)", |
| + i, f.SetAt, i-1, prev) |
| + } |
| + prev = f.SetAt |
| + } |
| +} |
diff --git a/internal/release/release.go b/internal/release/release.go| index e67ac5ab9..5fec54c66 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -81,6 +81,13 @@ type ChannelPointer struct { |
| // pointer written before the trail existed has no "audit" key, parses |
| // with none, and is not an error; that is most of the live fleet. |
| Audit []Promotion `json:"audit,omitempty"` |
| + // 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"` |
| } |
| // Promotion is one entry in a channel pointer's audit trail. It rides inside |
| @@ -99,8 +106,11 @@ type Promotion struct { |
| Actor string `json:"actor,omitempty"` |
| // Hotfix records that the ladder was bypassed. This is the whole reason |
| // the field exists: a bypass nobody can see is a broken gate. |
| - Hotfix bool `json:"hotfix,omitempty"` |
| - At time.Time `json:"at"` |
| + Hotfix bool `json:"hotfix,omitempty"` |
| + // 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"` |
| + At time.Time `json:"at"` |
| } |
| // historyMax bounds a pointer's rollback history; auditMax bounds its |
| @@ -272,6 +282,9 @@ func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) { |
| if err := sg.allows(p.Channel, p.PromotedAt); err != nil { |
| return nil, err |
| } |
| + 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) |
| + } |
| payload, err := json.MarshalIndent(p, "", " ") |
| if err != nil { |
| return nil, err |
| @@ -344,6 +357,9 @@ func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel s |
| if err := validatePointer(&p, wantApp, wantChannel); err != nil { |
| return nil, err |
| } |
| + 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) |
| + } |
| if scope != nil { |
| // promoted_at, not the wall clock: adoption re-verifies every pass, so |
| // comparing expiry against now would make an expired grant take a |
| @@ -854,6 +870,57 @@ func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel strin |
| return err |
| } |
| +// 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 |
| +} |
| + |
| // prependCapped puts v at the head of list, dropping the tail beyond max — |
| // the newest-first, bounded shape both the rollback history and the audit |
| // trail have. |
diff --git a/internal/release/release_test.go b/internal/release/release_test.go| index a4385b872..8c0101cbe 100644 |
| --- a/internal/release/release_test.go |
| +++ b/internal/release/release_test.go |
| @@ -537,3 +537,146 @@ func TestPointerWrittenBeforeAuditStillParses(t *testing.T) { |
| t.Fatalf("stable audit = %+v", sp.Audit) |
| } |
| } |
| + |
| +// 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, _ := 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) |
| + } |
| +} |