carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Bake windows — ported from Eleven bake.go, intact.
2//
3// The supply-chain bake window: beta and stable subscribers don't install a
4// release the moment it appears — the box waits until the release has sat at
5// the channel head for a while first, so a compromised release that gets
6// caught and pulled back never reaches them. The clock is THIS BOX's own
7// observation ("when did I first see this version at this channel head"),
8// never a manifest timestamp: an attacker holding the signing key controls
9// every timestamp in the feed and would simply backdate one, but cannot
10// backdate what the box already saw.
11//
12// canary/* has no window — it IS the operator's own deploy path (`ship &&
13// promote`). beta's 24h absorbs the caught-within-a-day mass of real
14// supply-chain incidents; stable's 72h additionally spans a weekend, so a
15// Friday-night malicious publish is seen by a human on Monday with margin to
16// pull it back. edge has no window either — merged to main is picked up as it
17// lands (pipelines spec §3).
18//
19// There is deliberately NO publisher-side bypass ("critical fix, skip the
20// hold") — that flag is the first thing an attacker sets. The bypass is
21// local and explicit (the ignore parameter of Hold), which needs root on the
22// box.
23package release
24
25import (
26 "encoding/json"
27 "errors"
28 "fmt"
29 "math/rand"
30 "os"
31 "path/filepath"
32 "strings"
33 "time"
34
35 "github.com/carlosframework/platform/internal/safefile"
36)
37
38// DefaultHolds is the default bake window per channel; per-deployment
39// overrides come from host config as a replacement map passed by the caller.
40var DefaultHolds = map[string]time.Duration{
41 "canary/*": 0,
42 "edge": 0,
43 "beta": 24 * time.Hour,
44 "stable": 72 * time.Hour,
45}
46
47// HoldFor resolves a channel's bake window: an exact entry wins, then a
48// "<head>/*" wildcard for two-segment channels (canary/<branch>), and an
49// unknown channel gets the longest standard window — fail closed, never
50// zero, for a channel nobody configured.
51func HoldFor(holds map[string]time.Duration, channel string) time.Duration {
52 if d, ok := holds[channel]; ok {
53 return d
54 }
55 if i := strings.Index(channel, "/"); i >= 0 {
56 if d, ok := holds[channel[:i]+"/*"]; ok {
57 return d
58 }
59 }
60 return 72 * time.Hour
61}
62
63// FirstSeenPath is the LEGACY bake clock location, under the box's opt dir.
64// It is read-only for every production caller now: BakeDir moved the
65// writable clock to the data dir (issue #13 — the sandboxed edge unit
66// cannot write under optDir, and must not be able to), and
67// LoadFirstSeenMigrating reaches back to this path only to carry an
68// existing box's observations across that move. Nothing in production
69// writes here anymore; a box still on the legacy path only reads it once,
70// on the pass that migrates it.
71func FirstSeenPath(optDir string) string {
72 return filepath.Join(optDir, "channel-first-seen.json")
73}
74
75// firstSeenKeep bounds each channel's history. Past heads are kept (not just
76// the current one) so a pull-back to a previously-seen version is immediately
77// eligible — evacuating a bad release must never itself sit in a bake window.
78const firstSeenKeep = 12
79
80// FirstSeen is the bake clock: for each account/app/channel, when this box
81// first observed each version at its head.
82type FirstSeen struct {
83 // Jitter desynchronises a fleet of boxes: each waits hold*(1+Jitter), so
84 // boxes don't all install in the same hour and the earliest become
85 // canaries for the rest. Minted once per box, in [0, 0.25).
86 Jitter float64 `json:"jitter"`
87 // Channels is keyed "<account>/<app>/<channel>" -> version -> first-seen
88 // time. Pre-accounts clocks have bare "<app>/<channel>" keys; see
89 // channelClockKey.
90 Channels map[string]map[string]time.Time `json:"channels"`
91
92 // Production is the per-app production-flag memory (2026-08-04 console
93 // bake controls spec §4), keyed with clockSep — the same SEPARATOR
94 // argument as channelClockKey (a channel segment may contain "/"; an
95 // account or app name never contains clockSep). prodKey folds "" and
96 // OpsAccount to the same key — there is no legacy bare shape to
97 // preserve here, this memory is new, so callers can pass whatever
98 // RouteAccount(rt) (adopt.go) returns and land on the right memory
99 // either way.
100 //
101 // ADDITIVE, but only one pass deep: an OLD binary that Saves this file
102 // re-marshals the struct IT knows about, which has no Production field —
103 // its write DELETES every app's production memory from the shared file.
104 // That is not silently unsafe: the latch re-latches from the bucket flag
105 // the very next pass a new binary reads it (true stays true), and a lost
106 // ratchet clock simply restarts from that pass's sighting (an
107 // in-progress off-ratchet gets pushed out, never skipped) — both are the
108 // closed direction on their own. A mixed-version rollout only ever costs
109 // up to one pass of this memory; it never lets an off-flag through
110 // early, and never lets a deleted true downgrade anything either.
111 Production map[string]*prodMemory `json:"production,omitempty"`
112
113 // Holds is the pipeline bake-ratchet memory (release pipelines v2 spec
114 // §3), keyed by channelClockKey(acct, app, channel) — the same key
115 // shape and the same account-scoping argument as Channels (an app name
116 // never contains clockSep or "/", so the two shapes cannot collide).
117 // Each entry is the ENFORCED window for that channel, which only ever
118 // tightens instantly and only ever loosens after the OLD window has
119 // elapsed on this box's own clock — a declared window is a policy
120 // change an attacker with bucket write (but no key) could otherwise use
121 // to shrink a channel's bake to zero and ship through it immediately.
122 //
123 // ADDITIVE, but only one pass deep, same as Production above: an OLD
124 // binary's Save re-marshals a struct with no Holds field, which DELETES
125 // every app's ratchet memory from the shared file. For the WINDOW that
126 // is not silently unsafe: the next new-binary pass re-latches every
127 // channel from HoldFor(DefaultHolds, name) — the fail-closed baseline —
128 // and any off-ratchet in flight simply restarts its clock from that
129 // pass's sighting. A restarted clock only ever DELAYS a loosening; it
130 // can never let one through early.
131 //
132 // That closed-direction argument covers Window/Pending/PendingAt and NOT
133 // holdMemory.DeletedAt, which fails OPEN when dropped (a deleted-pipeline
134 // app loses the only evidence it was ever pipelined, and the flagless
135 // legacy verdict adopts). See that field's own comment — including why
136 // today's exposure is a rollback within this branch rather than a fleet
137 // property.
138 Holds map[string]*holdMemory `json:"holds,omitempty"`
139
140 path string // where Save persists; set by LoadFirstSeen
141 save func() error
142
143 // migrated marks a clock LoadFirstSeenMigrating built from its
144 // legacy-fallback branch (the new path didn't exist yet). PassFilter
145 // checks this to force an immediate Save even when Observe reports no
146 // change this pass — see LoadFirstSeenMigrating's doc comment for why
147 // a changed-gated Save alone isn't enough to make the migration durable.
148 migrated bool
149
150 // tainted marks a clock whose file read succeeded but did NOT parse as
151 // JSON — distinct from a missing file, which is just a fresh, empty
152 // clock. Never serialized (unexported); see LoadFirstSeen and
153 // ProductionBake for why the two failure modes must be told apart.
154 tainted bool
155}
156
157// channelClockKey identifies one channel head's clock. The ACCOUNT is part of
158// it because app names are unique per account, not globally: without it, one
159// account's 72h stable observation would satisfy another account's
160// identically-named app on the same box, silently skipping the supply-chain
161// window that is the entire point of this file.
162//
163// The ops account keeps the pre-accounts key shape ("<app>/<channel>"), so
164// every existing box's observations carry across an upgrade. Restarting them
165// would freeze the whole fleet for 72h on the day this ships, which is a
166// worse failure than the one it would be protecting against. That shape is
167// injective on its own: an app name can never contain "/", so the first
168// segment is always the app and the rest is always the channel.
169//
170// Everyone else is joined with clockSep, NOT "/" — channels may contain a
171// "/" (canary/<branch>), so "/" between segments would make ops app "kass" on
172// channel "canary/x" collide with account "kass", app "canary", channel "x",
173// and one account's observation would satisfy another's window. clockSep
174// cannot appear in a sqid, an app name or a channel segment, so the split is
175// unambiguous — and because it can never appear in an ops key either, the two
176// shapes cannot collide with each other.
177func channelClockKey(acct, app, channel string) string {
178 if acct == "" || acct == OpsAccount {
179 return app + "/" + channel
180 }
181 return acct + clockSep + app + clockSep + channel
182}
183
184// clockSep separates the bake clock's key segments. "|" is outside every
185// character class the platform allows in a sqid (lowercase letters), an app
186// name (appRe) or a channel segment (channelSegRe).
187const clockSep = "|"
188
189// LoadFirstSeen reads the bake clock from path. For the CHANNEL clock, a
190// missing OR corrupt file is simply an empty clock — every head then bakes
191// from "now", which fails closed (a wiped clock delays updates; it never
192// lets one through early either way).
193//
194// For the PRODUCTION memory (§4), those two failure modes must be told
195// apart. A file that reads but does not PARSE marks the clock tainted, and
196// ProductionBake fails toward baking for every held head on a tainted
197// clock — the same closed direction, reached differently, because an
198// unmarshal error can leave Production nil or half-written rather than
199// usably empty. A file that is simply ABSENT, though, is indistinguishable
200// from a fresh box: the same read that lets the channel clock safely
201// restart "from now" also silently loses the production latch and the
202// off-ratchet's replay floor, because there is nothing on disk to say a
203// latch ever existed. That gap is accepted: the latch defends against
204// BUCKET-side tampering (someone with bucket write but no key deleting the
205// flag object) — a box's own /data is a different trust boundary. Whatever
206// already has write access there already holds adopt authority, i.e.
207// already controls what this box installs; deleting its bake-clock file
208// buys it nothing a corrupt bucket object would.
209func LoadFirstSeen(path string) *FirstSeen {
210 st := &FirstSeen{path: path}
211 if raw, err := safefile.ReadFile(path); err == nil {
212 if err := json.Unmarshal(raw, st); err != nil {
213 st.tainted = true
214 }
215 } else if !errors.Is(err, os.ErrNotExist) {
216 // The file EXISTS but this process could not read it (EACCES,
217 // EISDIR, an I/O error) — a different failure than "no file", and
218 // one this box cannot tell apart from a bucket object it can't
219 // trust either. Same taint, same fail-toward-baking direction.
220 st.tainted = true
221 }
222 if st.Channels == nil {
223 st.Channels = map[string]map[string]time.Time{}
224 }
225 if st.Production == nil {
226 st.Production = map[string]*prodMemory{}
227 }
228 if st.Holds == nil {
229 st.Holds = map[string]*holdMemory{}
230 }
231 if st.Jitter < 0 || st.Jitter >= 0.25 {
232 st.Jitter = 0
233 }
234 return st
235}
236
237// LoadFirstSeenMigrating reads the bake clock from path, reaching back to
238// legacyPath for its CONTENT when path does not exist yet — the one-time
239// migration off /opt/carlos (issue #13: the sandboxed edge cannot write the
240// version store, and must not be able to; the clock belongs in the data
241// dir). Save always writes path. The legacy file is never written or
242// deleted: an older binary still running against it keeps a working clock,
243// and this binary simply stops advancing it.
244//
245// The returned clock is marked migrated, which its caller (PassFilter) uses
246// to force that first Save even when nothing new was observed this pass —
247// Save is otherwise changed-gated (bake.go/adopt.go), and a box whose fleet
248// happens to see no new channel head could otherwise run for days with path
249// still absent, still re-reading legacyPath every pass. That window matters
250// because it invites deleting the legacy file as soon as this binary ships,
251// which — before the migrated Save lands — would silently wipe every stable
252// channel's observations and freeze the fleet on a fresh 72h window. Forcing
253// the first Save makes migration complete (path fully populated,
254// legacyPath now provably disposable) after exactly one pass.
255func LoadFirstSeenMigrating(path, legacyPath string) *FirstSeen {
256 if _, err := os.Stat(path); os.IsNotExist(err) {
257 st := LoadFirstSeen(legacyPath)
258 st.path = path
259 st.migrated = true
260 return st
261 }
262 return LoadFirstSeen(path)
263}
264
265// Observe records a channel head this box hasn't seen at that head before.
266// Returns whether anything changed (the caller persists via Save). Never
267// call it on a pointer that failed verification — the clock only ever ticks
268// for signed feeds. An already-known version's timestamp is NEVER touched:
269// that preserved first sighting is exactly what makes a pointer moved BACK
270// to a previously-seen version (rollback, evacuation) immediately eligible.
271func (st *FirstSeen) Observe(acct, app, channel, version string, now time.Time) bool {
272 changed := false
273 if st.Jitter == 0 && len(st.Channels) == 0 {
274 // A fresh box mints its jitter once, alongside its first observation.
275 st.Jitter = rand.Float64() * 0.25
276 changed = true
277 }
278 key := channelClockKey(acct, app, channel)
279 seen := st.Channels[key]
280 if seen == nil {
281 seen = map[string]time.Time{}
282 st.Channels[key] = seen
283 }
284 if _, ok := seen[version]; !ok {
285 seen[version] = now.UTC().Truncate(time.Second)
286 changed = true
287 }
288 // Prune oldest entries beyond the cap — the just-recorded head is the
289 // newest, so it always survives.
290 for len(seen) > firstSeenKeep {
291 oldest, at := "", time.Time{}
292 for v, t := range seen {
293 if oldest == "" || t.Before(at) {
294 oldest, at = v, t
295 }
296 }
297 delete(seen, oldest)
298 }
299 return changed
300}
301
302// Save persists the clock back to the path it was loaded from, via a
303// temp-file-in-the-same-dir + rename — the same co-written-file pattern
304// internal/appconfig/sync.go and syncexec.go already use for /data files
305// more than one uid writes (2026-08-03 review, issue #13): carlos-edge.service
306// runs as `carlos`, the root adopt one-shot runs as root, and BakeDir makes
307// both write this same path. An in-place O_TRUNC write needs write
308// permission on the EXISTING file, so whichever uid creates it first (0644,
309// owned by that uid) locks the other one out — EACCES with nothing to
310// repair the ownership (bootstrap only chowns /data once, at bootstrap).
311// rename(2), by contrast, only requires write permission on the containing
312// directory, which both uids have on /data, so either side can always
313// replace the file — and a crash mid-write leaves the old content intact
314// rather than a torn, half-written file.
315//
316// The temp name is minted fresh per attempt (os.CreateTemp's "channel-
317// first-seen-*.json"), never a fixed "<path>.tmp": two uids can each crash
318// between write and rename, and a fixed name would leave cross-uid residue —
319// a root-owned 0644 leftover that the carlos-uid loop then fails to
320// O_TRUNC-rewrite forever (EACCES, no repair path short of root's next
321// pass), aborting its whole Pass every tick until then. A unique name per
322// attempt means a crash never leaves a temp file another uid's next Save
323// must contend with. CreateTemp also mints its file 0600, which this
324// explicitly widens to 0644 with Chmod BEFORE the rename: a 0600 root:root
325// clock is unreadable by the carlos-uid loop, and LoadFirstSeen turns that
326// read failure into a silently EMPTY clock — not an error, just every
327// channel head baking from "now" again, freezing stable adoption for a
328// fresh 72h. Any failure path removes the temp file rather than leaving it
329// behind for the next Save to trip over.
330func (st *FirstSeen) Save() error {
331 if st.save != nil {
332 return st.save()
333 }
334 raw, err := json.MarshalIndent(st, "", " ")
335 if err != nil {
336 return err
337 }
338 raw = append(raw, '\n')
339 return safefile.WriteFile(st.path, raw, 0o644)
340}
341
342// EligibleAt is the moment a version first seen at a channel head may be
343// installed: firstSeen + hold*(1+jitter). ok=false when the version has no
344// record at this channel's head — brand new, so not eligible (callers that
345// Observe first make that unreachable; a status read that doesn't tick the
346// clock can legitimately see it).
347func (st *FirstSeen) EligibleAt(acct, app, channel, version string, holds map[string]time.Duration) (time.Time, bool) {
348 seen, ok := st.Channels[channelClockKey(acct, app, channel)][version]
349 if !ok {
350 return time.Time{}, false
351 }
352 d := HoldFor(holds, channel)
353 if d == 0 {
354 return seen, true
355 }
356 return seen.Add(time.Duration(float64(d) * (1 + st.Jitter))), true
357}
358
359// Hold returns a non-empty human reason while a channel head must not yet be
360// installed on this box, "" once it may be. ignore is the local, root-only
361// override — deliberately never a publisher-side flag.
362func Hold(st *FirstSeen, acct, app, channel, version string, holds map[string]time.Duration, ignore bool, now time.Time) string {
363 if ignore {
364 return ""
365 }
366 at, ok := st.EligibleAt(acct, app, channel, version, holds)
367 if !ok {
368 // Fail closed: no record means no clock has run, so nothing installs.
369 return "in its supply-chain bake window (clock starts this pass)"
370 }
371 if now.Before(at) {
372 return fmt.Sprintf("in its supply-chain bake window until %s", at.UTC().Format("2006-01-02 15:04 MST"))
373 }
374 return ""
375}
376
377// ---- production memory ------------------------------------------------------
378
379// prodMemory is one app's production-flag memory on this box. It exists to
380// hold two properties no bucket read can: DELETION IS NOT A DOWNGRADE (once
381// a verified production: true is seen, absence never clears it — anyone
382// with bucket write but no key can delete an object), and A STALE FLAG
383// CANNOT BE REPLAYED (an off from a past era, re-written by someone with
384// bucket access alone, must not roll back a newer on).
385type prodMemory struct {
386 // WasProduction latches on the first verified production: true.
387 WasProduction bool `json:"was_production,omitempty"`
388 // NewestSetAt is the replay floor: the newest set_at this box has
389 // honored. Anything at or below it is old news.
390 NewestSetAt time.Time `json:"newest_set_at,omitzero"`
391 // OffSetAt is the discriminator: non-zero means the newest verified word
392 // for this app is off (only ever read via IsZero — its VALUE isn't
393 // otherwise consulted; NewestSetAt already carries the replay floor).
394 // OffFirstSeen is when THIS BOX first saw that off-flag edition — the
395 // ratchet clock, the box's own observation, never the flag's own
396 // timestamp (same argument as the bake clock itself).
397 OffSetAt time.Time `json:"off_set_at,omitzero"`
398 OffFirstSeen time.Time `json:"off_first_seen,omitzero"`
399}
400
401// prodKey identifies one app's production memory. Joined with clockSep for
402// the same reason channelClockKey is (an app or account name can never
403// contain it, unlike a channel segment), and it folds "" and OpsAccount to
404// the same key — unlike channelClockKey there's no legacy bare shape to
405// keep separate, this memory is new, so RouteAccount(rt)'s either form
406// always resolves to the one record.
407func prodKey(acct, app string) string {
408 if acct == "" || acct == OpsAccount {
409 acct = OpsAccount
410 }
411 return acct + clockSep + app
412}
413
414// ObserveProduction folds one VERIFIED flag read into this box's memory.
415// nil (no object in the bucket) changes nothing — absence is not evidence.
416// Returns whether anything changed; the caller persists via Save. Never
417// call it with a flag that failed verification: like Observe, the memory
418// only ever ticks for signed documents.
419func (st *FirstSeen) ObserveProduction(acct, app string, flag *ProductionFlag, now time.Time) bool {
420 if flag == nil {
421 return false
422 }
423 key := prodKey(acct, app)
424 mem := st.Production[key]
425 if mem == nil {
426 mem = &prodMemory{}
427 }
428 // The replay floor. Equal set_at is the object already recorded — the
429 // every-pass re-read — so nothing changes; older is a replay and is
430 // ignored outright.
431 if !flag.SetAt.After(mem.NewestSetAt) {
432 return false
433 }
434 mem.NewestSetAt = flag.SetAt
435 if flag.Production {
436 mem.WasProduction = true
437 mem.OffSetAt, mem.OffFirstSeen = time.Time{}, time.Time{}
438 } else {
439 mem.OffSetAt = flag.SetAt
440 mem.OffFirstSeen = now.UTC().Truncate(time.Second)
441 }
442 st.Production[key] = mem
443 return true
444}
445
446// ProductionBake is the adopt pass's one question: does the supply-chain
447// window apply to this app on this box right now? Call ObserveProduction
448// first — this reads memory, plus flagErr, which is non-nil when a flag
449// object EXISTS but would not read or verify: that fails toward baking (an
450// attacker who can corrupt but not sign must not shorten anything).
451// hasBakeableHead is the spec §4 carve-out: with no held channel head
452// resolved this pass, an off-flag needs no ratchet — there is nothing at a
453// baked head to protect.
454func (st *FirstSeen) ProductionBake(acct, app string, flagErr error, hasBakeableHead bool, holds map[string]time.Duration, now time.Time) bool {
455 if st.tainted {
456 // This box's own clock file existed but couldn't be read or didn't
457 // parse — untrustworthy in a way absence never is. Fail toward
458 // baking for every held head, same direction as flagErr, for the
459 // lifetime of THIS load: tainted is set once, in LoadFirstSeen, and
460 // nothing clears it in memory afterward. Save rewrites the file
461 // from this struct's current (now-valid, in-memory) content, so the
462 // NEXT LoadFirstSeen of that path starts clean.
463 return true
464 }
465 if flagErr != nil {
466 return true
467 }
468 mem := st.Production[prodKey(acct, app)]
469 if mem == nil {
470 return false // never declared production — the default
471 }
472 if mem.OffSetAt.IsZero() {
473 // The newest verified word is production: true — or the flag has
474 // vanished since, which never downgrades (WasProduction latches).
475 return mem.WasProduction
476 }
477 // The newest verified word is production: false.
478 if !mem.WasProduction {
479 return false // off, and never was on: nothing to ratchet
480 }
481 if !hasBakeableHead {
482 return false // the carve-out
483 }
484 // The ratchet: off is honored only after the stable window has elapsed
485 // on this box's own sighting of the off-flag, jittered like any hold —
486 // the flag defends itself with the mechanism it controls.
487 d := HoldFor(holds, RungStable)
488 eligible := mem.OffFirstSeen.Add(time.Duration(float64(d) * (1 + st.Jitter)))
489 return now.Before(eligible)
490}
491
492// ---- pipeline bake ratchet ---------------------------------------------------
493
494// holdMemory is one channel's bake-window ratchet memory: the box's own
495// record of what window is actually enforced, versus what the pipeline
496// object currently declares. It exists for the same reason prodMemory
497// exists — a bucket write alone (no signing key) must never be able to
498// shrink a channel's supply-chain window and have that apply immediately.
499//
500// DELETION is covered by the same ratchet, not by prodMemory's
501// deletion-is-not-a-downgrade latch: a deleted pipeline means the app is
502// legacy again, so each remembered channel treats its LEGACY default
503// (HoldFor(DefaultHolds, name)) as the newly-declared window and ratchets
504// toward it through the ordinary pending machinery (ObservePipeline's
505// p == nil branch) — the old window is enforced for one full cycle first,
506// never collapsed instantly, and never pinned forever.
507type holdMemory struct {
508 // Window is the ENFORCED window right now.
509 Window time.Duration `json:"window"`
510 // SetAt is the replay floor: the newest Pipeline.SetAt this box has
511 // folded into this channel's memory. Anything at or before it is
512 // either the same object re-read or an older replay — neither changes
513 // policy, though a pending promotion may still land on its own clock.
514 SetAt time.Time `json:"set_at"`
515 // Pending is a declared window narrower than Window, waiting for the
516 // OLD Window to run its full course (from PendingAt) before it takes
517 // effect. Zero (with PendingAt zero) means nothing is pending.
518 Pending time.Duration `json:"pending,omitempty"`
519 // PendingAt is THIS BOX's own sighting of the narrower declaration —
520 // never Pipeline.SetAt, same argument as OffFirstSeen above: an
521 // attacker controlling the feed controls every timestamp in it, but
522 // not what the box already saw. A re-read that leaves Pending
523 // unchanged must not push this forward, or the clock would never
524 // elapse against a fleet that polls faster than the window itself.
525 PendingAt time.Time `json:"pending_at,omitzero"`
526 // DeletedAt is this box's own first sighting of the pipeline's ABSENCE
527 // for this channel — additive, and stamped only on the p == nil path.
528 //
529 // It exists because the pending machinery cannot carry the deletion
530 // ratchet on its own for every channel name. Deletion re-declares the
531 // name's LEGACY default, and for an app-defined name that default is
532 // HoldFor's 72h fail-closed fallback — LONGER than the window such a
533 // channel usually enforces, so it lands instantly (a longer bake always
534 // does) and stamps no Pending at all. The window is then perfectly safe
535 // and the memory looks, to any reader, exactly like a legacy app's:
536 // nothing pending, window == legacy. Without this stamp the adopt pass
537 // would have no evidence that this app was pipelined ninety seconds ago,
538 // would take the flagless legacy VERDICT, and one keyless DELETE would
539 // adopt a build minutes old — collapsing the very protection the ratchet
540 // exists to give (spec §3: "deletion never collapses a window
541 // instantly"). Re-observing absence must not restart it, exactly as
542 // PendingAt must not.
543 //
544 // It means "absence has been seen since the last VERIFIED read", which is
545 // a fact about the app, not about one channel: a verified pipeline clears
546 // it for every remembered channel under the app's prefix, including the
547 // ones that object does not name. Clearing only the named channels was
548 // poisonable — see the sweep in ObservePipeline's p != nil path.
549 //
550 // ADDITIVE, and one pass deep like every other field here — but NOT in
551 // the same direction, which is worth saying plainly. Holds's own caveat
552 // argues that a dropped ratchet memory restarts closed; that argument
553 // does not extend to this field. An old binary's Save drops DeletedAt
554 // along with the rest of Holds, and a dropped stamp does not delay a
555 // loosening — it removes the only evidence the adopt pass has that a
556 // deleted-pipeline app was ever pipelined, so floored comes up empty and
557 // the flagless legacy verdict adopts. That is FAIL OPEN, for the window
558 // between an old binary's Save and the next verified read (which, for a
559 // deleted pipeline, never comes).
560 //
561 // The exposure is bounded by deployment reality, not by the design:
562 // nothing in the fleet carries a Holds map at all yet — pipelines v2 has
563 // not shipped — so the only binary that could drop it is a rollback
564 // WITHIN this branch. If that stops being true before this ships, the
565 // mitigation is the same one spec §3 gives for the old-binary edge (the
566 // production: true co-write), and it wants saying in the spec rather
567 // than assumed here.
568 DeletedAt time.Time `json:"deleted_at,omitzero"`
569}
570
571// liveRatchet reports whether this box is still running a pipeline ratchet
572// for the channel — the adopt pass's evidence that the app IS pipelined as
573// far as this box knows, independent of whether the memory happens to RAISE
574// the window above legacy (an app-defined name's legacy resolution is 72h,
575// so an ordinary 24h production channel raises nothing at all).
576//
577// Three shapes, any of which is live:
578// - the enforced window differs from the name's legacy resolution, in
579// EITHER direction: something other than the legacy ladder set it.
580// - a pending loosening is waiting out its old window.
581// - a deletion is still running its cycle: the window now ENFORCED has not
582// yet elapsed on this box's own clock since the absence was first seen.
583//
584// Measuring that last cycle against the CURRENTLY enforced window is exact,
585// not an approximation, because the two deletion shapes divide cleanly:
586//
587// - the legacy default is LONGER (every app-defined name, whose legacy
588// resolution is HoldFor's 72h fallback): it lands immediately, stamps no
589// Pending, and the enforced window is now that longer one — so the cycle
590// served here is at least the window it replaced.
591// - the legacy default is SHORTER (edge, 0): it goes PENDING instead, and
592// the pending machinery runs the FULL old window before landing. By the
593// time the enforced window drops to the shorter value, the cycle this
594// clause would ask for has already been served in full — which is why a
595// landed deletion on a zero-window channel is over the moment it lands,
596// and why that is not a hole.
597//
598// The first two conditions cover the whole of the pending case anyway
599// (Window != legacy, then PendingAt non-zero), so nothing is resting on this
600// clause while a pending is in flight.
601//
602// name is the channel's own name, jitter and now the pass's (FirstSeen.Jitter
603// and its single stamped instant), so every hold on the box answers the same
604// clock.
605func (mem *holdMemory) liveRatchet(name string, jitter float64, now time.Time) bool {
606 if mem == nil {
607 return false
608 }
609 if mem.Window != HoldFor(DefaultHolds, name) || !mem.PendingAt.IsZero() {
610 return true
611 }
612 if mem.DeletedAt.IsZero() {
613 return false
614 }
615 return now.Before(mem.DeletedAt.Add(time.Duration(float64(mem.Window) * (1 + jitter))))
616}
617
618// holdKeyPrefix is channelClockKey's key shape without the channel segment,
619// for enumerating an app's remembered channels when no Pipeline is at hand
620// (ObservePipeline's p == nil case). Mirrors channelClockKey's two shapes
621// exactly, including the ops-account bare form.
622func holdKeyPrefix(acct, app string) string {
623 if acct == "" || acct == OpsAccount {
624 return app + "/"
625 }
626 return acct + clockSep + app + clockSep
627}
628
629// checkHoldPromotion applies mem's pending promotion if the OLD window has
630// now elapsed on this box's own clock (mem.PendingAt), jittered the same
631// way EligibleAt jitters a hold. Reports whether it promoted. nil and
632// no-pending are both no-ops so callers can call it unconditionally.
633func (f *FirstSeen) checkHoldPromotion(mem *holdMemory, now time.Time) bool {
634 if mem == nil || mem.PendingAt.IsZero() {
635 return false
636 }
637 elapsed := time.Duration(float64(mem.Window) * (1 + f.Jitter))
638 if now.Before(mem.PendingAt.Add(elapsed)) {
639 return false
640 }
641 mem.Window = mem.Pending
642 mem.Pending = 0
643 mem.PendingAt = time.Time{}
644 return true
645}
646
647// ObservePipeline folds one verified pipeline read into this box's
648// per-channel bake-window ratchet memory (release pipelines v2 spec §3).
649// Returns whether anything changed; the caller persists via Save.
650//
651// A brand-new channel baselines at HoldFor(DefaultHolds, name) — the
652// fail-closed default — never its own declared window, so a narrower
653// window a brand-new channel declares ratchets in like any other tightening
654// move rather than applying on first sight. A declared window that is no
655// narrower than what's enforced tightens immediately; a narrower one goes
656// pending and only takes effect once the OLD window has run its course,
657// timed from this box's OWN sighting of the narrower declaration
658// (PendingAt), never the pipeline's SetAt — the same argument as the
659// production-flag ratchet and the channel clock itself: an attacker with
660// bucket write but no signing key controls every timestamp in the object,
661// but not what this box already saw. Pending promotion is checked on every
662// call, including replays and p == nil, so a fleet that only polls this
663// path occasionally still lands the promotion the pass after it elapses.
664//
665// p == nil (pipeline deleted, or absent this pass) is a policy declaration
666// of the LEGACY defaults, ratcheted like any other: for every channel this
667// app has memory of, HoldFor(DefaultHolds, name) goes through the same
668// compare a declared window gets — no narrower than what's enforced applies
669// immediately, narrower goes pending for one old-window cycle, and
670// re-observed absence never restarts that clock. Deletion therefore never
671// collapses a ratcheted window instantly (a bucket write alone can delete
672// the object) and never pins an app to a dead pipeline's windows forever.
673// An app with no memory at all — every legacy app, every pass — is a
674// strict no-op here. Pending promotions still get their chance to land.
675//
676// A channel whose BakeWindow() fails to parse is skipped: old memory is
677// kept (fail closed) and this never prevents any other channel in the same
678// pipeline from being observed.
679//
680// Never call this with a pipeline that failed verification — like Observe
681// and ObserveProduction, memory only ever ticks for a signed object. p ==
682// nil must mean genuinely, verifiably absent (the object deleted); a read
683// or verify FAILURE is a different thing and must fail toward the longest
684// window instead, never be handed to this function as nil — passing a
685// corrupt read as nil would let it cheaply masquerade as deletion and
686// inherit the calm "never loosens from absence" path above instead of
687// failing closed. (Enforcing that at the call site is Task 5's job; this
688// function's contract is documented here so that call site gets it right.)
689func (f *FirstSeen) ObservePipeline(acct, app string, p *Pipeline, now time.Time) bool {
690 if f.Holds == nil {
691 f.Holds = map[string]*holdMemory{}
692 }
693 changed := false
694 if p == nil {
695 prefix := holdKeyPrefix(acct, app)
696 for key, mem := range f.Holds {
697 // mem == nil: {"holds":{"x":null}} parses cleanly into a nil
698 // entry — skip it, never dereference (same guard as the adopt
699 // pass's floor loops).
700 if mem == nil || !strings.HasPrefix(key, prefix) {
701 continue
702 }
703 name := key[len(prefix):]
704 if name == "" {
705 continue
706 }
707 // The legacy default is the newly-declared window, through the
708 // SAME compare as a declared pipeline. There is NO SetAt replay
709 // floor here: absence carries no SetAt to compare — and mem.SetAt
710 // is deliberately left standing, so a replayed pipeline object
711 // older than the one that was deleted still cannot tick the
712 // memory later.
713 // Stamp the absence itself, once. The window machinery below
714 // cannot express "this app was pipelined until just now" for a
715 // channel whose legacy default is LONGER than what it was
716 // enforcing (every app-defined name, whose legacy resolution is
717 // the 72h fallback): that lands instantly and leaves a memory
718 // indistinguishable from a legacy app's. DeletedAt is what the
719 // adopt pass's verdict rides on for one cycle afterwards — see
720 // holdMemory.DeletedAt and liveRatchet. Never restarted by
721 // re-observing absence, same rule as PendingAt.
722 if mem.DeletedAt.IsZero() {
723 mem.DeletedAt = now.UTC().Truncate(time.Second)
724 changed = true
725 }
726 w := HoldFor(DefaultHolds, name)
727 if w >= mem.Window {
728 if mem.Window != w || !mem.PendingAt.IsZero() {
729 mem.Window = w
730 mem.Pending = 0
731 mem.PendingAt = time.Time{}
732 changed = true
733 }
734 } else if mem.PendingAt.IsZero() || mem.Pending != w {
735 // Only a NEW pending value stamps the clock: re-observation
736 // of absence must not restart it — same rule as a re-read.
737 mem.Pending = w
738 mem.PendingAt = now
739 changed = true
740 }
741 if f.checkHoldPromotion(mem, now) {
742 changed = true
743 }
744 }
745 return changed
746 }
747 // A verified pipeline ends every deletion cycle this app is carrying —
748 // EVERY remembered channel under its prefix, not only the ones this
749 // object happens to name. The stamp means "absence has been seen since
750 // the last verified read", which is a fact about the APP, so it is
751 // cleared the same way the p == nil branch stamps it: by prefix.
752 //
753 // Per-channel clearing was poisonable (PR #258 re-review). There is no
754 // object-level replay floor — only a per-channel SetAt one — so a
755 // HISTORICAL signed pipeline naming a SUBSET of the app's channels can be
756 // PUT back: delete (every channel stamped), replay an old edge-only
757 // object (edge cleared, production's stamp left standing while the app
758 // reads as verifiably pipelined), dwell past production's cycle (the
759 // stale stamp expires unnoticed), delete again (already non-zero, so
760 // never restamped). The app then carries no live ratchet at all and its
761 // held heads adopt with no bake — a keyless bucket write reaching exactly
762 // the outcome the ratchet exists to prevent.
763 if prefix := holdKeyPrefix(acct, app); len(f.Holds) > 0 {
764 for key, mem := range f.Holds {
765 if mem == nil || !strings.HasPrefix(key, prefix) || mem.DeletedAt.IsZero() {
766 continue
767 }
768 mem.DeletedAt = time.Time{}
769 changed = true
770 }
771 }
772 for _, ch := range p.Channels {
773 key := channelClockKey(acct, app, ch.Name)
774 mem := f.Holds[key]
775 if w, err := ch.BakeWindow(); err == nil {
776 firstSighting := mem == nil
777 if firstSighting {
778 mem = &holdMemory{Window: HoldFor(DefaultHolds, ch.Name)}
779 f.Holds[key] = mem
780 changed = true
781 }
782 // The replay floor: equal or older SetAt is either the same
783 // object re-read or a replay — no policy value change, though
784 // the pending-promotion check below still runs.
785 if firstSighting || p.SetAt.After(mem.SetAt) {
786 mem.SetAt = p.SetAt
787 changed = true
788 if w >= mem.Window {
789 mem.Window = w
790 mem.Pending = 0
791 mem.PendingAt = time.Time{}
792 } else if mem.PendingAt.IsZero() || mem.Pending != w {
793 // PendingAt.IsZero() must stand on its own here, not
794 // just mem.Pending != w: a declared window of exactly
795 // ZERO is a legal value, and Pending's own zero value
796 // is indistinguishable from "nothing pending" — without
797 // this, a fresh channel whose first-ever declared bake
798 // is 0 would never stamp PendingAt (mem.Pending, freshly
799 // zeroed, already equals w=0), so checkHoldPromotion's
800 // PendingAt.IsZero() guard would short-circuit forever
801 // and the channel would sit pinned at its fail-closed
802 // baseline for the life of the box.
803 mem.Pending = w
804 mem.PendingAt = now
805 }
806 }
807 // OUTSIDE the replay floor, on every verified observation: a
808 // pending NARROWER than the currently-declared window can only
809 // have been stamped by something that is no longer the live
810 // policy — absence (the deletion ratchet), whose pipeline has
811 // since been restored byte-identically (same SetAt, so the
812 // policy block above is skipped; a same-object pending always
813 // equals its declared w). Left standing, it would land the
814 // legacy default one old-window cycle later and STAY, enforcing
815 // less than a live verified pipeline declares on every later
816 // pass. Cancel it — the closed direction: cancelling a
817 // loosening can only delay a shortening, never hasten one.
818 if mem != nil && !mem.PendingAt.IsZero() && mem.Pending < w {
819 mem.Pending, mem.PendingAt = 0, time.Time{}
820 changed = true
821 }
822 }
823 if f.checkHoldPromotion(mem, now) {
824 changed = true
825 }
826 }
827 return changed
828}
829
830// HoldsForApp returns the ENFORCED per-channel bake window map for a
831// pipelined app: one entry per channel p declares, using this box's
832// ratchet memory where it has any and HoldFor(DefaultHolds, name) — the
833// exact same fail-closed baseline ObservePipeline would latch on that
834// channel's first sighting — for a channel not yet observed. The two
835// paths therefore always agree, including for a rung name like "edge"
836// whose baseline is 0: synthesizing it here (rather than leaving the
837// channel absent from the map) matters precisely because HoldFor's OWN
838// unknown-channel fallback is a hardcoded 72h, which would silently
839// contradict a 0 baseline if this function ever left it out. nil when p
840// == nil, so the caller falls back to DefaultHolds entirely — the
841// pre-pipeline behaviour for an app with no pipeline at all.
842//
843// Never call this with a pipeline that failed verification, same
844// prohibition as ObservePipeline: p == nil must mean genuinely absent, not
845// "read or verify failed" — a verify failure must fail toward the longest
846// window at the call site instead (Task 5), never reach here as nil.
847func (f *FirstSeen) HoldsForApp(acct, app string, p *Pipeline) map[string]time.Duration {
848 if p == nil {
849 return nil
850 }
851 out := map[string]time.Duration{}
852 for _, ch := range p.Channels {
853 w := HoldFor(DefaultHolds, ch.Name)
854 if mem := f.Holds[channelClockKey(acct, app, ch.Name)]; mem != nil {
855 w = mem.Window
856 }
857 out[ch.Name] = w
858 }
859 return out
860}
861