bake: pipeline evidence is the ratchet's liveness, not a raised window
PR #258 review, C1. The bake verdict's "is this app pipelined" test read the appHolds overlay, which only ever RAISES a window above its legacy resolution. An app-defined channel name resolves to HoldFor's 72h fail-closed fallback, so the ordinary case — "production", bake 24h, ratcheted in — raises nothing at all: no overlay entry, no evidence, and a flagless app's held head adopted on the next corrupt read or DELETE. Only edge-named channels were covered, because 24h > edge's legacy 0. Membership is now tracked separately from the overlay: floored[app] is set for any remembered channel whose ratchet is still live (holdMemory.liveRatchet — a window differing from the name's legacy resolution in EITHER direction, a pending loosening, or a deletion whose cycle has not run out), and bakeIgnore reads that. The overlay keeps only the strictly-raising entries, unchanged. The deletion half needs one more thing, and the review's sketch alone left it failing (measured): deletion re-declares the legacy default, which for an app-defined name is LONGER than what was enforced, so it lands instantly, stamps no Pending, and leaves a memory identical to a legacy app's. holdMemory.DeletedAt — additive, omitzero, stamped once on first absence, cleared by a verified pipeline — carries the verdict for one cycle of the window now enforced, then expires and the app is legacy again in full. TestPassNoPipelineNoMemoryStaysLegacy is untouched: an app with no Holds entries mints nothing and takes the bare path. The doc comments that claimed the overlay WAS the evidence are corrected at all four sites, including adoptHost's `ignore` contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+381
−33
internal/release/adopt.go+61 −28internal/release/adopt_test.go+157 −0internal/release/bake.go+73 −0internal/release/bake_test.go+90 −5
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 0e9b7ecd0..7e7d453b3 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -350,8 +350,11 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // posture (spec §3's old-binary edge), whose production: true co-write |
| // remains the mitigation for a MEMORY-LESS box only. Where memory DOES |
| // exist, the floor is enforcement in full: the bake VERDICT rides on |
| - // the same evidence (bakeIgnore below never ignores a floored app), so |
| - // nothing here depends on the console having managed the co-write. |
| + // the same memory (bakeIgnore below never ignores an app whose ratchet |
| + // is still live — which is a WIDER condition than "the memory raises a |
| + // window", deliberately, since an app-defined channel's ratchet |
| + // normally raises nothing at all), so nothing here depends on the |
| + // console having managed the co-write. |
| // 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 — a deletion RATCHETS |
| @@ -431,13 +434,22 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // the pipeline does take that 72h (spec §3: a channel with no pipeline |
| // entry keeps the fail-closed default), including rung names like beta |
| // whose DefaultHolds entry no longer applies. |
| + // |
| + // floored is the SEPARATE question the bake verdict asks: does this box |
| + // hold pipeline evidence for the app at all (see bakeIgnore)? It is a |
| + // membership set, never derived from the overlay above — a ratchet can |
| + // be live while raising nothing, which is the ordinary state of every |
| + // app-defined channel name (holdMemory.liveRatchet has the full |
| + // argument). |
| appHolds := map[acctApp]map[string]time.Duration{} |
| + floored := map[acctApp]bool{} |
| for ak, ps := range pipes { |
| switch { |
| case ps.err == nil && ps.p != nil: |
| h := bake.HoldsForApp(ak.acct, ak.app, ps.p) |
| h["canary/*"] = 0 |
| appHolds[ak] = h |
| + floored[ak] = true |
| default: |
| // The memory floor — for an ERRORED read and for VERIFIED |
| // ABSENCE alike. Overrides overlay a COPY of DefaultHolds, |
| @@ -451,21 +463,31 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // 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. |
| + // For verified absence the floor is half of what makes the |
| + // deletion ratchet (ObservePipeline's p == nil branch, folded |
| + // above) ENFORCED: while a remembered channel's window still |
| + // exceeds its legacy default, deleting the object cannot adopt |
| + // a held head this pass. That covers the names whose legacy |
| + // default is NARROWER than what was enforced (edge, 0); the |
| + // other half — an app-defined name, whose legacy default is the |
| + // 72h fallback and therefore lands instantly, raising nothing — |
| + // is carried by the verdict alone (floored below, |
| + // holdMemory.DeletedAt). Once every remembered channel has |
| + // finished ratcheting, 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. |
| // |
| - // Whether a map lands in appHolds is therefore also the pass's |
| - // answer to "is this app pipelined as far as this box knows" — |
| - // bakeIgnore reads exactly that, so the floor and the verdict |
| - // can never be derived from two different pieces of evidence. |
| + // The overlay is NOT the pass's answer to "is this app |
| + // pipelined as far as this box knows" — floored below is, and |
| + // the two are deliberately separate. A raised window is only |
| + // one shape a live ratchet takes: an app-defined channel like |
| + // "production" resolves to HoldFor's 72h fail-closed default |
| + // under DefaultHolds, so a declared 24h ratchets DOWN to a |
| + // window BELOW legacy and raises nothing at all. Deriving the |
| + // verdict from this overlay would have found no evidence for |
| + // exactly that app and fallen to the production flag. |
| if len(bake.Holds) == 0 { |
| break |
| } |
| @@ -483,6 +505,9 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| if name == key || name == "" { |
| continue |
| } |
| + if mem.liveRatchet(name, bake.Jitter, now) { |
| + floored[ak] = true |
| + } |
| if mem.Window > HoldFor(DefaultHolds, name) { |
| if h == nil { |
| h = make(map[string]time.Duration, len(DefaultHolds)+1) |
| @@ -637,12 +662,19 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // pipeline's alone (spec §3) — never ignore, and a zero-window |
| // channel holds nothing anyway. |
| // |
| - // "Pipelined" here is EVERY app this pass built a window map for |
| - // (appHolds), not only the apps whose read verified this pass. The |
| - // memory floor is built exactly when this box's own ratchet |
| - // remembers a window above legacy for the app, and that memory IS |
| - // the evidence the app was pipelined — the errored-read and the |
| - // verified-absent-mid-ratchet cases both land here. Riding the |
| + // "Pipelined" here is every app this pass found pipeline EVIDENCE |
| + // for (floored), not only the apps whose read verified this pass: |
| + // a verified pipeline, or a ratchet this box is still running for |
| + // the app (holdMemory.liveRatchet — a window that differs from the |
| + // name's legacy resolution in EITHER direction, a pending |
| + // loosening, or a deletion whose cycle has not run out). That |
| + // memory IS the evidence the app was pipelined, and it is |
| + // deliberately NOT the appHolds overlay: the overlay only ever |
| + // RAISES a window above legacy, and an app-defined channel name |
| + // resolves to HoldFor's 72h fail-closed default, so a declared 24h |
| + // on "production" raises nothing while being the very policy the |
| + // hold exists to enforce. Reading the overlay collapsed exactly |
| + // that app (PR #258 review, C1). Riding the |
| // verdict on the console's production: true CO-WRITE instead would |
| // make enforcement depend on a bucket write this box can never |
| // confirm happened: an app pipelined before the co-write existed, an |
| @@ -655,10 +687,10 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551 |
| // |
| // The flag path is left to apps with no pipeline signal at all — |
| // every legacy app, byte-identically — and to a once-pipelined app |
| - // after its deletion ratchet has landed every remembered channel |
| - // back at its legacy default: no floor map is built then, and the |
| - // app is legacy again in full. |
| - if _, floored := appHolds[ak]; floored { |
| + // whose every remembered channel has finished ratcheting back to |
| + // its legacy default: nothing is live then, the set is empty for |
| + // the app, and it is a legacy app again in full. |
| + if floored[ak] { |
| return false |
| } |
| return !bake.ProductionBake(ak.acct, ak.app, prod[ak].err, bakeable[ak], DefaultHolds, now) |
| @@ -797,8 +829,9 @@ func RouteAccount(rt registry.Route) string { |
| // ignore is the app-level bake verdict from ProductionBake — the LEGACY |
| // production flag's doing, never a publisher field, and constant false for |
| // any app this box carries pipeline evidence for: a verified pipeline, or a |
| -// ratchet memory standing above legacy for it (see bakeIgnore). Such an |
| -// app's windows come from holds alone (spec §3). True |
| +// ratchet this box is still running for it (holdMemory.liveRatchet, which is |
| +// NOT the same question as "does the memory raise a window" — see |
| +// bakeIgnore). Such an app's windows come from holds alone (spec §3). True |
| // means the supply-chain window does not CURRENTLY apply to this app on |
| // this box (not declared production, or declared off and past the ratchet); |
| // the caller computes it once per route from the flag it read this pass. |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index 5498550a6..bcaf73ec0 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -3095,6 +3095,163 @@ func TestPassDeletedPipelineRatchetsDown(t *testing.T) { |
| } |
| } |
| +// ratchetedProductionApp arranges the case the whole app-defined-channel |
| +// posture turns on, and the one the edge-named tests below cannot reach: a |
| +// FLAGLESS app whose pipeline names a channel OUTSIDE the frozen vocabulary |
| +// ("production"), with a bake SHORTER than that name's legacy resolution. |
| +// HoldFor's fallback for an unrecognised name is the 72h fail-closed default, |
| +// so the declared 24h is a LOOSENING that ratchets in over the old window — |
| +// and once it has, the box enforces 24h while DefaultHolds still resolves the |
| +// same name at 72h. Any "is this app pipelined" test phrased as "does the |
| +// memory RAISE a window above legacy" answers NO here, which is exactly the |
| +// shape that made an app-defined channel the one place the verdict collapsed. |
| +// |
| +// Leaves: v1 adopted, the memory ratcheted in at 24h with nothing pending, |
| +// and v2 at the production head INSIDE that 24h window — held, on the |
| +// pipeline's word alone, with no production flag anywhere. |
| +func ratchetedProductionApp(t *testing.T, s Store, ctx context.Context, priv ed25519.PrivateKey, pub ed25519.PublicKey, opt string, reg *registry.Registry) { |
| + t.Helper() |
| + if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", []PipelineChannel{ |
| + {Name: "edge"}, {Name: "production", Bake: "24h"}, |
| + }, "test"); err != nil { |
| + t.Fatalf("SetPipeline: %v", err) |
| + } |
| + shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin one")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "edge", "v1"); err != nil { |
| + t.Fatalf("Promote edge: %v", err) |
| + } |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "production", "v1"); err != nil { |
| + t.Fatalf("Promote production: %v", err) |
| + } |
| + addRoute(t, reg, "p.example.com", "myapp", "production", false) |
| + |
| + // Pass 1 latches the 72h fail-closed baseline and parks the declared 24h |
| + // as pending — held. |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass 1: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "p.example.com"); v != "" { |
| + t.Fatalf("live = %q, want held at the 72h baseline", v) |
| + } |
| + // Elapse the OLD window on the box's own clock so the 24h lands, and age |
| + // v1 past 24h (but not past 72h) so adoption proves WHICH window governs. |
| + key := channelClockKey("bab", "myapp", "production") |
| + st := LoadFirstSeen(FirstSeenPath(opt)) |
| + mem := st.Holds[key] |
| + if mem == nil || mem.Window != 72*time.Hour || mem.Pending != 24*time.Hour { |
| + t.Fatalf("hold memory = %+v, want the 72h baseline with 24h pending", mem) |
| + } |
| + mem.PendingAt = mem.PendingAt.Add(-91 * time.Hour) |
| + if seen, ok := st.Channels[key]["v1"]; !ok { |
| + t.Fatalf("no first sighting recorded for v1") |
| + } else { |
| + 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 2: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "p.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want v1 — the ratcheted-in 24h elapsed", v) |
| + } |
| + if mem := LoadFirstSeen(FirstSeenPath(opt)).Holds[key]; mem == nil || mem.Window != 24*time.Hour || !mem.PendingAt.IsZero() { |
| + t.Fatalf("hold memory = %+v, want the landed 24h with nothing pending", mem) |
| + } |
| + // A fresh head inside the ratcheted window: held while the pipeline reads. |
| + shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("bin two")) |
| + if err := Promote(ctx, s, priv, "bab", "myapp", "production", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass 3: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "p.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want still v1 — v2 is inside the ratcheted 24h window", v) |
| + } |
| +} |
| + |
| +// TestPassErroredReadOnAppDefinedChannelHoldsWithoutFlag is the C1 probe: the |
| +// same collapse the edge-named test below covers, on the naming that actually |
| +// hides it. With the memory ratcheted DOWN to 24h against a 72h legacy |
| +// resolution, no window in the map is raised above legacy — so a verdict |
| +// derived from the OVERLAY finds nothing, falls to the production flag this |
| +// app has never had, and adopts v2 the moment one GET of pipeline.json errors. |
| +// The evidence has to be the ratchet's own liveness, not the overlay. |
| +func TestPassErroredReadOnAppDefinedChannelHoldsWithoutFlag(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + ratchetedProductionApp(t, s, ctx, priv, pub, opt, reg) |
| + |
| + _, 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: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "p.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want still v1 — an errored read on an app-defined channel must keep the hold, with no production flag in sight", v) |
| + } |
| +} |
| + |
| +// TestPassDeletedPipelineOnAppDefinedChannelHoldsWithoutFlag: the same probe |
| +// on the other keyless bucket write. Deleting pipeline.json hands the channel |
| +// its legacy 72h back — LONGER than the 24h the box was enforcing, so the |
| +// window itself never loosens — but the app must not fall to the flagless |
| +// legacy VERDICT in the same pass either, or one DELETE adopts a build that |
| +// was minutes old. It returns to the legacy path only once the deletion |
| +// ratchet has run the old window out on the box's own clock. |
| +func TestPassDeletedPipelineOnAppDefinedChannelHoldsWithoutFlag(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + ratchetedProductionApp(t, s, ctx, priv, pub, opt, reg) |
| + |
| + 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: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "p.example.com"); v != "v1" { |
| + t.Fatalf("live = %q, want still v1 — deleting the pipeline must not adopt a held head in the same pass", v) |
| + } |
| + |
| + // And it does return to legacy: elapse the deletion ratchet's own cycle on |
| + // the box's clock, and the app is a legacy app again — no floor, no |
| + // pipeline evidence, the flagless legacy verdict, so v2 adopts. |
| + key := channelClockKey("bab", "myapp", "production") |
| + st := LoadFirstSeen(FirstSeenPath(opt)) |
| + mem := st.Holds[key] |
| + if mem == nil { |
| + t.Fatalf("no hold memory for the deleted pipeline's channel") |
| + } |
| + if mem.DeletedAt.IsZero() { |
| + t.Fatalf("hold memory = %+v, want the deletion stamped so its ratchet can run out", mem) |
| + } |
| + mem.DeletedAt = mem.DeletedAt.Add(-91 * time.Hour) |
| + st.Holds[key] = mem |
| + 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, "p.example.com"); v != "v2" { |
| + t.Fatalf("live = %q, want v2 — once the deletion ratchet has run out the app is legacy again, and a flagless legacy app holds nothing", v) |
| + } |
| +} |
| + |
| // TestPassErroredPipelineHoldsWithoutProductionFlag: the memory floor is only |
| // half a defence if the app's held head is then ADOPTED anyway. The bake |
| // VERDICT ("does the window apply at all?") must ride on the same evidence |
diff --git a/internal/release/bake.go b/internal/release/bake.go| index 9372889c4..7331939da 100644 |
| --- a/internal/release/bake.go |
| +++ b/internal/release/bake.go |
| @@ -534,6 +534,57 @@ type holdMemory struct { |
| // unchanged must not push this forward, or the clock would never |
| // elapse against a fleet that polls faster than the window itself. |
| PendingAt time.Time `json:"pending_at,omitzero"` |
| + // DeletedAt is this box's own first sighting of the pipeline's ABSENCE |
| + // for this channel — additive, and stamped only on the p == nil path. |
| + // |
| + // It exists because the pending machinery cannot carry the deletion |
| + // ratchet on its own for every channel name. Deletion re-declares the |
| + // name's LEGACY default, and for an app-defined name that default is |
| + // HoldFor's 72h fail-closed fallback — LONGER than the window such a |
| + // channel usually enforces, so it lands instantly (a longer bake always |
| + // does) and stamps no Pending at all. The window is then perfectly safe |
| + // and the memory looks, to any reader, exactly like a legacy app's: |
| + // nothing pending, window == legacy. Without this stamp the adopt pass |
| + // would have no evidence that this app was pipelined ninety seconds ago, |
| + // would take the flagless legacy VERDICT, and one keyless DELETE would |
| + // adopt a build minutes old — collapsing the very protection the ratchet |
| + // exists to give (spec §3: "deletion never collapses a window |
| + // instantly"). Re-observing absence must not restart it, exactly as |
| + // PendingAt must not; a verified pipeline clears it. |
| + DeletedAt time.Time `json:"deleted_at,omitzero"` |
| +} |
| + |
| +// liveRatchet reports whether this box is still running a pipeline ratchet |
| +// for the channel — the adopt pass's evidence that the app IS pipelined as |
| +// far as this box knows, independent of whether the memory happens to RAISE |
| +// the window above legacy (an app-defined name's legacy resolution is 72h, |
| +// so an ordinary 24h production channel raises nothing at all). |
| +// |
| +// Three shapes, any of which is live: |
| +// - the enforced window differs from the name's legacy resolution, in |
| +// EITHER direction: something other than the legacy ladder set it. |
| +// - a pending loosening is waiting out its old window. |
| +// - a deletion is still running its cycle: the window it re-declared has |
| +// not yet elapsed on this box's own clock since the absence was first |
| +// seen. The cycle is measured against the window now ENFORCED, which |
| +// after a deletion is never shorter than the one it replaced — fail |
| +// closed, and self-clearing: a landed deletion whose legacy window is |
| +// zero is over the moment it lands. |
| +// |
| +// name is the channel's own name, jitter and now the pass's (FirstSeen.Jitter |
| +// and its single stamped instant), so every hold on the box answers the same |
| +// clock. |
| +func (mem *holdMemory) liveRatchet(name string, jitter float64, now time.Time) bool { |
| + if mem == nil { |
| + return false |
| + } |
| + if mem.Window != HoldFor(DefaultHolds, name) || !mem.PendingAt.IsZero() { |
| + return true |
| + } |
| + if mem.DeletedAt.IsZero() { |
| + return false |
| + } |
| + return now.Before(mem.DeletedAt.Add(time.Duration(float64(mem.Window) * (1 + jitter)))) |
| } |
| // holdKeyPrefix is channelClockKey's key shape without the channel segment, |
| @@ -631,6 +682,19 @@ func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time |
| // is deliberately left standing, so a replayed pipeline object |
| // older than the one that was deleted still cannot tick the |
| // memory later. |
| + // Stamp the absence itself, once. The window machinery below |
| + // cannot express "this app was pipelined until just now" for a |
| + // channel whose legacy default is LONGER than what it was |
| + // enforcing (every app-defined name, whose legacy resolution is |
| + // the 72h fallback): that lands instantly and leaves a memory |
| + // indistinguishable from a legacy app's. DeletedAt is what the |
| + // adopt pass's verdict rides on for one cycle afterwards — see |
| + // holdMemory.DeletedAt and liveRatchet. Never restarted by |
| + // re-observing absence, same rule as PendingAt. |
| + if mem.DeletedAt.IsZero() { |
| + mem.DeletedAt = now.UTC().Truncate(time.Second) |
| + changed = true |
| + } |
| w := HoldFor(DefaultHolds, name) |
| if w >= mem.Window { |
| if mem.Window != w || !mem.PendingAt.IsZero() { |
| @@ -655,6 +719,15 @@ func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time |
| for _, ch := range p.Channels { |
| key := channelClockKey(acct, app, ch.Name) |
| mem := f.Holds[key] |
| + // A verified pipeline naming the channel ends any deletion cycle: |
| + // the app is pipelined, on the object's own word, and the stamp's |
| + // only job was to carry the verdict across the gap. Cleared for a |
| + // channel whose bake string does not parse too — the app is |
| + // verifiably pipelined either way, which is all this stamp says. |
| + if mem != nil && !mem.DeletedAt.IsZero() { |
| + mem.DeletedAt = time.Time{} |
| + changed = true |
| + } |
| if w, err := ch.BakeWindow(); err == nil { |
| firstSighting := mem == nil |
| if firstSighting { |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| index 2f39f2a4d..97280d87b 100644 |
| --- a/internal/release/bake_test.go |
| +++ b/internal/release/bake_test.go |
| @@ -766,15 +766,26 @@ func TestObservePipelineChangedContract(t *testing.T) { |
| } |
| }) |
| - t.Run("p == nil with windows already at legacy reports no change", func(t *testing.T) { |
| + t.Run("p == nil stamps the deletion once, then reports no change", func(t *testing.T) { |
| f := &FirstSeen{Jitter: 0} |
| // 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. |
| + // nothing pending — so absence has no WINDOW to ratchet down. The |
| + // FIRST absence is still a change: it stamps DeletedAt, which is the |
| + // box's own record that this app was pipelined until just now, and |
| + // which the adopt pass's bake verdict rides on for one cycle (PR #258 |
| + // review, C1 — for an app-defined channel name the legacy default is |
| + // LONGER than the enforced window, so deletion lands instantly and |
| + // this stamp is the only evidence left). A stamp that is not |
| + // persisted is no evidence at all, so it must report changed. |
| 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 windows already at legacy reported changed") |
| + if !f.ObservePipeline("bae", "web", nil, now.Add(time.Hour)) { |
| + t.Fatal("the first p == nil did not report changed — the deletion stamp would never be saved") |
| + } |
| + // Every pass after it is the steady state a fleet actually sits in, |
| + // and must not force a Save: re-observed absence changes nothing. |
| + if f.ObservePipeline("bae", "web", nil, now.Add(2*time.Hour)) { |
| + t.Fatal("re-observed absence reported changed — the stamp must not be restamped") |
| } |
| }) |
| @@ -878,6 +889,80 @@ func TestHoldMemoryDeletionTightensBelowLegacyImmediately(t *testing.T) { |
| } |
| } |
| +// TestLiveRatchetIsWiderThanARaisedWindow pins the distinction PR #258's |
| +// review turned on (C1): "is this box still running a pipeline ratchet for |
| +// this channel" is NOT "does the memory raise the window above legacy". An |
| +// app-defined name resolves to HoldFor's 72h fallback, so an ordinary |
| +// production channel enforcing 24h raises nothing while being fully |
| +// pipelined — and after a deletion its window lands on that same 72h |
| +// instantly, leaving a memory that looks exactly like a legacy app's. The |
| +// DeletedAt stamp is what carries the verdict across that gap, and it runs |
| +// out. |
| +func TestLiveRatchetIsWiderThanARaisedWindow(t *testing.T) { |
| + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) |
| + legacyProduction := 72 * time.Hour // HoldFor's unknown-name fallback |
| + |
| + // Ratcheted DOWN below legacy: raises nothing, still live. |
| + mem := &holdMemory{Window: 24 * time.Hour, SetAt: now} |
| + if !mem.liveRatchet("production", 0, now) { |
| + t.Error("a window ratcheted BELOW legacy did not read as a live ratchet — this is the C1 collapse") |
| + } |
| + // Raised above legacy: live, the case that always worked. |
| + if !(&holdMemory{Window: 24 * time.Hour}).liveRatchet("edge", 0, now) { |
| + t.Error("a window raised above legacy did not read as a live ratchet") |
| + } |
| + // Exactly legacy with a pending loosening: live. |
| + if !(&holdMemory{Window: legacyProduction, Pending: time.Hour, PendingAt: now}).liveRatchet("production", 0, now) { |
| + t.Error("a pending loosening did not read as a live ratchet") |
| + } |
| + // Exactly legacy, nothing pending, never deleted: a legacy app's memory. |
| + if (&holdMemory{Window: legacyProduction, SetAt: now}).liveRatchet("production", 0, now) { |
| + t.Error("a memory sitting at legacy with nothing in flight read as a live ratchet") |
| + } |
| + // Deleted, cycle still running / run out. |
| + deleted := &holdMemory{Window: legacyProduction, SetAt: now, DeletedAt: now} |
| + if !deleted.liveRatchet("production", 0, now.Add(71*time.Hour)) { |
| + t.Error("a deletion inside its own cycle did not read as a live ratchet") |
| + } |
| + if deleted.liveRatchet("production", 0, now.Add(73*time.Hour)) { |
| + t.Error("a deletion whose cycle has run out still read as live — the app must return to legacy") |
| + } |
| + // nil is never live (the {"holds":{"x":null}} shape). |
| + if (*holdMemory)(nil).liveRatchet("production", 0, now) { |
| + t.Error("a nil memory read as a live ratchet") |
| + } |
| +} |
| + |
| +// TestHoldMemoryDeletionStampClearedByAVerifiedPipeline: the stamp says "this |
| +// app was pipelined until just now". A verified pipeline naming the channel |
| +// says it IS pipelined, on the object's own word, so the stamp has no job |
| +// left — and must not go on ticking a cycle that would later expire under a |
| +// live pipeline. |
| +func TestHoldMemoryDeletionStampClearedByAVerifiedPipeline(t *testing.T) { |
| + f := &FirstSeen{Jitter: 0} |
| + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) |
| + key := channelClockKey("bae", "web", "production") |
| + p := &Pipeline{Schema: 1, Channels: []PipelineChannel{{Name: "production", Bake: "24h"}}, SetAt: now} |
| + f.ObservePipeline("bae", "web", p, now) |
| + f.ObservePipeline("bae", "web", nil, now.Add(time.Hour)) |
| + if f.Holds[key].DeletedAt.IsZero() { |
| + t.Fatal("absence did not stamp DeletedAt") |
| + } |
| + stamp := f.Holds[key].DeletedAt |
| + // Re-observed absence must not restamp it. |
| + f.ObservePipeline("bae", "web", nil, now.Add(2*time.Hour)) |
| + if !f.Holds[key].DeletedAt.Equal(stamp) { |
| + t.Fatalf("re-observed absence restamped DeletedAt: %v, want %v", f.Holds[key].DeletedAt, stamp) |
| + } |
| + // The object comes back — same SetAt, the byte-identical restore. |
| + if !f.ObservePipeline("bae", "web", p, now.Add(3*time.Hour)) { |
| + t.Fatal("a restored pipeline that clears the deletion stamp did not report changed") |
| + } |
| + if !f.Holds[key].DeletedAt.IsZero() { |
| + t.Fatalf("DeletedAt = %v after the pipeline was restored, want cleared", f.Holds[key].DeletedAt) |
| + } |
| +} |
| + |
| // TestHoldMemoryRestoredPipelineCancelsAbsencePending: the re-review probe. |
| // Delete the pipeline (absence stamps the legacy default pending), then |
| // restore the object BYTE-IDENTICALLY — same SetAt: a bucket-versioning |