carlos / platform Public

release: errored pipeline read floors windows with the box's ratchet memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d9ae49ec9c1ef6dee06b84bbe62235ccceb60e18 parent 0a8c011
2 files changed, +122 −19
  • internal/release/adopt.go +63 −19
  • internal/release/adopt_test.go +59 −0
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index a86ea7060..ee3dd26fe 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -13,6 +13,7 @@ import (
"io"
"os"
"runtime"
+ "strings"
"syscall"
"time"
@@ -338,12 +339,18 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// here, the same cost property the production-flag reads below hold.
//
// A read/verify ERROR is kept, and kept apart from verified-absent
- // (nil, nil): the errored app falls to the legacy path below —
- // DefaultHolds plus the production-flag machinery, the same fail-closed
- // posture an old binary that cannot parse pipeline.json at all takes
- // (spec §3's old-binary edge; its mitigation, a nonzero-bake pipeline
- // also setting production: true, is what makes legacy the closed
- // direction). The error is NEVER folded into the ratchet as a nil: nil
+ // (nil, nil): the errored app's windows fall back to DefaultHolds
+ // FLOORED by the box's own ratchet memory (the appHolds loop below) —
+ // every channel the memory carries enforces max(its DefaultHolds
+ // resolution, the remembered window), so corrupting the object can
+ // never make this box looser than it already was (spec §3: corruption
+ // must never loosen; plain DefaultHolds would hand a tightened rung
+ // name like edge its ZERO legacy window back). A channel with no memory
+ // keeps the fail-closed DefaultHolds resolution — the old-binary
+ // posture (spec §3's old-binary edge), whose production: true co-write
+ // remains the mitigation for a MEMORY-LESS box only. The production-
+ // flag machinery still applies to an errored app exactly as to any
+ // legacy app. The error is NEVER folded into the ratchet as a nil: nil
// means verified-absent only (ObservePipeline's contract), and a
// corrupt object must not masquerade as a deletion.
type pipeState struct {
@@ -405,22 +412,56 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
}
// One enforced-window map per app for this pass. A verified pipeline
- // resolves through the box's ratchet memory (HoldsForApp); everything
- // else — no pipeline, or a read this pass could not trust — keeps
- // DefaultHolds, today's behaviour byte-for-byte. canary/* is stitched
- // back in at zero: it lives outside the pipeline (spec §2), so
- // HoldsForApp's map cannot carry it, and HoldFor's fallback for a name
- // missing from the map is the 72h fail-closed default — which would
- // silently freeze every canary deploy of a pipelined app. Every OTHER
- // channel outside 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.
+ // resolves through the box's ratchet memory (HoldsForApp); an ERRORED
+ // read keeps DefaultHolds floored by that same memory (never looser
+ // than what this box already enforced — see the pipes comment above);
+ // an app with no pipeline at all keeps DefaultHolds, today's behaviour
+ // byte-for-byte. canary/* is stitched back in at zero on the verified
+ // path: it lives outside the pipeline (spec §2), so HoldsForApp's map
+ // cannot carry it, and HoldFor's fallback for a name missing from the
+ // map is the 72h fail-closed default — which would silently freeze
+ // every canary deploy of a pipelined app. Every OTHER channel outside
+ // 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.
appHolds := map[acctApp]map[string]time.Duration{}
for ak, ps := range pipes {
- if ps.err == nil && ps.p != nil {
+ switch {
+ case ps.err == nil && ps.p != nil:
h := bake.HoldsForApp(ak.acct, ak.app, ps.p)
h["canary/*"] = 0
appHolds[ak] = h
+ case ps.err != nil:
+ // The memory floor. Overrides overlay a COPY of DefaultHolds,
+ // so a remembered channel is only ever RAISED above its legacy
+ // resolution, an unremembered one resolves exactly as legacy
+ // (HoldFor's 72h unknown-name fallback included), and canary/*
+ // keeps its zero: no pipeline can name canary, so no ratchet
+ // memory exists to raise it. With nothing remembered above
+ // legacy, no map is built and holdsFor falls to DefaultHolds
+ // itself. The prefix match is exact by construction: neither an
+ // account, an app name nor a non-canary channel can contain
+ // clockSep or "/" (channelClockKey's own injectivity argument).
+ var h map[string]time.Duration
+ prefix := holdKeyPrefix(ak.acct, ak.app)
+ for key, mem := range bake.Holds {
+ name := strings.TrimPrefix(key, prefix)
+ if name == key || name == "" {
+ continue
+ }
+ if mem.Window > HoldFor(DefaultHolds, name) {
+ if h == nil {
+ h = make(map[string]time.Duration, len(DefaultHolds)+1)
+ for ch, d := range DefaultHolds {
+ h[ch] = d
+ }
+ }
+ h[name] = mem.Window
+ }
+ }
+ if h != nil {
+ appHolds[ak] = h
+ }
}
}
holdsFor := func(ak acctApp) map[string]time.Duration {
@@ -561,7 +602,8 @@ 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. The flag stays meaningful for
// legacy apps, and for THIS app on a pass whose pipeline read
- // errored, which falls to exactly that legacy machinery.
+ // errored, which takes exactly that legacy verdict path (its
+ // WINDOWS are memory-floored above; the verdict is unchanged).
if ps := pipes[ak]; ps.err == nil && ps.p != nil {
return false
}
@@ -694,7 +736,9 @@ func RouteAccount(rt registry.Route) string {
//
// holds is the enforced per-channel window map the caller resolved for this
// route's app: a verified pipeline's ratcheted HoldsForApp map (canary/*
-// stitched back in at zero), else DefaultHolds — never consulted app-blind.
+// stitched back in at zero), DefaultHolds floored by the ratchet memory for
+// an app whose pipeline read errored, else DefaultHolds — never consulted
+// app-blind.
//
// ignore is the app-level bake verdict from ProductionBake — the LEGACY
// production flag's doing, never a publisher field, and constant false for
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index 6209ef80e..ec891f4fb 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -2949,6 +2949,65 @@ func TestPassCorruptPipelineFailsTowardLegacyHolds(t *testing.T) {
}
}
+// TestPassErroredPipelineReadKeepsTightenedWindows: the memory floor. A
+// verified pipeline tightened edge's window to 24h — and DefaultHolds gives
+// edge ZERO, so a plain fall-back to DefaultHolds on a later errored read
+// would adopt instantly: one transient GET or verify failure silently
+// defeating the exact window the bake exists to provide. Spec §3 is
+// binding: corruption must never loosen — on an errored read, every channel
+// the box's own ratchet remembers is enforced at max(its DefaultHolds
+// resolution, the remembered window).
+func TestPassErroredPipelineReadKeepsTightenedWindows(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ if _, err := SetPipeline(ctx, s, priv, "bab", "myapp", []PipelineChannel{
+ {Name: "edge", Bake: "24h"},
+ }, "test"); err != nil {
+ t.Fatalf("SetPipeline: %v", err)
+ }
+ // The flag the console co-writes with any nonzero-bake pipeline.
+ if err := SetProduction(ctx, s, priv, "bab", "myapp", true, "test"); err != nil {
+ t.Fatalf("SetProduction: %v", err)
+ }
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("bin"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "edge", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "e.example.com", "myapp", "edge", false)
+
+ // Pass 1, pipeline verified: edge's zero baseline tightens to 24h
+ // immediately (a tighten is never pending), and v1's clock starts — held.
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "e.example.com"); v != "" {
+ t.Fatalf("live = %q, want held — the verified pipeline tightened edge to 24h", v)
+ }
+ if mem := LoadFirstSeen(FirstSeenPath(opt)).Holds[channelClockKey("bab", "myapp", "edge")]; mem == nil || mem.Window != 24*time.Hour {
+ t.Fatalf("hold memory = %+v, want edge tightened to 24h", mem)
+ }
+
+ // Corrupt the object in place. The next read ERRORS (exists, won't
+ // verify) — and must not hand edge back its zero legacy window.
+ _, 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, "e.example.com"); v != "" {
+ t.Fatalf("live = %q, want STILL held — an errored read must keep the ratcheted 24h floor on edge, never fall to its zero legacy window", v)
+ }
+}
+
// The 2-3 SECOND canary fast loop must never pay a pipeline bucket read
// either: canary/* is outside every pipeline, so no canary-only pass has a
// verdict the object could change — the mirror of