carlos / platform Public

bake: pipeline deletion ratchets down to legacy; nil-entry guards on Holds loops

Final-review findings 1 and 3. Deleting pipeline.json is a plain bucket
write — no signing key — and used to collapse a ratcheted window
instantly: verified absence built no window map, so a channel named edge
with a ratcheted 24h bake fell straight back to its zero legacy default.
Absence is now a policy declaration of the legacy defaults, ratcheted
like any other: ObservePipeline's p == nil branch runs each remembered
channel's legacy default through the same tighten-immediately /
narrower-goes-pending compare as a declared window (no SetAt replay
floor — absence carries none, and the remembered floor stays standing),
re-observed absence never restarts the clock, and the adopt pass's
verified-absent case takes the same memory floor the errored-read case
already had — so the old window is enforced for one full cycle before a
legacy-shaped pass resumes. An app with no pipeline AND no memory keeps
the bare-DefaultHolds path byte-identically, allocation-free.

Finding 3: {"holds":{"x":null}} parses cleanly into a nil *holdMemory
and the floor loop dereferenced it unguarded — a panic that takes the
adopt pass down fleet-wide. Both Holds range loops now skip nil entries.

Spec §3 documents the deletion behaviour; holdMemory's doc no longer
implies prodMemory's deletion-is-not-a-downgrade latch applies here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1c03714759f6f474a35d20ad3191ae9eee49774b parent 7af8d4a
5 files changed, +385 −14
  • docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md +5 −0
  • internal/release/adopt.go +32 −5
  • internal/release/adopt_test.go +170 −0
  • internal/release/bake.go +50 −5
  • internal/release/bake_test.go +128 −4
