carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Adoption — one check-and-apply sweep over the registry, shaped after
2// Eleven updater.go's updatePass, simplified: channel pointers replace the
3// GitHub feed, the Store replaces HTTP downloads, and per-host update prefs
4// are the registry row itself (Channel, Hibernate).
5package release
6
7import (
8 "bytes"
9 "context"
10 "crypto/ed25519"
11 "errors"
12 "fmt"
13 "io"
14 "os"
15 "runtime"
16 "slices"
17 "strings"
18 "syscall"
19 "time"
20
21 "github.com/carlosframework/platform/internal/caps"
22 "github.com/carlosframework/platform/internal/controlname"
23 "github.com/carlosframework/platform/internal/hostwrite"
24 "github.com/carlosframework/platform/internal/registry"
25 "github.com/carlosframework/platform/internal/safefile"
26 "github.com/carlosframework/platform/internal/updatewords"
27)
28
29// adoptPlatform picks which manifest artifact a box installs — a var so
30// tests can exercise resolution without faking runtime.GOOS/GOARCH.
31var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH
32
33// PassOption configures one call to Pass or PassFilter beyond their required
34// arguments — NagExpiredCanaries and WithCapabilities today. The type exists
35// so a call site opts in explicitly and a future option composes the same
36// way, rather than every option becoming its own trailing parameter.
37type PassOption func(*passConfig)
38
39type passConfig struct {
40 openRootUpdates RootUpdateOpener
41 rootUpdates *RootUpdateInputs
42 legacyRecovery func() (bool, error)
43 recovery func(context.Context, registry.Route) (bool, error)
44 rootHistory *RootHistoryPass
45 requireRootHistory bool
46 historySelected map[string]historySelection
47 runningVersion func(context.Context, registry.Route) (string, error)
48 propagateRestarts bool
49 restartAttemptSettled func(string) error
50 restartErrors *[]error
51 hostWrites hostwrite.Config
52 selectedRestart, selectedStart hostwrite.Callback
53 // selected is this pass's release work per adoptable host, captured once
54 // in PassFilter. D1's group rollback reuses a sibling's entry, so the
55 // sibling's rollback writes are checked against the same selection its
56 // forward adoption ran under, never a fresh one that could be a
57 // successor's.
58 selected map[string]hostwrite.Work
59 // ratchet is #253's per-box ratchet (WithDigestRatchet); nil admits
60 // every verified pointer, as before.
61 ratchet *DigestRatchet
62 // nag is where the canary-TTL nag (spec §5) is printed; nil (the
63 // default — every call site that does not pass NagExpiredCanaries)
64 // means the nag never fires at all, not even to a discarded writer.
65 nag io.Writer
66
67 // capsOps arms the capability converge inside adoptHost; nil (the
68 // default — every call site that does not pass WithCapabilities, and
69 // every existing test) means converge never runs and this package's
70 // behaviour is byte-identical to before the capability design landed.
71 capsOps *caps.Ops
72
73 // bakeDir, when non-empty, is the directory holding the bake clock
74 // (BakeDir option); empty keeps the legacy /opt-dir location.
75 bakeDir string
76
77 // stageBlocked is called when staging fails because this process cannot
78 // write the version store (OnStageBlocked). nil — every existing caller
79 // and every existing test — means the pass reports such a failure exactly
80 // as it always has and nothing else happens.
81 stageBlocked func(host, ver string, err error)
82
83 // restartHibernating is the callback hibernate-flagged routes get
84 // (RestartHibernating). nil — the default, and RunAdopt — keeps the
85 // original split: sleepers are repointed with restart=nil and no callback
86 // is consulted for them at all.
87 restartHibernating func(host string) error
88
89 // unitsToRoot hands every route that names its own unit to the root pass
90 // before anything is staged or repointed (RootRestartsUnits). false, the
91 // default and RunAdopt's, restarts the named unit in this pass.
92 unitsToRoot bool
93
94 // startUnit is consulted ONLY on adoptHost's restart-failure rollback
95 // path, after the symlink has gone back (WithStartUnit). nil — every
96 // call site that does not pass the option, and every existing test —
97 // keeps today's behaviour byte-identically: the rollback repoints and
98 // stops.
99 startUnit func(host string) error
100
101 // rollbackRestart replaces startUnit on D1's path only — a named unit
102 // whose restart returned *UnitNotUpError. It must REPLACE whatever is
103 // running (reset-failed + restart), because a deadline failure can leave
104 // the rejected build running unready and `start` is a no-op on it.
105 rollbackRestart func(host string) error
106
107 // proof is D3's gate (WithProofGate, proofgate.go). nil, the default for
108 // every existing caller and test, means no proof is read or required.
109 proof *ProofGate
110
111 // pins is WithPinSource's provider: staged copies that something beyond
112 // the registry rows, the live links and the generations file still
113 // refers to. nil adds nothing.
114 pins PinSource
115 generationPins PinSource
116
117 // stage is what ensureStagedChecked needs to judge a staged copy it is
118 // about to reuse (spec §4.2). PassFilter sets it once per pass.
119 stage stageCheck
120
121 // settings arms the instance update settings (settings spec §4,
122 // WithUpdateSettings): adoptHost asks Target which build to install for
123 // each exec instance. resolve is root's alone: only root writes the
124 // consumed file. openFailed opens the zero-downtime failure book;
125 // PassFilter calls it once per pass into failed (or failedErr), so a
126 // long-lived option never reads a stale book. managed is
127 // rollout.Managed: an unreadable book holds those routes too.
128 settings bool
129 resolve bool
130 openFailed func() (FailedBuild, error)
131 managed func(registry.Route) bool
132 failed FailedBuild
133 failedErr error
134 // route reads one registry row now, for root's re-check under the carry
135 // fence (publishConsumed, ruling R-CARRY-FENCE); PassFilter sets it.
136 route func(host string) (*registry.Route, error)
137 // pub verifies promotion records (§4.1 evidence); PassFilter sets it.
138 pub ed25519.PublicKey
139 // tags is "<acct>/<app>" -> release version -> its newest semver tag, read
140 // once per pass for apps with a route under a major setting.
141 tags map[string]map[string]string
142
143 // pointerGate is WithPointerGate's gate; nil (every caller but the
144 // agent's fast sweep) reads every group's pointer, as before.
145 pointerGate PointerGate
146}
147
148// PointerGate lets a caller skip a channel pointer read that cannot have
149// anything new (S3 change-index design §5). PassFilter asks it once per
150// (account, app, channel) group, with the group's routes this pass would
151// adopt. skip true leaves the whole group out of this pass: no pointer read
152// and no adoption, exactly as if extra had refused its routes. Otherwise
153// read, when non-nil, is called once with the outcome of the group's read:
154// the verified pointer, or the read's error or the ratchet's refusal.
155type PointerGate func(acct, app, channel string, routes []registry.Route) (skip bool, read func(ptr *ChannelPointer, err error))
156
157// WithPointerGate arms g for this pass. The agent's fast sweep passes one
158// that skips settled canary groups whose app scope has not moved.
159func WithPointerGate(g PointerGate) PassOption {
160 return func(c *passConfig) { c.pointerGate = g }
161}
162
163// gatePointers asks cfg.pointerGate about every group adopts admits, and
164// returns adopts narrowed to the groups it did not skip, with each kept
165// group's read callback.
166func (cfg *passConfig) gatePointers(routes []registry.Route, adopts func(registry.Route) bool) (func(registry.Route) bool, map[[3]string]func(*ChannelPointer, error)) {
167 if cfg.pointerGate == nil {
168 return adopts, nil
169 }
170 groupOf := func(rt registry.Route) [3]string { return [3]string{RouteAccount(rt), rt.App, routeChannel(rt)} }
171 groups := map[[3]string][]registry.Route{}
172 var order [][3]string
173 for _, rt := range routes {
174 if !adopts(rt) {
175 continue
176 }
177 k := groupOf(rt)
178 if _, ok := groups[k]; !ok {
179 order = append(order, k)
180 }
181 groups[k] = append(groups[k], rt)
182 }
183 reads := map[[3]string]func(*ChannelPointer, error){}
184 skipped := map[[3]string]bool{}
185 for _, k := range order {
186 skip, read := cfg.pointerGate(k[0], k[1], k[2], groups[k])
187 switch {
188 case skip:
189 skipped[k] = true
190 case read != nil:
191 reads[k] = read
192 }
193 }
194 if len(skipped) == 0 {
195 return adopts, reads
196 }
197 return func(rt registry.Route) bool { return adopts(rt) && !skipped[groupOf(rt)] }, reads
198}
199
200// WithRunningVersion retains root's verified running build through a repoint.
201// It must already be named by current or previous; PointLive checks that again.
202func WithRunningVersion(running func(context.Context, registry.Route) (string, error)) PassOption {
203 return func(c *passConfig) { c.runningVersion = running }
204}
205
206// FailedBuild reports whether host may not be moved onto build: the
207// zero-downtime failure book (rollout.FailureBook), taken as a func because
208// rollout imports this package. served is build == the route's GoodVersion.
209type FailedBuild func(acct, app, build, host string, served bool) bool
210
211// ErrInstallRequestNeedsRoot is what an agent pass hands OnStageBlocked's
212// callback when it stops for an install request only root may resolve
213// (settings spec §4.3 rule 2; ruling R-AGENT-RELAY). Nothing failed: the
214// callback only asks for root's pass, and the version is the request's T as
215// this pass sees it. Never shown to members.
216var ErrInstallRequestNeedsRoot = errors.New("release: an install request waits for root's pass to resolve it")
217
218// ErrUnitRestartNeedsRoot is what a RootRestartsUnits pass hands
219// OnStageBlocked's callback for a route that names its own unit: nothing
220// failed, and the version is the build root's pass should install. Never
221// shown to members.
222var ErrUnitRestartNeedsRoot = errors.New("release: a route's named unit is restarted by root's pass")
223
224// RootRestartsUnits is for a pass whose process cannot restart a unit an
225// operator named (issue #315): the agent's in-process loops when they do not
226// run as root. carlos-edge.service is User=carlos, and systemd refuses it
227// ("Interactive authentication required") for console.service and every
228// other route.Unit. Before this option such a pass repointed the live link,
229// failed the restart, read the refusal as a broken build, rolled back,
230// poisoned the version and tried to start the old build over a process that
231// had never stopped.
232//
233// With it, a route that names its unit, and every route in that unit's
234// process group (processGroup: same account, app, channel and upstream), is
235// handed to root before anything is staged or repointed, through
236// OnStageBlocked with ErrUnitRestartNeedsRoot, the way a build this process
237// cannot stage is. The live links keep naming the running build, so no
238// sibling is stamped with a build the unit does not run. Nothing is poisoned
239// and no refusal is recorded; root's
240// pass stages, repoints, restarts and stamps. Every hold, refusal and proof
241// check before the hand-off still applies, so root is only asked for a build
242// this pass would have installed.
243//
244// Lifecycle host writes (WithHostWrites) restart through the selected
245// receiver instead, so the option does nothing under them. Nor for a route
246// this pass would not restart (restart nil: a sleeper on a pass without
247// RestartHibernating, a sidecar).
248func RootRestartsUnits() PassOption {
249 return func(c *passConfig) { c.unitsToRoot = true }
250}
251
252// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
253// line per expired canary/* route the call sees, written to w.
254//
255// DO NOT pass this to a fast/frequent adoption loop. It belongs to the
256// HOURLY ROOT adopt pass ALONE — carlos-adopt.service's RunAdopt
257// (internal/host/agent.go), the only caller that sets it (2026-08-03
258// review), and every future caller must keep it that way. The same split
259// PR #9's config sync uses, for the same reason: the in-process adoptLoop
260// (hourly + jitter, inside the sandboxed carlos-edge.service) and
261// fastConvergeLoop (every 2-3 SECONDS — the whole point of which is fast
262// pickup of a freshly-promoted canary or a serial bump) must NEVER
263// set this. An expired canary sitting still on that fast loop would
264// otherwise nag every 2-3 seconds forever, turning a visibility feature
265// into a journal-flooding one; gating it to the hourly root pass keeps the
266// nag journal-friendly — once an hour, not a poll-storm of log lines. This
267// is enforced by convention (which call sites pass the option), not by the
268// type system — see agent.go's adoptLoop/fastPassOpts for the loud
269// "never add this" comments at the two places it would be easiest to add
270// by accident.
271func NagExpiredCanaries(w io.Writer) PassOption {
272 return func(c *passConfig) { c.nag = w }
273}
274
275// WithCapabilities arms the capability converge inside adoptHost (spec §4d):
276// on a version change, a unit-backed route's drop-ins are materialized for
277// the NEW version before its live symlink moves, and restored with the
278// symlink when the restart fails. Unset — every existing caller and every
279// existing test — means converge never runs and this package behaves exactly
280// as it did before.
281//
282// The zero Ops is the production one: real /etc/systemd/system, real
283// /var/lib/carlos-app, real systemctl, real useradd. A test injects temp
284// dirs and recorders through the same value.
285//
286// DO NOT pass this to a fast/frequent adoption loop, and DO NOT pass it from
287// anything running inside carlos-edge.service. It belongs to the HOURLY ROOT
288// adopt pass ALONE — carlos-adopt.service's RunAdopt (internal/host/agent.go),
289// the only caller that sets it — for a harder reason than the nag's: this
290// option WRITES /etc/systemd/system, and carlos-edge.service is
291// ProtectSystem=strict with ReadWritePaths=/data /run/carlos, so the
292// in-process adoptLoop and fastConvergeLoop physically cannot. Arming it
293// there would not converge anything; it would log a permission denial every
294// 2-3 seconds forever. Enforced by convention (which call sites pass the
295// option), not by the type system — see agent.go's adoptLoop/fastPassOpts
296// for the loud "never add this" comments at the two places it would be
297// easiest to add by accident.
298//
299// The consequence, ruled on and accepted (2026-08-03): a freshly promoted
300// canary picked up by the fast loop runs the new binary against the unit
301// shape the PREVIOUS version left, until the hourly root pass converges it.
302// adoptHost prints one line per such repoint so the window is visible —
303// see the capsOps == nil branch there.
304func WithCapabilities(o caps.Ops) PassOption {
305 return func(c *passConfig) { c.capsOps = &o }
306}
307
308// BakeDir moves this pass's bake clock into dir — in production the data
309// dir (CARLOS_DATA_DIR, default /data), the one place both the sandboxed
310// edge unit (ReadWritePaths=/data) and the root adopt one-shot can write.
311// Reads migrate: a box whose clock still lives at the legacy
312// /opt/carlos/channel-first-seen.json keeps every observation (issue #13).
313// Unset, the clock stays at its legacy path — existing tests and callers
314// are unaffected.
315//
316// The migration is durable after the FIRST pass this option is set on a box
317// still on the legacy path: PassFilter forces a Save even when no new
318// channel head was observed, so the new path is fully populated (and the
319// legacy file provably safe to delete) one pass later, not whenever the
320// fleet next happens to see a new head.
321func BakeDir(dir string) PassOption {
322 return func(c *passConfig) { c.bakeDir = dir }
323}
324
325// OnStageBlocked reports a version this pass could not stage or point live
326// because the process cannot WRITE the install directory — never because
327// the bucket, the signature or the manifest was bad. It is the release half
328// of the relay carlos-adopt.path already runs for config: the agent's
329// in-process loops are
330// sandboxed (carlos-edge.service sets ProtectSystem=strict with no /opt write),
331// so they can see a promotion within seconds and stage nothing, while the only
332// process that CAN stage runs hourly off carlos-adopt.timer. Without a relay a
333// promote is invisible for up to an hour on a box that noticed it immediately.
334//
335// Deliberately narrow. A read failure, a bad signature or a poisoned version
336// must NOT fire this: none of them is fixed by re-running as root, and a
337// callback that fired on any staging error would relay a bucket outage into a
338// root pass every tick. Only EROFS/EPERM/EACCES — "this uid cannot write
339// there" — describe a job a privileged pass would actually finish.
340//
341// Never fatal, and never a substitute for the timer: the hourly pass remains
342// the floor, exactly as it is for config. A dropped or throttled request costs
343// latency, not correctness.
344func OnStageBlocked(fn func(host, ver string, err error)) PassOption {
345 return func(c *passConfig) { c.stageBlocked = fn }
346}
347
348// RestartHibernating extends restart to hibernate-flagged routes, which
349// otherwise get the pure repoint (restart=nil) and are never offered to the
350// callback at all.
351//
352// It exists because the flag is not the question the restart decision
353// actually wants answered. Hibernate=true means "this route MAY sleep", not
354// "it is asleep right now" — an exec instance is minted hibernating
355// (provision/reconcile.go) and then spends every serving minute awake. With
356// the flag alone deciding, a busy exec child kept serving the binary its
357// live symlink no longer names until it happened to go idle. Handing the
358// callback the sleepers lets it answer the real question at the moment it
359// matters.
360//
361// This does NOT weaken "an adopt pass never wakes a sleeping host" — it moves
362// the guarantee from withholding the callback to the callback's own
363// semantics, and only a callback with those semantics may be passed here.
364// The agent's makeRestartRoute qualifies on both halves: activator Bounce
365// returns false without starting anything when the host is not awake, and
366// `systemctl try-restart` is defined as a no-op on a unit that is not
367// running. Nothing on either path can start a process. A callback that could
368// start one belongs on the awake loop only. (WithStartUnit narrows that
369// invariant without breaking it: the ONE start this package can now perform
370// fires only after this callback RETURNED AN ERROR — and no stopped unit's
371// try-restart produces one, so a sleeper cannot arm it. See WithStartUnit.)
372//
373// DO NOT pass this from RunAdopt (carlos-adopt.service). That process is not
374// the activator's parent: the exec children belong to the agent, so its only
375// available restart is systemctl, which cannot reach them — offering it
376// sleepers would buy nothing and cost a try-restart per sleeping host per
377// version change. Set by the agent's in-process adoptLoop/fastConvergeLoop
378// alone, the two loops that run inside the process owning the children.
379//
380// Sleepers are still adopted AFTER every awake host and still poison a bad
381// build the same way, both unchanged: the option decides who the callback
382// sees, never the order or the health gate.
383func RestartHibernating(restart func(host string) error) PassOption {
384 return func(c *passConfig) { c.restartHibernating = restart }
385}
386
387// WithStartUnit gives the pass a way to START a route's process after a
388// FAILED restart has been rolled back — the one moment adoption knows the
389// symlink is correct and the service behind it is down. Without it the
390// rollback repoints the symlink and stops there: the unit systemd left
391// failed stays failed, and because `systemctl try-restart` is by definition
392// a no-op on a unit that is not running (the same property that lets a
393// deliberately stopped service stay stopped), the NEXT pass's restart exits
394// 0 and SetVersions stamps a build that has never run — the exact lying
395// header the named-unit arm's returned error exists to prevent, now over a
396// service that is not running at all.
397//
398// start fires ONLY on that rollback path, and only when the rollback
399// actually HAPPENED: a first adoption whose restart fails has no previous
400// version, and a rollback PointLive itself refused (the old version's
401// directory gone — materialize.go's "link present, staged version gone"
402// state) rolled nothing — in both the symlink still names the refused
403// build, and starting there would run the exact build the failure refused.
404// A start that also fails changes nothing about the refusal (the version
405// stays poisoned and unstamped) but is printed, because at that point the
406// box is most likely serving nothing and an operator must not read the
407// rollback line as "recovered".
408//
409// This cannot WAKE a hibernating route, and the reasoning is the contract:
410// the path is reached only after a restart that was ATTEMPTED and returned
411// an error. Of the agent's dispatch arms (internal/host's makeRestartRoute)
412// only the NAMED-unit arm returns one, and its try-restart exits 0 on a
413// loaded-but-stopped unit — so an error means the unit was running and
414// would not come back, or the name resolves to nothing (in which case the
415// start reaches nothing either). The bounce and carlos-app@ arms swallow
416// their failures and return nil, so a sleeping exec child can never route
417// here. The converged-daemon arm (caps.Restart) is excluded at the guard
418// itself: the restart that failed there was carlos-app@<host>'s, its
419// rollback belongs to caps.Restore, and starting the route's NAMED unit
420// there would start a unit that pass never touched.
421//
422// start must start ONLY a route that NAMES its unit (registry Unit column)
423// and return nil for everything else — internal/host's makeStartRoute is
424// the intended shape. It is `systemctl start`, not try-restart, because the
425// entire premise is that the unit is down; on a unit something else already
426// brought up (caps.Restore's socket sequence) start is an exit-0 no-op, so
427// the two cannot fight.
428func WithStartUnit(start func(host string) error) PassOption {
429 return func(c *passConfig) { c.startUnit = start }
430}
431
432// WithRollbackRestart sets D1's rollback restart (see passConfig).
433func WithRestartAttemptSettled(fn func(string) error) PassOption {
434 return func(c *passConfig) { c.restartAttemptSettled = fn }
435}
436
437func WithRestartFailurePropagation() PassOption {
438 return func(c *passConfig) { c.propagateRestarts = true }
439}
440
441func WithRollbackRestart(fn func(host string) error) PassOption {
442 return func(c *passConfig) { c.rollbackRestart = fn }
443}
444
445// WithProofGate arms D3 (proofgate.go): a build of an app the policy file
446// lists is adopted only with a verified proof for its bytes, or from this
447// box's healthy history. Pass the SAME gate to every pass of a process, so
448// its 30 s miss cache and its waiting list survive between ticks.
449func WithProofGate(g *ProofGate) PassOption {
450 return func(c *passConfig) { c.proof = g }
451}
452
453// WithDigestRatchet arms #253's per-box ratchet for this pass (spec §4.6):
454// a pointer without digests is refused on a channel this box has seen with
455// them, or everywhere under CARLOS_REQUIRE_POINTER_DIGEST. Root and the
456// agent each pass their own (internal/host pointerRatchet).
457func WithDigestRatchet(r *DigestRatchet) PassOption {
458 return func(c *passConfig) { c.ratchet = r }
459}
460
461// WithUpdateSettings arms settings spec §4 for this pass: each exec
462// instance installs what Target decides, with §4.1's evidence. book opens
463// the failure book; it is called at the start of every pass, so pass the
464// opener, never a book read once (nil: no failure records). managed is
465// rollout.Managed (nil: none). resolve is set on root's pass only: root
466// alone resolves install requests and writes the consumed file (§4.3 rule
467// 2); the agent's passes apply the same decisions and write nothing.
468//
469// A route with default settings and no consumed file takes today's
470// adoption unchanged and never consults the book. An unreadable book holds
471// every route with settings and every managed route (the zero-downtime
472// plans' refuseManaged posture).
473func WithUpdateSettings(book func() (FailedBuild, error), managed func(registry.Route) bool, resolve bool) PassOption {
474 return func(c *passConfig) {
475 c.settings, c.openFailed, c.managed, c.resolve = true, book, managed, resolve
476 }
477}
478
479// WithPinSource adds src's references to the pin set a reused staged copy is
480// checked against (spec §4.2), on top of the live links, the registry rows
481// and the generations file, which every pass with a data dir reads itself.
482func WithPinSource(src PinSource) PassOption {
483 return func(c *passConfig) { c.pins = src }
484}
485
486// stageBlocked reports whether err is the version store refusing a write to
487// THIS uid, rather than any of the many other ways staging fails. errors.Is
488// unwraps the *os.PathError that mkdir/openat return.
489func stageBlocked(err error) bool {
490 return errors.Is(err, syscall.EROFS) ||
491 errors.Is(err, syscall.EPERM) ||
492 errors.Is(err, syscall.EACCES)
493}
494
495// StageBlocked is stageBlocked for callers outside this package: the
496// relay-or-fail decision a move's held stage makes (MaterializeHeld). EROFS
497// is what ProtectSystem=strict answers a write with, and it is not
498// fs.ErrPermission, so a caller must not substitute that test for this one.
499func StageBlocked(err error) bool { return stageBlocked(err) }
500
501// Pass is one full adoption sweep: group instance routes by (App, Channel),
502// resolve each channel pointer once, verify signatures and artifact sha256s,
503// download/stage missing versions, and repoint live/<host> symlinks.
504//
505// Die-free by design: per-host and per-channel problems log and continue —
506// an unreachable channel or one broken host must not strand the rest of the
507// fleet unchecked. Only foundation failures (registry unreadable, bake clock
508// unwritable) fail the pass.
509//
510// restart cycles a host's process onto the new symlink target (the
511// activator's proc converge); nil means symlink-only for every host. Hosts
512// whose route has Hibernate=true get symlink-only by default and are NEVER
513// woken: an hourly wake would defeat hibernation, and the next wake execs
514// the new binary anyway since the spawn resolves live/<host> at exec time.
515// The trade (from Eleven): no health check for a sleeper — a bad build
516// surfaces at wake, recovered by Rollback, which repoints back. Hibernating
517// hosts are processed AFTER every awake host, so an awake host discovers a
518// boot-broken build (and poisons it) before any sleeper is moved onto it.
519//
520// RestartHibernating overrides only the FIRST half of that: it gives
521// hibernate-flagged hosts a restart callback too, because the flag means "may
522// sleep", not "is asleep" — a warm exec child must not go on serving a binary
523// its live symlink no longer names. It never makes the pass wake anything;
524// the option only accepts a callback that cannot start a process — and the
525// start WithStartUnit adds is armed only by that callback returning an error,
526// which a sleeper's exit-0 try-restart never does (see WithStartUnit). See
527// its doc comment. Everything else above holds unchanged: sleepers still go
528// last,
529// still poison a bad build, still have no health gate of their own.
530func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, opts ...PassOption) error {
531 return PassFilter(ctx, reg, s, pub, optDir, restart, nil, opts...)
532}
533
534// PassFilter is Pass narrowed to the routes extra additionally accepts
535// (nil extra makes it identical to Pass) — the seam a faster, narrower
536// sweep composes on top of the full one without duplicating the group
537// resolution, bake-clock, staging, or restart logic. Built for the
538// fast-converge sweep (internal/host/agent.go's fastConvergeLoop): canary
539// channels are individually created and inherently low-cardinality (one
540// deliberate rollout at a time, never the whole fleet — see bake.go's
541// "canary/* has no window"), and unstaged routes drain out of the set as
542// they stage (with per-host backoff while they can't), so sweeping just
543// those every few seconds instead of hourly can't turn into a fleet-wide
544// poll storm the way sweeping every route that fast would.
545func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, extra func(registry.Route) bool, opts ...PassOption) error {
546 var cfg passConfig
547 for _, opt := range opts {
548 opt(&cfg)
549 }
550 if cfg.propagateRestarts || cfg.requireRootHistory {
551 cfg.restartErrors = new([]error)
552 }
553 if cfg.openRootUpdates != nil {
554 var err error
555 cfg.rootUpdates, err = cfg.openRootUpdates(ctx, s, pub)
556 if err != nil {
557 return err
558 }
559 if cfg.rootUpdates != nil && cfg.rootUpdates.Close != nil {
560 defer cfg.rootUpdates.Close()
561 }
562 }
563 cfg.pub = pub
564 cfg.route = reg.Get
565 if cfg.recovery != nil {
566 routes, err := reg.List()
567 if err != nil {
568 return err
569 }
570 if cfg.rootUpdates != nil {
571 routes, err = cfg.rootUpdates.routes(ctx, routes)
572 if err != nil {
573 return err
574 }
575 }
576 handled := map[string]bool{}
577 var failures []error
578 remaining := false
579 for _, rt := range routes {
580 if extra != nil && !extra(rt) {
581 continue
582 }
583 done, err := cfg.recovery(ctx, rt)
584 handled[rt.Host] = done
585 if err != nil {
586 failures = append(failures, err)
587 }
588 if !done && (Adoptable(rt) || SidecarAdoptable(rt)) {
589 remaining = true
590 }
591 }
592 if !remaining && len(handled) > 0 {
593 return errors.Join(failures...)
594 }
595 for _, done := range handled {
596 if done {
597 prior := extra
598 extra = func(rt registry.Route) bool { return !handled[rt.Host] && (prior == nil || prior(rt)) }
599 break
600 }
601 }
602 cfg.recovery = nil
603 opts = append(opts, func(c *passConfig) { c.recovery = nil })
604 if cfg.legacyRecovery != nil {
605 legacy := len(routes) == 0
606 var err error
607 if !legacy {
608 legacy, err = cfg.legacyRecovery()
609 }
610 if err != nil {
611 return errors.Join(errors.Join(failures...), err)
612 }
613 if legacy {
614 opts = append(opts, func(c *passConfig) { c.requireRootHistory = false; c.rootHistory = nil })
615 }
616 }
617 return errors.Join(errors.Join(failures...), PassFilter(ctx, reg, s, pub, optDir, restart, extra, opts...))
618 }
619 // Settings spec §4.3: the failure book is read fresh for every pass, so a
620 // record the agent persisted since the last one counts now.
621 if cfg.rootUpdates == nil && cfg.settings && cfg.openFailed != nil {
622 cfg.failed, cfg.failedErr = cfg.openFailed()
623 if cfg.failedErr != nil && cfg.resolve {
624 fmt.Printf(" failure book unreadable (%v): routes with update settings and managed routes install nothing this pass\n", cfg.failedErr)
625 }
626 }
627 // D3: re-read the proof policy at the start of every pass, so an edit
628 // reaches the agent and root alike without a restart (spec §4.5).
629 cfg.proof.BeginPass()
630 routes, err := reg.List()
631 if err != nil {
632 return err
633 }
634 if cfg.rootUpdates != nil {
635 routes, err = cfg.rootUpdates.routes(ctx, routes)
636 if err != nil {
637 return err
638 }
639 }
640 adopts := func(rt registry.Route) bool {
641 return (Adoptable(rt) || SidecarAdoptable(rt)) && (extra == nil || extra(rt))
642 }
643 // WithPointerGate: a skipped group leaves the pass before anything is
644 // selected for it.
645 adopts, pointerRead := cfg.gatePointers(routes, adopts)
646 selected := make(map[string]hostwrite.Work, len(routes))
647 cfg.historySelected = make(map[string]historySelection)
648 for _, rt := range routes {
649 if !adopts(rt) {
650 continue
651 }
652 if cfg.requireRootHistory {
653 sel, err := cfg.rootHistory.selectRoute(ctx, rt)
654 if err != nil {
655 return err
656 }
657 cfg.historySelected[rt.Host] = sel
658 }
659 work, selectErr := cfg.hostWrites.Select(ctx, rt.Host, "release")
660 if selectErr != nil {
661 return fmt.Errorf("%s: select release work: %w", rt.Host, selectErr)
662 }
663 selected[rt.Host] = work
664 }
665 cfg.selected = selected
666
667 // The reuse check's view of what refers to a staged copy (spec §4.2). It
668 // is asked only when a staged copy turns out to differ from its canonical
669 // release, so a healthy pass never pays for it, and it reads fresh then,
670 // so a host this pass has already repointed counts.
671 cfg.stage = stageCheck{
672 beforeWrite: func(path string) error { return cfg.generationWriteCheck(ctx, optDir, path) },
673 verified: map[string]string{},
674 refs: func() (StagedPaths, error) {
675 rows, err := reg.List()
676 if err != nil {
677 return nil, fmt.Errorf("list routes: %w", err)
678 }
679 // An app whose settings references are incomplete comes back
680 // with the pins (*IncompleteRefsError); stageFrom refuses that
681 // app's restage only.
682 pins, incomplete := BoxPins(optDir, rows)
683 var inc *IncompleteRefsError
684 if incomplete != nil && (pins == nil || !errors.As(incomplete, &inc)) {
685 return nil, incomplete
686 }
687 // Live and pending generations, from the file the agent's
688 // activator writes into the data dir (index amendments; its
689 // workers' captured builds since ruling R17). Every production
690 // pass passes the data dir as BakeDir: root's RunAdopt, the
691 // agent's loops, and cold materialization. A pass with no data
692 // dir (tests) has no agent beside it to ask.
693 if cfg.bakeDir != "" {
694 gen, err := GenerationPins(GenerationsPath(cfg.bakeDir), func() ([]registry.Route, error) { return rows, nil }).StagedPins(ctx, optDir)
695 if err != nil {
696 return nil, err
697 }
698 for p := range gen {
699 pins.add(p)
700 }
701 }
702 if cfg.pins != nil {
703 more, err := cfg.pins.StagedPins(ctx, optDir)
704 if err != nil {
705 return nil, err
706 }
707 for p := range more {
708 pins.add(p)
709 }
710 }
711 if inc != nil {
712 return pins, inc
713 }
714 return pins, nil
715 },
716 }
717
718 // Resolve every distinct (Account, App, Channel) once. Verification
719 // failures kill the group (nil entry), not the pass.
720 //
721 // The account is part of the group key, not just the app: app names are
722 // unique WITHIN an account, so two routes named the same app in different
723 // accounts are two different apps following two different channel
724 // pointers. Grouping on (app, channel) alone would resolve one of them and
725 // silently install it on both.
726 type groupKey struct{ acct, app, channel string }
727 pointers := map[groupKey]*ChannelPointer{}
728 // Whether an app has anything under its qualified prefix, cached for the
729 // pass: one listing per (account, app), not one per channel. It decides
730 // which of the two missing-pointer lines below an app gets.
731 type acctApp struct{ acct, app string }
732 // migratedState caches one pass's answer to "has this app anything under
733 // its qualified prefix", together with the listing failure when there was
734 // one — a failed listing must neither invent the migrate advice nor claim
735 // the healthy skip (2026-08-03 review).
736 type migratedState struct {
737 has bool
738 listErr error
739 }
740 migrated := map[acctApp]migratedState{}
741 for _, rt := range routes {
742 if !adopts(rt) {
743 continue
744 }
745 k := groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}
746 if _, done := pointers[k]; done {
747 continue
748 }
749 ptr, err := Channel(ctx, s, pub, k.acct, k.app, k.channel)
750 read := pointerRead[[3]string{k.acct, k.app, k.channel}]
751 if err != nil {
752 if read != nil {
753 read(nil, err)
754 }
755 pointers[k] = nil // resolved-and-failed: don't retry per host
756 if errors.Is(err, os.ErrNotExist) {
757 // SKIP, never abort: one app with no pointer must not stop
758 // every other app on the box from installing.
759 //
760 // Two deployments produce a missing pointer, and only one of
761 // them should be told to migrate. A channel with nothing
762 // promoted yet is ordinary — a migrated app waiting on its
763 // first stable promotion sits here for days — and gets the
764 // plain line. An app with NOTHING under its qualified prefix
765 // upgraded without migrating, and gets the store's error,
766 // which names the verb. Advice that fires every hour on a
767 // healthy fleet is advice nobody reads.
768 ak := acctApp{k.acct, k.app}
769 st, cached := migrated[ak]
770 if !cached {
771 has, lerr := AppHasQualifiedObjects(ctx, s, k.acct, k.app)
772 // A listing we could not perform suppresses the advice
773 // rather than inventing it: a bucket this box cannot list
774 // is not evidence that an app never migrated. It is not
775 // evidence of a healthy skip either, so the failure rides
776 // along and is printed instead of a claim this box never
777 // established (realistic trigger: GetObject IAM without
778 // ListBucket).
779 st = migratedState{has: has || lerr != nil, listErr: lerr}
780 migrated[ak] = st
781 }
782 switch {
783 case st.listErr != nil:
784 fmt.Printf(" %s/%s/%s: channel state unknown (could not list the app's prefix: %v)\n", k.acct, k.app, k.channel, st.listErr)
785 case st.has:
786 fmt.Printf(" %s/%s/%s: channel has no release yet\n", k.acct, k.app, k.channel)
787 default:
788 fmt.Printf(" %s/%s/%s: channel has no release yet (%v)\n", k.acct, k.app, k.channel, err)
789 }
790 } else {
791 fmt.Printf(" %s/%s/%s: channel pointer rejected: %v\n", k.acct, k.app, k.channel, err)
792 }
793 continue
794 }
795 // Any error from Admit refuses the pointer, ErrRatchetUnpersisted
796 // included, not only ErrPointerWithoutDigests.
797 if rerr := cfg.ratchet.Admit(k.acct, ptr); rerr != nil {
798 if read != nil {
799 read(nil, rerr)
800 }
801 pointers[k] = nil // refused: the group keeps what it has
802 fmt.Printf(" %s/%s/%s: channel pointer refused: %v\n", k.acct, k.app, k.channel, rerr)
803 continue
804 }
805 if read != nil {
806 read(ptr, nil)
807 }
808 pointers[k] = ptr
809 }
810
811 // Settings spec §3.1: a major setting holds a head whose semver tag is
812 // absent or in another major. Tags are read once per app that has a route
813 // under one. ListTags is semver-descending, so the first tag seen for a
814 // release is its newest. An unreadable list leaves the app's map empty:
815 // every head reads as untagged, so a major-held instance stays held.
816 if cfg.settings {
817 cfg.tags = map[string]map[string]string{}
818 for _, rt := range routes {
819 u, err := ParseUpdateSettingsLenient(rt.UpdateSettings)
820 if err != nil || u.Major == nil || !adopts(rt) {
821 continue
822 }
823 k := RouteAccount(rt) + "/" + rt.App
824 if _, done := cfg.tags[k]; done {
825 continue
826 }
827 m := map[string]string{}
828 tags, err := ListTags(ctx, s, RouteAccount(rt), rt.App)
829 if err != nil {
830 fmt.Printf(" %s: tags unreadable (%v): every head reads as untagged\n", k, err)
831 }
832 for _, tg := range tags {
833 if _, seen := m[tg.Release]; !seen {
834 m[tg.Release] = tg.Version
835 }
836 }
837 cfg.tags[k] = m
838 }
839 }
840
841 // Resolve each app's pipeline once per pass — for every app with a
842 // resolved NON-CANARY head, never one read per channel or per route.
843 // canary/* lives outside the pipeline entirely (spec §2): no pipeline
844 // can name it, its window is zero either way, so a canary-only
845 // appearance has no verdict a read could change — which is also what
846 // keeps the 2-3-second canary fast sweep at zero per-tick bucket reads
847 // here, the same cost property the production-flag reads below hold.
848 //
849 // A read/verify ERROR is kept, and kept apart from verified-absent
850 // (nil, nil): the errored app's windows fall back to DefaultHolds
851 // FLOORED by the box's own ratchet memory (the appHolds loop below) —
852 // every channel the memory carries enforces max(its DefaultHolds
853 // resolution, the remembered window), so corrupting the object can
854 // never make this box looser than it already was (spec §3: corruption
855 // must never loosen; plain DefaultHolds would hand a tightened rung
856 // name like edge its ZERO legacy window back). A channel with no memory
857 // keeps the fail-closed DefaultHolds resolution — the old-binary
858 // posture (spec §3's old-binary edge), whose production: true co-write
859 // remains the mitigation for a MEMORY-LESS box only. Where memory DOES
860 // exist, the floor is enforcement in full: the bake VERDICT rides on
861 // the same memory (bakeIgnore below never ignores an app whose ratchet
862 // is still live — which is a WIDER condition than "the memory raises a
863 // window", deliberately, since an app-defined channel's ratchet
864 // normally raises nothing at all), so nothing here depends on the
865 // console having managed the co-write.
866 // The error is NEVER folded into the ratchet as a nil: nil
867 // means verified-absent only (ObservePipeline's contract), and a
868 // corrupt object must not masquerade as a deletion — a deletion RATCHETS
869 // each remembered channel down to its legacy default over one old-window
870 // cycle (ObservePipeline's p == nil branch), where an error holds every
871 // remembered window exactly where it is.
872 type pipeState struct {
873 p *Pipeline
874 err error // non-nil: the object exists but would not read or verify
875 }
876 pipes := map[acctApp]pipeState{}
877 for k, ptr := range pointers {
878 if ptr == nil {
879 continue
880 }
881 if r, ok := Rung(k.channel); ok && r == RungCanary {
882 continue
883 }
884 ak := acctApp{k.acct, k.app}
885 if _, done := pipes[ak]; done {
886 continue
887 }
888 p, perr := GetPipeline(ctx, s, pub, ak.acct, ak.app)
889 pipes[ak] = pipeState{p: p, err: perr}
890 if perr != nil {
891 // SAY SO, like the production flag below — same realistic
892 // non-tampering trigger too: on a GetObject-without-ListBucket
893 // fleet a GET of an ABSENT key answers 403, not 404, and every
894 // app would land here every pass.
895 fmt.Printf(" %s/%s: pipeline unreadable (%v) — remembered windows still apply; anything unremembered falls to legacy holds\n", ak.acct, ak.app, perr)
896 }
897 }
898
899 // The bake clock, loaded before any of this pass's reads are folded into
900 // it. now is stamped once so every observation and verdict this pass
901 // answers the same instant.
902 now := time.Now()
903 var bake *FirstSeen
904 if cfg.requireRootHistory {
905 bindings := make([]HistoryBinding, 0, len(cfg.historySelected))
906 for _, sel := range cfg.historySelected {
907 bindings = append(bindings, sel.binding)
908 }
909 bake, err = cfg.rootHistory.History.LoadBake(bindings, now)
910 if err != nil {
911 return err
912 }
913 } else if cfg.bakeDir != "" {
914 bake = LoadFirstSeenMigrating(FirstSeenPath(cfg.bakeDir), FirstSeenPath(optDir))
915 } else {
916 bake = LoadFirstSeen(FirstSeenPath(optDir))
917 }
918 // bake.migrated forces this pass's Save even if nothing new is observed
919 // below: otherwise a changed-gated Save could leave the new path absent
920 // for days on a fleet that happens to see no new channel head, and the
921 // legacy file would look safe to delete long before it actually is (see
922 // LoadFirstSeenMigrating and BakeDir's doc comments, 2026-08-03 review).
923 changed := bake.migrated
924
925 // ORDER IS LOAD-BEARING: fold this pass's verified pipeline reads into
926 // the ratchet memory HERE, before the per-app window maps below are
927 // computed from it — a tightened window read this pass must be enforced
928 // this pass, and a pending loosening whose old window has elapsed must
929 // land before the verdicts that depend on it. Same same-pass rule as
930 // ObserveProduction's fold further down. The err == nil guard is the
931 // contract, not paranoia: nil reaches ObservePipeline only for a
932 // verified-absent object (which never loosens anything, but does give
933 // this app's pending promotions their chance to land); an errored read
934 // never ticks the memory at all.
935 for ak, ps := range pipes {
936 if ps.err == nil && bake.ObservePipeline(ak.acct, ak.app, ps.p, now) {
937 changed = true
938 }
939 }
940
941 // One enforced-window map per app for this pass. A verified pipeline
942 // resolves through the box's ratchet memory (HoldsForApp); an ERRORED
943 // read keeps DefaultHolds floored by that same memory (never looser
944 // than what this box already enforced — see the pipes comment above);
945 // a VERIFIED-ABSENT pipeline takes the same memory floor, which is what
946 // enforces the deletion ratchet-down for as long as the memory rides
947 // above legacy; an app with no pipeline AND no memory keeps bare
948 // DefaultHolds, today's behaviour byte-for-byte. canary/* is stitched back in at zero on the verified
949 // path: it lives outside the pipeline (spec §2), so HoldsForApp's map
950 // cannot carry it, and HoldFor's fallback for a name missing from the
951 // map is the 72h fail-closed default — which would silently freeze
952 // every canary deploy of a pipelined app. Every OTHER channel outside
953 // the pipeline does take that 72h (spec §3: a channel with no pipeline
954 // entry keeps the fail-closed default), including rung names like beta
955 // whose DefaultHolds entry no longer applies.
956 //
957 // floored is the SEPARATE question the bake verdict asks: does this box
958 // hold pipeline evidence for the app at all (see bakeIgnore)? It is a
959 // membership set, never derived from the overlay above — a ratchet can
960 // be live while raising nothing, which is the ordinary state of every
961 // app-defined channel name (holdMemory.liveRatchet has the full
962 // argument).
963 appHolds := map[acctApp]map[string]time.Duration{}
964 floored := map[acctApp]bool{}
965 for ak, ps := range pipes {
966 switch {
967 case ps.err == nil && ps.p != nil:
968 h := bake.HoldsForApp(ak.acct, ak.app, ps.p)
969 h["canary/*"] = 0
970 appHolds[ak] = h
971 floored[ak] = true
972 default:
973 // The memory floor — for an ERRORED read and for VERIFIED
974 // ABSENCE alike. Overrides overlay a COPY of DefaultHolds,
975 // so a remembered channel is only ever RAISED above its legacy
976 // resolution, an unremembered one resolves exactly as legacy
977 // (HoldFor's 72h unknown-name fallback included), and canary/*
978 // keeps its zero: no pipeline can name canary, so no ratchet
979 // memory exists to raise it. With nothing remembered above
980 // legacy, no map is built and holdsFor falls to DefaultHolds
981 // itself. The prefix match is exact by construction: neither an
982 // account, an app name nor a non-canary channel can contain
983 // clockSep or "/" (channelClockKey's own injectivity argument).
984 //
985 // For verified absence the floor is half of what makes the
986 // deletion ratchet (ObservePipeline's p == nil branch, folded
987 // above) ENFORCED: while a remembered channel's window still
988 // exceeds its legacy default, deleting the object cannot adopt
989 // a held head this pass. That covers the names whose legacy
990 // default is NARROWER than what was enforced (edge, 0); the
991 // other half — an app-defined name, whose legacy default is the
992 // 72h fallback and therefore lands instantly, raising nothing —
993 // is carried by the verdict alone (floored below,
994 // holdMemory.DeletedAt). Once every remembered channel has
995 // finished ratcheting, nothing exceeds the default, no map is
996 // built, and the app is back on the bare-DefaultHolds path. An
997 // app with no memory at all — every legacy app, every pass —
998 // takes that bare path byte-identically, and the len check
999 // keeps the fleet-wide no-pipeline case allocation-free.
1000 //
1001 // The overlay is NOT the pass's answer to "is this app
1002 // pipelined as far as this box knows" — floored below is, and
1003 // the two are deliberately separate. A raised window is only
1004 // one shape a live ratchet takes: an app-defined channel like
1005 // "production" resolves to HoldFor's 72h fail-closed default
1006 // under DefaultHolds, so a declared 24h ratchets DOWN to a
1007 // window BELOW legacy and raises nothing at all. Deriving the
1008 // verdict from this overlay would have found no evidence for
1009 // exactly that app and fallen to the production flag.
1010 if len(bake.Holds) == 0 {
1011 break
1012 }
1013 var h map[string]time.Duration
1014 prefix := holdKeyPrefix(ak.acct, ak.app)
1015 for key, mem := range bake.Holds {
1016 // mem == nil: {"holds":{"x":null}} parses cleanly into a
1017 // nil entry — skip it, never dereference; a panic here
1018 // takes down the adopt pass on every box that loads the
1019 // file.
1020 if mem == nil {
1021 continue
1022 }
1023 name := strings.TrimPrefix(key, prefix)
1024 if name == key || name == "" {
1025 continue
1026 }
1027 if mem.liveRatchet(name, bake.Jitter, now) {
1028 floored[ak] = true
1029 }
1030 if mem.Window > HoldFor(DefaultHolds, name) {
1031 if h == nil {
1032 h = make(map[string]time.Duration, len(DefaultHolds)+1)
1033 for ch, d := range DefaultHolds {
1034 h[ch] = d
1035 }
1036 }
1037 h[name] = mem.Window
1038 }
1039 }
1040 if h != nil {
1041 appHolds[ak] = h
1042 }
1043 }
1044 }
1045 holdsFor := func(ak acctApp) map[string]time.Duration {
1046 if h, ok := appHolds[ak]; ok {
1047 return h
1048 }
1049 return DefaultHolds
1050 }
1051
1052 // Resolve each app's production flag once per pass — but ONLY for apps
1053 // with a HELD head resolved this pass (spec §6), held now meaning held
1054 // under the app's OWN window map above. That filter is a cost property,
1055 // not a nicety: any pass pays at most one read per (account, app) with a
1056 // held head, never one per channel or per route. For the fast-converge
1057 // sweep (PassFilter every 2-3 SECONDS) that usually means zero flag
1058 // reads — canary/* has a zero window on every path, and a fully-staged
1059 // box's sweep carries nothing else — but an UNSTAGED held route in the
1060 // sweep's set does resolve its held channel and pays this read too.
1061 // What keeps that bounded is the sweep's own membership, not this
1062 // filter: an unstaged host backs off 2s→5min (internal/host's
1063 // fastMembers), so a held, never-staging route costs one flag read per
1064 // backoff window at worst, not one per tick. A PIPELINED app's held
1065 // head pays the read as well even though its verdict never consults the
1066 // flag (bakeIgnore below): the fold keeps the legacy latch fresh for
1067 // the day the pipeline is deleted AND its deletion ratchet has landed,
1068 // which is when the app falls back to the flag for real.
1069 type prodState struct {
1070 flag *ProductionFlag
1071 err error // non-nil: the object exists but would not read or verify
1072 }
1073 bakeable := map[acctApp]bool{}
1074 for k, ptr := range pointers {
1075 if ptr != nil && HoldFor(holdsFor(acctApp{k.acct, k.app}), k.channel) > 0 {
1076 bakeable[acctApp{k.acct, k.app}] = true
1077 }
1078 }
1079 prod := map[acctApp]prodState{}
1080 for ak := range bakeable {
1081 // A missing flag is (nil, nil) — the default, not production. Any
1082 // OTHER failure is kept as an error and fails toward baking in
1083 // ProductionBake: an attacker who can corrupt but not sign must not
1084 // be able to shorten a window by breaking the object.
1085 f, err := GetProduction(ctx, s, pub, ak.acct, ak.app)
1086 prod[ak] = prodState{flag: f, err: err}
1087 if err != nil {
1088 // SAY SO. This failure re-imposes the full window on every held
1089 // head of the app, and the most likely cause is not tampering at
1090 // all: on a GetObject-without-ListBucket fleet (the same IAM shape
1091 // the missing-pointer branch above already handles) a GET of an
1092 // ABSENT key answers 403, not 404 — so the spec's default-OFF
1093 // silently inverts to production for every app with no flag. A
1094 // 72h hold nobody can see the reason for is the worst version of
1095 // this feature; log it like every other per-app problem here and
1096 // carry on.
1097 fmt.Printf(" %s/%s: production flag unreadable (%v) — treating as production, holds apply\n", ak.acct, ak.app, err)
1098 }
1099 }
1100
1101 // Tick the bake clock: record every channel head this box hasn't seen at
1102 // that head before, so the supply-chain window is measured from the box's
1103 // own first sighting, never a feed timestamp (bake.go). The clock only
1104 // ever ticks for pointers that verified above.
1105 for k, ptr := range pointers {
1106 if ptr != nil && bake.Observe(k.acct, k.app, k.channel, ptr.Version, now) {
1107 changed = true
1108 }
1109 }
1110 // ORDER IS LOAD-BEARING: fold this pass's verified flag reads into the
1111 // box's production memory HERE, before any bake verdict below is taken.
1112 // That is what makes prodMemory's "a mixed-version rollout costs at most
1113 // one pass of this memory" true — a binary that wiped the latch re-latches
1114 // it from the bucket and honors it in the SAME pass, never a pass later.
1115 //
1116 // The err == nil guard is the second lock on a door ObserveProduction's
1117 // own nil-check already holds (GetProduction returns a nil flag on every
1118 // error path, and a nil flag is a no-op there). It stays because the rule
1119 // it states — the memory only ever ticks for VERIFIED documents — is
1120 // worth being true at the call site too, not only at the callee.
1121 for ak, ps := range prod {
1122 if ps.err == nil && bake.ObserveProduction(ak.acct, ak.app, ps.flag, now) {
1123 changed = true
1124 }
1125 }
1126 // bake.tainted forces a Save for a different reason than migrated: the
1127 // clock file existed but did not read or parse, so every held head on
1128 // this box is failing toward baking until that file is replaced
1129 // (ProductionBake). Left alone, the corrupt file would survive every
1130 // changed-gated pass and the box would bake forever.
1131 //
1132 // But ONLY a full pass may perform that repair. A NARROWED pass
1133 // (extra != nil — the fast-converge sweep, every 2-3 seconds) fetches
1134 // production flags only for the held heads inside its narrowed set —
1135 // often none (canary/* has no hold), sometimes a few (an unstaged
1136 // stable/beta member), never reliably ALL of the apps whose latches the
1137 // clock file carries: its in-memory Production map is PARTIAL, not
1138 // repaired. The pipeline ratchet's Holds map is partial for the same
1139 // reason (pipelines are read only for the narrowed set's own non-canary
1140 // apps), so this skip protects both memories at once. Letting it write
1141 // would overwrite the corrupt file with that partial view AND clear the
1142 // taint that is currently failing closed,
1143 // dropping the latch of every app outside its set before anything
1144 // re-observed those flags — and if a bucket flag had also been deleted,
1145 // that latch would be gone for good. So a tainted clock on a narrowed
1146 // pass skips the Save entirely, forced or ordinary: a pass that observed
1147 // only a slice of the flags cannot repair a file that carries them all.
1148 // Nothing is lost — the observations it would have recorded re-occur
1149 // within seconds, and the hourly pass does the repair with the full flag
1150 // set actually in hand.
1151 //
1152 // Residual, accepted: even the full pass only re-observes flags for apps
1153 // with a currently-resolved HELD head, so an app whose channel pointer is
1154 // simultaneously unreadable keeps its latch out of the repaired file
1155 // until that pointer heals. That is triple-tamper territory (corrupt
1156 // clock file AND unreadable pointer AND deleted flag) and sits inside the
1157 // box-file trust boundary LoadFirstSeen already documents as accepted.
1158 switch {
1159 case bake.tainted && extra != nil:
1160 // skip: see above.
1161 case changed || bake.tainted:
1162 if err := bake.Save(); err != nil {
1163 return fmt.Errorf("record bake clock: %v", err)
1164 }
1165 }
1166
1167 // A version that failed its restart on one host isn't tried on the next
1168 // this pass — one bad build shouldn't bounce every host in turn.
1169 poisoned := map[string]bool{}
1170
1171 // One definition of "does the window apply here", shared by the awake and
1172 // hibernating loops below — the same question must not be able to be
1173 // answered two ways for the same app depending on whether it sleeps.
1174 //
1175 // RouteAccount(rt), never the raw rt.Account column: the group and
1176 // bakeable keys were built from RouteAccount output, and an unmigrated
1177 // row can still carry an account NAME there, which resolves to ops.
1178 bakeIgnore := func(rt registry.Route) bool {
1179 ak := acctApp{RouteAccount(rt), rt.App}
1180 // A PIPELINED app never consults the flag: its bake policy is the
1181 // pipeline's alone (spec §3) — never ignore, and a zero-window
1182 // channel holds nothing anyway.
1183 //
1184 // "Pipelined" here is every app this pass found pipeline EVIDENCE
1185 // for (floored), not only the apps whose read verified this pass:
1186 // a verified pipeline, or a ratchet this box is still running for
1187 // the app (holdMemory.liveRatchet — a window that differs from the
1188 // name's legacy resolution in EITHER direction, a pending
1189 // loosening, or a deletion whose cycle has not run out). That
1190 // memory IS the evidence the app was pipelined, and it is
1191 // deliberately NOT the appHolds overlay: the overlay only ever
1192 // RAISES a window above legacy, and an app-defined channel name
1193 // resolves to HoldFor's 72h fail-closed default, so a declared 24h
1194 // on "production" raises nothing while being the very policy the
1195 // hold exists to enforce. Reading the overlay collapsed exactly
1196 // that app (PR #258 review, C1). Riding the
1197 // verdict on the console's production: true CO-WRITE instead would
1198 // make enforcement depend on a bucket write this box can never
1199 // confirm happened: an app pipelined before the co-write existed, an
1200 // app whose flag was deleted, a direct-bucket pipeline write — each
1201 // leaves a flagless app whose held heads would adopt the instant one
1202 // GET of pipeline.json errored, or the instant the object was
1203 // deleted, defeating the very floor computed above. The co-write
1204 // keeps its own job, which was always the MEMORY-LESS box (spec §3's
1205 // old-binary edge — any box that never saw the pipeline).
1206 //
1207 // The flag path is left to apps with no pipeline signal at all —
1208 // every legacy app, byte-identically — and to a once-pipelined app
1209 // whose every remembered channel has finished ratcheting back to
1210 // its legacy default: nothing is live then, the set is empty for
1211 // the app, and it is a legacy app again in full.
1212 if floored[ak] {
1213 return false
1214 }
1215 return !bake.ProductionBake(ak.acct, ak.app, prod[ak].err, bakeable[ak], DefaultHolds, now)
1216 }
1217 // routeHolds resolves the same per-app window map for a route that the
1218 // bakeable filter above used — shared for the same reason bakeIgnore is:
1219 // the same question must not be answerable two ways for one app.
1220 routeHolds := func(rt registry.Route) map[string]time.Duration {
1221 return holdsFor(acctApp{RouteAccount(rt), rt.App})
1222 }
1223
1224 // Awake hosts first, hibernating second: repoints have no health gate, so
1225 // let an awake host discover (and poison) a broken build before any
1226 // sleeper is moved onto it.
1227 var hibernating, sidecars []registry.Route
1228 for _, rt := range routes {
1229 if !adopts(rt) {
1230 continue
1231 }
1232 if rt.IsSidecar() {
1233 sidecars = append(sidecars, rt)
1234 continue
1235 }
1236 if rt.Hibernate {
1237 hibernating = append(hibernating, rt)
1238 continue
1239 }
1240 ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
1241 adoptHost(ctx, selected[rt.Host], reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg, routeHolds(rt), bakeIgnore(rt))
1242 }
1243 for _, rt := range hibernating {
1244 ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
1245 // cfg.restartHibernating is nil by default (and for RunAdopt): the
1246 // pure repoint, never woken. The capability converge still runs for
1247 // them (a drop-in is unit text, not a wake); see adoptHost.
1248 //
1249 // When it IS set — the agent's in-process loops — the callback is
1250 // consulted for sleepers too, and the "never woken" guarantee moves
1251 // into the callback: RestartHibernating only accepts one that cannot
1252 // start a process (Bounce is false-when-not-awake, try-restart is a
1253 // no-op on a stopped unit). The flag says a route MAY sleep, not that
1254 // it is asleep now, and a warm exec child must not keep serving a
1255 // binary its symlink no longer names.
1256 adoptHost(ctx, selected[rt.Host], reg, s, optDir, bake, poisoned, rt, ptr, cfg.restartHibernating, now, cfg, routeHolds(rt), bakeIgnore(rt))
1257 }
1258 // Native sidecars LAST, and never through a restart callback (spec
1259 // §7.9). The pass stages, repoints and stamps. The running worker moves
1260 // when the activator's drift check sees the link change, which plan 5
1261 // queues as a paced release attempt; a Bounce here would stop an always-on
1262 // worker with nothing to start it. Last for the reason sleepers go after
1263 // awake hosts: a repoint has no health gate, so a build an instance
1264 // poisons this pass never reaches one.
1265 for _, rt := range sidecars {
1266 ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
1267 adoptHost(ctx, selected[rt.Host], reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg, routeHolds(rt), bakeIgnore(rt))
1268 }
1269 if cfg.restartErrors != nil {
1270 return errors.Join(*cfg.restartErrors...)
1271 }
1272 return nil
1273}
1274
1275// Adoptable filters the routes a Pass moves: lifecycle-managed instances of
1276// a known app that are ready (an app-less or mid-provision route has nothing
1277// to adopt or must not be disturbed), excluding platform-minted routes.
1278//
1279// A platform domain (internal/domains) is an ALIAS for an app's canonical
1280// route — a second name pointing at the same backend — not a host with a
1281// lifecycle of its own. Adopting one would stage a redundant copy of the
1282// artifacts, mint a live/<alias> symlink nothing ever execs, try-restart a
1283// carlos-app@<alias> unit that does not exist, and stamp Version onto a row
1284// the console then renders as though it were a real instance. The canonical
1285// route it aliases is adopted normally — that IS the deploy, and the alias
1286// serves the new version the moment it lands.
1287//
1288// Exported for host.fastMembers (the fast-converge sweep's membership scan),
1289// the same reason CapabilityRoute below is: the sweep must count as
1290// "outstanding work" exactly the routes a pass would consider, or a route
1291// the pass refuses on shape would be swept forever. A second copy of this
1292// predicate in another package is exactly the drift that would let the two
1293// disagree.
1294func Adoptable(rt registry.Route) bool {
1295 return rt.Kind == "instance" && rt.App != "" && !rt.Platform &&
1296 (rt.Status == "" || rt.Status == "ready")
1297}
1298
1299// SidecarAdoptable is the pass's SECOND admission predicate (zero-downtime
1300// deploys spec §7.9): a native sidecar row (Kind "sidecar") of a known app,
1301// not platform-minted, and ready. Its build follows the channel on its own
1302// row, as sidecar_reconcile.go provisions it, through the same signed
1303// selection, bake holds, staging, repoint and stamp an instance gets.
1304//
1305// Adoptable is deliberately NOT widened: capability drop-ins, the fast
1306// sweep's membership, config sync, the restart passes and the console's
1307// Restartable flag all keep their own answer for a sidecar. The pass opts in
1308// by name, and only the pass.
1309func SidecarAdoptable(rt registry.Route) bool {
1310 return rt.IsSidecar() && rt.App != "" && !rt.Platform &&
1311 (rt.Status == "" || rt.Status == "ready")
1312}
1313
1314// CapabilityRoute reports whether this route may materialize capabilities:
1315// a lifecycle-managed instance of a known app, unit-backed, non-platform,
1316// ready (spec §4e). It is Adoptable() plus the unit backing, deliberately —
1317// a route this pass would not adopt is not one whose unit shape this pass
1318// has any business rewriting.
1319//
1320// A platform ALIAS never materialises anything: an alias is a second name
1321// for one process, and a second carlos-app@ unit claiming :25 would break
1322// the real one. Adoptable() already excludes them and this depends on that.
1323//
1324// An EXEC-backed route (Backing == "") whose release declares capabilities
1325// materialises nothing here and is told so once per pass by RunAdopt's
1326// advisory, not by this function — that is the guided path through the §5
1327// migration.
1328//
1329// Exported for host.capabilityPass (the hourly grant-drift pass): the two
1330// converge entry points must agree, to the character, on which routes may
1331// have a root-owned unit drop-in written for them. A second copy of this
1332// predicate in another package is exactly the drift that would let one pass
1333// materialize a shape the other refuses.
1334func CapabilityRoute(rt registry.Route) bool {
1335 return Adoptable(rt) && rt.Backing == registry.BackingUnit
1336}
1337
1338// routeChannel is the channel a route follows; "" means stable.
1339func routeChannel(rt registry.Route) string {
1340 if rt.Channel == "" {
1341 return "stable"
1342 }
1343 return rt.Channel
1344}
1345
1346// RouteAccount is the account whose bucket keys a route's app lives under:
1347// the sqid stamped on the row, else the built-in ops.
1348//
1349// The ValidAccount gate is doing migration work, not paranoia. Before the
1350// multi-account change the account column held an account NAME ("ops",
1351// "paul@keymail.dev"), and a box can be running this code against a registry
1352// still carrying those. A name is not a sqid, so it reads as ops — whose keys
1353// are tried qualified first and then fall back to the legacy unqualified
1354// shape, which is exactly where an unmigrated app's objects still are.
1355func RouteAccount(rt registry.Route) string {
1356 if ValidAccount(rt.Account) {
1357 return rt.Account
1358 }
1359 return OpsAccount
1360}
1361
1362// adoptHost converges one host onto its channel head ptr (nil = the group's
1363// pointer didn't resolve; skip quietly, the group phase already logged).
1364// restart==nil is the symlink-only path (a caller with no proc manager, or a
1365// hibernating host on a pass that did not set RestartHibernating): repoint
1366// and record, never wake, never health-gate. A hibernating host WITH that
1367// option set arrives here with a non-nil callback and takes the ordinary
1368// path — safe because the option only accepts a callback that cannot start a
1369// process, so "never woken" survives the callback being consulted. The one
1370// start this function itself can perform (cfg.startUnit, the restart-failure
1371// rollback below) does not weaken that: it is armed only by that callback
1372// RETURNING AN ERROR, which a stopped unit's exit-0 try-restart never
1373// produces — WithStartUnit's doc carries the full argument.
1374// cfg is the call's PassOptions, already applied: cfg.nag nil (the ordinary
1375// case) means the canary-TTL nag below never fires, never even builds its
1376// string, and cfg.capsOps nil means the capability converge never runs at
1377// all. See NagExpiredCanaries and WithCapabilities for who sets each and why.
1378//
1379// holds is the enforced per-channel window map the caller resolved for this
1380// route's app: a verified pipeline's ratcheted HoldsForApp map (canary/*
1381// stitched back in at zero), DefaultHolds floored by the ratchet memory for
1382// an app whose pipeline read errored or is verified-absent mid-ratchet, else
1383// DefaultHolds — never consulted app-blind.
1384//
1385// ignore is the app-level bake verdict from ProductionBake — the LEGACY
1386// production flag's doing, never a publisher field, and constant false for
1387// any app this box carries pipeline evidence for: a verified pipeline, or a
1388// ratchet this box is still running for it (holdMemory.liveRatchet, which is
1389// NOT the same question as "does the memory raise a window" — see
1390// bakeIgnore). Such an app's windows come from holds alone (spec §3). True
1391// means the supply-chain window does not CURRENTLY apply to this app on
1392// this box (not declared production, or declared off and past the ratchet);
1393// the caller computes it once per route from the flag it read this pass.
1394func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time, cfg passConfig, holds map[string]time.Duration, ignore bool) {
1395 // settings: this route's build is the instance update settings' decision
1396 // (settings spec §4.3). Rules 1-3 need no channel head, so a route with
1397 // settings is not skipped when the pointer is unreadable (Astra I1 #9).
1398 settings := cfg.settings && ((rt.Kind == "instance" && rt.Unit == "") || cfg.rootUpdates != nil)
1399 if ptr == nil && !settings {
1400 return
1401 }
1402 nag := cfg.nag
1403 // The nag (spec §5 "Canaries expire"): visibility only, and deliberately
1404 // BEFORE the up-to-date short-circuit below — an expired canary that
1405 // isn't moving (the common case: the branch shipped once and nobody
1406 // touched it since) must still be seen every pass it's part of, not just
1407 // the passes that happen to change something. Nothing here blocks adopt,
1408 // restart or promote; it only (optionally) prints. Gated on nag != nil
1409 // rather than always printing to os.Stdout: see NagExpiredCanaries — this
1410 // must stay silent on the 2-3-second canary fast loop and speak only on
1411 // the hourly root adopt pass, or an expired canary sitting still would
1412 // nag every few seconds forever instead of once an hour.
1413 if nag != nil && ptr != nil && CanaryExpired(ptr, now) {
1414 // FormatCanaryAge(CanaryAge(...)), NOT a raw now.Sub(ptr.PromotedAt):
1415 // that would print the pointer's TOTAL age (CanaryAge + CanaryTTL),
1416 // overstating how long it's been expired by exactly one lease length
1417 // (2026-08-03 review, IMPORTANT 2) — and disagreeing with the
1418 // console's badge, which renders the same CanaryAge/FormatCanaryAge
1419 // pair (internal/console/ladder.go's canaryExpiry). One formatter,
1420 // shared, so the two operator-facing surfaces of this feature cannot
1421 // drift apart on what a duration means.
1422 fmt.Fprintf(nag, " %s: canary %s (%s) has been expired %s — still serving, nothing blocked\n",
1423 rt.Host, ptr.Channel, ptr.Version, FormatCanaryAge(CanaryAge(ptr, now)))
1424 }
1425 channel := routeChannel(rt)
1426 ver := ""
1427 if ptr != nil {
1428 ver = ptr.Version
1429 }
1430 cur, err := LiveVersion(optDir, rt.Host)
1431 if err != nil {
1432 fmt.Printf(" %s: read live link: %v — skipped\n", rt.Host, err)
1433 return
1434 }
1435 // Repair opportunity for the LIVE version's sibling, every pass, whether
1436 // or not this pass moves the symlink. C1 (2026-08-03 review):
1437 // ensureStaged's own back-fill only runs from the call below, which sits
1438 // behind the cur == ver short-circuit right after this — a version
1439 // that's already live never calls ensureStaged again, so a sibling write
1440 // that failed once (a full disk, a permissions hiccup) would otherwise
1441 // be stuck missing forever for exactly the version actually running.
1442 // This call never fails the pass: an absent or unreadable sibling is the
1443 // safe leave-alone state, so a repeated failure here is only a log line.
1444 if cur != "" && ptr != nil {
1445 backfillManifestDigest(ctx, s, optDir, RouteAccount(rt), rt.App, cur, rt.Host, ptr.DigestOf(cur), cfg.stage)
1446 }
1447 // Instance update settings (settings spec §4): one decision per route.
1448 // Everything below (bake for the decisions that take it, the proof gate,
1449 // refusals, D2, caps, restart) applies to whatever it chooses. A route
1450 // with default settings and no consumed file gets today's head.
1451 var dec TargetDecision
1452 if settings {
1453 var stop bool
1454 ver, dec, stop = settingsTarget(ctx, optDir, bake, rt, ptr, cur, now, cfg, holds, ignore)
1455 if stop {
1456 return
1457 }
1458 }
1459 // sel is the build this pass installs, as the gates below read it (Astra
1460 // I2 #8): the head itself when the decision is the head, else a pointer
1461 // naming only the selected build, the way heldProofAdmit's stand-in does
1462 // (materializeheld.go). The bake waiver, the proof gate (admit and bind),
1463 // D1, D2 and the refusal records judge the build that would run, never a
1464 // head the decision passed over. ptr stays the real head, nil or not, for
1465 // the staging digest below.
1466 sel := ptr
1467 if ptr == nil || ver != ptr.Version {
1468 sel = &ChannelPointer{Channel: channel, Version: ver}
1469 }
1470 if cfg.requireRootHistory && sel == ptr && ptr != nil {
1471 if err := cfg.recheckHistory(ctx, rt); err != nil {
1472 cfg.historyError(err)
1473 return
1474 }
1475 if err := cfg.rootHistory.History.RetireD1(cfg.historySelected[rt.Host].binding, ptr); err != nil {
1476 cfg.historyError(err)
1477 return
1478 }
1479 }
1480 if cur == ver {
1481 cfg.proof.clear(rt.Host) // live on its target: nothing left to wait for
1482 // A refusal that no longer blocks the pointer is stale even though
1483 // nothing moves: v2 was refused and rolled back to v1, then the
1484 // channel went back to v1. Without this every later pass returns
1485 // here and the watcher reports the v2 refusal forever (final review
1486 // F8). Best-effort, like the clear after a successful adoption: the
1487 // agent cannot remove root's file, so its failure is ignored and the
1488 // next root pass retries. A record that still blocks stays: the link
1489 // is on the refused build only when its rollback failed, and that is
1490 // worth showing. The record is judged against the build this route
1491 // selected (sel) and against the head (ptr): under a pin or a hold
1492 // a refusal of the head that is still current stays, so an unpin
1493 // does not retry a promotion this box already refused (Task 12a).
1494 if cfg.bakeDir != "" {
1495 if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err == nil && r != nil && !r.Blocks(sel) && !r.Blocks(ptr) {
1496 _ = ClearRefusal(cfg.bakeDir, rt.Host)
1497 }
1498 }
1499 return // up to date — stay quiet on the periodic tick
1500 }
1501 // Two independent waivers, both root-signed in origin: ignore is the
1502 // app's legacy production verdict (§2/§4; constant false for a pipelined
1503 // app), ptr.FastTrack is this ONE pointer's per-promotion waiver (§5) —
1504 // deliberately unratcheted (pipelines spec §3 amendment: ratcheting it
1505 // would make it wait out the very window it exists to skip). Either one
1506 // alone releases the head.
1507 //
1508 // Each return from here to admit stops D3's gate being asked about this
1509 // head, so each one also takes the host off the proof waiting list
1510 // (cfg.proof.clear). A wait left behind for a head the channel has moved
1511 // off would stay due for ever, and the fast loop would sweep the host
1512 // every tick for the whole hold (plan 3 ruling 5).
1513 //
1514 // Under settings only rule 4 and a new held instance's first head bake
1515 // (dec.Bake): a pin, a resolved T and a recovery waive it (§4.3). Both
1516 // baking decisions name the head, so sel is ptr there.
1517 if !settings || dec.Bake {
1518 if hold := Hold(bake, RouteAccount(rt), rt.App, channel, ver, holds, ignore || sel.FastTrack, now); hold != "" {
1519 fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, ver, channel, hold)
1520 cfg.proof.clear(rt.Host)
1521 return
1522 }
1523 }
1524 if poisoned[ver] {
1525 fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver)
1526 cfg.proof.clear(rt.Host)
1527 return
1528 }
1529 // D1's refusal (refusal.go): this box rolled this exact promotion back
1530 // because the unit did not come up. Checked before ensureStaged so a
1531 // refused build is neither downloaded nor relayed to a root pass.
1532 //
1533 // A D3 refusal (RefusalNotProven) is the one kind that depends on the
1534 // policy: it stands only while this pass's policy still requires a proof
1535 // for the app, or failed to load (closed). Once the require entry or the
1536 // whole file is gone, the record is ignored and best-effort cleared, so
1537 // removing the requirement is a break glass that works after a binding
1538 // refusal too (final review H3). The agent cannot remove root's file;
1539 // that failure is ignored and the next root pass clears it. D1 and D2
1540 // records never depend on the policy.
1541 if cfg.bakeDir != "" && !cfg.requireRootHistory {
1542 r, err := ReadRefusal(cfg.bakeDir, rt.Host)
1543 if err == nil && r.Blocks(sel) && r.Kind == RefusalNotProven {
1544 if required, _ := cfg.proof.Requires(rt.App); !required {
1545 if ClearRefusal(cfg.bakeDir, rt.Host) == nil {
1546 fmt.Printf(" %s: cleared the not-proven refusal of %s: this box no longer requires a proof for %s\n", rt.Host, ver, rt.App)
1547 }
1548 r = nil
1549 }
1550 }
1551 if err != nil {
1552 fmt.Printf(" %s: reading refusal record: %v\n", rt.Host, err)
1553 } else if r.Blocks(sel) {
1554 switch r.Kind {
1555 case RefusalWrongProgram:
1556 // --retry would re-stage the same bytes and be refused again,
1557 // and re-shipping this version cannot help: its manifest is
1558 // immutable, so the consent has to ride on a new version.
1559 fmt.Printf(" %s: refused: %s is the wrong program for this route (%s); still on %s; ship a new version with carlos ship --program-change and promote it, if the change is intended\n",
1560 rt.Host, ver, r.Reason, r.RestoredVersion)
1561 case RefusalNotProven:
1562 // D3's binding check (proofgate.go bind). Nothing was started
1563 // or rolled back, and --retry would re-sign the pointer over
1564 // the same staged bytes and be refused again.
1565 fmt.Printf(" %s: refused: %s's staged bytes do not match dev's proof (%s); still on %s; roll the exact bytes dev proved\n",
1566 rt.Host, ver, strings.TrimPrefix(r.Reason, "not proven: "), orNothing(r.RestoredVersion))
1567 default:
1568 fmt.Printf(" %s: refused: %s did not come up on this box at %s (%s); retry with: carlos promote --retry %s %s\n",
1569 rt.Host, ver, r.RefusedAt.Format(time.RFC3339), r.Reason, ver, channel)
1570 }
1571 cfg.proof.clear(rt.Host)
1572 return
1573 }
1574 }
1575 if cfg.requireRootHistory {
1576 selHistory := cfg.historySelected[rt.Host]
1577 refs, err := cfg.rootHistory.History.Refusals(selHistory.binding)
1578 if err != nil {
1579 cfg.historyError(err)
1580 return
1581 }
1582 for _, ref := range refs {
1583 if ref.Kind == RefusalNotUp && ref.Blocks(sel) {
1584 return
1585 }
1586 }
1587 }
1588 // D3's cheap gate (proofgate.go, spec §4.5 check 1): before ensureStaged,
1589 // so an unproven build is neither downloaded nor relayed to root.
1590 verdict, admitted := cfg.proof.admit(ctx, s, optDir, rt, sel, cur)
1591 if !admitted {
1592 return
1593 }
1594 // A named unit this process cannot restart is root's (RootRestartsUnits),
1595 // and so is every route its process serves: ask for root's pass and touch
1596 // nothing. Silent on the tick; the requester logs each request it writes.
1597 if cfg.unitsToRoot && restart != nil && !cfg.hostWrites.Enabled && unitServed(reg, rt) {
1598 if cfg.stageBlocked != nil {
1599 cfg.stageBlocked(rt.Host, ver, ErrUnitRestartNeedsRoot)
1600 }
1601 return
1602 }
1603 // The evidence that authorizes ver (settings spec §4.1). Only today's
1604 // adoption of the head, and a request whose T is still the head, rest on
1605 // the signed pointer and its digest; everything else needs a promotion
1606 // record with a matching manifest digest (stageAuthorized). The pointer
1607 // branch is refused for any build but the head itself: a non-head build
1608 // has no pointer digest, and staging it unbound on a digested channel is
1609 // the #253 replay (invariant 3).
1610 var m *Manifest
1611 switch {
1612 case !settings || dec.Evidence == EvidencePointer:
1613 if ptr == nil || ver != ptr.Version {
1614 fmt.Printf(" %s: %s: not the signed head, and the decision named no other evidence; not staged\n", rt.Host, ver)
1615 cfg.proof.clear(rt.Host)
1616 return
1617 }
1618 m, err = ensureStagedDigest(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host, ptr.DigestOf(ver), cfg.stage)
1619 case dec.Evidence == EvidenceRecord:
1620 m, err = stageAuthorized(ctx, s, cfg.pub, optDir, RouteAccount(rt), rt.App, ver, rt.Host, cfg.stage)
1621 default:
1622 fmt.Printf(" %s: %s: no evidence this pass can check; not staged\n", rt.Host, ver)
1623 cfg.proof.clear(rt.Host)
1624 return
1625 }
1626 if err != nil {
1627 fmt.Printf(" %s: %v\n", rt.Host, err)
1628 // A version this uid cannot write is the one staging failure a
1629 // privileged pass would finish; everything else (bucket, signature,
1630 // manifest) fails the same way as root. See OnStageBlocked.
1631 if cfg.stageBlocked != nil && stageBlocked(err) {
1632 // No manifest identity exists on this refusal path, so there is no
1633 // admitted host mutation to relay under lifecycle mode.
1634 if !cfg.hostWrites.Enabled {
1635 cfg.stageBlocked(rt.Host, ver, err)
1636 }
1637 }
1638 return
1639 }
1640 id, err := releaseIdentity(m)
1641 if err != nil {
1642 fmt.Printf(" %s: release identity: %v\n", rt.Host, err)
1643 return
1644 }
1645
1646 // D2 (programhistory.go): the staged bytes, not the manifest, decide.
1647 // Skipped for non-Go binaries and when this pass has no bake dir.
1648 newProg := FileProgram(VersionPath(optDir, RouteAccount(rt), ver, rt.App))
1649 if (cfg.bakeDir != "" || cfg.requireRootHistory) && newProg != "" {
1650 var hist []string
1651 fresh := false
1652 if cfg.requireRootHistory {
1653 hist, err = cfg.rootHistory.History.Programs(cfg.historySelected[rt.Host].binding)
1654 if err != nil {
1655 cfg.historyError(err)
1656 return
1657 }
1658 sel := cfg.historySelected[rt.Host]
1659 fresh, err = cfg.rootHistory.History.FreshInstallation(sel.binding, sel.creation)
1660 if err != nil {
1661 cfg.historyError(err)
1662 return
1663 }
1664 } else {
1665 hist = ReadPrograms(cfg.bakeDir, rt.Host)
1666 }
1667 // The live binary is what the route is running, so its program belongs
1668 // in the history whatever the file already says: a history of [A] on a
1669 // route that is live on B (a change made before this check existed, or
1670 // a record lost) must not refuse the next B build.
1671 if cur != "" && !cfg.requireRootHistory {
1672 if live := FileProgram(VersionPath(optDir, RouteAccount(rt), cur, rt.App)); live != "" && !slices.Contains(hist, live) {
1673 _ = RecordProgram(cfg.bakeDir, rt.Host, live)
1674 hist = append(slices.Clone(hist), live)
1675 }
1676 }
1677 if (len(hist) > 0 || cfg.requireRootHistory && !fresh) && !slices.Contains(hist, newProg) && m == nil {
1678 // ensureStaged has the binary but could not obtain its manifest
1679 // (no sibling, and the store did not answer), so whether the
1680 // release carries --program-change is unknown. Unknown is not a
1681 // refusal: a record here would sit at the gate above and stop the
1682 // next pass from ever reading the manifest, so an authorised
1683 // change would stay blocked after the store recovered. Not pointed
1684 // live, and the next pass asks again.
1685 fmt.Printf(" %s: %s is %s, not in this route's history, and its manifest could not be read to check for --program-change; not pointed live, will retry\n",
1686 rt.Host, ver, newProg)
1687 return
1688 }
1689 programChange := m != nil && m.ProgramChange
1690 if cfg.requireRootHistory {
1691 programChange = programChange && ptr.DigestOf(ver) != ""
1692 }
1693 if (len(hist) > 0 || cfg.requireRootHistory && !fresh) && !slices.Contains(hist, newProg) && !programChange {
1694 fmt.Printf(" %s: %s is %s, but this route has only run %s. not pointed live (ship a new version with carlos ship --program-change and promote it, if the change is intended)\n",
1695 rt.Host, ver, newProg, strings.Join(hist, ", "))
1696 sha := m.Artifacts[adoptPlatform].SHA256
1697 // Nothing was restarted, so the box is still on its old build.
1698 if err := cfg.recordRefusal(ctx, rt, Refusal{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha,
1699 Platform: adoptPlatform, PointerPromotedAt: sel.PromotedAt, RefusedAt: now.UTC(),
1700 Reason: "wrong program: " + newProg,
1701 Kind: RefusalWrongProgram, Recovered: true, RestoredVersion: cur}); err != nil {
1702 cfg.historyError(err)
1703 fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err)
1704 }
1705 return
1706 }
1707 }
1708
1709 // D3's binding check (spec §4.5 check 2): the staged bytes themselves
1710 // must be the proven ones, or a build this box has run well.
1711 if !cfg.bindProof(ctx, optDir, rt, sel, verdict, cur, now) {
1712 return
1713 }
1714 var historyAttempt string
1715 if cfg.requireRootHistory {
1716 if m == nil || m.Artifacts[adoptPlatform].SHA256 == "" {
1717 cfg.historyError(os.ErrPermission)
1718 return
1719 }
1720 historyIdentity := id
1721 if !cfg.hostWrites.Enabled {
1722 historyIdentity = hostwrite.Identity{}
1723 }
1724 historyAttempt, err = cfg.rootHistory.begin(ctx, rt, cfg.historySelected[rt.Host], sel, strings.ToLower(m.Artifacts[adoptPlatform].SHA256), now, historyIdentity)
1725 if err != nil {
1726 cfg.historyError(err)
1727 return
1728 }
1729 }
1730
1731 // The capability converge for the NEW version, BEFORE PointLive (spec
1732 // §4d). m is the manifest ensureStaged has in hand — never a re-read of
1733 // the staged sibling, whose write is best-effort: a sibling that failed
1734 // to write reads back as (nil, nil), which converge would take as
1735 // "leave the drop-ins alone", and the new version would go live against
1736 // the previous version's unit shape silently. Here a manifest this pass
1737 // could not obtain REFUSES the version instead (capabilityPlan).
1738 //
1739 // A hibernating route converges here too: a drop-in is unit text, not a
1740 // wake, and nothing below starts it. (It reaches this with restart == nil
1741 // on an ordinary pass, or with the RestartHibernating callback on the
1742 // agent's loops — which cannot start anything either. In practice this
1743 // branch is root-only regardless: cfg.capsOps is set by RunAdopt alone,
1744 // and RunAdopt does not set RestartHibernating.)
1745 var (
1746 prev caps.Snapshot
1747 plan caps.Plan
1748 converged bool
1749 )
1750 if cfg.capsOps != nil && CapabilityRoute(rt) {
1751 // restart != nil is what makes the deferral safe: it says somebody is
1752 // going to start this daemon after the symlink moves. With no restart
1753 // callback at all (a caller with no proc manager) converge keeps
1754 // today's behaviour and brings the unit up itself.
1755 plan, err = capabilityPlan(rt, m, restart != nil)
1756 var res caps.Result
1757 if err == nil {
1758 err = withReleaseWork(ctx, work, id, func() error {
1759 if err := cfg.recheckHistory(ctx, rt); err != nil {
1760 cfg.historyError(err)
1761 return err
1762 }
1763 var convergeErr error
1764 res, convergeErr = caps.Converge(*cfg.capsOps, plan)
1765 return convergeErr
1766 })
1767 prev = res.Prev
1768 }
1769 if err != nil {
1770 // A converge REFUSAL refuses the VERSION on this box, in the same
1771 // direction and for the same reason as an ensureStaged failure:
1772 // nothing is pointed live, so the box keeps serving what it has
1773 // rather than run a new binary against a unit shape the platform
1774 // declined to write. Converge itself has already written nothing
1775 // and logged the operator's fix.
1776 //
1777 // The version is deliberately NOT poisoned here (2026-08-03
1778 // review). A refusal bounces nothing — no symlink moved, no unit
1779 // cycled — so there is no bad build to keep off the next host,
1780 // which is the only thing the poison exists for. And the refusal
1781 // is often HOST-shaped: a port another route's drop-in already
1782 // claims, a state-directory flip mid-migration. Poisoning on one
1783 // of those would let a single misconfigured route freeze every
1784 // other host on the box out of a version that is fine for them,
1785 // pass after pass, until an operator noticed.
1786 //
1787 // Converge's own contract: bytes are still moved only when it
1788 // reports Changed with the error — a failure AFTER its
1789 // daemon-reload, which it deliberately does not undo because the
1790 // version-change caller owns it. That is here. Without this the
1791 // NEW version's unit shape stays on disk under the OLD binary
1792 // permanently: the next pass finds no diff, snapshots those bytes
1793 // as its Prev, and every later "restore" restores the wrong
1794 // shape. Restore is NOT called for an ordinary refusal, where
1795 // Prev is empty and would be read as "this route had no drop-ins"
1796 // — that is a deletion, not a restore.
1797 if res.Changed {
1798 if err := withReleaseWork(ctx, work, id, func() error { return caps.Restore(*cfg.capsOps, prev) }); err != nil {
1799 fmt.Printf(" %s: restoring the previous version's capability drop-ins: %v\n", rt.Host, err)
1800 }
1801 }
1802 fmt.Printf(" %s: capabilities for %s were refused: %v — not pointed live\n", rt.Host, ver, err)
1803 return
1804 }
1805 converged = true
1806 }
1807
1808 if err := withReleaseWork(ctx, work, id, func() error {
1809 if err := cfg.recheckHistory(ctx, rt); err != nil {
1810 return err
1811 }
1812 running := ""
1813 if cfg.runningVersion != nil {
1814 var err error
1815 running, err = cfg.runningVersion(ctx, rt)
1816 if err != nil {
1817 return err
1818 }
1819 }
1820 if err := PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App, running); err != nil {
1821 return err
1822 }
1823 if running != "" {
1824 cur = running
1825 }
1826 return nil
1827 }); err != nil {
1828 cfg.historyError(err)
1829 // The symlink did not move, so the box is still serving cur — and the
1830 // unit shape on disk is the new version's. Put the old one back, or a
1831 // port the running binary never asked for stays publicly bound.
1832 // Unlike the restart-failure branch below there is no cur == ""
1833 // exception: nothing is live either way, so "restore what the symlink
1834 // names" means removing what this pass just wrote.
1835 rolled := ""
1836 if converged {
1837 if rerr := withReleaseWork(ctx, work, id, func() error { return caps.Restore(*cfg.capsOps, prev) }); rerr != nil {
1838 fmt.Printf(" %s: restoring the previous version's capability drop-ins: %v\n", rt.Host, rerr)
1839 } else {
1840 rolled = " — restored the capability drop-ins the symlink still names"
1841 }
1842 }
1843 fmt.Printf(" %s: repoint failed: %v%s\n", rt.Host, err, rolled)
1844 // A release staged by an earlier root pass can still need root to
1845 // swap its live symlink. Relay that write refusal too, or the fast
1846 // loop never requests another pass once the artifact exists.
1847 if cfg.stageBlocked != nil && stageBlocked(err) {
1848 if guardErr := withReleaseWork(ctx, work, id, func() error { return nil }); guardErr != nil {
1849 fmt.Printf(" %s: release authority changed before privileged staging relay: %v\n", rt.Host, guardErr)
1850 } else {
1851 cfg.stageBlocked(rt.Host, ver, err)
1852 }
1853 }
1854 return
1855 }
1856 if cfg.bakeDir != "" && newProg != "" && !cfg.requireRootHistory {
1857 if err := RecordProgram(cfg.bakeDir, rt.Host, newProg); err != nil {
1858 fmt.Printf(" %s: recording program history: %v\n", rt.Host, err)
1859 }
1860 }
1861
1862 // The accepted window, made visible (2026-08-03 ruling). A pass with no
1863 // WithCapabilities — the in-process adoptLoop, and above all the 2-3
1864 // second fastConvergeLoop, neither of which can write /etc from inside
1865 // carlos-edge.service — has just moved a unit-backed route onto a version
1866 // that declares capabilities, without converging its unit shape.
1867 //
1868 // The signal is "the new manifest declares anything at all", not "the new
1869 // set differs from what is materialized": the honest difference test needs
1870 // the route's grants and a render of both sides, and this line exists to
1871 // mark a window, not to diff it. It costs nothing — m is already in hand —
1872 // and it errs toward one extra line, once per version change per host,
1873 // never per tick.
1874 if cfg.capsOps == nil && CapabilityRoute(rt) && m != nil && m.Capabilities != nil {
1875 fmt.Printf(" %s: %s declares capabilities and this pass does not write unit configuration — it runs against the drop-ins the previous version left until the root adopt pass (carlos-adopt.service, hourly) converges them\n", rt.Host, ver)
1876 }
1877
1878 if restart != nil {
1879 if err := withReleaseWork(ctx, work, id, func() error { return cfg.recheckHistory(ctx, rt) }); err != nil {
1880 cfg.historyError(err)
1881 fmt.Printf(" %s: release authority changed before restart: %v\n", rt.Host, err)
1882 return
1883 }
1884 // A converged DAEMON's restart is caps.Restart, not the caller's
1885 // callback: this is the only call site that knows the plan, and it is
1886 // the only restart whose error must be real. rootRestartRoute's
1887 // try-restart (and its swallow-and-continue) keeps owning every other
1888 // route, byte-identically to before.
1889 //
1890 // plan.Set is non-nil whenever converged is true (capabilityPlan
1891 // guarantees it); the nil check is here anyway, because a nil deref
1892 // on this path would panic the root adopt pass for the whole box.
1893 if cfg.restartAttemptSettled != nil {
1894 defer func() {
1895 if err := cfg.restartAttemptSettled(rt.Host); err != nil && cfg.restartErrors != nil {
1896 *cfg.restartErrors = append(*cfg.restartErrors, err)
1897 }
1898 }()
1899 }
1900 var rerr error
1901 daemonRestart := converged && plan.Set != nil && plan.Set.Daemon
1902 if cfg.hostWrites.Enabled {
1903 if cfg.selectedRestart == nil {
1904 rerr = hostwrite.ErrUnavailable
1905 } else {
1906 rerr = cfg.selectedRestart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id})
1907 }
1908 } else if daemonRestart {
1909 rerr = caps.Restart(*cfg.capsOps, plan)
1910 } else {
1911 rerr = restart(rt.Host)
1912 }
1913 if rerr != nil {
1914 if cfg.restartErrors != nil {
1915 *cfg.restartErrors = append(*cfg.restartErrors, rerr)
1916 }
1917 // Poison the build for the rest of the pass and repoint back —
1918 // the host keeps running what it ran. The symlink goes back
1919 // FIRST: caps.Restore may start the service, and it must start
1920 // on the old binary.
1921 //
1922 // The repoint's error is CHECKED, where it was once discarded:
1923 // "link present, staged version gone" is a documented real state
1924 // (materialize.go — an operator's /opt cleanup, a partially
1925 // restored volume), and in it PointLive refuses and the symlink
1926 // STILL NAMES ver. Everything below that claims a rollback — the
1927 // restore, the summary line, above all the start — must key on
1928 // whether the rollback actually HAPPENED, not on whether one was
1929 // attempted.
1930 var temporary interface{ Temporary() bool }
1931 if !errors.As(rerr, &temporary) || !temporary.Temporary() {
1932 poisoned[ver] = true
1933 }
1934 var notUp *UnitNotUpError
1935 isNotUp := errors.As(rerr, &notUp)
1936 rolledBack := false
1937 var rollbackErr error
1938 if cur != "" {
1939 if perr := withReleaseWork(ctx, work, id, func() error { return PointLive(optDir, rt.Host, RouteAccount(rt), cur, rt.App) }); perr != nil {
1940 fmt.Printf(" %s: could not roll the symlink back to %s: %v\n", rt.Host, cur, perr)
1941 rollbackErr = perr
1942 } else {
1943 rolledBack = true
1944 }
1945 }
1946 // The rule is one line long: restore the unit shape that matches
1947 // whatever the symlink NOW names.
1948 //
1949 // With cur == "" there was no previous version and the symlink
1950 // above could not be rolled back, so it still names ver — and the
1951 // drop-ins already match it. Restoring here would hand Restore an
1952 // empty snapshot, which it reads as "this route had no generated
1953 // drop-ins" and honours by REMOVING the ones this pass wrote,
1954 // leaving the live version running with no capability shape at
1955 // all until the hourly grant-drift pass rebuilds it. A rollback
1956 // that itself FAILED lands in the same place for the other
1957 // reason: the symlink still names ver, and putting the previous
1958 // version's drop-ins under it would be exactly the shape/binary
1959 // mismatch this whole branch exists to prevent.
1960 rolled := ""
1961 if converged && rolledBack {
1962 // Bytes are not a binding: Restore re-runs the socket
1963 // sequence itself when the socket half moves, or the MX
1964 // stays off :25 after a rollback.
1965 if err := withReleaseWork(ctx, work, id, func() error { return caps.Restore(*cfg.capsOps, prev) }); err != nil {
1966 fmt.Printf(" %s: restoring the previous version's capability drop-ins: %v\n", rt.Host, err)
1967 rolled = " but could not restore the previous version's capability drop-ins"
1968 } else {
1969 rolled = " and restored the previous version's capability drop-ins"
1970 }
1971 } else if converged {
1972 if cur == "" {
1973 rolled = "; there is no previous version to roll back to, so its capability drop-ins stand as converged"
1974 } else {
1975 rolled = "; its capability drop-ins stand as converged with the version the symlink still names"
1976 }
1977 }
1978 action := "rolled the symlink back"
1979 if cur != "" && !rolledBack {
1980 action = "could not roll the symlink back"
1981 }
1982 fmt.Printf(" %s: update to %s failed: %v — %s%s\n", rt.Host, ver, rerr, action, rolled)
1983 // Start the service again on the version the symlink NOW names —
1984 // but ONLY when that is the restored one. The rollback above put
1985 // the bytes back; without this the box is left with a correct
1986 // symlink and no process — and because try-restart is a no-op on
1987 // a unit that is not running (which is what lets a
1988 // deliberately-stopped service stay stopped), the NEXT pass's
1989 // restart would exit 0, SetVersions would stamp the build that
1990 // has never run, and the header would claim it over a service
1991 // that is down. Start, not try-restart: the whole premise here is
1992 // that the unit is not running.
1993 //
1994 // Gated on rolledBack, not cur != "": a rollback that FAILED (the
1995 // old version's directory gone — PointLive refuses) leaves the
1996 // symlink naming ver, and a start there would launch the exact
1997 // build whose restart just failed. And not for a converged daemon
1998 // (daemonRestart): the restart that failed there was
1999 // carlos-app@<host>'s, via caps.Restart; its rollback belongs to
2000 // caps.Restore above, and this callback would start rt.Unit — a
2001 // unit that pass never touched. AFTER caps.Restore, deliberately:
2002 // the drop-in shape must match the binary before anything execs
2003 // it, and on a unit Restore's socket sequence already brought up,
2004 // start is an exit-0 no-op.
2005 //
2006 // A start that also fails changes nothing about the refusal — the
2007 // version stays poisoned and unstamped — but it is printed,
2008 // because at that point the box is very likely serving nothing
2009 // and the operator must not read the rollback line as
2010 // "recovered". "May": a try-restart that failed without stopping
2011 // anything (a permission-denied systemctl) fails its start the
2012 // same way while the old process still serves — the two errors on
2013 // the line tell them apart.
2014 //
2015 // Under lifecycle host writes (hostwrite) the start goes through
2016 // the selected receiver, as the forward restart does; D1's checked
2017 // replace (rollbackRestart) is the legacy path's start for a
2018 // not-up refusal.
2019 start := cfg.startUnit
2020 if isNotUp && cfg.rollbackRestart != nil {
2021 start = cfg.rollbackRestart
2022 }
2023 // D1's group (rollback.go): siblings follow bound onto the
2024 // restored build only when bound's own rollback happened, each
2025 // under ONE release work of its own: the pass's original
2026 // selection where it has one (the one the sibling's forward
2027 // adoption ran under), else a single Select made here. Never a
2028 // Select per write: a second one could capture a successor's
2029 // installation and stamp it.
2030 var group []string
2031 if isNotUp {
2032 restored := ""
2033 if rolledBack {
2034 restored = cur
2035 }
2036 group = rollbackGroup(reg, optDir, rt, ver, restored, func(h string) (func(func() error) error, error) {
2037 w, ok := cfg.selected[h]
2038 if !ok || w == nil {
2039 var err error
2040 if w, err = cfg.hostWrites.Select(ctx, h, "release"); err != nil {
2041 return nil, err
2042 }
2043 }
2044 return func(fn func() error) error { return withReleaseWork(ctx, w, id, fn) }, nil
2045 })
2046 if notUp.Journal != "" {
2047 fmt.Printf(" %s: %s's last lines:\n%s\n", rt.Host, notUp.Unit, indent(notUp.Journal, " "))
2048 }
2049 }
2050 started := false
2051 var serr, authErr error
2052 if start != nil && rolledBack && !daemonRestart {
2053 // Authority lost here skips the start, and every process
2054 // mutation after it, but NOT the refusal records below: the
2055 // next pass has fresh authority and an empty poisoned map, and
2056 // without a record it would adopt the rejected build again.
2057 if guardErr := withReleaseWork(ctx, work, id, func() error { return nil }); guardErr != nil {
2058 fmt.Printf(" %s: release authority changed before rollback start: %v — not started\n", rt.Host, guardErr)
2059 authErr = guardErr
2060 }
2061 }
2062 if start != nil && rolledBack && !daemonRestart && authErr == nil {
2063 started = true
2064 if cfg.hostWrites.Enabled {
2065 if cfg.selectedStart == nil {
2066 serr = hostwrite.ErrUnavailable
2067 } else {
2068 serr = cfg.selectedStart(ctx, hostwrite.Selection{Host: rt.Host, Work: work, Identity: id})
2069 }
2070 } else {
2071 serr = start(rt.Host)
2072 }
2073 if serr != nil {
2074 if cfg.restartErrors != nil {
2075 *cfg.restartErrors = append(*cfg.restartErrors, serr)
2076 }
2077 if rt.Unit == "" && !isNotUp {
2078 fmt.Printf(" %s: rolled back to %s but could not start it: %v\n", rt.Host, cur, serr)
2079 } else {
2080 fmt.Printf(" %s: rolled back to %s but could not start it: %v — this host may be serving nothing\n",
2081 rt.Host, cur, serr)
2082 }
2083 }
2084 }
2085 if isNotUp {
2086 // The refusals go down AFTER the restart, carrying what it
2087 // did: a record written before it would tell the watcher
2088 // "rolled back" about a box whose restored build never came
2089 // up (final review F6).
2090 rec := recovery{recovered: true}
2091 switch {
2092 case cur == "":
2093 rec = recovery{err: "there is no previous version to roll back to"}
2094 case !rolledBack:
2095 rec = recovery{err: fmt.Sprintf("could not roll the symlink back to %s: %v", cur, rollbackErr)}
2096 case authErr != nil:
2097 rec = recovery{restored: cur, err: "release authority changed before rollback start: " + authErr.Error()}
2098 case !started:
2099 rec = recovery{restored: cur, err: "nothing on this pass restarts the unit on " + cur}
2100 case serr != nil:
2101 rec = recovery{restored: cur, err: serr.Error()}
2102 default:
2103 rec.restored = cur
2104 }
2105 sha := ""
2106 if m != nil {
2107 sha = m.Artifacts[adoptPlatform].SHA256
2108 }
2109 writeGroupRefusals(cfg, group, rt.App, sel, ver, sha, adoptPlatform, notUp, rec, now)
2110 }
2111 return
2112 }
2113 }
2114 if cfg.requireRootHistory {
2115 if err := cfg.rootHistory.complete(ctx, rt, cfg.historySelected[rt.Host], historyAttempt, ver); err != nil {
2116 cfg.historyError(err)
2117 return
2118 }
2119 }
2120 if err := withReleaseWork(ctx, work, id, func() error { return reg.SetVersions(rt.Host, ver, cur) }); err != nil {
2121 fmt.Printf(" %s: repointed but recording the version failed: %v\n", rt.Host, err)
2122 return
2123 }
2124 // A record left from an older promotion is stale once a build is live.
2125 // Best-effort: the agent cannot remove root's file, the root pass can.
2126 if cfg.bakeDir != "" {
2127 _ = ClearRefusal(cfg.bakeDir, rt.Host)
2128 }
2129 if rt.IsSidecar() {
2130 fmt.Printf(" %s: repointed %s -> %s (%s; sidecar, its worker moves onto it on the agent's drift check)\n", rt.Host, cur, ver, channel)
2131 return
2132 }
2133 if rt.Hibernate {
2134 // Two different facts, one grep. With no restart callback this pass
2135 // genuinely only moved a symlink, and the next wake is when the new
2136 // version runs. With one (RestartHibernating, the agent's loops) the
2137 // callback has ALREADY been consulted above: a warm exec child was
2138 // bounced onto ver a few lines ago, and a sleeping one was a no-op.
2139 // Which of the two happened is makeRestartRoute's to log — it prints
2140 // the bounce — so this line stops claiming the sleeping case for
2141 // both, rather than growing a third state to track.
2142 if restart != nil {
2143 fmt.Printf(" %s: repointed %s -> %s (%s; hibernating — bounced if awake, else runs it on next wake)\n", rt.Host, cur, ver, channel)
2144 } else {
2145 fmt.Printf(" %s: repointed %s -> %s (%s; hibernating — runs it on next wake)\n", rt.Host, cur, ver, channel)
2146 }
2147 } else {
2148 fmt.Printf(" %s: updated %s -> %s (%s)\n", rt.Host, cur, ver, channel)
2149 }
2150}
2151
2152// capabilityPlan turns a route plus the manifest of the version being
2153// adopted into one converge plan, or refuses.
2154//
2155// Two rulings live here, and both are the difference between this feature
2156// working and this feature quietly doing the opposite of what it says:
2157//
2158// - A manifest this pass could not obtain (m == nil: the binary is staged,
2159// so ensureStaged succeeded, but neither the sibling nor the bucket
2160// could answer for its manifest) REFUSES the version. caps.Plan's nil
2161// Set means "leave the drop-ins alone", which is the right posture for a
2162// pass that is merely surveying the box — and exactly the wrong one on a
2163// version change, where it would point a new binary at the previous
2164// version's unit shape and say nothing.
2165//
2166// - A manifest that RESOLVED and declares NOTHING is not the same thing.
2167// Manifest.Capabilities is a nil pointer for both a legacy manifest and
2168// a fetch that never happened, so it is never passed through verbatim:
2169// a resolved manifest always carries a non-nil Set, empty when it
2170// declares nothing, which is what makes converge REMOVE stale drop-ins.
2171// Set: m.Capabilities verbatim would mean a capability dropped from a
2172// manifest is never removed — the exact opposite of spec §4d's removal
2173// semantics.
2174//
2175// An unparseable grants column refuses too, rather than defaulting to no
2176// grants: "no grants" is a real instruction that revokes :25 and drops a
2177// mail server's listener, and a column this binary cannot read is not
2178// evidence that an operator asked for that.
2179//
2180// canRestart arms caps.Plan.DeferRestart: this converge runs BEFORE the
2181// symlink moves, so a daemon it started would be the OLD binary under the NEW
2182// unit shape, and the caller would then cycle it a second time. Deferring is
2183// only honest when somebody is actually going to do the restart — with no
2184// restart callback at all, converge keeps bringing the unit up itself.
2185func capabilityPlan(rt registry.Route, m *Manifest, canRestart bool) (caps.Plan, error) {
2186 if m == nil {
2187 return caps.Plan{}, fmt.Errorf("the manifest for this version could not be read or fetched, so its unit shape is unknown")
2188 }
2189 g, err := caps.ParseGrants(rt.Caps)
2190 if err != nil {
2191 return caps.Plan{}, fmt.Errorf("capability grants %q: %v", rt.Caps, err)
2192 }
2193 set := m.Capabilities
2194 if set == nil {
2195 set = &caps.Set{V: caps.Version}
2196 }
2197 return caps.Plan{
2198 Host: rt.Host,
2199 App: rt.App,
2200 Set: set,
2201 Grants: g,
2202 Hibernate: rt.Hibernate,
2203 DeferRestart: canRestart,
2204 }, nil
2205}
2206
2207// stageCheck is what ensureStagedChecked needs to judge a staged copy it is
2208// about to reuse (spec §4.2).
2209type stageCheck struct {
2210 beforeWrite func(string) error
2211 // refs reports the staged copies something on this box still refers to.
2212 // nil, or an error, means unknown, and unknown never permits a restage.
2213 refs func() (StagedPaths, error)
2214 // verified holds staged paths whose bytes already matched their canonical
2215 // release this pass, so N hosts moving onto one build hash it once, each
2216 // with the artifact sha it matched: a digest-bound reuse trusts the memory
2217 // only for the sha its own checked manifest names (stageFrom). nil
2218 // remembers nothing.
2219 verified map[string]string
2220}
2221
2222// ensureStaged makes ver's build of app available in the version store,
2223// downloading and sha256-verifying it if needed. Idempotent across hosts in
2224// one pass. The manifest supplies the sha; the bytes are refused unless they
2225// hash to it — the channel pointer's signature plus this gate is the whole
2226// trust chain from operator to disk. host is used only to prefix this
2227// function's own log lines, matching every other line this file prints.
2228//
2229// It also keeps the staged manifest sibling (versions.go's StageManifest)
2230// current: a fresh download writes the sibling alongside the binary, and a
2231// binary that is ALREADY staged but missing its sibling gets one written
2232// without a re-download — the back-fill for every version staged before this
2233// sibling existed. For copies staged by this binary or later the sibling is
2234// the canonical manifest's exact bytes; only a copy an older binary staged
2235// carries a lossy re-marshal (see ReadStagedManifest). The bucket object stays
2236// canonical either way.
2237//
2238// The back-fill's own fetch failure is NOT fatal when the binary is already
2239// staged (2026-08-03 review, I2): this function's whole promise is "the
2240// binary is on disk", and a version already staged must not depend on the
2241// bucket answering for a sibling write that can be retried. Without this, a
2242// transient GetManifest failure would hard-fail adoption of a binary that
2243// needs no adoption at all — and on a canary route's 2-3-second fast loop,
2244// flood the journal every tick. adoptHost's backfillManifest (C1, same
2245// review) is what makes leaving this non-fatal safe: every subsequent pass
2246// gets another chance at the live version's sibling, so a transient failure
2247// here heals on its own instead of being permanently missed.
2248//
2249// It RETURNS the manifest, because adoptHost's capability converge builds its
2250// plan from it (spec §4d) and must never re-read the sibling to get one: the
2251// sibling write is best-effort, and a failed write reads back as (nil, nil) —
2252// "leave the drop-ins alone" — which on a version change would point a new
2253// binary at the previous version's unit shape without a word. The one path
2254// that has no manifest of its own to give (a staged binary whose manifest
2255// neither disk nor bucket would answer for) returns (nil, nil) DELIBERATELY,
2256// and adoptHost turns that into a refusal when — and only when — capabilities
2257// are armed for the route. Every other caller keeps today's contract exactly:
2258// an error means "the binary is not on disk", nil means it is.
2259//
2260// A version directory that is already staged is no longer trusted by name
2261// (zero-downtime deploys spec §4.2). A version name can be shipped again with
2262// different bytes once retention has pruned it, and the staged path is keyed
2263// by name alone. So a staged binary is hashed and compared with the canonical
2264// manifest's digest for this platform:
2265// - a match reuses it, as before;
2266// - a mismatch restages over it only when nothing on the box refers to it
2267// (chk.refs: live links, Version, PrevVersion, the generations file,
2268// and whatever WithPinSource adds); otherwise the version is refused with
2269// ErrVersionReused, so a copy under another instance's build never
2270// changes underneath it;
2271// - a canonical manifest that cannot be read keeps today's answer: the copy
2272// is used as staged, because a box cannot compare what it cannot read,
2273// and the agent runs any attempt onto it in hold (CanonicalDemand).
2274//
2275// ensureStaged is ensureStagedChecked with no reference check. It has no
2276// registry in hand, so it cannot know what refers to a staged copy and never
2277// restages one: a copy that differs from its release is refused. It stays
2278// refused until a pass that adopts that version restages it, or the release
2279// is shipped under a new name.
2280func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host string) (*Manifest, error) {
2281 return ensureStagedChecked(ctx, s, optDir, acct, app, ver, host, stageCheck{})
2282}
2283
2284// ensureStagedChecked is ensureStaged with the reuse check's references
2285// supplied by chk; see ensureStaged's doc for the whole contract.
2286func ensureStagedChecked(ctx context.Context, s Store, optDir, acct, app, ver, host string, chk stageCheck) (*Manifest, error) {
2287 path := VersionPath(optDir, acct, ver, app)
2288 _, statErr := os.Stat(path)
2289 staged := statErr == nil
2290 m, raw, err := GetManifestRaw(ctx, s, acct, app, ver)
2291 if err != nil {
2292 if !staged {
2293 return nil, fmt.Errorf("manifest for %s/%s: %w", app, ver, err)
2294 }
2295 // The binary IS staged, which is this function's whole promise, and
2296 // the canonical manifest cannot be read to compare it with. A sibling
2297 // already on disk is the manifest in hand, exactly as before this
2298 // check existed. Without one, a fetch failure is only worth a line;
2299 // backfillManifest retries on every pass.
2300 sib, serr := ReadStagedManifest(optDir, acct, ver, app)
2301 if serr == nil && sib != nil {
2302 return sib, nil
2303 }
2304 if serr != nil {
2305 fmt.Printf(" %s: staged manifest for %s: %v\n", host, ver, serr)
2306 }
2307 fmt.Printf(" %s: back-fill manifest for %s: %v\n", host, ver, err)
2308 return nil, nil
2309 }
2310 return stageFrom(ctx, s, optDir, acct, app, ver, host, chk, staged, m, raw, false)
2311}
2312
2313// stageFrom is ensureStagedChecked from the point it holds the canonical
2314// manifest: m and raw, its exact bytes, read once by the caller. staged says
2315// whether a copy was on disk under the name. bound says m passed the
2316// authorizing pointer's digest (ensureStagedDigest), and changes two things
2317// only: a copy this pass already verified is reused only if it was verified
2318// against THIS artifact sha, never another manifest's under the same name;
2319// and the sibling is made exactly raw (syncSibling) rather than repaired only
2320// when it does not read. Unbound, it is ensureStagedChecked as zdd-4 wrote it.
2321func stageFrom(ctx context.Context, s Store, optDir, acct, app, ver, host string, chk stageCheck, staged bool, m *Manifest, raw []byte, bound bool) (*Manifest, error) {
2322 path := VersionPath(optDir, acct, ver, app)
2323 sibling := repairSibling
2324 if bound {
2325 sibling = syncSibling
2326 }
2327 art, hasBuild := m.Artifacts[adoptPlatform]
2328 if staged {
2329 if v := chk.verified[path]; v != "" && (!bound || hasBuild && strings.EqualFold(v, art.SHA256)) {
2330 sibling(optDir, acct, app, ver, host, raw, chk)
2331 return m, nil
2332 }
2333 sum, herr := FileSHA256(path)
2334 if herr != nil {
2335 return nil, fmt.Errorf("staged %s/%s: %w", app, ver, herr)
2336 }
2337 if hasBuild && strings.EqualFold(sum, art.SHA256) {
2338 if chk.verified != nil {
2339 chk.verified[path] = art.SHA256
2340 }
2341 sibling(optDir, acct, app, ver, host, raw, chk)
2342 return m, nil
2343 }
2344 // Different bytes under the same name. Overwrite only a copy nothing
2345 // refers to: overwriting a referenced one would change the bytes under
2346 // another instance's build (spec §4.2).
2347 if chk.refs == nil {
2348 return nil, fmt.Errorf("%s/%s: the staged copy differs from the release and what refers to it is unknown, so it was not restaged", app, ver)
2349 }
2350 pins, perr := chk.refs()
2351 // References another app could not complete (an unread build state
2352 // of one of its held instances) do not stop this one; this app's
2353 // do (Astra round 1 finding 2).
2354 if inc := (*IncompleteRefsError)(nil); errors.As(perr, &inc) && pins != nil && !inc.Covers(acct, app) {
2355 perr = nil
2356 }
2357 if perr != nil {
2358 return nil, fmt.Errorf("%s/%s: the staged copy differs from the release and what refers to it is unknown (%v), so it was not restaged", app, ver, perr)
2359 }
2360 // By the copy's real path as well as its name, so a symlinked opt dir
2361 // cannot make a pinned copy look unpinned (Astra r3 N4).
2362 referred, rerr := pins.Refers(path)
2363 if rerr != nil {
2364 return nil, fmt.Errorf("%s/%s: the staged copy differs from the release and what refers to it is unknown (%v), so it was not restaged", app, ver, rerr)
2365 }
2366 if referred {
2367 return nil, fmt.Errorf("%s/%s: %w", app, ver, ErrVersionReused)
2368 }
2369 fmt.Printf(" %s: staged %s differs from the release in the store and nothing on this box refers to it; restaging\n", host, ver)
2370 }
2371 if !hasBuild {
2372 // A box facing a release with no build for its platform: skip, never
2373 // install the wrong arch. The next release ships it and the box
2374 // catches up.
2375 return nil, fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform)
2376 }
2377 // Streamed to disk and hashed on the way (#269): a build read whole
2378 // outgrew the stage pass's memory cap.
2379 if chk.beforeWrite != nil {
2380 if err := chk.beforeWrite(path); err != nil {
2381 return nil, err
2382 }
2383 }
2384 err := stageArtifactFrom(optDir, acct, ver, app, art.SHA256, func() (io.ReadCloser, error) {
2385 rc, _, err := OpenQualified(ctx, s, ArtifactKey(acct, app, ver, art.File))
2386 return rc, err
2387 })
2388 if err != nil {
2389 return nil, err
2390 }
2391 if chk.verified != nil {
2392 chk.verified[path] = art.SHA256
2393 }
2394 // The sibling is the CANONICAL bytes (spec §4.2), and a restage replaces
2395 // whatever sibling the stale copy had. Best-effort: a failure here is not
2396 // fatal to staging the binary, and backfillManifest gets another chance
2397 // at it on every later pass.
2398 if err := StageManifest(optDir, acct, ver, app, raw); err != nil {
2399 fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
2400 }
2401 return m, nil
2402}
2403
2404// ensureStagedDigest is adoptHost's staging entry: ensureStagedChecked bound
2405// to the digest the authorizing pointer entry signs for ver (#253, spec
2406// §4.6). want "" is ensureStagedChecked exactly: a pointer from before
2407// digests, or one written with the marker off.
2408//
2409// With a digest, the canonical manifest must hash to want BEFORE anything is
2410// downloaded or restaged, and the manifest that passed is the one staging
2411// uses (stageFrom): it is never read a second time, so a swap between the
2412// check and the download cannot slip through. Everything after the check is
2413// zdd-4's reuse rule (spec §4.2), judged against that checked manifest: a
2414// staged copy whose bytes match it is reused, one that differs is restaged
2415// only when nothing on the box refers to it and refused otherwise. Unlike the
2416// no-digest path, a manifest that cannot be read or does not hash to want is
2417// a refusal even when a copy is staged: that copy cannot be shown to be the
2418// build the pointer signed.
2419func ensureStagedDigest(ctx context.Context, s Store, optDir, acct, app, ver, host, want string, chk stageCheck) (*Manifest, error) {
2420 if want == "" {
2421 return ensureStagedChecked(ctx, s, optDir, acct, app, ver, host, chk)
2422 }
2423 m, raw, err := CheckManifestDigest(ctx, s, acct, app, ver, want)
2424 if err != nil {
2425 return nil, fmt.Errorf("manifest for %s/%s: %w", app, ver, err)
2426 }
2427 _, statErr := os.Stat(VersionPath(optDir, acct, ver, app))
2428 return stageFrom(ctx, s, optDir, acct, app, ver, host, chk, statErr == nil, m, raw, true)
2429}
2430
2431// syncSibling makes the sibling beside a digest-bound copy exactly raw, the
2432// manifest bytes that passed the pointer's digest (spec §4.2, §4.6): left
2433// alone when it already is, written otherwise, including over a sibling that
2434// reads but is some other manifest's bytes (an older binary's re-marshal, or
2435// a release since replaced under the name). Best-effort, like repairSibling.
2436func syncSibling(optDir, acct, app, ver, host string, raw []byte, checks ...stageCheck) {
2437 if cur, err := safefile.ReadFile(StagedManifestPath(optDir, acct, ver, app)); err == nil && bytes.Equal(cur, raw) {
2438 return
2439 }
2440 if err := stageGenerationSibling(optDir, acct, app, ver, raw, checks); err != nil {
2441 fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
2442 }
2443}
2444
2445// repairSibling writes the canonical bytes beside a reused binary whose
2446// sibling is absent or does not read: the back-fill for versions staged
2447// before the sibling existed, and the repair for one a different binary
2448// wrote. A sibling that reads is left alone, as before. Best-effort.
2449func repairSibling(optDir, acct, app, ver, host string, raw []byte, checks ...stageCheck) {
2450 if sib, err := ReadStagedManifest(optDir, acct, ver, app); err == nil && sib != nil {
2451 return
2452 }
2453 if err := stageGenerationSibling(optDir, acct, app, ver, raw, checks); err != nil {
2454 fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
2455 }
2456}
2457
2458// backfillManifest is adoptHost's per-pass repair for a LIVE version's
2459// staged sibling (2026-08-03 review, C1). ensureStaged's own back-fill only
2460// fires from the call inside adoptHost that sits behind the cur == ver
2461// short-circuit — so a version already live, once adopted, never calls
2462// ensureStaged again on its own account, and a sibling write that failed
2463// once would stay missing forever for exactly the version actually running.
2464// This gives every pass another shot at it: os.Stat first (the steady-state
2465// cost — one stat when the sibling is already there), fetch only when
2466// absent, and never return an error — an absent or unreadable sibling is
2467// the safe leave-alone state (ReadStagedManifest's nil-nil contract; the
2468// converge pass treats it as "unknown", never as "no capabilities"), so a
2469// repeated failure here is only ever worth a log line, host-prefixed like
2470// every other line adoptHost prints.
2471func backfillManifest(ctx context.Context, s Store, optDir, acct, app, ver, host string, checks ...stageCheck) {
2472 if _, err := os.Stat(StagedManifestPath(optDir, acct, ver, app)); err == nil {
2473 return
2474 }
2475 _, raw, err := GetManifestRaw(ctx, s, acct, app, ver)
2476 if err != nil {
2477 fmt.Printf(" %s: back-fill manifest for %s: %v\n", host, ver, err)
2478 return
2479 }
2480 if err := stageGenerationSibling(optDir, acct, app, ver, raw, checks); err != nil {
2481 fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
2482 }
2483}
2484
2485// backfillManifestDigest is backfillManifest bound to the digest the
2486// authorizing pointer signs for ver (#253, spec §4.6). want "" is
2487// backfillManifest exactly. With one, the bucket manifest must hash to it
2488// (CheckManifestDigest) and the checked canonical bytes are what is written;
2489// a mismatch is a log line and no sibling, the same leave-alone state as any
2490// other failure here.
2491func backfillManifestDigest(ctx context.Context, s Store, optDir, acct, app, ver, host, want string, checks ...stageCheck) {
2492 if want == "" {
2493 backfillManifest(ctx, s, optDir, acct, app, ver, host, checks...)
2494 return
2495 }
2496 if _, err := os.Stat(StagedManifestPath(optDir, acct, ver, app)); err == nil {
2497 return
2498 }
2499 _, raw, err := CheckManifestDigest(ctx, s, acct, app, ver, want)
2500 if err != nil {
2501 fmt.Printf(" %s: back-fill manifest for %s: %v\n", host, ver, err)
2502 return
2503 }
2504 if err := stageGenerationSibling(optDir, acct, app, ver, raw, checks); err != nil {
2505 fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
2506 }
2507}
2508
2509// settingsTarget runs Target for one exec instance (settings spec §4.3) and
2510// does what the decision needs before staging: on root's pass, write the
2511// consumed file (a resolution BEFORE staging); refuse what installs nothing.
2512// It returns the version to install, the decision, and stop: nothing is
2513// installed this pass. Every stop takes the host off the proof waiting list,
2514// as each of adoptHost's pre-admit returns does (plan 3 ruling 5).
2515//
2516// A route with default settings and no consumed file is today's adoption:
2517// the head, with bake, on the pointer's evidence, and no book, no Target and
2518// no consumed write. Only an unreadable failure book on a managed route
2519// changes that (the zero-downtime plans' refuseManaged posture). A consumed
2520// file another tenant of the hostname left (Consumed.ForeignTo) is read as
2521// none, and root's write replaces it (ruling R26).
2522func settingsTarget(ctx context.Context, optDir string, bake *FirstSeen, rt registry.Route, ptr *ChannelPointer, cur string, now time.Time,
2523 cfg passConfig, holds map[string]time.Duration, ignore bool) (string, TargetDecision, bool) {
2524 if cfg.rootUpdates != nil {
2525 return cfg.rootUpdates.target(ctx, rt, ptr, bake, now, cfg, holds, ignore)
2526 }
2527 stop := func(dec TargetDecision) (string, TargetDecision, bool) {
2528 cfg.proof.clear(rt.Host)
2529 return "", dec, true
2530 }
2531 acct, channel := RouteAccount(rt), routeChannel(rt)
2532 u, uerr := ParseUpdateSettingsLenient(rt.UpdateSettings)
2533 plain := uerr == nil && u.Default()
2534 managed := cfg.managed != nil && cfg.managed(rt)
2535 consumed, cerr := ReadConsumed(optDir, rt.Host)
2536 // A file root wrote for another tenant of this hostname is no record of
2537 // this route's (ruling R26): every rule below decides as if there were
2538 // none, and NextConsumed starts the route's record empty. A default route
2539 // still takes the Target path over one, so root's write replaces the old
2540 // tenant's file with this route's own (the decision is today's adoption
2541 // either way). An untagged file is read as the route's own, as before.
2542 foreign := consumed.ForeignTo(rt)
2543 if foreign {
2544 consumed = nil
2545 }
2546 if plain && consumed == nil && !foreign && (cerr == nil || !controlname.ValidHostShape(rt.Host)) {
2547 if ptr == nil {
2548 return "", TargetDecision{}, true // today's quiet skip
2549 }
2550 if managed && cfg.failedErr != nil {
2551 return stop(TargetDecision{})
2552 }
2553 return ptr.Version, TargetDecision{Version: ptr.Version, Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true, Detail: ptr.Version}, false
2554 }
2555 if cerr != nil {
2556 fmt.Printf(" %s: consumed file unreadable (%v): nothing installed\n", rt.Host, cerr)
2557 return stop(TargetDecision{})
2558 }
2559 // A column that does not parse even leniently is held where it is, never
2560 // read as auto (ruling R-SETTINGS-UNREADABLE): it may carry a hold this
2561 // binary cannot see. Root records reason unknown, so the wake check holds
2562 // too; nothing is installed and the live link stays.
2563 if uerr != nil {
2564 dec := TargetDecision{Reason: ReasonUnknown, Detail: uerr.Error()}
2565 if cfg.resolve {
2566 fmt.Printf(" %s: update settings unreadable (%v): held, nothing installed\n", rt.Host, uerr)
2567 in := TargetInput{Route: rt, Pointer: ptr, Consumed: consumed, Installed: cur}
2568 if err := publishConsumed(ctx, optDir, cfg.route, NextConsumed(in, dec)); err != nil {
2569 fmt.Printf(" %s: recording the update decision: %v\n", rt.Host, err)
2570 }
2571 }
2572 return stop(dec)
2573 }
2574 if cfg.failedErr != nil && (!plain || managed) {
2575 return stop(TargetDecision{}) // PassFilter said why, once
2576 }
2577 bs, berr := ParseBuildStateCopy(rt.BuildState)
2578 if berr != nil {
2579 if cfg.resolve {
2580 fmt.Printf(" %s: %v: read as unknown\n", rt.Host, berr)
2581 }
2582 bs = BuildStateCopy{}
2583 }
2584 in := TargetInput{Route: rt, Pointer: ptr, Settings: u, Build: bs, Consumed: consumed, Installed: cur}
2585 if ptr != nil {
2586 in.HeadHeld = Hold(bake, acct, rt.App, channel, ptr.Version, holds, ignore || ptr.FastTrack, now)
2587 in.HeadTag = cfg.tags[acct+"/"+rt.App][ptr.Version]
2588 }
2589 // The failure records outrank every rule for a route with settings
2590 // (§4.3). A route with default settings keeps today's adoption, which
2591 // never read the book.
2592 if !plain && cfg.failed != nil {
2593 in.Failed = func(v string) bool { return cfg.failed(acct, rt.App, v, rt.Host, v == rt.GoodVersion) }
2594 }
2595 dec := Target(in)
2596 if cfg.resolve {
2597 // Under the carry fence (ruling R-CARRY-FENCE): a route a move has
2598 // held since this pass listed it gets no record, and nothing is
2599 // installed for it on this pass.
2600 if err := publishConsumed(ctx, optDir, cfg.route, NextConsumed(in, dec)); err != nil {
2601 fmt.Printf(" %s: recording the update decision: %v; nothing installed\n", rt.Host, err)
2602 return stop(dec)
2603 }
2604 } else if dec.Resolve != nil && dec.Reason == ReasonRequest {
2605 // Only root resolves (§4.3 rule 2), and only root's write makes T
2606 // the request's: the next root pass does both. A decision that
2607 // carries a Resolve for another reason (a pin, a hold, a failure:
2608 // R-TARGET-FAILEDHEAD) does not depend on it, so it goes ahead.
2609 // The stop asks root for a pass through the stage relay (ruling
2610 // R-AGENT-RELAY), the way a stage this process cannot write does, so
2611 // a cold wake waits on root's resolution rather than an hour.
2612 if cfg.stageBlocked != nil && !cfg.hostWrites.Enabled {
2613 cfg.stageBlocked(rt.Host, dec.Version, ErrInstallRequestNeedsRoot)
2614 }
2615 return stop(dec)
2616 }
2617 if dec.Version == "" {
2618 if cfg.resolve && dec.Reason != "" {
2619 fmt.Printf(" %s: %s%s: nothing installed\n", rt.Host, dec.Reason, orDetail(dec.Detail))
2620 }
2621 return stop(dec)
2622 }
2623 return dec.Version, dec, false
2624}
2625
2626// orDetail renders a decision's detail after its reason, or nothing.
2627func orDetail(d string) string {
2628 if d == "" {
2629 return ""
2630 }
2631 return " (" + d + ")"
2632}
2633
2634// stageAuthorized stages a build settings spec §4.1 authorizes by its
2635// promotion record: the record's digest binds the manifest before anything
2636// is downloaded or replaced (ensureStagedDigest). This box's own staged copy
2637// is evidence on its own (§4.1's retained identity; Astra round 1 finding
2638// 7): when its bytes still hash to the artifact digest in the copy's
2639// sibling, the manifest it was verified against when it was staged, it is
2640// recovered whatever the remote side says: no record (promoted before
2641// records existed), a record or manifest that cannot be read now, or a
2642// staging the record path refused. Without such a copy the build is
2643// refused, and nothing is downloaded on anything weaker than the record's
2644// digest. A failure to read the record is never read as "no record".
2645// Nothing is backfilled.
2646func stageAuthorized(ctx context.Context, s Store, pub ed25519.PublicKey, optDir, acct, app, ver, host string, chk stageCheck) (*Manifest, error) {
2647 rec, err := ReadPromotionRecord(ctx, s, pub, acct, app, ver)
2648 var refused error
2649 switch {
2650 case err == nil && rec.ManifestDigest == "":
2651 refused = fmt.Errorf("%s: promotion record carries no manifest digest", ver)
2652 case err == nil:
2653 m, serr := ensureStagedDigest(ctx, s, optDir, acct, app, ver, host, rec.ManifestDigest, chk)
2654 if serr == nil {
2655 return m, nil
2656 }
2657 refused = serr
2658 case errors.Is(err, ErrNoPromotionRecord):
2659 refused = fmt.Errorf("%s: %s", ver, updatewords.Text("settings.evidence.no-record"))
2660 default:
2661 refused = fmt.Errorf("%s: promotion record: %w", ver, err)
2662 }
2663 if id, m := RetainedStaged(optDir, acct, app, ver); id != nil {
2664 fmt.Printf(" %s: %s recovered from this box's verified staged copy (%v)\n", host, ver, refused)
2665 return m, nil
2666 }
2667 return nil, refused
2668}
2669