carlos / platform Public

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>
Paul Campbell 84d43259ef3a54056d45793618b90b3f77d6f8ef parent da39baf
4 files changed, +381 −33
  • internal/release/adopt.go +61 −28
  • internal/release/adopt_test.go +157 −0
  • internal/release/bake.go +73 −0
  • internal/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