carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 39e0e08f9ca4efa8ef243f12168f76b27b1c73db parent 764802a
3 files changed, +60 −2
  • internal/console/pendinghandlers.go +4 −2
  • internal/release/bake.go +15 −0
  • internal/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.