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>
5 files changed,
+385
−14
docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md+5 −0internal/release/adopt.go+32 −5internal/release/adopt_test.go+170 −0internal/release/bake.go+50 −5internal/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") |
| + } |
| +} |