bake: a restored pipeline cancels an absence-stamped pending
Re-review regression on the deletion ratchet: delete pipeline.json (absence stamps the legacy default as pending), then restore the object byte-identically — same SetAt, a bucket-versioning restore, an undo, a replay. The replay floor rightly skips the policy block, but the absence-stamped pending survived it and landed one old-window cycle later: enforced 0s under a live verified pipeline declaring 24h, on every later pass. Outside the replay floor, every verified observation now cancels a pending narrower than the currently-declared window — a same-object pending always equals its declared w, so anything narrower was stamped by a policy that is no longer live. Closed direction: cancelling a loosening can only delay a shortening. The plain deletion path (never restored) still lands at legacy, pinned by the existing tests. Also corrects pendingSummary's stale "display only — execution is untouched" note on the Rollback flag: execution now reads it to voice the audit row and the flash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+60
−2
internal/console/pendinghandlers.go+4 −2internal/release/bake.go+15 −0internal/release/bake_test.go+41 −0
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go| index c8574fb1a..7e783e49e 100644 |
| --- a/internal/console/pendinghandlers.go |
| +++ b/internal/console/pendinghandlers.go |
| @@ -141,8 +141,10 @@ func pendingSummary(pc PendingChange) string { |
| // "roll back Y to vX", not "promote vX to Y" — the same pin, read |
| // the way a confirmer who never asked for vX can actually judge |
| // it (Task 8 review): a rollback proposal pins the OLDER version |
| - // as a promotion (Hotfix, order bypassed), and this is display |
| - // only — execution is untouched. |
| + // as a promotion (Hotfix, order bypassed). What lands is |
| + // unchanged by the flag; execution reads it only to voice the |
| + // audit row and the flash in this same rollback wording |
| + // (executePendingPromote). |
| return "roll back " + p.Channel + " to " + p.Version |
| } |
| out := "promote " + p.Version + " to " + p.Channel |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index 994e43b88..9372889c4 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -687,6 +687,21 @@ func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time |
| mem.PendingAt = now |
| } |
| } |
| + // OUTSIDE the replay floor, on every verified observation: a |
| + // pending NARROWER than the currently-declared window can only |
| + // have been stamped by something that is no longer the live |
| + // policy — absence (the deletion ratchet), whose pipeline has |
| + // since been restored byte-identically (same SetAt, so the |
| + // policy block above is skipped; a same-object pending always |
| + // equals its declared w). Left standing, it would land the |
| + // legacy default one old-window cycle later and STAY, enforcing |
| + // less than a live verified pipeline declares on every later |
| + // pass. Cancel it — the closed direction: cancelling a |
| + // loosening can only delay a shortening, never hasten one. |
| + if mem != nil && !mem.PendingAt.IsZero() && mem.Pending < w { |
| + mem.Pending, mem.PendingAt = 0, time.Time{} |
| + changed = true |
| + } |
| } |
| if f.checkHoldPromotion(mem, now) { |
| changed = true |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index 6beacaf2d..2f39f2a4d 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -878,6 +878,47 @@ func TestHoldMemoryDeletionTightensBelowLegacyImmediately(t *testing.T) { |
| } |
| } |
| +// TestHoldMemoryRestoredPipelineCancelsAbsencePending: the re-review probe. |
| +// Delete the pipeline (absence stamps the legacy default pending), then |
| +// restore the object BYTE-IDENTICALLY — same SetAt: a bucket-versioning |
| +// restore, an undo, a replay. The replay floor rightly skips the policy |
| +// block, but the absence-stamped pending must not survive a live verified |
| +// pipeline declaring more: unlanded it is cancelled, and one old-window |
| +// cycle later the enforced window is still the declared 24h, never the |
| +// legacy 0 the deletion briefly put in flight. |
| +func TestHoldMemoryRestoredPipelineCancelsAbsencePending(t *testing.T) { |
| + f := &FirstSeen{Jitter: 0} |
| + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) |
| + p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "24h"}}, SetAt: now} |
| + f.ObservePipeline("bae", "web", p, now) |
| + key := channelClockKey("bae", "web", "edge") |
| + |
| + // The deletion: legacy 0 goes pending under the enforced 24h. |
| + del := now.Add(time.Hour) |
| + f.ObservePipeline("bae", "web", nil, del) |
| + if mem := f.Holds[key]; mem.Pending != 0 || !mem.PendingAt.Equal(del) { |
| + t.Fatalf("absence did not stamp the legacy pending: %+v", mem) |
| + } |
| + |
| + // The restore, byte-identical (same SetAt). The pending is no longer |
| + // the live policy — it must be cancelled, and that is a change. |
| + if !f.ObservePipeline("bae", "web", p, del.Add(time.Hour)) { |
| + t.Fatal("cancelling the absence pending did not report changed — it would never persist") |
| + } |
| + if mem := f.Holds[key]; mem.Window != 24*time.Hour || mem.Pending != 0 || !mem.PendingAt.IsZero() { |
| + t.Fatalf("memory after the restore = %+v, want 24h enforced with nothing pending", mem) |
| + } |
| + |
| + // Past the old window, still observing the restored pipeline: the |
| + // declared 24h holds — the legacy 0 never lands. |
| + if f.ObservePipeline("bae", "web", p, del.Add(25*time.Hour)) { |
| + t.Fatal("a quiescent restored pipeline reported changed") |
| + } |
| + if mem := f.Holds[key]; mem.Window != 24*time.Hour || !mem.PendingAt.IsZero() { |
| + t.Fatalf("memory past the old window = %+v, want the declared 24h still enforced", mem) |
| + } |
| +} |
| + |
| // TestObservePipelineNullHoldsEntryDoesNotPanic: {"holds":{"x":null}} parses |
| // cleanly into a nil *holdMemory. Every loop over Holds must skip it, never |
| // dereference it — a panic here takes down the adopt pass fleet-wide. |