release: fix production-memory review findings — corrupt clocks fail toward baking, prodKey folds ops account, ratchet mutants closed
Task-4 review found the box's own corrupt clock file (read but unparsed) silently collapsed to "no memory", making ProductionBake return false and un-baking every production app on the box — the opposite of its fail-toward -baking design. LoadFirstSeen now marks the clock tainted when the file reads but doesn't parse (distinct from simply absent), and ProductionBake checks that first. Also: prodKey now folds "" and OpsAccount to the same key like channelClockKey does; SetProduction bumps SetAt past an equal or regressed existing stamp so a same-second double flip can't share a set_at and get swallowed by the box-side replay floor; prodMemory's time fields use omitzero; and five scenarios (equal-set_at re-read, off-state persistence, jitter, off-only apps, ops-account folding) get their own regression tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VioXzogWf1Xi4Rswz2B6EK
5 files changed,
+247
−17
docs/superpowers/specs/2026-08-04-console-bake-controls-design.md+4 −1internal/release/bake.go+75 −14internal/release/bake_test.go+125 −0internal/release/production.go+13 −2internal/release/production_test.go+30 −0
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 baf25c278..1f6b5f31d 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 |
| @@ -46,7 +46,10 @@ promotions; its boxes just don't sit out the hold. |
| 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. |
| +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: |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index dfb5c8a9e..76536775e 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -87,9 +87,24 @@ type FirstSeen struct { |
| 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 "<acct>|<app>" with clockSep — the same |
| - // collision argument as channelClockKey. ADDITIVE: old clock files have |
| - // no key, old binaries drop this one. |
| + // 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 |
| @@ -100,6 +115,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 |
| @@ -134,13 +155,32 @@ 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 |
| + } |
| } |
| if st.Channels == nil { |
| st.Channels = map[string]map[string]time.Time{} |
| @@ -327,15 +367,29 @@ type prodMemory struct { |
| 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"` |
| + 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"` |
| } |
| -func prodKey(acct, app string) string { return acct + clockSep + app } |
| +// 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. |
| @@ -378,6 +432,13 @@ func (st *FirstSeen) ObserveProduction(acct, app string, flag *ProductionFlag, n |
| // 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 read but did not parse — untrustworthy |
| + // in a way absence never is. Fail toward baking for every held |
| + // head, same direction as flagErr, until the next Save overwrites |
| + // it with something this binary wrote. |
| + return true |
| + } |
| if flagErr != nil { |
| return true |
| } |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index a4ca340c1..f597123a7 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -466,4 +466,129 @@ func TestProductionMemoryRatchet(t *testing.T) { |
| 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") |
| + } |
| } |
diff --git a/internal/release/production.go b/internal/release/production.go| index d22c4f010..b4a679c49 100644 |
| --- a/internal/release/production.go |
| +++ b/internal/release/production.go |
| @@ -62,15 +62,26 @@ func SetProduction(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, |
| return fmt.Errorf("set production: invalid app %q", app) |
| } |
| key := ProductionKey(acct, app) |
| - _, etag, err := s.Get(ctx, key) |
| + 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: time.Now().UTC().Truncate(time.Second), |
| + Actor: actor, SetAt: setAt, |
| } |
| if err := validateProductionFlag(f, acct, app); err != nil { |
| return fmt.Errorf("set production: %w", err) |
diff --git a/internal/release/production_test.go b/internal/release/production_test.go| index d37e79d12..50b86c9ab 100644 |
| --- a/internal/release/production_test.go |
| +++ b/internal/release/production_test.go |
| @@ -205,3 +205,33 @@ func TestProductionFlagRefusesTamperedPayload(t *testing.T) { |
| // 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): 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. Two back-to-back SetProduction calls (as |
| +// fast as a test loop runs them, i.e. very likely within one truncated |
| +// second) must still produce strictly increasing set_ats. |
| +func TestSetProductionMonotonicSetAtOnRapidFlip(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 on: %v", err) |
| + } |
| + f1, err := GetProduction(ctx, s, pub, "bab", "kass") |
| + if err != nil || f1 == nil { |
| + t.Fatalf("GetProduction after first flip: %v, %v", f1, err) |
| + } |
| + if err := SetProduction(ctx, s, priv, "bab", "kass", false, "paul@keymail.dev"); err != nil { |
| + t.Fatalf("SetProduction off: %v", err) |
| + } |
| + f2, err := GetProduction(ctx, s, pub, "bab", "kass") |
| + if err != nil || f2 == nil { |
| + t.Fatalf("GetProduction after second flip: %v, %v", f2, err) |
| + } |
| + if !f2.SetAt.After(f1.SetAt) { |
| + t.Fatalf("second flip's set_at %v not strictly after first's %v (same-second collision not bumped)", f2.SetAt, f1.SetAt) |
| + } |
| +} |