// 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
}