carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12)
13
14// Jitter is in [0, 0.25), so a hold of H is eligible somewhere in
15// [firstSeen+H, firstSeen+1.25H). 91h is safely past stable's worst case
16// (72h * 1.25 = 90h); 71h is safely inside the best case.
17const (
18 insideStableHold = 71 * time.Hour
19 pastStableHold = 91 * time.Hour
20)
21
22func TestBakeHoldBlocksNewStableUntilHeld(t *testing.T) {
23 path := filepath.Join(t.TempDir(), "first-seen.json")
24 st := LoadFirstSeen(path)
25 t0 := time.Now()
26 if !st.Observe("bab", "myapp", "stable", "v1", t0) {
27 t.Fatalf("first Observe reported no change")
28 }
29 if hold := Hold(st, "bab", "myapp", "stable", "v1", DefaultHolds, false, t0); hold == "" {
30 t.Fatalf("brand-new stable head not held")
31 }
32 if hold := Hold(st, "bab", "myapp", "stable", "v1", DefaultHolds, false, t0.Add(insideStableHold)); hold == "" {
33 t.Fatalf("stable head released before its 72h window")
34 }
35 if hold := Hold(st, "bab", "myapp", "stable", "v1", DefaultHolds, false, t0.Add(pastStableHold)); hold != "" {
36 t.Fatalf("stable head still held past its window: %s", hold)
37 }
38 // A version with NO first-seen record fails closed.
39 if hold := Hold(st, "bab", "myapp", "stable", "vGhost", DefaultHolds, false, t0.Add(pastStableHold)); hold == "" {
40 t.Fatalf("unseen version not held (must fail closed)")
41 }
42 // The local root-only override releases immediately.
43 if hold := Hold(st, "bab", "myapp", "stable", "v1", DefaultHolds, true, t0); hold != "" {
44 t.Fatalf("ignore=true still held: %s", hold)
45 }
46 // canary/* has no window: eligible the moment it is first seen.
47 st.Observe("bab", "myapp", "canary/x", "v1", t0)
48 if hold := Hold(st, "bab", "myapp", "canary/x", "v1", DefaultHolds, false, t0); hold != "" {
49 t.Fatalf("canary held: %s", hold)
50 }
51}
52
53// TestRollbackNeverBakes is THE key property: a pointer moved BACK to a
54// previously-seen version applies immediately — its first-seen record
55// predates the hold (that's how it got installed the first time), and
56// Observe never resets an existing timestamp. Evacuating a bad release must
57// never itself sit in a bake window.
58func TestRollbackNeverBakes(t *testing.T) {
59 s := NewDirStore(t.TempDir())
60 ctx := context.Background()
61 priv, pub := testKeys(t)
62 shipRelease(t, s, "bab", "myapp", "v1", "linux-amd64", []byte("one"))
63 shipRelease(t, s, "bab", "myapp", "v2", "linux-amd64", []byte("two"))
64
65 st := LoadFirstSeen(filepath.Join(t.TempDir(), "first-seen.json"))
66 t0 := time.Now()
67
68 // v1 promoted to stable; the box first sees it at t0, bakes, installs.
69 if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v1"); err != nil {
70 t.Fatalf("Promote v1: %v", err)
71 }
72 ptr, err := Channel(ctx, s, pub, "bab", "myapp", "stable")
73 if err != nil {
74 t.Fatalf("Channel: %v", err)
75 }
76 st.Observe("bab", "myapp", "stable", ptr.Version, t0)
77
78 // Time passes; v1's window elapses; v2 is promoted and first seen NOW.
79 t1 := t0.Add(pastStableHold)
80 if err := Promote(ctx, s, priv, "bab", "myapp", "stable", "v2"); err != nil {
81 t.Fatalf("Promote v2: %v", err)
82 }
83 ptr, _ = Channel(ctx, s, pub, "bab", "myapp", "stable")
84 st.Observe("bab", "myapp", "stable", ptr.Version, t1)
85 if hold := Hold(st, "bab", "myapp", "stable", ptr.Version, DefaultHolds, false, t1); hold == "" {
86 t.Fatalf("fresh v2 not held")
87 }
88
89 // v2 turns out bad — rollback. The pointer is back at v1, and v1 applies
90 // IMMEDIATELY: its first-seen record is t0, long past the hold. Observe
91 // must not reset it.
92 if err := Rollback(ctx, s, priv, "bab", "myapp", "stable"); err != nil {
93 t.Fatalf("Rollback: %v", err)
94 }
95 ptr, err = Channel(ctx, s, pub, "bab", "myapp", "stable")
96 if err != nil {
97 t.Fatalf("Channel after rollback: %v", err)
98 }
99 if ptr.Version != "v1" {
100 t.Fatalf("rollback pointer = %s, want v1", ptr.Version)
101 }
102 st.Observe("bab", "myapp", "stable", ptr.Version, t1) // re-seen at head: no reset
103 if hold := Hold(st, "bab", "myapp", "stable", ptr.Version, DefaultHolds, false, t1); hold != "" {
104 t.Fatalf("rolled-back v1 held: %s (evacuation must never bake)", hold)
105 }
106 // And v2 stays held — the rollback didn't launder it.
107 if hold := Hold(st, "bab", "myapp", "stable", "v2", DefaultHolds, false, t1); hold == "" {
108 t.Fatalf("bad v2 no longer held after rollback")
109 }
110}
111
112func TestLoadFirstSeenCorruptFailsClosed(t *testing.T) {
113 path := filepath.Join(t.TempDir(), "first-seen.json")
114 if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil {
115 t.Fatal(err)
116 }
117 st := LoadFirstSeen(path)
118 // A corrupt file is an empty clock: everything bakes from now — a wiped
119 // clock delays updates, it never lets one through early.
120 if hold := Hold(st, "bab", "myapp", "stable", "v1", DefaultHolds, false, time.Now()); hold == "" {
121 t.Fatalf("corrupt clock did not fail closed")
122 }
123 // Missing file: same.
124 st2 := LoadFirstSeen(filepath.Join(t.TempDir(), "absent.json"))
125 if hold := Hold(st2, "bab", "myapp", "stable", "v1", DefaultHolds, false, time.Now()); hold == "" {
126 t.Fatalf("missing clock did not fail closed")
127 }
128}
129
130func TestFirstSeenSaveLoadRoundTrip(t *testing.T) {
131 path := filepath.Join(t.TempDir(), "first-seen.json")
132 st := LoadFirstSeen(path)
133 t0 := time.Now()
134 st.Observe("bab", "myapp", "stable", "v1", t0)
135 if st.Jitter < 0 || st.Jitter >= 0.25 {
136 t.Fatalf("minted jitter %v outside [0, 0.25)", st.Jitter)
137 }
138 if err := st.Save(); err != nil {
139 t.Fatalf("Save: %v", err)
140 }
141 st2 := LoadFirstSeen(path)
142 if st2.Jitter != st.Jitter {
143 t.Fatalf("jitter not persisted: %v != %v", st2.Jitter, st.Jitter)
144 }
145 at, ok := st2.EligibleAt("bab", "myapp", "stable", "v1", DefaultHolds)
146 if !ok {
147 t.Fatalf("first-seen record lost on reload")
148 }
149 want := t0.UTC().Truncate(time.Second).Add(time.Duration(float64(72*time.Hour) * (1 + st.Jitter)))
150 if !at.Equal(want) {
151 t.Fatalf("EligibleAt = %v, want %v", at, want)
152 }
153}
154
155func TestHoldForWildcardAndUnknown(t *testing.T) {
156 if d := HoldFor(DefaultHolds, "canary/any-branch"); d != 0 {
157 t.Fatalf("canary/* = %v, want 0", d)
158 }
159 if d := HoldFor(DefaultHolds, "beta"); d != 24*time.Hour {
160 t.Fatalf("beta = %v, want 24h", d)
161 }
162 if d := HoldFor(DefaultHolds, "stable"); d != 72*time.Hour {
163 t.Fatalf("stable = %v, want 72h", d)
164 }
165 // An unknown channel fails closed to the longest standard window.
166 if d := HoldFor(DefaultHolds, "mystery"); d != 72*time.Hour {
167 t.Fatalf("unknown channel = %v, want 72h (fail closed)", d)
168 }
169 // A per-deployment override map replaces the defaults wholesale.
170 custom := map[string]time.Duration{"stable": time.Hour}
171 if d := HoldFor(custom, "stable"); d != time.Hour {
172 t.Fatalf("override stable = %v, want 1h", d)
173 }
174}
175
176// The bake clock is per (account, app, channel, version). Keyed on the app
177// name alone, one account's 72h stable observation would satisfy ANOTHER
178// account's identically-named app on the same box — its supply-chain window
179// silently skipped, which is the one thing this clock exists to prevent.
180func TestBakeClockIsPerAccount(t *testing.T) {
181 st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json"))
182 long := time.Now().Add(-100 * time.Hour)
183
184 // Account bab saw v1 at stable 100h ago; account kqmv has never seen it.
185 st.Observe("bab", "blog", "stable", "v1", long)
186 if hold := Hold(st, "bab", "blog", "stable", "v1", DefaultHolds, false, time.Now()); hold != "" {
187 t.Fatalf("the account that saw it 100h ago is held: %s", hold)
188 }
189 if hold := Hold(st, "kqmv", "blog", "stable", "v1", DefaultHolds, false, time.Now()); hold == "" {
190 t.Fatal("another account's identically-named app skipped its bake window " +
191 "on the strength of the first account's clock")
192 }
193}
194
195// The clock key must be injective. Channels may contain "/", so joining
196// segments with "/" would make ops app "kass" + channel "canary/x" collide
197// with account "kass" + app "canary" + channel "x" — one account's
198// observation silently satisfying another's window, which is the same bug
199// TestBakeClockIsPerAccount closes, reached by a different route.
200func TestBakeClockKeyIsInjective(t *testing.T) {
201 st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json"))
202 long := time.Now().Add(-100 * time.Hour)
203
204 // ops app "kass" on channel "canary/x".
205 st.Observe(OpsAccount, "kass", "canary/x", "v1", long)
206 // Account "kass", app "canary", channel "x" — three different segments
207 // that concatenate the same way, and must NOT resolve to that record.
208 if _, ok := st.EligibleAt("kass", "canary", "x", "v1", DefaultHolds); ok {
209 t.Fatal("the clock key collides across (account, app, channel) boundaries")
210 }
211 if hold := Hold(st, "kass", "canary", "x", "v1", DefaultHolds, false, time.Now()); hold == "" {
212 t.Fatal("a colliding key let another account skip its bake window")
213 }
214 // The record it was actually written under still resolves.
215 if hold := Hold(st, OpsAccount, "kass", "canary/x", "v1", DefaultHolds, false, time.Now()); hold != "" {
216 t.Fatalf("the real observation stopped resolving: %s", hold)
217 }
218}
219
220// A clock written before accounts existed has bare "<app>/<channel>" keys.
221// Reading those as the ops account keeps every existing box's observations —
222// otherwise the whole fleet's windows restart on upgrade, and nothing
223// installs for 72h.
224func TestBakeClockReadsPreAccountRecordsAsOps(t *testing.T) {
225 path := filepath.Join(t.TempDir(), "clock.json")
226 long := time.Now().Add(-100 * time.Hour).UTC().Truncate(time.Second)
227 legacy := map[string]any{
228 "jitter": 0.1,
229 "channels": map[string]any{
230 "blog/stable": map[string]any{"v1": long},
231 },
232 }
233 raw, err := json.MarshalIndent(legacy, "", " ")
234 if err != nil {
235 t.Fatal(err)
236 }
237 if err := os.WriteFile(path, raw, 0o644); err != nil {
238 t.Fatal(err)
239 }
240 st := LoadFirstSeen(path)
241 if hold := Hold(st, OpsAccount, "blog", "stable", "v1", DefaultHolds, false, time.Now()); hold != "" {
242 t.Fatalf("a pre-accounts observation was lost on upgrade: %s", hold)
243 }
244 // A non-ops account gets no free ride from it.
245 if hold := Hold(st, "kqmv", "blog", "stable", "v1", DefaultHolds, false, time.Now()); hold == "" {
246 t.Fatal("a pre-accounts record satisfied a different account's window")
247 }
248}
249
250func TestLoadFirstSeenMigrating(t *testing.T) {
251 now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
252
253 t.Run("prefers the new path when it exists", func(t *testing.T) {
254 dir := t.TempDir()
255 newPath := filepath.Join(dir, "new.json")
256 oldPath := filepath.Join(dir, "old.json")
257 stNew := LoadFirstSeen(newPath)
258 stNew.Observe("acct", "app", "stable", "v-new", now)
259 if err := stNew.Save(); err != nil {
260 t.Fatal(err)
261 }
262 stOld := LoadFirstSeen(oldPath)
263 stOld.Observe("acct", "app", "stable", "v-old", now)
264 if err := stOld.Save(); err != nil {
265 t.Fatal(err)
266 }
267 st := LoadFirstSeenMigrating(newPath, oldPath)
268 if _, ok := st.EligibleAt("acct", "app", "stable", "v-new", nil); !ok {
269 t.Fatal("new path's observation missing")
270 }
271 if _, ok := st.EligibleAt("acct", "app", "stable", "v-old", nil); ok {
272 t.Fatal("legacy content leaked in despite the new path existing")
273 }
274 })
275
276 t.Run("falls back to legacy content, saves to the new path, leaves legacy untouched", func(t *testing.T) {
277 dir := t.TempDir()
278 newPath := filepath.Join(dir, "new.json")
279 oldPath := filepath.Join(dir, "old.json")
280 stOld := LoadFirstSeen(oldPath)
281 stOld.Observe("acct", "app", "stable", "v1", now)
282 if err := stOld.Save(); err != nil {
283 t.Fatal(err)
284 }
285 legacyRaw, err := os.ReadFile(oldPath)
286 if err != nil {
287 t.Fatal(err)
288 }
289 st := LoadFirstSeenMigrating(newPath, oldPath)
290 if _, ok := st.EligibleAt("acct", "app", "stable", "v1", nil); !ok {
291 t.Fatal("legacy observation not carried over")
292 }
293 if err := st.Save(); err != nil {
294 t.Fatal(err)
295 }
296 if _, err := os.Stat(newPath); err != nil {
297 t.Fatalf("Save did not write the new path: %v", err)
298 }
299 afterRaw, err := os.ReadFile(oldPath)
300 if err != nil || !bytes.Equal(legacyRaw, afterRaw) {
301 t.Fatalf("legacy file must be byte-identical after migration (err %v)", err)
302 }
303 })
304
305 t.Run("both missing is a fresh clock at the new path", func(t *testing.T) {
306 dir := t.TempDir()
307 newPath := filepath.Join(dir, "new.json")
308 st := LoadFirstSeenMigrating(newPath, filepath.Join(dir, "absent.json"))
309 st.Observe("acct", "app", "stable", "v1", now)
310 if err := st.Save(); err != nil {
311 t.Fatal(err)
312 }
313 if _, err := os.Stat(newPath); err != nil {
314 t.Fatalf("Save must create the new path: %v", err)
315 }
316 })
317}
318
319// TestFirstSeenSaveReplacesFileItCannotWriteInPlace stands in for the real
320// production race (2026-08-03 review, CRITICAL): carlos-edge.service writes
321// this path as user `carlos`, the root adopt one-shot writes it as root —
322// whichever creates the file first locks the other uid out, because an
323// in-place O_TRUNC write needs write permission on the FILE, and a 0644 file
324// owned by the other uid has none for anyone else. A file this process
325// cannot open for writing (0o444, no write bit for anyone, including its own
326// owner) reproduces the same "cannot write in place" shape without needing a
327// second uid: rename(2) only requires write permission on the containing
328// directory, never on the file being replaced, so a Save implemented as
329// temp-file+rename must still succeed here where a truncating write cannot.
330func TestFirstSeenSaveReplacesFileItCannotWriteInPlace(t *testing.T) {
331 path := filepath.Join(t.TempDir(), "clock.json")
332 if err := os.WriteFile(path, []byte("{}"), 0o444); err != nil {
333 t.Fatal(err)
334 }
335 st := LoadFirstSeen(path)
336 st.Observe("acct", "app", "stable", "v1", time.Now())
337 if err := st.Save(); err != nil {
338 t.Fatalf("Save must replace via rename, not an in-place write: %v", err)
339 }
340 // The temp name is minted fresh per attempt (CreateTemp's
341 // ".carlos-write-*"), not a fixed "<path>.tmp" — glob for the
342 // pattern rather than stat-ing one fixed name.
343 if matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), ".carlos-write-*")); err != nil {
344 t.Fatalf("glob for stray temp files: %v", err)
345 } else if len(matches) != 0 {
346 t.Fatalf("Save left a temp file behind: %v", matches)
347 }
348 raw, err := os.ReadFile(path)
349 if err != nil {
350 t.Fatal(err)
351 }
352 var reloaded FirstSeen
353 if err := json.Unmarshal(raw, &reloaded); err != nil {
354 t.Fatalf("Save produced invalid/partial content: %v", err)
355 }
356 if _, ok := reloaded.Channels[channelClockKey("acct", "app", "stable")]["v1"]; !ok {
357 t.Fatal("observation missing after Save")
358 }
359}
360
361// The pipelines spec defines edge as "picked up as it lands — no human in
362// the loop, by definition". Without its own entry, HoldFor fail-closes edge
363// to 72h — longer than beta, and a spec violation (2026-08-04 console bake
364// controls spec, §1/§8).
365func TestHoldForEdgeIsZero(t *testing.T) {
366 if d := HoldFor(DefaultHolds, "edge"); d != 0 {
367 t.Fatalf("edge hold = %v, want 0", d)
368 }
369}
370
371// The box-side production memory (2026-08-04 console bake controls spec §4).
372// Scenario table, because the security content IS the state machine: the
373// default is off; on is instant; off ratchets on the box's own clock; a
374// DELETED flag never downgrades a box that saw true; a stale replayed off
375// cannot roll back a newer on.
376func TestProductionMemoryRatchet(t *testing.T) {
377 base := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
378 flag := func(prod bool, setAt time.Time) *ProductionFlag {
379 return &ProductionFlag{Schema: 1, Account: "bab", App: "kass",
380 Production: prod, SetAt: setAt}
381 }
382 newClock := func() *FirstSeen {
383 st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json"))
384 st.Jitter = 0 // determinism: eligibility is exactly firstSeen + hold
385 return st
386 }
387
388 t.Run("default is off", func(t *testing.T) {
389 st := newClock()
390 if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
391 t.Fatal("an app with no flag ever seen bakes")
392 }
393 })
394
395 t.Run("unverifiable flag fails toward baking", func(t *testing.T) {
396 st := newClock()
397 if !st.ProductionBake("bab", "kass", fmt.Errorf("sig"), true, DefaultHolds, base) {
398 t.Fatal("a corrupt flag read as the default instead of production")
399 }
400 })
401
402 t.Run("on is instant, deletion is not a downgrade", func(t *testing.T) {
403 st := newClock()
404 if !st.ObserveProduction("bab", "kass", flag(true, base), base) {
405 t.Fatal("first observation did not change the clock")
406 }
407 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
408 t.Fatal("production: true did not apply immediately")
409 }
410 // The object vanishes from the bucket (flag == nil every later pass):
411 // the box holds its verdict.
412 st.ObserveProduction("bab", "kass", nil, base.Add(time.Hour))
413 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour)) {
414 t.Fatal("deleting the flag object downgraded a box that saw true")
415 }
416 })
417
418 t.Run("off ratchets for the stable window", func(t *testing.T) {
419 st := newClock()
420 st.ObserveProduction("bab", "kass", flag(true, base), base)
421 off := flag(false, base.Add(time.Hour))
422 st.ObserveProduction("bab", "kass", off, base.Add(2*time.Hour)) // box sees it an hour late
423 hold := HoldFor(DefaultHolds, RungStable)
424 // Still baking just inside the window, measured from the box's OWN
425 // sighting (base+2h), not the flag's set_at (base+1h).
426 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold-time.Minute)) {
427 t.Fatal("off was honored before the ratchet elapsed")
428 }
429 if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold+time.Minute)) {
430 t.Fatal("off was not honored after the ratchet elapsed")
431 }
432 })
433
434 t.Run("no bakeable head skips the ratchet", func(t *testing.T) {
435 st := newClock()
436 st.ObserveProduction("bab", "kass", flag(true, base), base)
437 st.ObserveProduction("bab", "kass", flag(false, base.Add(time.Hour)), base.Add(time.Hour))
438 if st.ProductionBake("bab", "kass", nil, false, DefaultHolds, base.Add(time.Hour)) {
439 t.Fatal("carve-out: nothing at a held head, yet off still ratcheted")
440 }
441 })
442
443 t.Run("a stale off cannot be replayed over a newer on", func(t *testing.T) {
444 st := newClock()
445 staleOff := flag(false, base)
446 st.ObserveProduction("bab", "kass", staleOff, base)
447 st.ObserveProduction("bab", "kass", flag(true, base.Add(time.Hour)), base.Add(time.Hour))
448 // Attacker re-writes the old signed off object; the box refuses to
449 // regress below the newest set_at it has honored.
450 if st.ObserveProduction("bab", "kass", staleOff, base.Add(2*time.Hour)) {
451 t.Fatal("a replayed stale flag changed the clock")
452 }
453 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(100*time.Hour)) {
454 t.Fatal("a replayed stale off eventually turned the bake off")
455 }
456 })
457
458 t.Run("memory survives a save/load round trip", func(t *testing.T) {
459 st := newClock()
460 st.ObserveProduction("bab", "kass", flag(true, base), base)
461 if err := st.Save(); err != nil {
462 t.Fatal(err)
463 }
464 st2 := LoadFirstSeen(st.path)
465 if !st2.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
466 t.Fatal("production memory did not survive persistence")
467 }
468 })
469
470 // Task-4-review fix 2: prodKey must fold the ops account the same way
471 // channelClockKey does, or an app addressed as "" (the pre-accounts
472 // bare shape some callers still use) and the same app addressed as
473 // OpsAccount silently keep SEPARATE production memories.
474 t.Run("prodKey folds the ops account like channelClockKey", func(t *testing.T) {
475 st := newClock()
476 st.ObserveProduction(OpsAccount, "kass", flag(true, base), base)
477 if !st.ProductionBake("", "kass", nil, true, DefaultHolds, base) {
478 t.Fatal(`observed under OpsAccount, not found evaluating under ""`)
479 }
480
481 st2 := newClock()
482 st2.ObserveProduction("", "kass", flag(true, base), base)
483 if !st2.ProductionBake(OpsAccount, "kass", nil, true, DefaultHolds, base) {
484 t.Fatal(`observed under "", not found evaluating under OpsAccount`)
485 }
486 // No free ride for an unrelated account: "bab"/"kass" (the ops
487 // account) must not satisfy "zzz"/"kass".
488 if st2.ProductionBake("zzz", "kass", nil, true, DefaultHolds, base) {
489 t.Fatal("an unrelated account satisfied the ops account's production memory")
490 }
491 })
492
493 // Task-4-review fix 3a: re-reading the SAME off object (the every-pass
494 // re-read, not a fresh sighting) must not change anything — in
495 // particular must not push the ratchet clock forward from the box's
496 // FIRST sighting to whatever pass happens to re-read it.
497 t.Run("equal set_at re-read does not reset the ratchet clock", func(t *testing.T) {
498 st := newClock()
499 st.ObserveProduction("bab", "kass", flag(true, base), base)
500 off := flag(false, base.Add(time.Hour))
501 st.ObserveProduction("bab", "kass", off, base.Add(2*time.Hour)) // first sighting
502 if st.ObserveProduction("bab", "kass", off, base.Add(3*time.Hour)) {
503 t.Fatal("re-reading the identical off object reported a change")
504 }
505 hold := HoldFor(DefaultHolds, RungStable)
506 // Still measured from the FIRST sighting (base+2h), not the re-read
507 // (base+3h) — a re-read one hour later must not buy the flag one
508 // more hour of ratchet.
509 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold-time.Minute)) {
510 t.Fatal("off honored before the ratchet from its first sighting elapsed")
511 }
512 if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(2*time.Hour).Add(hold+time.Minute)) {
513 t.Fatal("re-reading the same object pushed the ratchet clock forward")
514 }
515 })
516
517 // Task-4-review fix 3b: an in-progress off-ratchet (not just a bare
518 // "on" latch) must itself survive Save/LoadFirstSeen.
519 t.Run("off-state ratchet survives a save/load round trip", func(t *testing.T) {
520 st := newClock()
521 st.ObserveProduction("bab", "kass", flag(true, base), base)
522 st.ObserveProduction("bab", "kass", flag(false, base.Add(time.Hour)), base.Add(time.Hour))
523 if err := st.Save(); err != nil {
524 t.Fatal(err)
525 }
526 st2 := LoadFirstSeen(st.path)
527 hold := HoldFor(DefaultHolds, RungStable)
528 if !st2.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(hold-time.Minute)) {
529 t.Fatal("off-state ratchet lost on reload: should still be baking mid-window")
530 }
531 if st2.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(hold+time.Minute)) {
532 t.Fatal("off-state ratchet lost on reload: should be off past the window")
533 }
534 })
535
536 // Task-4-review fix 3c: the ratchet is jittered exactly like any other
537 // hold (HoldFor's formula), not a bare, un-jittered 72h.
538 t.Run("the ratchet is jittered like any other hold", func(t *testing.T) {
539 st := LoadFirstSeen(filepath.Join(t.TempDir(), "clock.json"))
540 st.Jitter = 0.2
541 st.ObserveProduction("bab", "kass", flag(true, base), base)
542 st.ObserveProduction("bab", "kass", flag(false, base.Add(time.Hour)), base.Add(time.Hour))
543 hold := HoldFor(DefaultHolds, RungStable) // 72h, unjittered
544 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(hold).Add(time.Minute)) {
545 t.Fatal("jitter not applied: off honored right at the un-jittered window")
546 }
547 jittered := time.Duration(float64(hold) * 1.2)
548 if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(time.Hour).Add(jittered).Add(time.Minute)) {
549 t.Fatal("jitter not applied: off still honored past the jittered window")
550 }
551 })
552
553 // Task-4-review fix 3d: an app declared off and NEVER on has nothing to
554 // ratchet — it must never bake, immediately and forever.
555 t.Run("an app declared off and never on never bakes", func(t *testing.T) {
556 st := newClock()
557 st.ObserveProduction("bab", "kass", flag(false, base), base)
558 if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
559 t.Fatal("an app declared off, never on, baked immediately")
560 }
561 if st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base.Add(1000*time.Hour)) {
562 t.Fatal("an app declared off, never on, baked eventually")
563 }
564 })
565}
566
567// Task-4-review fix 1 (Critical): LoadFirstSeen must distinguish a file that
568// read but did not PARSE from a file that is simply absent. Before this fix
569// both collapsed to the same "empty clock" result, which for the CHANNEL
570// clock is fine (fails closed either way — everything re-bakes from now),
571// but for the PRODUCTION memory silently failed OPEN: a corrupt clock file
572// (mem == nil for every app) made ProductionBake return false, instantly
573// un-baking every production app on the box. A corrupt file must instead
574// fail TOWARD baking (st.tainted), while an absent file — a genuinely fresh
575// box — still gets the ordinary false default.
576func TestCorruptClockFailsTowardBakingNotAwayFromIt(t *testing.T) {
577 base := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
578
579 path := filepath.Join(t.TempDir(), "clock.json")
580 if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil {
581 t.Fatal(err)
582 }
583 st := LoadFirstSeen(path)
584 if !st.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
585 t.Fatal("a corrupt clock file did not fail toward baking")
586 }
587
588 // A genuinely fresh box (no file at all) is a different case entirely —
589 // still the ordinary false default, not tainted.
590 fresh := LoadFirstSeen(filepath.Join(t.TempDir(), "absent.json"))
591 if fresh.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
592 t.Fatal("a fresh box with no clock file baked by default")
593 }
594
595 // A file that EXISTS but can't be read at all (not "did not parse") is
596 // a different os.ReadFile failure mode than os.ErrNotExist — a
597 // directory sitting at the clock path is the easiest way to reproduce
598 // it portably (EISDIR), and review found the first cut of this fix only
599 // tainted the "read OK, parse failed" branch, leaving this one to
600 // silently fail open.
601 dirPath := filepath.Join(t.TempDir(), "clock.json")
602 if err := os.Mkdir(dirPath, 0o755); err != nil {
603 t.Fatal(err)
604 }
605 unreadable := LoadFirstSeen(dirPath)
606 if !unreadable.ProductionBake("bab", "kass", nil, true, DefaultHolds, base) {
607 t.Fatal("an unreadable (directory-at-path) clock file did not fail toward baking")
608 }
609}
610
611func TestHoldMemoryRatchet(t *testing.T) {
612 f := &FirstSeen{Jitter: 0}
613 now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
614 pipe := func(bake string, setAt time.Time) *Pipeline {
615 return &Pipeline{Schema: 1, Channels: []PipelineChannel{
616 {Name: "edge"}, {Name: "production", Bake: bake}}, SetAt: setAt}
617 }
618 // First sighting: production baselines at the fail-closed 72h, NOT its declared 24h.
619 f.ObservePipeline("bae", "web", pipe("24h", now), now)
620 holds := f.HoldsForApp("bae", "web", pipe("24h", now))
621 if holds["production"] != 72*time.Hour {
622 t.Fatalf("first-sighting window = %v; want fail-closed 72h baseline", holds["production"])
623 }
624 if holds["edge"] != 0 {
625 t.Fatalf("edge window = %v; want 0 (legacy baseline)", holds["edge"])
626 }
627 // After the old window elapses on the box's own clock, the pending 24h lands.
628 later := now.Add(72*time.Hour + time.Second)
629 f.ObservePipeline("bae", "web", pipe("24h", now), later)
630 if h := f.HoldsForApp("bae", "web", pipe("24h", now)); h["production"] != 24*time.Hour {
631 t.Fatalf("post-ratchet window = %v; want 24h", h["production"])
632 }
633 // Tightening is immediate.
634 f.ObservePipeline("bae", "web", pipe("96h", now.Add(80*time.Hour)), later.Add(time.Minute))
635 if h := f.HoldsForApp("bae", "web", nil); h != nil {
636 t.Fatalf("HoldsForApp(nil pipeline) = %v; want nil", h)
637 }
638 if h := f.HoldsForApp("bae", "web", pipe("96h", now)); h["production"] != 96*time.Hour {
639 t.Fatalf("tighten = %v; want 96h immediately", h["production"])
640 }
641}
642
643func TestHoldMemoryReplayFloorAndReReadKeepsClock(t *testing.T) {
644 f := &FirstSeen{Jitter: 0}
645 now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
646 p1 := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "1h"}}, SetAt: now}
647 f.ObservePipeline("bae", "web", p1, now)
648 // Re-reading the same object an hour later must NOT restart the pending clock…
649 f.ObservePipeline("bae", "web", p1, now.Add(time.Hour))
650 // …so the loosening lands 72h after the FIRST sighting, not the second.
651 f.ObservePipeline("bae", "web", p1, now.Add(72*time.Hour+time.Second))
652 if h := f.HoldsForApp("bae", "web", p1); h["stable"] != time.Hour {
653 t.Fatalf("window = %v; want 1h landed 72h after first sighting", h["stable"])
654 }
655 // A replayed OLDER SetAt can never loosen further.
656 old := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "0s"}}, SetAt: now.Add(-time.Hour)}
657 f.ObservePipeline("bae", "web", old, now.Add(73*time.Hour))
658 if h := f.HoldsForApp("bae", "web", p1); h["stable"] != time.Hour {
659 t.Fatalf("replayed old object loosened the window to %v", h["stable"])
660 }
661}
662
663func TestHoldMemorySurvivesSaveLoadAndOldBinaryDrop(t *testing.T) {
664 // Additive-field discipline: marshal a FirstSeen with Holds, unmarshal,
665 // memory intact; unmarshal a PRE-CHANGE JSON (no holds key) into the new
666 // struct — no error, nil map, and ObservePipeline re-latches from scratch
667 // (the closed direction: a restarted clock only ever delays loosening).
668 f := &FirstSeen{Jitter: 0, Holds: map[string]*holdMemory{
669 channelClockKey("bae", "web", "production"): {Window: 24 * time.Hour, SetAt: time.Now().UTC()}}}
670 b, err := json.Marshal(f)
671 if err != nil {
672 t.Fatal(err)
673 }
674 var back FirstSeen
675 if err := json.Unmarshal(b, &back); err != nil {
676 t.Fatal(err)
677 }
678 if back.Holds[channelClockKey("bae", "web", "production")].Window != 24*time.Hour {
679 t.Fatal("holds memory lost across marshal round trip")
680 }
681 var legacy FirstSeen
682 if err := json.Unmarshal([]byte(`{"jitter":0,"channels":{}}`), &legacy); err != nil || legacy.Holds != nil {
683 t.Fatalf("legacy clock parse = %v, holds %v", err, legacy.Holds)
684 }
685}
686
687// TestHoldMemoryZeroBakeRatchetsIn is the review fix for the critical bug:
688// Pending's zero value is indistinguishable from "nothing pending", and a
689// declared bake of exactly ZERO is a legal value — without PendingAt as
690// its own sentinel, a brand-new channel with no bake declared would latch
691// its 72h fail-closed baseline and NEVER ratchet down, because
692// mem.Pending (freshly zeroed) already equals w=0 on the very first call.
693func TestHoldMemoryZeroBakeRatchetsIn(t *testing.T) {
694 f := &FirstSeen{Jitter: 0}
695 now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
696 pipe := func(setAt time.Time) *Pipeline {
697 return &Pipeline{Schema: 1, Channels: []PipelineChannel{
698 {Name: "dev"}, {Name: "live"}}, SetAt: setAt}
699 }
700 f.ObservePipeline("bae", "web", pipe(now), now)
701 if h := f.HoldsForApp("bae", "web", pipe(now)); h["dev"] != 72*time.Hour || h["live"] != 72*time.Hour {
702 t.Fatalf("first-sighting windows = %v; want fail-closed 72h baseline on both channels", h)
703 }
704 later := now.Add(72*time.Hour + time.Second)
705 if !f.ObservePipeline("bae", "web", pipe(now), later) {
706 t.Fatal("re-observe past the elapsed baseline did not report changed")
707 }
708 h := f.HoldsForApp("bae", "web", pipe(now))
709 if h["dev"] != 0 || h["live"] != 0 {
710 t.Fatalf("post-ratchet windows = %v; want 0 (declared zero bake) on both channels", h)
711 }
712}
713
714// TestHoldsForAppBeforeObserveReturnsBaselines is the review fix for
715// HoldsForApp's fallback-agreement claim: before any ObservePipeline call,
716// every declared channel must still report the exact baseline
717// ObservePipeline would have latched on first sighting — including a rung
718// name like "edge" whose baseline is 0, which HoldFor's own unknown-channel
719// fallback (72h) would silently get wrong if left out of the map.
720func TestHoldsForAppBeforeObserveReturnsBaselines(t *testing.T) {
721 f := &FirstSeen{Jitter: 0}
722 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{
723 {Name: "edge"}, {Name: "custom"}}, SetAt: time.Now()}
724 h := f.HoldsForApp("bae", "web", p)
725 if h["edge"] != 0 {
726 t.Fatalf("edge baseline = %v; want 0 (rung default) before any observation", h["edge"])
727 }
728 if h["custom"] != 72*time.Hour {
729 t.Fatalf("custom baseline = %v; want 72h fail-closed before any observation", h["custom"])
730 }
731}
732
733// TestObservePipelineChangedContract pins the `changed` return value —
734// PassFilter's sole trigger for a changed-gated Save — across the cases
735// that matter: a genuine first sighting, a replay that changes nothing, a
736// pending promotion actually landing, and p == nil with nothing to
737// promote.
738func TestObservePipelineChangedContract(t *testing.T) {
739 now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
740
741 t.Run("first sighting reports changed", func(t *testing.T) {
742 f := &FirstSeen{Jitter: 0}
743 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "96h"}}, SetAt: now}
744 if !f.ObservePipeline("bae", "web", p, now) {
745 t.Fatal("first sighting did not report changed")
746 }
747 })
748
749 t.Run("replay of the same object with nothing pending reports no change", func(t *testing.T) {
750 f := &FirstSeen{Jitter: 0}
751 // 96h >= the 72h baseline: tightens immediately, no pending created.
752 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "96h"}}, SetAt: now}
753 f.ObservePipeline("bae", "web", p, now)
754 if f.ObservePipeline("bae", "web", p, now.Add(time.Hour)) {
755 t.Fatal("replay of the identical object (nothing pending) reported changed")
756 }
757 })
758
759 t.Run("pending promotion firing reports changed", func(t *testing.T) {
760 f := &FirstSeen{Jitter: 0}
761 // 1h < the 72h baseline: goes pending at the first sighting.
762 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "1h"}}, SetAt: now}
763 f.ObservePipeline("bae", "web", p, now)
764 if !f.ObservePipeline("bae", "web", p, now.Add(72*time.Hour+time.Second)) {
765 t.Fatal("pending promotion elapsing did not report changed")
766 }
767 })
768
769 t.Run("p == nil stamps the deletion once, then reports no change", func(t *testing.T) {
770 f := &FirstSeen{Jitter: 0}
771 // 72h == stable's legacy default: tightens to exactly the baseline,
772 // nothing pending — so absence has no WINDOW to ratchet down. The
773 // FIRST absence is still a change: it stamps DeletedAt, which is the
774 // box's own record that this app was pipelined until just now, and
775 // which the adopt pass's bake verdict rides on for one cycle (PR #258
776 // review, C1 — for an app-defined channel name the legacy default is
777 // LONGER than the enforced window, so deletion lands instantly and
778 // this stamp is the only evidence left). A stamp that is not
779 // persisted is no evidence at all, so it must report changed.
780 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "stable", Bake: "72h"}}, SetAt: now}
781 f.ObservePipeline("bae", "web", p, now)
782 if !f.ObservePipeline("bae", "web", nil, now.Add(time.Hour)) {
783 t.Fatal("the first p == nil did not report changed — the deletion stamp would never be saved")
784 }
785 // Every pass after it is the steady state a fleet actually sits in,
786 // and must not force a Save: re-observed absence changes nothing.
787 if f.ObservePipeline("bae", "web", nil, now.Add(2*time.Hour)) {
788 t.Fatal("re-observed absence reported changed — the stamp must not be restamped")
789 }
790 })
791
792 t.Run("p == nil with no memory at all reports no change", func(t *testing.T) {
793 // Every legacy app on every pass lands here — the byte-identical
794 // no-behaviour-change case the deletion ratchet must not disturb.
795 f := &FirstSeen{Jitter: 0}
796 if f.ObservePipeline("bae", "web", nil, now) {
797 t.Fatal("p == nil with no memory reported changed")
798 }
799 if len(f.Holds) != 0 {
800 t.Fatalf("p == nil minted memory out of nothing: %v", f.Holds)
801 }
802 })
803}
804
805// TestHoldMemoryDeletionRatchetsDownToLegacy: deleting pipeline.json is a
806// bucket write, no signing key required — so absence must be treated as a
807// POLICY DECLARATION of the legacy defaults, ratcheted like any other: a
808// channel whose legacy default is narrower than what's enforced goes pending
809// for one old-window cycle; deletion never collapses a window instantly, and
810// never sticks forever. Re-observed absence must not restart the clock, and
811// absence carries no SetAt, so the remembered replay floor stays untouched.
812func TestHoldMemoryDeletionRatchetsDownToLegacy(t *testing.T) {
813 f := &FirstSeen{Jitter: 0}
814 now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
815 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "24h"}}, SetAt: now}
816 f.ObservePipeline("bae", "web", p, now)
817 key := channelClockKey("bae", "web", "edge")
818 if f.Holds[key].Window != 24*time.Hour {
819 t.Fatalf("window = %v, want 24h tightened immediately", f.Holds[key].Window)
820 }
821 setAt := f.Holds[key].SetAt
822
823 // The deletion: edge's legacy default (0) is narrower than the enforced
824 // 24h, so it goes PENDING — the 24h stays enforced for one full cycle.
825 del := now.Add(time.Hour)
826 if !f.ObservePipeline("bae", "web", nil, del) {
827 t.Fatal("first observation of absence did not report changed — the pending clock would never persist")
828 }
829 mem := f.Holds[key]
830 if mem.Window != 24*time.Hour {
831 t.Fatalf("window = %v the pass after deletion, want the ratcheted 24h still enforced", mem.Window)
832 }
833 if mem.Pending != 0 || !mem.PendingAt.Equal(del) {
834 t.Fatalf("pending = %v at %v, want the legacy 0 pending from the deletion sighting %v", mem.Pending, mem.PendingAt, del)
835 }
836 if !mem.SetAt.Equal(setAt) {
837 t.Fatalf("SetAt = %v after absence, want %v untouched — absence carries no SetAt", mem.SetAt, setAt)
838 }
839
840 // Re-observation of absence must NOT restart the clock — same rule as a
841 // re-read of the same object.
842 if f.ObservePipeline("bae", "web", nil, del.Add(time.Hour)) {
843 t.Fatal("re-observation of absence reported changed")
844 }
845 if !f.Holds[key].PendingAt.Equal(del) {
846 t.Fatalf("re-observation restarted the pending clock: %v, want %v", f.Holds[key].PendingAt, del)
847 }
848
849 // After the OLD window elapses on the box's own clock, legacy lands.
850 if !f.ObservePipeline("bae", "web", nil, del.Add(24*time.Hour+time.Second)) {
851 t.Fatal("the pending legacy window elapsing did not report changed")
852 }
853 if f.Holds[key].Window != 0 {
854 t.Fatalf("window = %v after the old window elapsed, want edge's legacy 0", f.Holds[key].Window)
855 }
856 // Quiescent thereafter — nothing left to change, so the changed-gated
857 // Save is not forced every pass forever.
858 if f.ObservePipeline("bae", "web", nil, del.Add(48*time.Hour)) {
859 t.Fatal("absence at the legacy windows reported changed")
860 }
861
862 // And a later replayed pipeline OLDER than the remembered SetAt still
863 // cannot tick the memory — deletion did not soften the replay floor.
864 old := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "1h"}}, SetAt: setAt.Add(-time.Hour)}
865 f.ObservePipeline("bae", "web", old, del.Add(49*time.Hour))
866 if f.Holds[key].Window != 0 || !f.Holds[key].PendingAt.IsZero() {
867 t.Fatalf("replayed older pipeline ticked the memory after deletion: %+v", f.Holds[key])
868 }
869}
870
871// TestHoldMemoryDeletionTightensBelowLegacyImmediately: a channel whose
872// enforced window sits BELOW its legacy default (a fully ratcheted-in short
873// bake) tightens back to the legacy default the moment the pipeline is
874// deleted — widening is always immediate, exactly as for a declared window.
875func TestHoldMemoryDeletionTightensBelowLegacyImmediately(t *testing.T) {
876 f := &FirstSeen{Jitter: 0}
877 now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
878 key := channelClockKey("bae", "web", "stable")
879 f.Holds = map[string]*holdMemory{key: {Window: time.Hour, SetAt: now}}
880 if !f.ObservePipeline("bae", "web", nil, now.Add(time.Minute)) {
881 t.Fatal("deletion tightening did not report changed")
882 }
883 mem := f.Holds[key]
884 if mem.Window != 72*time.Hour {
885 t.Fatalf("window = %v after deletion, want stable's fail-closed 72h immediately", mem.Window)
886 }
887 if mem.Pending != 0 || !mem.PendingAt.IsZero() {
888 t.Fatalf("pending = %v at %v after a tighten, want cleared", mem.Pending, mem.PendingAt)
889 }
890}
891
892// TestLiveRatchetIsWiderThanARaisedWindow pins the distinction PR #258's
893// review turned on (C1): "is this box still running a pipeline ratchet for
894// this channel" is NOT "does the memory raise the window above legacy". An
895// app-defined name resolves to HoldFor's 72h fallback, so an ordinary
896// production channel enforcing 24h raises nothing while being fully
897// pipelined — and after a deletion its window lands on that same 72h
898// instantly, leaving a memory that looks exactly like a legacy app's. The
899// DeletedAt stamp is what carries the verdict across that gap, and it runs
900// out.
901func TestLiveRatchetIsWiderThanARaisedWindow(t *testing.T) {
902 now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
903 legacyProduction := 72 * time.Hour // HoldFor's unknown-name fallback
904
905 // Ratcheted DOWN below legacy: raises nothing, still live.
906 mem := &holdMemory{Window: 24 * time.Hour, SetAt: now}
907 if !mem.liveRatchet("production", 0, now) {
908 t.Error("a window ratcheted BELOW legacy did not read as a live ratchet — this is the C1 collapse")
909 }
910 // Raised above legacy: live, the case that always worked.
911 if !(&holdMemory{Window: 24 * time.Hour}).liveRatchet("edge", 0, now) {
912 t.Error("a window raised above legacy did not read as a live ratchet")
913 }
914 // Exactly legacy with a pending loosening: live.
915 if !(&holdMemory{Window: legacyProduction, Pending: time.Hour, PendingAt: now}).liveRatchet("production", 0, now) {
916 t.Error("a pending loosening did not read as a live ratchet")
917 }
918 // Exactly legacy, nothing pending, never deleted: a legacy app's memory.
919 if (&holdMemory{Window: legacyProduction, SetAt: now}).liveRatchet("production", 0, now) {
920 t.Error("a memory sitting at legacy with nothing in flight read as a live ratchet")
921 }
922 // Deleted, cycle still running / run out.
923 deleted := &holdMemory{Window: legacyProduction, SetAt: now, DeletedAt: now}
924 if !deleted.liveRatchet("production", 0, now.Add(71*time.Hour)) {
925 t.Error("a deletion inside its own cycle did not read as a live ratchet")
926 }
927 if deleted.liveRatchet("production", 0, now.Add(73*time.Hour)) {
928 t.Error("a deletion whose cycle has run out still read as live — the app must return to legacy")
929 }
930 // nil is never live (the {"holds":{"x":null}} shape).
931 if (*holdMemory)(nil).liveRatchet("production", 0, now) {
932 t.Error("a nil memory read as a live ratchet")
933 }
934}
935
936// TestHoldMemoryDeletionStampClearedByAVerifiedPipeline: the stamp says "this
937// app was pipelined until just now". A verified pipeline naming the channel
938// says it IS pipelined, on the object's own word, so the stamp has no job
939// left — and must not go on ticking a cycle that would later expire under a
940// live pipeline.
941func TestHoldMemoryDeletionStampClearedByAVerifiedPipeline(t *testing.T) {
942 f := &FirstSeen{Jitter: 0}
943 now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
944 key := channelClockKey("bae", "web", "production")
945 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "production", Bake: "24h"}}, SetAt: now}
946 f.ObservePipeline("bae", "web", p, now)
947 f.ObservePipeline("bae", "web", nil, now.Add(time.Hour))
948 if f.Holds[key].DeletedAt.IsZero() {
949 t.Fatal("absence did not stamp DeletedAt")
950 }
951 stamp := f.Holds[key].DeletedAt
952 // Re-observed absence must not restamp it.
953 f.ObservePipeline("bae", "web", nil, now.Add(2*time.Hour))
954 if !f.Holds[key].DeletedAt.Equal(stamp) {
955 t.Fatalf("re-observed absence restamped DeletedAt: %v, want %v", f.Holds[key].DeletedAt, stamp)
956 }
957 // The object comes back — same SetAt, the byte-identical restore.
958 if !f.ObservePipeline("bae", "web", p, now.Add(3*time.Hour)) {
959 t.Fatal("a restored pipeline that clears the deletion stamp did not report changed")
960 }
961 if !f.Holds[key].DeletedAt.IsZero() {
962 t.Fatalf("DeletedAt = %v after the pipeline was restored, want cleared", f.Holds[key].DeletedAt)
963 }
964}
965
966// TestHoldMemoryRestoredPipelineCancelsAbsencePending: the re-review probe.
967// Delete the pipeline (absence stamps the legacy default pending), then
968// restore the object BYTE-IDENTICALLY — same SetAt: a bucket-versioning
969// restore, an undo, a replay. The replay floor rightly skips the policy
970// block, but the absence-stamped pending must not survive a live verified
971// pipeline declaring more: unlanded it is cancelled, and one old-window
972// cycle later the enforced window is still the declared 24h, never the
973// legacy 0 the deletion briefly put in flight.
974func TestHoldMemoryRestoredPipelineCancelsAbsencePending(t *testing.T) {
975 f := &FirstSeen{Jitter: 0}
976 now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
977 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "24h"}}, SetAt: now}
978 f.ObservePipeline("bae", "web", p, now)
979 key := channelClockKey("bae", "web", "edge")
980
981 // The deletion: legacy 0 goes pending under the enforced 24h.
982 del := now.Add(time.Hour)
983 f.ObservePipeline("bae", "web", nil, del)
984 if mem := f.Holds[key]; mem.Pending != 0 || !mem.PendingAt.Equal(del) {
985 t.Fatalf("absence did not stamp the legacy pending: %+v", mem)
986 }
987
988 // The restore, byte-identical (same SetAt). The pending is no longer
989 // the live policy — it must be cancelled, and that is a change.
990 if !f.ObservePipeline("bae", "web", p, del.Add(time.Hour)) {
991 t.Fatal("cancelling the absence pending did not report changed — it would never persist")
992 }
993 if mem := f.Holds[key]; mem.Window != 24*time.Hour || mem.Pending != 0 || !mem.PendingAt.IsZero() {
994 t.Fatalf("memory after the restore = %+v, want 24h enforced with nothing pending", mem)
995 }
996
997 // Past the old window, still observing the restored pipeline: the
998 // declared 24h holds — the legacy 0 never lands.
999 if f.ObservePipeline("bae", "web", p, del.Add(25*time.Hour)) {
1000 t.Fatal("a quiescent restored pipeline reported changed")
1001 }
1002 if mem := f.Holds[key]; mem.Window != 24*time.Hour || !mem.PendingAt.IsZero() {
1003 t.Fatalf("memory past the old window = %+v, want the declared 24h still enforced", mem)
1004 }
1005}
1006
1007// TestObservePipelineNullHoldsEntryDoesNotPanic: {"holds":{"x":null}} parses
1008// cleanly into a nil *holdMemory. Every loop over Holds must skip it, never
1009// dereference it — a panic here takes down the adopt pass fleet-wide.
1010func TestObservePipelineNullHoldsEntryDoesNotPanic(t *testing.T) {
1011 path := filepath.Join(t.TempDir(), "clock.json")
1012 blob := `{"jitter":0,"channels":{},"holds":{"` + channelClockKey("bae", "web", "edge") + `":null}}`
1013 if err := os.WriteFile(path, []byte(blob), 0o644); err != nil {
1014 t.Fatal(err)
1015 }
1016 f := LoadFirstSeen(path)
1017 if f.tainted {
1018 t.Fatal("a null holds entry is legal JSON, not a tainted clock")
1019 }
1020 now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
1021 f.ObservePipeline("bae", "web", nil, now) // must not panic
1022 p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "edge", Bake: "24h"}}, SetAt: now}
1023 f.ObservePipeline("bae", "web", p, now) // must not panic; re-latches over the null
1024 if f.Holds[channelClockKey("bae", "web", "edge")].Window != 24*time.Hour {
1025 t.Fatal("observation over a null entry did not re-latch the memory")
1026 }
1027}
1028