diff --git a/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md b/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md
index 3035bed3e..8e09f6b12 100644
--- a/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md
+++ b/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md
@@ -95,6 +95,11 @@ change. A *longer* bake is honoured immediately. A channel the box has no
memory of starts from its fail-closed legacy window (`HoldFor` semantics:
the rung's default, 72h for an unknown name), so a brand-new channel's
narrower window also ratchets in rather than applying instantly.
+*(Amended 2026-08-22, final review:)* deleting `pipeline.json` outright — a
+bucket write, no key — ratchets the same way: each channel the box remembers
+treats its legacy default as the newly-declared window, so the app returns
+to legacy holds over one old-window cycle; deletion never collapses a window
+instantly, and never pins the app to a dead pipeline's windows forever.
*(Amended 2026-08-20 during planning:)* a **fast-track of one specific held
release** stays honoured immediately, exactly as shipped today
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index ee3dd26fe..718c6cee2 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -352,7 +352,10 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// flag machinery still applies to an errored app exactly as to any
// legacy app. The error is NEVER folded into the ratchet as a nil: nil
// means verified-absent only (ObservePipeline's contract), and a
- // corrupt object must not masquerade as a deletion.
+ // corrupt object must not masquerade as a deletion — a deletion RATCHETS
+ // each remembered channel down to its legacy default over one old-window
+ // cycle (ObservePipeline's p == nil branch), where an error holds every
+ // remembered window exactly where it is.
type pipeState struct {
p *Pipeline
err error // non-nil: the object exists but would not read or verify
@@ -415,8 +418,10 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// resolves through the box's ratchet memory (HoldsForApp); an ERRORED
// read keeps DefaultHolds floored by that same memory (never looser
// than what this box already enforced — see the pipes comment above);
- // an app with no pipeline at all keeps DefaultHolds, today's behaviour
- // byte-for-byte. canary/* is stitched back in at zero on the verified
+ // a VERIFIED-ABSENT pipeline takes the same memory floor, which is what
+ // enforces the deletion ratchet-down for as long as the memory rides
+ // above legacy; an app with no pipeline AND no memory keeps bare
+ // DefaultHolds, today's behaviour byte-for-byte. canary/* is stitched back in at zero on the verified
// path: it lives outside the pipeline (spec §2), so HoldsForApp's map
// cannot carry it, and HoldFor's fallback for a name missing from the
// map is the 72h fail-closed default — which would silently freeze
@@ -431,8 +436,9 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
h := bake.HoldsForApp(ak.acct, ak.app, ps.p)
h["canary/*"] = 0
appHolds[ak] = h
- case ps.err != nil:
- // The memory floor. Overrides overlay a COPY of DefaultHolds,
+ default:
+ // The memory floor — for an ERRORED read and for VERIFIED
+ // ABSENCE alike. Overrides overlay a COPY of DefaultHolds,
// so a remembered channel is only ever RAISED above its legacy
// resolution, an unremembered one resolves exactly as legacy
// (HoldFor's 72h unknown-name fallback included), and canary/*
@@ -442,9 +448,30 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// itself. The prefix match is exact by construction: neither an
// account, an app name nor a non-canary channel can contain
// clockSep or "/" (channelClockKey's own injectivity argument).
+ //
+ // For verified absence the floor is what makes the deletion
+ // ratchet (ObservePipeline's p == nil branch, folded above)
+ // ENFORCED: during the ratchet-down cycle a remembered channel's
+ // window still exceeds its legacy default, so deleting the
+ // object cannot adopt a held head this pass. Once the cycle
+ // lands the memory at legacy, nothing exceeds the default, no
+ // map is built, and the app is back on the bare-DefaultHolds
+ // path. An app with no memory at all — every legacy app, every
+ // pass — takes that bare path byte-identically, and the len
+ // check keeps the fleet-wide no-pipeline case allocation-free.
+ if len(bake.Holds) == 0 {
+ break
+ }
var h map[string]time.Duration
prefix := holdKeyPrefix(ak.acct, ak.app)
for key, mem := range bake.Holds {
+ // mem == nil: {"holds":{"x":null}} parses cleanly into a
+ // nil entry — skip it, never dereference; a panic here
+ // takes down the adopt pass on every box that loads the
+ // file.
+ if mem == nil {
+ continue
+ }
name := strings.TrimPrefix(key, prefix)
if name == key || name == "" {
continue
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index ec891f4fb..0268cd28b 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -3008,6 +3008,176 @@ func TestPassErroredPipelineReadKeepsTightenedWindows(t *testing.T) {
}
}
+// TestPassDeletedPipelineRatchetsDown: deleting pipeline.json is a plain
+// bucket write — no signing key — so verified absence must never collapse a
+// ratcheted window instantly. Deletion ratchets DOWN to the legacy defaults
+// instead: each remembered channel keeps its enforced window for one full
+// old-window cycle, then lands at HoldFor(DefaultHolds, name) and the app
+// resumes a legacy-shaped pass.
+func TestPassDeletedPipelineRatchetsDown(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", []PipelineChannel{
+ {Name: "edge", Bake: "24h"},
+ }, "test"); err != nil {
+ t.Fatalf("SetPipeline: %v", err)
+ }
+ // The flag the console co-writes with any nonzero-bake pipeline.
+ if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil {
+ t.Fatalf("SetProduction: %v", err)
+ }
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "edge", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "e.example.com", "myapp", "edge", false)
+
+ // Pass 1, pipeline verified: edge tightens to 24h immediately — held.
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "e.example.com"); v != "" {
+ t.Fatalf("live = %q, want held — the verified pipeline tightened edge to 24h", v)
+ }
+
+ // The attack: delete the object outright.
+ if err := s.Delete(ctx, PipelineKey("bab", "myapp")); err != nil {
+ t.Fatalf("delete pipeline: %v", err)
+ }
+
+ // Pass 2: STILL held. Bare DefaultHolds would hand edge its zero legacy
+ // window back and adopt v1 in this very pass.
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "e.example.com"); v != "" {
+ t.Fatalf("live = %q, want STILL held — deletion must ratchet the 24h window down, never collapse it", v)
+ }
+ key := channelClockKey("bab", "myapp", "edge")
+ st := LoadFirstSeen(FirstSeenPath(opt))
+ mem := st.Holds[key]
+ if mem == nil || mem.Window != 24*time.Hour || mem.Pending != 0 || mem.PendingAt.IsZero() {
+ t.Fatalf("hold memory = %+v, want 24h enforced with the legacy 0 pending", mem)
+ }
+ pendingAt := mem.PendingAt
+
+ // Pass 3, absence re-observed: the pending clock must NOT restart.
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if mem := LoadFirstSeen(FirstSeenPath(opt)).Holds[key]; mem == nil || !mem.PendingAt.Equal(pendingAt) {
+ t.Fatalf("re-observed absence restarted the pending clock: %+v, want PendingAt %v", mem, pendingAt)
+ }
+
+ // Elapse the OLD window on the box's own clock (24h jittered < 30h <
+ // 31h). Next pass: the legacy default lands, edge is back at zero, and a
+ // legacy-shaped pass adopts v1.
+ st = LoadFirstSeen(FirstSeenPath(opt))
+ st.Holds[key].PendingAt = st.Holds[key].PendingAt.Add(-31 * time.Hour)
+ if seen, ok := st.Channels[key]["v1"]; ok {
+ st.Channels[key]["v1"] = seen.Add(-31 * time.Hour)
+ }
+ if err := st.Save(); err != nil {
+ t.Fatalf("Save backdated clock: %v", err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "e.example.com"); v != "v1" {
+ t.Fatalf("live = %q, want v1 — the old window elapsed, so legacy holds (edge 0) resume", v)
+ }
+ if mem := LoadFirstSeen(FirstSeenPath(opt)).Holds[key]; mem == nil || mem.Window != 0 || !mem.PendingAt.IsZero() {
+ t.Fatalf("persisted hold memory = %+v, want the landed legacy 0", mem)
+ }
+}
+
+// TestPassNoPipelineNoMemoryStaysLegacy pins the other half of the deletion
+// ratchet's contract: an app with NO pipeline and NO ratchet memory — every
+// legacy app on every pass — keeps the bare DefaultHolds path byte-identically.
+// Edge adopts in the first pass, and no holds memory is minted from nothing.
+func TestPassNoPipelineNoMemoryStaysLegacy(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", "edge", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "e.example.com", "myapp", "edge", 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, "e.example.com"); v != "v1" {
+ t.Fatalf("live = %q, want v1 — a legacy app's edge adopts immediately, untouched", v)
+ }
+ if holds := LoadFirstSeen(FirstSeenPath(opt)).Holds; len(holds) != 0 {
+ t.Fatalf("holds memory = %v, want none — no pipeline was ever observed", holds)
+ }
+}
+
+// TestPassNullHoldsEntryDoesNotPanic: {"holds":{"x":null}} parses cleanly
+// into a nil *holdMemory, and the adopt pass's floor loops iterate the map.
+// An unguarded mem.Window dereference panics the whole pass — fleet blast
+// radius — on both the errored-read and the verified-absent path.
+func TestPassNullHoldsEntryDoesNotPanic(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", []PipelineChannel{
+ {Name: "edge", Bake: "24h"},
+ }, "test"); err != nil {
+ t.Fatalf("SetPipeline: %v", err)
+ }
+ if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil {
+ t.Fatalf("SetProduction: %v", err)
+ }
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "edge", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "e.example.com", "myapp", "edge", false)
+
+ // A clock file whose holds map carries a null entry — legal JSON, loads
+ // clean, nil pointer in the map.
+ blob := `{"jitter":0,"channels":{},"holds":{"` + channelClockKey("bab", "myapp", "edge") + `":null}}`
+ if err := os.WriteFile(FirstSeenPath(opt), []byte(blob), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Errored read: corrupt the pipeline object so the memory-floor loop runs.
+ _, etag, err := s.Get(ctx, PipelineKey("bab", "myapp"))
+ if err != nil {
+ t.Fatalf("read pipeline etag: %v", err)
+ }
+ if _, err := s.Put(ctx, PipelineKey("bab", "myapp"), []byte("not a wrapper"), etag); err != nil {
+ t.Fatalf("corrupt the pipeline object: %v", err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass over a null holds entry (errored read): %v", err)
+ }
+
+ // Verified absence: same clock shape, object deleted outright.
+ if err := os.WriteFile(FirstSeenPath(opt), []byte(blob), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Delete(ctx, PipelineKey("bab", "myapp")); err != nil {
+ t.Fatalf("delete pipeline: %v", err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass over a null holds entry (verified absent): %v", err)
+ }
+}
+
// The 2-3 SECOND canary fast loop must never pay a pipeline bucket read
// either: canary/* is outside every pipeline, so no canary-only pass has a
// verdict the object could change — the mirror of
diff --git a/internal/release/bake.go b/internal/release/bake.go
index 24a44f1db..994e43b88 100644
--- a/internal/release/bake.go
+++ b/internal/release/bake.go
@@ -507,6 +507,14 @@ func (st *FirstSeen) ProductionBake(acct, app string, flagErr error, hasBakeable
// object currently declares. It exists for the same reason prodMemory
// exists — a bucket write alone (no signing key) must never be able to
// shrink a channel's supply-chain window and have that apply immediately.
+//
+// DELETION is covered by the same ratchet, not by prodMemory's
+// deletion-is-not-a-downgrade latch: a deleted pipeline means the app is
+// legacy again, so each remembered channel treats its LEGACY default
+// (HoldFor(DefaultHolds, name)) as the newly-declared window and ratchets
+// toward it through the ordinary pending machinery (ObservePipeline's
+// p == nil branch) — the old window is enforced for one full cycle first,
+// never collapsed instantly, and never pinned forever.
type holdMemory struct {
// Window is the ENFORCED window right now.
Window time.Duration `json:"window"`
@@ -575,10 +583,16 @@ func (f *FirstSeen) checkHoldPromotion(mem *holdMemory, now time.Time) bool {
// call, including replays and p == nil, so a fleet that only polls this
// path occasionally still lands the promotion the pass after it elapses.
//
-// p == nil (pipeline deleted, or absent this pass) never loosens anything
-// from absence — existing memory persists exactly as ObserveProduction
-// leaves Production alone on a missing flag — but pending promotions for
-// this app's already-remembered channels still get their chance to land.
+// p == nil (pipeline deleted, or absent this pass) is a policy declaration
+// of the LEGACY defaults, ratcheted like any other: for every channel this
+// app has memory of, HoldFor(DefaultHolds, name) goes through the same
+// compare a declared window gets — no narrower than what's enforced applies
+// immediately, narrower goes pending for one old-window cycle, and
+// re-observed absence never restarts that clock. Deletion therefore never
+// collapses a ratcheted window instantly (a bucket write alone can delete
+// the object) and never pins an app to a dead pipeline's windows forever.
+// An app with no memory at all — every legacy app, every pass — is a
+// strict no-op here. Pending promotions still get their chance to land.
//
// A channel whose BakeWindow() fails to parse is skipped: old memory is
// kept (fail closed) and this never prevents any other channel in the same
@@ -601,7 +615,38 @@ func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time
if p == nil {
prefix := holdKeyPrefix(acct, app)
for key, mem := range f.Holds {
- if strings.HasPrefix(key, prefix) && f.checkHoldPromotion(mem, now) {
+ // mem == nil: {"holds":{"x":null}} parses cleanly into a nil
+ // entry — skip it, never dereference (same guard as the adopt
+ // pass's floor loops).
+ if mem == nil || !strings.HasPrefix(key, prefix) {
+ continue
+ }
+ name := key[len(prefix):]
+ if name == "" {
+ continue
+ }
+ // The legacy default is the newly-declared window, through the
+ // SAME compare as a declared pipeline. There is NO SetAt replay
+ // floor here: absence carries no SetAt to compare — and mem.SetAt
+ // is deliberately left standing, so a replayed pipeline object
+ // older than the one that was deleted still cannot tick the
+ // memory later.
+ w := HoldFor(DefaultHolds, name)
+ if w >= mem.Window {
+ if mem.Window != w || !mem.PendingAt.IsZero() {
+ mem.Window = w
+ mem.Pending = 0
+ mem.PendingAt = time.Time{}
+ changed = true
+ }
+ } else if mem.PendingAt.IsZero() || mem.Pending != w {
+ // Only a NEW pending value stamps the clock: re-observation
+ // of absence must not restart it — same rule as a re-read.
+ mem.Pending = w
+ mem.PendingAt = now
+ changed = true
+ }
+ if f.checkHoldPromotion(mem, now) {
changed = true
}
}
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go
index ed65b0af1..6beacaf2d 100644
--- a/internal/release/bake_test.go
+++ b/internal/release/bake_test.go
@@ -766,12 +766,136 @@ func TestObservePipelineChangedContract(t *testing.T) {
}
})
- t.Run("p == nil with nothing to promote reports no change", func(t *testing.T) {
+ t.Run("p == nil with windows already at legacy reports no change", func(t *testing.T) {
f := &FirstSeen{Jitter: 0}
- p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "96h"}}, SetAt: now}
- f.ObservePipeline("bae", "web", p, now) // tightens only, nothing pending
+ // 72h == stable's legacy default: tightens to exactly the baseline,
+ // nothing pending — so absence has nothing to ratchet down and must
+ // report no change on every subsequent pass.
+ p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "72h"}}, SetAt: now}
+ f.ObservePipeline("bae", "web", p, now)
if f.ObservePipeline("bae", "web", nil, now.Add(time.Hour)) {
- t.Fatal("p == nil with nothing pending reported changed")
+ t.Fatal("p == nil with windows already at legacy reported changed")
+ }
+ })
+
+ t.Run("p == nil with no memory at all reports no change", func(t *testing.T) {
+ // Every legacy app on every pass lands here — the byte-identical
+ // no-behaviour-change case the deletion ratchet must not disturb.
+ f := &FirstSeen{Jitter: 0}
+ if f.ObservePipeline("bae", "web", nil, now) {
+ t.Fatal("p == nil with no memory reported changed")
+ }
+ if len(f.Holds) != 0 {
+ t.Fatalf("p == nil minted memory out of nothing: %v", f.Holds)
}
})
}
+
+// TestHoldMemoryDeletionRatchetsDownToLegacy: deleting pipeline.json is a
+// bucket write, no signing key required — so absence must be treated as a
+// POLICY DECLARATION of the legacy defaults, ratcheted like any other: a
+// channel whose legacy default is narrower than what's enforced goes pending
+// for one old-window cycle; deletion never collapses a window instantly, and
+// never sticks forever. Re-observed absence must not restart the clock, and
+// absence carries no SetAt, so the remembered replay floor stays untouched.
+func TestHoldMemoryDeletionRatchetsDownToLegacy(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")
+ if f.Holds[key].Window != 24*time.Hour {
+ t.Fatalf("window = %v, want 24h tightened immediately", f.Holds[key].Window)
+ }
+ setAt := f.Holds[key].SetAt
+
+ // The deletion: edge's legacy default (0) is narrower than the enforced
+ // 24h, so it goes PENDING — the 24h stays enforced for one full cycle.
+ del := now.Add(time.Hour)
+ if !f.ObservePipeline("bae", "web", nil, del) {
+ t.Fatal("first observation of absence did not report changed — the pending clock would never persist")
+ }
+ mem := f.Holds[key]
+ if mem.Window != 24*time.Hour {
+ t.Fatalf("window = %v the pass after deletion, want the ratcheted 24h still enforced", mem.Window)
+ }
+ if mem.Pending != 0 || !mem.PendingAt.Equal(del) {
+ t.Fatalf("pending = %v at %v, want the legacy 0 pending from the deletion sighting %v", mem.Pending, mem.PendingAt, del)
+ }
+ if !mem.SetAt.Equal(setAt) {
+ t.Fatalf("SetAt = %v after absence, want %v untouched — absence carries no SetAt", mem.SetAt, setAt)
+ }
+
+ // Re-observation of absence must NOT restart the clock — same rule as a
+ // re-read of the same object.
+ if f.ObservePipeline("bae", "web", nil, del.Add(time.Hour)) {
+ t.Fatal("re-observation of absence reported changed")
+ }
+ if !f.Holds[key].PendingAt.Equal(del) {
+ t.Fatalf("re-observation restarted the pending clock: %v, want %v", f.Holds[key].PendingAt, del)
+ }
+
+ // After the OLD window elapses on the box's own clock, legacy lands.
+ if !f.ObservePipeline("bae", "web", nil, del.Add(24*time.Hour+time.Second)) {
+ t.Fatal("the pending legacy window elapsing did not report changed")
+ }
+ if f.Holds[key].Window != 0 {
+ t.Fatalf("window = %v after the old window elapsed, want edge's legacy 0", f.Holds[key].Window)
+ }
+ // Quiescent thereafter — nothing left to change, so the changed-gated
+ // Save is not forced every pass forever.
+ if f.ObservePipeline("bae", "web", nil, del.Add(48*time.Hour)) {
+ t.Fatal("absence at the legacy windows reported changed")
+ }
+
+ // And a later replayed pipeline OLDER than the remembered SetAt still
+ // cannot tick the memory — deletion did not soften the replay floor.
+ old := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "1h"}}, SetAt: setAt.Add(-time.Hour)}
+ f.ObservePipeline("bae", "web", old, del.Add(49*time.Hour))
+ if f.Holds[key].Window != 0 || !f.Holds[key].PendingAt.IsZero() {
+ t.Fatalf("replayed older pipeline ticked the memory after deletion: %+v", f.Holds[key])
+ }
+}
+
+// TestHoldMemoryDeletionTightensBelowLegacyImmediately: a channel whose
+// enforced window sits BELOW its legacy default (a fully ratcheted-in short
+// bake) tightens back to the legacy default the moment the pipeline is
+// deleted — widening is always immediate, exactly as for a declared window.
+func TestHoldMemoryDeletionTightensBelowLegacyImmediately(t *testing.T) {
+ f := &FirstSeen{Jitter: 0}
+ now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
+ key := channelClockKey("bae", "web", "stable")
+ f.Holds = map[string]*holdMemory{key: {Window: time.Hour, SetAt: now}}
+ if !f.ObservePipeline("bae", "web", nil, now.Add(time.Minute)) {
+ t.Fatal("deletion tightening did not report changed")
+ }
+ mem := f.Holds[key]
+ if mem.Window != 72*time.Hour {
+ t.Fatalf("window = %v after deletion, want stable's fail-closed 72h immediately", mem.Window)
+ }
+ if mem.Pending != 0 || !mem.PendingAt.IsZero() {
+ t.Fatalf("pending = %v at %v after a tighten, want cleared", mem.Pending, mem.PendingAt)
+ }
+}
+
+// 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.
+func TestObservePipelineNullHoldsEntryDoesNotPanic(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "clock.json")
+ blob := `{"jitter":0,"channels":{},"holds":{"` + channelClockKey("bae", "web", "edge") + `":null}}`
+ if err := os.WriteFile(path, []byte(blob), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ f := LoadFirstSeen(path)
+ if f.tainted {
+ t.Fatal("a null holds entry is legal JSON, not a tainted clock")
+ }
+ now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
+ f.ObservePipeline("bae", "web", nil, now) // must not panic
+ p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "24h"}}, SetAt: now}
+ f.ObservePipeline("bae", "web", p, now) // must not panic; re-latches over the null
+ if f.Holds[channelClockKey("bae", "web", "edge")].Window != 24*time.Hour {
+ t.Fatal("observation over a null entry did not re-latch the memory")
+ }
+}