review: final-review fixes — flag repair path, migrate skip, fast-track hold gate
Six findings from the whole-branch review of the console bake controls. M1: ladder.html's Err branch rendered error text and NO form, so an app whose production flag will not verify bakes forever with no console repair — and a root key rotation puts every production app there at once. The branch now renders the turn-ON form (any member's, the safe direction, gated on CanPromote alone) as "Re-declare production"; SetProduction CASes over the unverifiable object. Render tests for the Err branch AND the TurningOff branch, which had never been exercised through HTML. M2: `carlos accounts migrate` copied production.json verbatim. Its signed payload names the SOURCE account, so the copy fails validateProductionFlag at the destination forever — which every box reads as "keep baking", with no expiry and nothing that reads like an error (verifyMigratedPointers checks channel pointers only). migrationPlan now skips the exact key and the verb says, loudly, that the declaration must be re-declared. Warning comment at ProductionKey for the next thing that copies app objects. R1: fast-track accepted a zero-hold channel. Inert for adoption, not inert for the pointer: the re-sign re-stamps PromotedAt, which is what the canary TTL lease is measured from, and the badge is permanent. Refused now — after the owner 404 and the step-up, so a non-owner's response cannot vary with the channel name. R2: the turning-off estimate used set_at + the bare stable hold; boxes ratchet from their own sighting with jitter up to 1.25x, so the console could claim "hosts adopt immediately" for ~19h while boxes still baked. It quotes the maximum now and errs late. R3: enable_bake failures were invisible — silently skipped on a read error, log-only on a write error. Both say so in the flash. O1: spec §9 records the member-race asymmetry (ON is instant and any member's; OFF is owner + passkey + 72h) as accepted design.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VioXzogWf1Xi4Rswz2B6EK
9 files changed,
+359
−23
accounts.go+46 −11docs/superpowers/specs/2026-08-04-console-bake-controls-design.md+14 −0e2e_test.go+94 −0internal/console/bakecontrols_test.go+63 −0internal/console/ladder_test.go+71 −0internal/console/server.go+40 −7internal/console/templates/ladder.html+11 −1internal/console/view.go+13 −4internal/release/production.go+7 −0
diff --git a/accounts.go b/accounts.go| index d7e46b3c3..8c690053f 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -269,7 +269,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)) |
| } |
| @@ -343,6 +343,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) |
| } |
| @@ -395,6 +403,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 |
| @@ -443,9 +471,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. |
| @@ -460,11 +492,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) { |
| @@ -479,11 +512,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 { |
| @@ -517,19 +551,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/specs/2026-08-04-console-bake-controls-design.md b/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md| index 1f6b5f31d..592b20e7b 100644 |
| --- a/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md |
| +++ b/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md |
| @@ -226,6 +226,20 @@ 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 |
diff --git a/e2e_test.go b/e2e_test.go| index ec7c817f9..707f0dbc7 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -1008,6 +1008,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| index 1ec838ced..d62dc69e0 100644 |
| --- a/internal/console/bakecontrols_test.go |
| +++ b/internal/console/bakecontrols_test.go |
| @@ -190,6 +190,41 @@ func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) { |
| 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) { |
| @@ -248,6 +283,34 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) { |
| } |
| } |
| +// 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. |
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go| index 8c993278a..62734d3e4 100644 |
| --- a/internal/console/ladder_test.go |
| +++ b/internal/console/ladder_test.go |
| @@ -652,6 +652,77 @@ func TestLadderBakeControlsMemberView(t *testing.T) { |
| } |
| } |
| +// 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) { |
diff --git a/internal/console/server.go b/internal/console/server.go| index 3f2eb2b15..7669ba265 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1383,13 +1383,29 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| // 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" && ferr == nil && !baking { |
| - 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." |
| - baking = true |
| + // 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 { |
| @@ -1563,6 +1579,23 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| 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) |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index f4290fe0c..f176e9c34 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -6,7 +6,17 @@ 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.</p> |
| + <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}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index 02c56fa03..2f34e51ed 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -160,9 +160,10 @@ type ProductionView struct { |
| 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. |
| + // 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 |
| @@ -398,8 +399,16 @@ func assembleProduction(ctx context.Context, s release.Store, pub ed25519.Public |
| } |
| } |
| 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(hold); now.Before(until) { |
| + if until := flag.SetAt.Add(time.Duration(float64(hold) * 1.25)); now.Before(until) { |
| pv.TurningOff, pv.EffectiveAt = true, until |
| } |
| } |
diff --git a/internal/release/production.go b/internal/release/production.go| index b4a679c49..29383b989 100644 |
| --- a/internal/release/production.go |
| +++ b/internal/release/production.go |
| @@ -43,6 +43,13 @@ type ProductionFlag struct { |
| // 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" |
| } |