// Bake windows — ported from Eleven bake.go, intact. // // The supply-chain bake window: beta and stable subscribers don't install a // release the moment it appears — the box waits until the release has sat at // the channel head for a while first, so a compromised release that gets // caught and pulled back never reaches them. The clock is THIS BOX's own // observation ("when did I first see this version at this channel head"), // never a manifest timestamp: an attacker holding the signing key controls // every timestamp in the feed and would simply backdate one, but cannot // backdate what the box already saw. // // canary/* has no window — it IS the operator's own deploy path (`ship && // promote`). beta's 24h absorbs the caught-within-a-day mass of real // supply-chain incidents; stable's 72h additionally spans a weekend, so a // Friday-night malicious publish is seen by a human on Monday with margin to // pull it back. edge has no window either — merged to main is picked up as it // lands (pipelines spec §3). // // There is deliberately NO publisher-side bypass ("critical fix, skip the // hold") — that flag is the first thing an attacker sets. The bypass is // local and explicit (the ignore parameter of Hold), which needs root on the // box. package release import ( "encoding/json" "errors" "fmt" "math/rand" "os" "path/filepath" "strings" "time" "github.com/carlosframework/platform/internal/safefile" ) // DefaultHolds is the default bake window per channel; per-deployment // overrides come from host config as a replacement map passed by the caller. var DefaultHolds = map[string]time.Duration{ "canary/*": 0, "edge": 0, "beta": 24 * time.Hour, "stable": 72 * time.Hour, } // HoldFor resolves a channel's bake window: an exact entry wins, then a // "/*" wildcard for two-segment channels (canary/), and an // unknown channel gets the longest standard window — fail closed, never // zero, for a channel nobody configured. func HoldFor(holds map[string]time.Duration, channel string) time.Duration { if d, ok := holds[channel]; ok { return d } if i := strings.Index(channel, "/"); i >= 0 { if d, ok := holds[channel[:i]+"/*"]; ok { return d } } return 72 * time.Hour } // FirstSeenPath is the LEGACY bake clock location, under the box's opt dir. // It is read-only for every production caller now: BakeDir moved the // writable clock to the data dir (issue #13 — the sandboxed edge unit // cannot write under optDir, and must not be able to), and // LoadFirstSeenMigrating reaches back to this path only to carry an // existing box's observations across that move. Nothing in production // writes here anymore; a box still on the legacy path only reads it once, // on the pass that migrates it. func FirstSeenPath(optDir string) string { return filepath.Join(optDir, "channel-first-seen.json") } // firstSeenKeep bounds each channel's history. Past heads are kept (not just // the current one) so a pull-back to a previously-seen version is immediately // eligible — evacuating a bad release must never itself sit in a bake window. const firstSeenKeep = 12 // FirstSeen is the bake clock: for each account/app/channel, when this box // first observed each version at its head. type FirstSeen struct { // Jitter desynchronises a fleet of boxes: each waits hold*(1+Jitter), so // boxes don't all install in the same hour and the earliest become // canaries for the rest. Minted once per box, in [0, 0.25). Jitter float64 `json:"jitter"` // Channels is keyed "//" -> version -> first-seen // time. Pre-accounts clocks have bare "/" keys; see // channelClockKey. Channels map[string]map[string]time.Time `json:"channels"` // Production is the per-app production-flag memory (2026-08-04 console // bake controls spec §4), keyed with clockSep — the same SEPARATOR // argument as channelClockKey (a channel segment may contain "/"; an // account or app name never contains clockSep). prodKey folds "" and // OpsAccount to the same key — there is no legacy bare shape to // preserve here, this memory is new, so callers can pass whatever // RouteAccount(rt) (adopt.go) returns and land on the right memory // either way. // // ADDITIVE, but only one pass deep: an OLD binary that Saves this file // re-marshals the struct IT knows about, which has no Production field — // its write DELETES every app's production memory from the shared file. // That is not silently unsafe: the latch re-latches from the bucket flag // the very next pass a new binary reads it (true stays true), and a lost // ratchet clock simply restarts from that pass's sighting (an // in-progress off-ratchet gets pushed out, never skipped) — both are the // closed direction on their own. A mixed-version rollout only ever costs // up to one pass of this memory; it never lets an off-flag through // early, and never lets a deleted true downgrade anything either. Production map[string]*prodMemory `json:"production,omitempty"` // Holds is the pipeline bake-ratchet memory (release pipelines v2 spec // §3), keyed by channelClockKey(acct, app, channel) — the same key // shape and the same account-scoping argument as Channels (an app name // never contains clockSep or "/", so the two shapes cannot collide). // Each entry is the ENFORCED window for that channel, which only ever // tightens instantly and only ever loosens after the OLD window has // elapsed on this box's own clock — a declared window is a policy // change an attacker with bucket write (but no key) could otherwise use // to shrink a channel's bake to zero and ship through it immediately. // // ADDITIVE, but only one pass deep, same as Production above: an OLD // binary's Save re-marshals a struct with no Holds field, which DELETES // every app's ratchet memory from the shared file. For the WINDOW that // is not silently unsafe: the next new-binary pass re-latches every // channel from HoldFor(DefaultHolds, name) — the fail-closed baseline — // and any off-ratchet in flight simply restarts its clock from that // pass's sighting. A restarted clock only ever DELAYS a loosening; it // can never let one through early. // // That closed-direction argument covers Window/Pending/PendingAt and NOT // holdMemory.DeletedAt, which fails OPEN when dropped (a deleted-pipeline // app loses the only evidence it was ever pipelined, and the flagless // legacy verdict adopts). See that field's own comment — including why // today's exposure is a rollback within this branch rather than a fleet // property. Holds map[string]*holdMemory `json:"holds,omitempty"` path string // where Save persists; set by LoadFirstSeen save func() error // migrated marks a clock LoadFirstSeenMigrating built from its // legacy-fallback branch (the new path didn't exist yet). PassFilter // checks this to force an immediate Save even when Observe reports no // change this pass — see LoadFirstSeenMigrating's doc comment for why // a changed-gated Save alone isn't enough to make the migration durable. migrated bool // tainted marks a clock whose file read succeeded but did NOT parse as // JSON — distinct from a missing file, which is just a fresh, empty // clock. Never serialized (unexported); see LoadFirstSeen and // ProductionBake for why the two failure modes must be told apart. tainted bool } // channelClockKey identifies one channel head's clock. The ACCOUNT is part of // it because app names are unique per account, not globally: without it, one // account's 72h stable observation would satisfy another account's // identically-named app on the same box, silently skipping the supply-chain // window that is the entire point of this file. // // The ops account keeps the pre-accounts key shape ("/"), so // every existing box's observations carry across an upgrade. Restarting them // would freeze the whole fleet for 72h on the day this ships, which is a // worse failure than the one it would be protecting against. That shape is // injective on its own: an app name can never contain "/", so the first // segment is always the app and the rest is always the channel. // // Everyone else is joined with clockSep, NOT "/" — channels may contain a // "/" (canary/), so "/" between segments would make ops app "kass" on // channel "canary/x" collide with account "kass", app "canary", channel "x", // and one account's observation would satisfy another's window. clockSep // cannot appear in a sqid, an app name or a channel segment, so the split is // unambiguous — and because it can never appear in an ops key either, the two // shapes cannot collide with each other. func channelClockKey(acct, app, channel string) string { if acct == "" || acct == OpsAccount { return app + "/" + channel } return acct + clockSep + app + clockSep + channel } // clockSep separates the bake clock's key segments. "|" is outside every // character class the platform allows in a sqid (lowercase letters), an app // name (appRe) or a channel segment (channelSegRe). const clockSep = "|" // LoadFirstSeen reads the bake clock from path. For the CHANNEL clock, a // missing OR corrupt file is simply an empty clock — every head then bakes // from "now", which fails closed (a wiped clock delays updates; it never // lets one through early either way). // // For the PRODUCTION memory (§4), those two failure modes must be told // apart. A file that reads but does not PARSE marks the clock tainted, and // ProductionBake fails toward baking for every held head on a tainted // clock — the same closed direction, reached differently, because an // unmarshal error can leave Production nil or half-written rather than // usably empty. A file that is simply ABSENT, though, is indistinguishable // from a fresh box: the same read that lets the channel clock safely // restart "from now" also silently loses the production latch and the // off-ratchet's replay floor, because there is nothing on disk to say a // latch ever existed. That gap is accepted: the latch defends against // BUCKET-side tampering (someone with bucket write but no key deleting the // flag object) — a box's own /data is a different trust boundary. Whatever // already has write access there already holds adopt authority, i.e. // already controls what this box installs; deleting its bake-clock file // buys it nothing a corrupt bucket object would. func LoadFirstSeen(path string) *FirstSeen { st := &FirstSeen{path: path} if raw, err := safefile.ReadFile(path); err == nil { if err := json.Unmarshal(raw, st); err != nil { st.tainted = true } } else if !errors.Is(err, os.ErrNotExist) { // The file EXISTS but this process could not read it (EACCES, // EISDIR, an I/O error) — a different failure than "no file", and // one this box cannot tell apart from a bucket object it can't // trust either. Same taint, same fail-toward-baking direction. st.tainted = true } if st.Channels == nil { st.Channels = map[string]map[string]time.Time{} } if st.Production == nil { st.Production = map[string]*prodMemory{} } if st.Holds == nil { st.Holds = map[string]*holdMemory{} } if st.Jitter < 0 || st.Jitter >= 0.25 { st.Jitter = 0 } return st } // LoadFirstSeenMigrating reads the bake clock from path, reaching back to // legacyPath for its CONTENT when path does not exist yet — the one-time // migration off /opt/carlos (issue #13: the sandboxed edge cannot write the // version store, and must not be able to; the clock belongs in the data // dir). Save always writes path. The legacy file is never written or // deleted: an older binary still running against it keeps a working clock, // and this binary simply stops advancing it. // // The returned clock is marked migrated, which its caller (PassFilter) uses // to force that first Save even when nothing new was observed this pass — // Save is otherwise changed-gated (bake.go/adopt.go), and a box whose fleet // happens to see no new channel head could otherwise run for days with path // still absent, still re-reading legacyPath every pass. That window matters // because it invites deleting the legacy file as soon as this binary ships, // which — before the migrated Save lands — would silently wipe every stable // channel's observations and freeze the fleet on a fresh 72h window. Forcing // the first Save makes migration complete (path fully populated, // legacyPath now provably disposable) after exactly one pass. func LoadFirstSeenMigrating(path, legacyPath string) *FirstSeen { if _, err := os.Stat(path); os.IsNotExist(err) { st := LoadFirstSeen(legacyPath) st.path = path st.migrated = true return st } return LoadFirstSeen(path) } // Observe records a channel head this box hasn't seen at that head before. // Returns whether anything changed (the caller persists via Save). Never // call it on a pointer that failed verification — the clock only ever ticks // for signed feeds. An already-known version's timestamp is NEVER touched: // that preserved first sighting is exactly what makes a pointer moved BACK // to a previously-seen version (rollback, evacuation) immediately eligible. func (st *FirstSeen) Observe(acct, app, channel, version string, now time.Time) bool { changed := false if st.Jitter == 0 && len(st.Channels) == 0 { // A fresh box mints its jitter once, alongside its first observation. st.Jitter = rand.Float64() * 0.25 changed = true } key := channelClockKey(acct, app, channel) seen := st.Channels[key] if seen == nil { seen = map[string]time.Time{} st.Channels[key] = seen } if _, ok := seen[version]; !ok { seen[version] = now.UTC().Truncate(time.Second) changed = true } // Prune oldest entries beyond the cap — the just-recorded head is the // newest, so it always survives. for len(seen) > firstSeenKeep { oldest, at := "", time.Time{} for v, t := range seen { if oldest == "" || t.Before(at) { oldest, at = v, t } } delete(seen, oldest) } return changed } // Save persists the clock back to the path it was loaded from, via a // temp-file-in-the-same-dir + rename — the same co-written-file pattern // internal/appconfig/sync.go and syncexec.go already use for /data files // more than one uid writes (2026-08-03 review, issue #13): carlos-edge.service // runs as `carlos`, the root adopt one-shot runs as root, and BakeDir makes // both write this same path. An in-place O_TRUNC write needs write // permission on the EXISTING file, so whichever uid creates it first (0644, // owned by that uid) locks the other one out — EACCES with nothing to // repair the ownership (bootstrap only chowns /data once, at bootstrap). // rename(2), by contrast, only requires write permission on the containing // directory, which both uids have on /data, so either side can always // replace the file — and a crash mid-write leaves the old content intact // rather than a torn, half-written file. // // The temp name is minted fresh per attempt (os.CreateTemp's "channel- // first-seen-*.json"), never a fixed ".tmp": two uids can each crash // between write and rename, and a fixed name would leave cross-uid residue — // a root-owned 0644 leftover that the carlos-uid loop then fails to // O_TRUNC-rewrite forever (EACCES, no repair path short of root's next // pass), aborting its whole Pass every tick until then. A unique name per // attempt means a crash never leaves a temp file another uid's next Save // must contend with. CreateTemp also mints its file 0600, which this // explicitly widens to 0644 with Chmod BEFORE the rename: a 0600 root:root // clock is unreadable by the carlos-uid loop, and LoadFirstSeen turns that // read failure into a silently EMPTY clock — not an error, just every // channel head baking from "now" again, freezing stable adoption for a // fresh 72h. Any failure path removes the temp file rather than leaving it // behind for the next Save to trip over. func (st *FirstSeen) Save() error { if st.save != nil { return st.save() } raw, err := json.MarshalIndent(st, "", " ") if err != nil { return err } raw = append(raw, '\n') return safefile.WriteFile(st.path, raw, 0o644) } // EligibleAt is the moment a version first seen at a channel head may be // installed: firstSeen + hold*(1+jitter). ok=false when the version has no // record at this channel's head — brand new, so not eligible (callers that // Observe first make that unreachable; a status read that doesn't tick the // clock can legitimately see it). func (st *FirstSeen) EligibleAt(acct, app, channel, version string, holds map[string]time.Duration) (time.Time, bool) { seen, ok := st.Channels[channelClockKey(acct, app, channel)][version] if !ok { return time.Time{}, false } d := HoldFor(holds, channel) if d == 0 { return seen, true } return seen.Add(time.Duration(float64(d) * (1 + st.Jitter))), true } // Hold returns a non-empty human reason while a channel head must not yet be // installed on this box, "" once it may be. ignore is the local, root-only // override — deliberately never a publisher-side flag. func Hold(st *FirstSeen, acct, app, channel, version string, holds map[string]time.Duration, ignore bool, now time.Time) string { if ignore { return "" } at, ok := st.EligibleAt(acct, app, channel, version, holds) if !ok { // Fail closed: no record means no clock has run, so nothing installs. return "in its supply-chain bake window (clock starts this pass)" } if now.Before(at) { return fmt.Sprintf("in its supply-chain bake window until %s", at.UTC().Format("2006-01-02 15:04 MST")) } return "" } // ---- production memory ------------------------------------------------------ // prodMemory is one app's production-flag memory on this box. It exists to // hold two properties no bucket read can: DELETION IS NOT A DOWNGRADE (once // a verified production: true is seen, absence never clears it — anyone // with bucket write but no key can delete an object), and A STALE FLAG // CANNOT BE REPLAYED (an off from a past era, re-written by someone with // bucket access alone, must not roll back a newer on). type prodMemory struct { // WasProduction latches on the first verified production: true. WasProduction bool `json:"was_production,omitempty"` // NewestSetAt is the replay floor: the newest set_at this box has // honored. Anything at or below it is old news. NewestSetAt time.Time `json:"newest_set_at,omitzero"` // OffSetAt is the discriminator: non-zero means the newest verified word // for this app is off (only ever read via IsZero — its VALUE isn't // otherwise consulted; NewestSetAt already carries the replay floor). // OffFirstSeen is when THIS BOX first saw that off-flag edition — the // ratchet clock, the box's own observation, never the flag's own // timestamp (same argument as the bake clock itself). OffSetAt time.Time `json:"off_set_at,omitzero"` OffFirstSeen time.Time `json:"off_first_seen,omitzero"` } // prodKey identifies one app's production memory. Joined with clockSep for // the same reason channelClockKey is (an app or account name can never // contain it, unlike a channel segment), and it folds "" and OpsAccount to // the same key — unlike channelClockKey there's no legacy bare shape to // keep separate, this memory is new, so RouteAccount(rt)'s either form // always resolves to the one record. func prodKey(acct, app string) string { if acct == "" || acct == OpsAccount { acct = OpsAccount } return acct + clockSep + app } // ObserveProduction folds one VERIFIED flag read into this box's memory. // nil (no object in the bucket) changes nothing — absence is not evidence. // Returns whether anything changed; the caller persists via Save. Never // call it with a flag that failed verification: like Observe, the memory // only ever ticks for signed documents. func (st *FirstSeen) ObserveProduction(acct, app string, flag *ProductionFlag, now time.Time) bool { if flag == nil { return false } key := prodKey(acct, app) mem := st.Production[key] if mem == nil { mem = &prodMemory{} } // The replay floor. Equal set_at is the object already recorded — the // every-pass re-read — so nothing changes; older is a replay and is // ignored outright. if !flag.SetAt.After(mem.NewestSetAt) { return false } mem.NewestSetAt = flag.SetAt if flag.Production { mem.WasProduction = true mem.OffSetAt, mem.OffFirstSeen = time.Time{}, time.Time{} } else { mem.OffSetAt = flag.SetAt mem.OffFirstSeen = now.UTC().Truncate(time.Second) } st.Production[key] = mem return true } // ProductionBake is the adopt pass's one question: does the supply-chain // window apply to this app on this box right now? Call ObserveProduction // first — this reads memory, plus flagErr, which is non-nil when a flag // object EXISTS but would not read or verify: that fails toward baking (an // attacker who can corrupt but not sign must not shorten anything). // hasBakeableHead is the spec §4 carve-out: with no held channel head // resolved this pass, an off-flag needs no ratchet — there is nothing at a // baked head to protect. func (st *FirstSeen) ProductionBake(acct, app string, flagErr error, hasBakeableHead bool, holds map[string]time.Duration, now time.Time) bool { if st.tainted { // This box's own clock file existed but couldn't be read or didn't // parse — untrustworthy in a way absence never is. Fail toward // baking for every held head, same direction as flagErr, for the // lifetime of THIS load: tainted is set once, in LoadFirstSeen, and // nothing clears it in memory afterward. Save rewrites the file // from this struct's current (now-valid, in-memory) content, so the // NEXT LoadFirstSeen of that path starts clean. return true } if flagErr != nil { return true } mem := st.Production[prodKey(acct, app)] if mem == nil { return false // never declared production — the default } if mem.OffSetAt.IsZero() { // The newest verified word is production: true — or the flag has // vanished since, which never downgrades (WasProduction latches). return mem.WasProduction } // The newest verified word is production: false. if !mem.WasProduction { return false // off, and never was on: nothing to ratchet } if !hasBakeableHead { return false // the carve-out } // The ratchet: off is honored only after the stable window has elapsed // on this box's own sighting of the off-flag, jittered like any hold — // the flag defends itself with the mechanism it controls. d := HoldFor(holds, RungStable) eligible := mem.OffFirstSeen.Add(time.Duration(float64(d) * (1 + st.Jitter))) return now.Before(eligible) } // ---- pipeline bake ratchet --------------------------------------------------- // holdMemory is one channel's bake-window ratchet memory: the box's own // record of what window is actually enforced, versus what the pipeline // object currently declares. It exists for the same reason prodMemory // exists — a bucket write alone (no signing key) must never be able to // shrink a channel's supply-chain window and have that apply immediately. // // DELETION is covered by the same ratchet, not by prodMemory's // deletion-is-not-a-downgrade latch: a deleted pipeline means the app is // legacy again, so each remembered channel treats its LEGACY default // (HoldFor(DefaultHolds, name)) as the newly-declared window and ratchets // toward it through the ordinary pending machinery (ObservePipeline's // p == nil branch) — the old window is enforced for one full cycle first, // never collapsed instantly, and never pinned forever. type holdMemory struct { // Window is the ENFORCED window right now. Window time.Duration `json:"window"` // SetAt is the replay floor: the newest Pipeline.SetAt this box has // folded into this channel's memory. Anything at or before it is // either the same object re-read or an older replay — neither changes // policy, though a pending promotion may still land on its own clock. SetAt time.Time `json:"set_at"` // Pending is a declared window narrower than Window, waiting for the // OLD Window to run its full course (from PendingAt) before it takes // effect. Zero (with PendingAt zero) means nothing is pending. Pending time.Duration `json:"pending,omitempty"` // PendingAt is THIS BOX's own sighting of the narrower declaration — // never Pipeline.SetAt, same argument as OffFirstSeen above: an // attacker controlling the feed controls every timestamp in it, but // not what the box already saw. A re-read that leaves Pending // 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. // // It means "absence has been seen since the last VERIFIED read", which is // a fact about the app, not about one channel: a verified pipeline clears // it for every remembered channel under the app's prefix, including the // ones that object does not name. Clearing only the named channels was // poisonable — see the sweep in ObservePipeline's p != nil path. // // ADDITIVE, and one pass deep like every other field here — but NOT in // the same direction, which is worth saying plainly. Holds's own caveat // argues that a dropped ratchet memory restarts closed; that argument // does not extend to this field. An old binary's Save drops DeletedAt // along with the rest of Holds, and a dropped stamp does not delay a // loosening — it removes the only evidence the adopt pass has that a // deleted-pipeline app was ever pipelined, so floored comes up empty and // the flagless legacy verdict adopts. That is FAIL OPEN, for the window // between an old binary's Save and the next verified read (which, for a // deleted pipeline, never comes). // // The exposure is bounded by deployment reality, not by the design: // nothing in the fleet carries a Holds map at all yet — pipelines v2 has // not shipped — so the only binary that could drop it is a rollback // WITHIN this branch. If that stops being true before this ships, the // mitigation is the same one spec §3 gives for the old-binary edge (the // production: true co-write), and it wants saying in the spec rather // than assumed here. 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 now ENFORCED has not // yet elapsed on this box's own clock since the absence was first seen. // // Measuring that last cycle against the CURRENTLY enforced window is exact, // not an approximation, because the two deletion shapes divide cleanly: // // - the legacy default is LONGER (every app-defined name, whose legacy // resolution is HoldFor's 72h fallback): it lands immediately, stamps no // Pending, and the enforced window is now that longer one — so the cycle // served here is at least the window it replaced. // - the legacy default is SHORTER (edge, 0): it goes PENDING instead, and // the pending machinery runs the FULL old window before landing. By the // time the enforced window drops to the shorter value, the cycle this // clause would ask for has already been served in full — which is why a // landed deletion on a zero-window channel is over the moment it lands, // and why that is not a hole. // // The first two conditions cover the whole of the pending case anyway // (Window != legacy, then PendingAt non-zero), so nothing is resting on this // clause while a pending is in flight. // // 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, // for enumerating an app's remembered channels when no Pipeline is at hand // (ObservePipeline's p == nil case). Mirrors channelClockKey's two shapes // exactly, including the ops-account bare form. func holdKeyPrefix(acct, app string) string { if acct == "" || acct == OpsAccount { return app + "/" } return acct + clockSep + app + clockSep } // checkHoldPromotion applies mem's pending promotion if the OLD window has // now elapsed on this box's own clock (mem.PendingAt), jittered the same // way EligibleAt jitters a hold. Reports whether it promoted. nil and // no-pending are both no-ops so callers can call it unconditionally. func (f *FirstSeen) checkHoldPromotion(mem *holdMemory, now time.Time) bool { if mem == nil || mem.PendingAt.IsZero() { return false } elapsed := time.Duration(float64(mem.Window) * (1 + f.Jitter)) if now.Before(mem.PendingAt.Add(elapsed)) { return false } mem.Window = mem.Pending mem.Pending = 0 mem.PendingAt = time.Time{} return true } // ObservePipeline folds one verified pipeline read into this box's // per-channel bake-window ratchet memory (release pipelines v2 spec §3). // Returns whether anything changed; the caller persists via Save. // // A brand-new channel baselines at HoldFor(DefaultHolds, name) — the // fail-closed default — never its own declared window, so a narrower // window a brand-new channel declares ratchets in like any other tightening // move rather than applying on first sight. A declared window that is no // narrower than what's enforced tightens immediately; a narrower one goes // pending and only takes effect once the OLD window has run its course, // timed from this box's OWN sighting of the narrower declaration // (PendingAt), never the pipeline's SetAt — the same argument as the // production-flag ratchet and the channel clock itself: an attacker with // bucket write but no signing key controls every timestamp in the object, // but not what this box already saw. Pending promotion is checked on every // call, including replays and p == nil, so a fleet that only polls this // path occasionally still lands the promotion the pass after it elapses. // // p == nil (pipeline deleted, or absent this pass) is a policy declaration // of the LEGACY defaults, ratcheted like any other: for every channel this // app has memory of, HoldFor(DefaultHolds, name) goes through the same // compare a declared window gets — no narrower than what's enforced applies // immediately, narrower goes pending for one old-window cycle, and // re-observed absence never restarts that clock. Deletion therefore never // collapses a ratcheted window instantly (a bucket write alone can delete // the object) and never pins an app to a dead pipeline's windows forever. // An app with no memory at all — every legacy app, every pass — is a // strict no-op here. Pending promotions still get their chance to land. // // A channel whose BakeWindow() fails to parse is skipped: old memory is // kept (fail closed) and this never prevents any other channel in the same // pipeline from being observed. // // Never call this with a pipeline that failed verification — like Observe // and ObserveProduction, memory only ever ticks for a signed object. p == // nil must mean genuinely, verifiably absent (the object deleted); a read // or verify FAILURE is a different thing and must fail toward the longest // window instead, never be handed to this function as nil — passing a // corrupt read as nil would let it cheaply masquerade as deletion and // inherit the calm "never loosens from absence" path above instead of // failing closed. (Enforcing that at the call site is Task 5's job; this // function's contract is documented here so that call site gets it right.) func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time) bool { if f.Holds == nil { f.Holds = map[string]*holdMemory{} } changed := false if p == nil { prefix := holdKeyPrefix(acct, app) for key, mem := range f.Holds { // mem == nil: {"holds":{"x":null}} parses cleanly into a nil // entry — skip it, never dereference (same guard as the adopt // pass's floor loops). if mem == nil || !strings.HasPrefix(key, prefix) { continue } name := key[len(prefix):] if name == "" { continue } // The legacy default is the newly-declared window, through the // SAME compare as a declared pipeline. There is NO SetAt replay // floor here: absence carries no SetAt to compare — and mem.SetAt // is deliberately left standing, so a replayed pipeline object // older than the one that was deleted still cannot tick the // memory later. // 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() { mem.Window = w mem.Pending = 0 mem.PendingAt = time.Time{} changed = true } } else if mem.PendingAt.IsZero() || mem.Pending != w { // Only a NEW pending value stamps the clock: re-observation // of absence must not restart it — same rule as a re-read. mem.Pending = w mem.PendingAt = now changed = true } if f.checkHoldPromotion(mem, now) { changed = true } } return changed } // A verified pipeline ends every deletion cycle this app is carrying — // EVERY remembered channel under its prefix, not only the ones this // object happens to name. The stamp means "absence has been seen since // the last verified read", which is a fact about the APP, so it is // cleared the same way the p == nil branch stamps it: by prefix. // // Per-channel clearing was poisonable (PR #258 re-review). There is no // object-level replay floor — only a per-channel SetAt one — so a // HISTORICAL signed pipeline naming a SUBSET of the app's channels can be // PUT back: delete (every channel stamped), replay an old edge-only // object (edge cleared, production's stamp left standing while the app // reads as verifiably pipelined), dwell past production's cycle (the // stale stamp expires unnoticed), delete again (already non-zero, so // never restamped). The app then carries no live ratchet at all and its // held heads adopt with no bake — a keyless bucket write reaching exactly // the outcome the ratchet exists to prevent. if prefix := holdKeyPrefix(acct, app); len(f.Holds) > 0 { for key, mem := range f.Holds { if mem == nil || !strings.HasPrefix(key, prefix) || mem.DeletedAt.IsZero() { continue } mem.DeletedAt = time.Time{} changed = true } } for _, ch := range p.Channels { key := channelClockKey(acct, app, ch.Name) mem := f.Holds[key] if w, err := ch.BakeWindow(); err == nil { firstSighting := mem == nil if firstSighting { mem = &holdMemory{Window: HoldFor(DefaultHolds, ch.Name)} f.Holds[key] = mem changed = true } // The replay floor: equal or older SetAt is either the same // object re-read or a replay — no policy value change, though // the pending-promotion check below still runs. if firstSighting || p.SetAt.After(mem.SetAt) { mem.SetAt = p.SetAt changed = true if w >= mem.Window { mem.Window = w mem.Pending = 0 mem.PendingAt = time.Time{} } else if mem.PendingAt.IsZero() || mem.Pending != w { // PendingAt.IsZero() must stand on its own here, not // just mem.Pending != w: a declared window of exactly // ZERO is a legal value, and Pending's own zero value // is indistinguishable from "nothing pending" — without // this, a fresh channel whose first-ever declared bake // is 0 would never stamp PendingAt (mem.Pending, freshly // zeroed, already equals w=0), so checkHoldPromotion's // PendingAt.IsZero() guard would short-circuit forever // and the channel would sit pinned at its fail-closed // baseline for the life of the box. mem.Pending = w mem.PendingAt = now } } // OUTSIDE the replay floor, on every verified observation: a // pending NARROWER than the currently-declared window can only // have been stamped by something that is no longer the live // policy — absence (the deletion ratchet), whose pipeline has // since been restored byte-identically (same SetAt, so the // policy block above is skipped; a same-object pending always // equals its declared w). Left standing, it would land the // legacy default one old-window cycle later and STAY, enforcing // less than a live verified pipeline declares on every later // pass. Cancel it — the closed direction: cancelling a // loosening can only delay a shortening, never hasten one. if mem != nil && !mem.PendingAt.IsZero() && mem.Pending < w { mem.Pending, mem.PendingAt = 0, time.Time{} changed = true } } if f.checkHoldPromotion(mem, now) { changed = true } } return changed } // HoldsForApp returns the ENFORCED per-channel bake window map for a // pipelined app: one entry per channel p declares, using this box's // ratchet memory where it has any and HoldFor(DefaultHolds, name) — the // exact same fail-closed baseline ObservePipeline would latch on that // channel's first sighting — for a channel not yet observed. The two // paths therefore always agree, including for a rung name like "edge" // whose baseline is 0: synthesizing it here (rather than leaving the // channel absent from the map) matters precisely because HoldFor's OWN // unknown-channel fallback is a hardcoded 72h, which would silently // contradict a 0 baseline if this function ever left it out. nil when p // == nil, so the caller falls back to DefaultHolds entirely — the // pre-pipeline behaviour for an app with no pipeline at all. // // Never call this with a pipeline that failed verification, same // prohibition as ObservePipeline: p == nil must mean genuinely absent, not // "read or verify failed" — a verify failure must fail toward the longest // window at the call site instead (Task 5), never reach here as nil. func (f *FirstSeen) HoldsForApp(acct, app string, p *Pipeline) map[string]time.Duration { if p == nil { return nil } out := map[string]time.Duration{} for _, ch := range p.Channels { w := HoldFor(DefaultHolds, ch.Name) if mem := f.Holds[channelClockKey(acct, app, ch.Name)]; mem != nil { w = mem.Window } out[ch.Name] = w } return out }