carlos / platform Public

merge: canary TTL — expiry is visibility, never a gate (spec §5)

Every canary/* pointer carries an implicit 7-day lease measured from
promoted_at; re-promoting refreshes it, so actively-updated canaries
never expire. Expiry is computed at read time — no new storage, no
expired bit — and surfaces as a console badge with age, a loud-null
count on the fleet status feed, and one nag line per expired route per
HOURLY adopt pass (the fast canary loop is forbidden from nagging, or a
stationary expired canary floods the journal every 2-3s).

Nothing stops serving, adopting or promoting: that contract is pinned at
all three surfaces, including the console promote handler, where a
mutation-tested gate previously failed zero tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell 3c7c7bc3f6d6c57aaa9b2caa4b2c220c11c228af parent 079b085parent 081d50f
18 files changed, +1266 −19
  • internal/console/icons.go +9 −0
  • internal/console/ladder.go +57 −1
  • internal/console/ladder_test.go +179 −0
  • internal/console/templates/ladder.html +1 −0
  • internal/console/templates/layout.html +4 −0
  • internal/console/view.go +1 −1
  • internal/host/agent.go +88 −1
  • internal/host/host_test.go +208 −0
  • internal/release/adopt.go +73 −6
  • internal/release/adopt_test.go +252 −0
  • internal/release/canary_ttl.go +85 −0
  • internal/release/canary_ttl_test.go +116 −0
  • internal/status/assets/index.html +18 −0
  • internal/status/contract_test.go +7 −3
  • internal/status/dryrun.go +9 −7
  • internal/status/fleet.go +36 −0
  • internal/status/snapshot.go +16 −0
  • internal/status/status_test.go +107 −0
diff --git a/internal/console/icons.go b/internal/console/icons.go
index 6e5a22618..b16ac55cb 100644
--- a/internal/console/icons.go
+++ b/internal/console/icons.go
@@ -84,6 +84,14 @@ const (
`<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/>` +
`<path d="m6.08 9.5-3.5 1.6a1 1 0 0 0 0 1.81l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83l-3.5-1.59"/>` +
`<path d="m6.08 14.5-3.5 1.6a1 1 0 0 0 0 1.81l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83l-3.5-1.59"/></svg>`
+
+ // A canary/* route whose lease has run out (spec §5 "Canaries expire") —
+ // distinct from iconTriangleAlert's prod-wired warning, because expiry is
+ // a clock running out, not a hazard (Lucide: hourglass).
+ iconHourglass template.HTML = iconOpen +
+ `<path d="M5 22h14"/><path d="M5 2h14"/>` +
+ `<path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/>` +
+ `<path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`
)
// icons maps each Lucide slug to its vendored markup. Slugs are the Lucide
@@ -96,6 +104,7 @@ var icons = map[string]template.HTML{
"flask-conical": iconFlask,
"triangle-alert": iconTriangleAlert,
"layers": iconLayers,
+ "hourglass": iconHourglass,
}
// icon renders one chrome icon by its Lucide slug: {{icon "chevron-down"}}.
diff --git a/internal/console/ladder.go b/internal/console/ladder.go
index 2d7e290a9..ceadd0ab7 100644
--- a/internal/console/ladder.go
+++ b/internal/console/ladder.go
@@ -19,6 +19,11 @@ package console
// and prodWiredCanaryHosts below answers "production" from that AND from
// which host is the app's apex: the environment says what glue a canary is
// wired to, the apex says whether it is in front of everybody.
+//
+// canaryExpiry answers a different question (spec §5 "Canaries expire"):
+// not whether a canary is dangerous, but whether it has been sitting there
+// so long it stopped being a canary and became infrastructure nobody
+// remembers deploying. Visibility only — an expired canary keeps serving.
import (
"sort"
@@ -73,6 +78,15 @@ type LadderRoute struct {
// environment as one of the app's stable routes, OR sitting on the app's
// apex host. See prodWiredCanaryHosts for why it is both.
ProdWired bool
+ // Expired is whether this route's canary/* channel has outlived
+ // release.CanaryTTL, measured from the pointer's own PromotedAt (spec
+ // §5 "Canaries expire"). Always false for a non-canary route. Visibility
+ // only: an expired canary keeps serving, keeps adopting and stays
+ // promotable — this badges it, nothing more.
+ Expired bool
+ // ExpiredFor is how long past the TTL the channel is, formatted for a
+ // human ("9d", "6h") — valid only when Expired is true.
+ ExpiredFor string
}
// LadderStep is the move from one rung to the one above it.
@@ -107,16 +121,20 @@ var gates = map[string]string{
// lists it. A host following a rung channel that has no pointer DOES appear,
// as an error line: a host serving nothing is exactly the kind of thing this
// section exists to stop being invisible.
-func assembleLadder(channels []ChannelView, routes []RouteView) []LadderRung {
+func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) []LadderRung {
prod := prodWiredCanaryHosts(routes)
+ expiredFor := canaryExpiry(channels, now)
byChannel := map[string][]LadderRoute{}
for _, rt := range routes {
rung, ok := release.Rung(rt.Channel)
+ age := expiredFor[rt.Channel]
byChannel[rt.Channel] = append(byChannel[rt.Channel], LadderRoute{
Host: rt.Host,
Environment: rt.Environment,
Canary: ok && rung == release.RungCanary,
ProdWired: prod[rt.Host],
+ Expired: age != "",
+ ExpiredFor: age,
})
}
entries := map[string][]LadderChannel{}
@@ -250,3 +268,41 @@ func prodWiredCanaryHosts(routes []RouteView) map[string]bool {
}
return out
}
+
+// canaryExpiry maps each canary/* channel with a readable pointer to how
+// long past release.CanaryTTL it is, formatted for a human — spec §5
+// "Canaries expire". A channel that has not (yet) expired, or whose pointer
+// carries an Err (assembleChannels degraded it to one bad row already), is
+// simply absent from the map, which is exactly what LadderRoute.Expired
+// reads as false.
+//
+// Built once per ladder assembly, keyed by channel name rather than host,
+// because expiry is a property of the CHANNEL's PromotedAt — every route
+// following the same canary shares one answer, computed once rather than
+// re-derived per host.
+//
+// Formatting goes through release.CanaryAge + release.FormatCanaryAge —
+// the SAME pair the adopt pass's nag calls (internal/release/adopt.go) —
+// rather than computing a duration here and rendering it locally. That used
+// to be two independent implementations that quietly disagreed by exactly
+// CanaryTTL (2026-08-03 review, IMPORTANT 2): sharing the formatter is what
+// makes "the nag and the badge cannot drift apart" a structural fact
+// instead of a coincidence two people have to keep re-verifying.
+func canaryExpiry(channels []ChannelView, now time.Time) map[string]string {
+ out := map[string]string{}
+ for _, cv := range channels {
+ if cv.Err != "" {
+ continue
+ }
+ // A synthetic pointer carrying only the two fields release.CanaryExpired
+ // reads: the channel's name (so it recognizes canary/*) and the
+ // promotion timestamp ChannelView already carries. No pointer re-read —
+ // expiry is computed from data the console already has in hand.
+ ptr := &release.ChannelPointer{Channel: cv.Name, PromotedAt: cv.PromotedAt}
+ if !release.CanaryExpired(ptr, now) {
+ continue
+ }
+ out[cv.Name] = release.FormatCanaryAge(release.CanaryAge(ptr, now))
+ }
+ return out
+}
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index 4742efb0d..2194c2768 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -6,7 +6,11 @@ package console
// passkey, and a skip up the ladder is refused however it is posted.
import (
+ "bytes"
"context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
"net/http"
"net/url"
"strings"
@@ -357,3 +361,178 @@ func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) {
t.Errorf("a member does not see route environment chrome:\n%s", stableBlock)
}
}
+
+// ---- Canary TTL (spec §5 "Canaries expire") --------------------------------
+
+// A canary promoted moments ago — exactly what every real promotion through
+// this console produces, Promote always stamping "now" — must carry no
+// expired badge. This is the case CLAUDE.md's own console route is in on
+// every deploy (canary/console, re-promoted each time): a false positive
+// here would badge the console's own production traffic as neglected.
+func TestLadderFreshCanaryCarriesNoExpiredBadge(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9002"})
+ seedChannel(t, fx, "kass", "canary/x", "v2") // stamps PromotedAt = now
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ block := rungBlock(t, body, "canary")
+ line := routeLine(t, block, "rc.kass.carlosapp.dev")
+
+ if strings.Contains(line, "chip-expired") {
+ t.Errorf("a freshly-promoted canary is badged expired:\n%s", line)
+ }
+ // This route binds the same (default) environment as the fixture's
+ // stable route, so it is ALSO prod-wired (TestProdWiredCanaryIsBadgedDistinctly
+ // covers that badge on its own) — chip-prod or chip-canary, either is the
+ // "this is a canary" badge the expired flag would sit beside; the point
+ // of this test is only that neither carries chip-expired.
+ if !strings.Contains(line, "chip-prod") && !strings.Contains(line, "chip-canary") {
+ t.Errorf("the fresh canary lost its canary-family badge entirely:\n%s", line)
+ }
+}
+
+// assembleLadder is exercised directly here (no store, no signing) because
+// there is no way to get a genuinely aged, verifiably-signed pointer through
+// the public Promote path — it always stamps the real clock, deliberately
+// (release.TestSignStampsPromotedAt pins that). Constructing the ChannelView
+// by hand is the same input assembleChannels would have produced from a
+// pointer that really was promoted eight days ago.
+func TestLadderExpiredCanaryIsBadgedWithAge(t *testing.T) {
+ now := time.Now()
+ old := now.Add(-(release.CanaryTTL + 8*24*time.Hour)) // 8 days past the TTL
+ channels := []ChannelView{{Name: "canary/x", Version: "v9", PromotedAt: old}}
+ // A stable route on a DIFFERENT, non-apex, non-matching-environment host
+ // keeps this canary out of BOTH prod-wired signals (prodWiredCanaryHosts
+ // is exercised on its own elsewhere) — this test is about the expired
+ // badge specifically, so it stays clear of the prod-wired one.
+ routes := []RouteView{
+ {Host: "a.example.com", Channel: "stable", Environment: "prod-env"},
+ {Host: "rc.example.com", Channel: "canary/x"},
+ }
+
+ rungs := assembleLadder(channels, routes, now)
+ var canary *LadderRung
+ for i := range rungs {
+ if rungs[i].Rung == release.RungCanary {
+ canary = &rungs[i]
+ }
+ }
+ if canary == nil || len(canary.Channels) != 1 || len(canary.Channels[0].Routes) != 1 {
+ t.Fatalf("canary rung = %+v, want one channel with one route", canary)
+ }
+ rt := canary.Channels[0].Routes[0]
+ if !rt.Expired {
+ t.Fatal("a canary promoted more than CanaryTTL ago must be marked Expired")
+ }
+ if rt.ExpiredFor != "8d" {
+ t.Errorf("ExpiredFor = %q, want \"8d\"", rt.ExpiredFor)
+ }
+ if !rt.Canary {
+ t.Error("an expired canary route must still carry the ordinary canary flag")
+ }
+
+ // Render it through the real template — proves the badge actually
+ // reaches the page, with the icon and the age, beside the plain canary
+ // badge rather than instead of it.
+ p := page{Base: "/a/bab", Role: RoleOwner, CanPromote: true,
+ Detail: &AppView{App: "demo", Ladder: rungs}}
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "ladder", p); err != nil {
+ t.Fatalf("render ladder: %v", err)
+ }
+ html := buf.String()
+ line := routeLine(t, rungBlock(t, html, "canary"), "rc.example.com")
+ if !strings.Contains(line, "chip-expired") {
+ t.Errorf("rendered route carries no chip-expired badge:\n%s", line)
+ }
+ if !strings.Contains(line, "8d") {
+ t.Errorf("rendered badge does not show the age:\n%s", line)
+ }
+ if !strings.Contains(line, "chip-canary") {
+ t.Errorf("the expired badge replaced the ordinary canary badge instead of sitting beside it:\n%s", line)
+ }
+ if !strings.Contains(line, "<svg") {
+ t.Error("the expired badge is a text glyph, not an icon")
+ }
+}
+
+// A channel that has not yet reached CanaryTTL contributes nothing to
+// canaryExpiry's map — the direct unit-level twin of the fixture-based fresh
+// test above, pinned at the function boundary rather than through HTTP.
+func TestCanaryExpiryOmitsUnexpiredChannels(t *testing.T) {
+ now := time.Now()
+ channels := []ChannelView{
+ {Name: "canary/fresh", PromotedAt: now},
+ {Name: "canary/old", PromotedAt: now.Add(-(release.CanaryTTL + time.Hour))},
+ {Name: "stable", PromotedAt: now.Add(-365 * 24 * time.Hour)}, // no TTL at all
+ {Name: "canary/broken", PromotedAt: now.Add(-365 * 24 * time.Hour), Err: "unreadable"},
+ }
+ got := canaryExpiry(channels, now)
+ if _, ok := got["canary/fresh"]; ok {
+ t.Error("a fresh canary must not appear in the expiry map")
+ }
+ if _, ok := got["stable"]; ok {
+ t.Error("stable carries no TTL and must never appear in the expiry map")
+ }
+ if _, ok := got["canary/broken"]; ok {
+ t.Error("a channel whose pointer errored must not be guessed at as expired")
+ }
+ if _, ok := got["canary/old"]; !ok {
+ t.Error("an old canary must appear in the expiry map")
+ }
+}
+
+// seedExpiredCanary hand-signs a canary pointer whose lease ran out long
+// ago — every public promotion path stamps the real clock, so forging
+// PromotedAt in the wrapper is the only way to put an EXPIRED canary in
+// front of a console under test (the same technique, for the same reason,
+// as internal/release's writeBackdatedPointer, which is deliberately not
+// exported).
+func seedExpiredCanary(t *testing.T, fx *consoleFixture, app, channel, version string) {
+ t.Helper()
+ p := &release.ChannelPointer{Schema: 1, App: app, Channel: channel,
+ Version: version, PromotedAt: time.Now().Add(-(release.CanaryTTL + 30*24*time.Hour))}
+ payload, err := json.Marshal(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ wrapper, err := json.Marshal(struct {
+ Payload string `json:"payload"`
+ Sig string `json:"sig"`
+ }{b64, base64.StdEncoding.EncodeToString(ed25519.Sign(fx.priv, []byte(b64)))})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(context.Background(),
+ release.ChannelKey(release.OpsAccount, app, channel), wrapper, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestLadderPromotesOntoAnExpiredCanary pins spec §5's visibility-only
+// contract at the one surface the release-layer pins cannot reach: the
+// console's promote handler. An expired canary is exactly where the next
+// build lands when work on that branch resumes, and a "this canary expired"
+// gate here — however well-meant a confirmation might look — would turn a
+// guard rail into an obstacle at the moment it is least wanted. (2026-08-03
+// review, IMPORTANT 1: a mutation gating handlePromote on expiry failed
+// zero tests before this one existed.)
+func TestLadderPromotesOntoAnExpiredCanary(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v8")
+ seedExpiredCanary(t, fx, "kass", "canary/old-branch", "v1")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"canary/old-branch"}, "version": {"v8"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("promotion onto an expired canary = %d, want 303:\n%s", status, body)
+ }
+ if p := fx.channelVersion(t, "kass", "canary/old-branch"); p.Version != "v8" {
+ t.Fatalf("expired canary still holds %s — expiry has become a gate", p.Version)
+ }
+}
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index 2f8da0342..72448ae20 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -25,6 +25,7 @@ touching what any host is wired to.</p>
<span class="chip chip-env">{{icon "layers"}} {{if .Environment}}{{.Environment}}{{else}}default{{end}}</span>
{{if .ProdWired}}<span class="chip chip-prod">{{icon "triangle-alert"}} canary in front of everybody</span>
{{else if .Canary}}<span class="chip chip-canary">{{icon "flask-conical"}} canary</span>{{end}}
+ {{if .Expired}}<span class="chip chip-expired">{{icon "hourglass"}} expired {{.ExpiredFor}}</span>{{end}}
</li>
{{end}}
</ul>
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 8d4185ec2..cf142d4d1 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -146,6 +146,10 @@
every user is the one thing on this page a reader must not skim past. */
.chip-canary { border: 1px solid var(--accent); color: var(--accent); }
.chip-prod { background: var(--bad); color: var(--bg); }
+ /* An expired canary (spec §5) keeps serving — this is "trust this less",
+ the same bordered-only warning as .chip-stale, never a filled outage
+ marker like .chip-prod. */
+ .chip-expired { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
/* A route's bound environment — a fact, not a warning, so it is bordered
like .chip-canary but in a neutral tone: it sits on every route, not
just the ones worth a second look. */
diff --git a/internal/console/view.go b/internal/console/view.go
index 4da58704c..9b61c1ca2 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -241,7 +241,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
Domains: domainsByApp[app],
Channels: channels,
Canaries: assembleCanaries(channels, byApp[app]),
- Ladder: assembleLadder(channels, byApp[app]),
+ Ladder: assembleLadder(channels, byApp[app], now),
RouteAccount: mismatch,
})
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 9e42508c9..7edbdcc9d 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -64,6 +64,7 @@ package host
import (
"context"
+ "crypto/ed25519"
"errors"
"flag"
"io"
@@ -205,6 +206,13 @@ func adoptLoop(reg *registry.Registry) {
for {
time.Sleep(delay)
delay = time.Hour + rand.N(10*time.Minute)
+ // DO NOT add release.NagExpiredCanaries(...) to this call. This loop
+ // runs hourly, but it is NOT "the hourly root adopt pass" the option's
+ // own doc comment names — that's RunAdopt (carlos-adopt.service)
+ // below, root-privileged and the one place config sync also runs. Two
+ // hourly callers both nagging would double the log line every hour;
+ // worse, if this loop's cadence ever changes, a nag wired here changes
+ // frequency with it silently. One caller, one nag.
if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
@@ -283,6 +291,12 @@ func canaryAdoptLoop(reg *registry.Registry) {
log.Printf("agent: canary adopt loop armed (2s + jitter, canary/* routes only)")
for {
time.Sleep(2*time.Second + rand.N(time.Second))
+ // DO NOT add release.NagExpiredCanaries(...) to this call, ever. This
+ // is THE loop release.NagExpiredCanaries' doc comment warns about by
+ // name: every 2-3 seconds, canary/* only. An expired canary sitting
+ // still would nag on every single tick — a journal-flooding poll storm
+ // out of what is supposed to be an hourly, easy-to-skim log line. The
+ // nag belongs to RunAdopt (carlos-adopt.service) alone.
if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute); err != nil {
log.Printf("agent: canary adopt pass: %v", err)
}
@@ -298,6 +312,54 @@ func isCanaryRoute(rt registry.Route) bool {
return strings.HasPrefix(rt.Channel, "canary/")
}
+// countExpiredCanaries counts routes (this box's own, platform aliases
+// excluded — same convention as CollectBox) on a canary/* channel whose
+// pointer has passed release.CanaryTTL, for the fleet status page's §5
+// count (internal/status Probes.ExpiredCanaries). A canary route whose
+// pointer can't be resolved at all — store unavailable, pubkey unusable, or
+// the specific channel won't verify — nils the WHOLE count rather than
+// undercounting: "we couldn't check" and "there are none" are different
+// claims, and this function only ever makes the second one when it's sure.
+// No canary routes at all is a confident, known 0 — it needs no store to
+// answer "none" about routes that don't exist.
+//
+// Pointers are resolved at most once per distinct (account, app, channel)
+// among the routes given (the local `cache` map below), mirroring adopt.go's
+// Pass — this function is called once per 5-minute status tick
+// (statusLoop), so N routes on this box following the same canary channel
+// cost exactly ONE release.Channel() store read for that tick, not N
+// (2026-08-03 review: confirmed and pinned by
+// TestCountExpiredCanariesReadsEachChannelOnce). The cache is local to one
+// call/one tick — nothing persists across ticks, which is fine at a
+// 5-minute cadence.
+func countExpiredCanaries(ctx context.Context, routes []registry.Route, store release.Store, pub ed25519.PublicKey, now time.Time) *int {
+ type key struct{ acct, app, channel string }
+ cache := map[key]*release.ChannelPointer{}
+ n := 0
+ for _, rt := range routes {
+ if rt.Platform || !isCanaryRoute(rt) {
+ continue
+ }
+ if store == nil || len(pub) != ed25519.PublicKeySize {
+ return nil // a canary route exists but nothing here can resolve its pointer
+ }
+ k := key{release.RouteAccount(rt), rt.App, rt.Channel}
+ ptr, cached := cache[k]
+ if !cached {
+ p, err := release.Channel(ctx, store, pub, k.acct, k.app, k.channel)
+ if err != nil {
+ return nil // this route's own pointer won't verify — unknown, not zero
+ }
+ ptr = p
+ cache[k] = ptr
+ }
+ if release.CanaryExpired(ptr, now) {
+ n++
+ }
+ }
+ return &n
+}
+
// peekAcmeCache pre-parses the agent args for -acme-cache the same way
// peekRegistryPath peeks -registry: the edge does the authoritative parse,
// this just lets the domains loop stamp cert expiry from the SAME cache dir
@@ -519,6 +581,21 @@ func statusLoop(reg *registry.Registry) {
// place an operator can confirm which bucket actually won.
log.Printf("agent: status loop armed (5min + jitter), publishing to bucket %s", bucket)
+ // canaryStore/canaryPub resolve the release deployment config
+ // countExpiredCanaries needs to answer §5's "how many canaries on this
+ // box have expired" — the SAME env pair adoptLoop reads
+ // (CARLOS_DEPLOYMENT_BUCKET/_DIR + CARLOS_RELEASE_PUBKEY), not
+ // necessarily the status bucket above, which may deliberately be a
+ // separate one. Either can come back unusable: no deployment store
+ // configured, or no pubkey set. That is a normal, valid box shape (the
+ // same "routes managed by hand" case adoptLoop's own doc comment
+ // names) — it just means countExpiredCanaries can't answer and reports
+ // nil, same as every other unmeasured probe in this loop. Resolved once
+ // here, not re-read every tick, mirroring adoptLoop's own one-time
+ // resolution.
+ canaryStore, _ := openDeployStore(ctx)
+ canaryPub, _ := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+
// Jittered on the same shape as adoptLoop's delay: every box on the same
// 5-minute cadence would all hit the lease and the bucket in the same
// second otherwise.
@@ -551,6 +628,9 @@ func statusLoop(reg *registry.Registry) {
// flagship's silent no-adopt state, which nothing on any page
// could see before this.
AdoptRunning: adoptTimerActive(),
+ // ExpiredCanaries answers spec §5 for this box's own routes; see
+ // countExpiredCanaries and the canaryStore/canaryPub comment above.
+ ExpiredCanaries: countExpiredCanaries(ctx, routes, canaryStore, canaryPub, time.Now()),
// LagSecs stays nil: litestream's per-DB replication lag isn't
// surfaced up to the agent process anywhere yet — the
// replication machinery (internal/lease's Replicator) lives
@@ -691,7 +771,14 @@ func RunAdopt(args []string) {
}
}
- if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute); err != nil {
+ // NagExpiredCanaries(os.Stdout) is set HERE ONLY (2026-08-03 review) — the
+ // canary-TTL nag (spec §5) belongs to the hourly root pass
+ // (carlos-adopt.service, this verb's ExecStart), the same split PR #9's
+ // config sync above uses. adoptLoop and canaryAdoptLoop in this same
+ // package call release.Pass/PassFilter with no options at all, so an
+ // expired canary nags once an hour from here, never every 2-3 seconds
+ // from the in-process fast loop.
+ if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout)); err != nil {
log.Fatalf("adopt: %v", err)
}
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 4312df011..b2fb565f0 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -2,6 +2,9 @@ package host
import (
"context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
"net"
"os"
"path/filepath"
@@ -485,3 +488,208 @@ func TestAdoptTimerStateReading(t *testing.T) {
}
func boolPtr(v bool) *bool { return &v }
+
+// ---- Pipelines: canary TTL, the status loop's expired-canary count --------
+
+// wireWrapper mirrors release's unexported signedWrapper wire shape
+// (payload/sig) — duplicated here rather than imported (it isn't exported,
+// deliberately: forging a pointer's PromotedAt is not something production
+// code anywhere should be able to do) so this test can write a validly
+// SIGNED but arbitrarily-dated channel pointer straight into a DirStore,
+// the only way to get an actually-expired pointer under test since every
+// public promotion path (release.Promote) stamps the real clock.
+type wireWrapper struct {
+ Payload string `json:"payload"`
+ Sig string `json:"sig"`
+}
+
+// writeBackdatedPointer signs and writes a channel pointer with an
+// arbitrary PromotedAt directly to key release.ChannelKey(acct, app,
+// channel), bypassing Promote's clock stamp.
+func writeBackdatedPointer(t *testing.T, s release.Store, priv ed25519.PrivateKey, acct, app, channel, version string, promotedAt time.Time) {
+ t.Helper()
+ p := struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ }{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: promotedAt}
+ payload, err := json.Marshal(p)
+ if err != nil {
+ t.Fatalf("marshal backdated pointer: %v", err)
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ sig := ed25519.Sign(priv, []byte(b64))
+ wrapper, err := json.Marshal(wireWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)})
+ if err != nil {
+ t.Fatalf("marshal backdated wrapper: %v", err)
+ }
+ if _, err := s.Put(context.Background(), release.ChannelKey(acct, app, channel), wrapper, ""); err != nil {
+ t.Fatalf("write backdated pointer: %v", err)
+ }
+}
+
+func testCanaryKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
+ t.Helper()
+ seedB64, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+ priv, err := release.ParseSigningKey(seedB64, "test")
+ if err != nil {
+ t.Fatalf("ParseSigningKey: %v", err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePublicKey: %v", err)
+ }
+ return priv, pub
+}
+
+// No canary routes at all needs no store to answer "none" — a confident,
+// known 0, not the unknown/nil path a missing store would otherwise imply.
+func TestCountExpiredCanariesNoCanaryRoutesIsZero(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "a.example.com", Kind: "instance", App: "myapp", Channel: "stable"},
+ {Host: "b.example.com", Kind: "static"},
+ }
+ got := countExpiredCanaries(context.Background(), routes, nil, nil, time.Now())
+ if got == nil || *got != 0 {
+ t.Errorf("count = %v, want 0 (no canary routes, no store needed)", got)
+ }
+}
+
+// A canary route exists but there is no deployment store to resolve its
+// pointer against — the "routes managed by hand" box shape adoptLoop's own
+// doc comment names. That must publish unknown, never a guessed 0.
+func TestCountExpiredCanariesNilStoreIsUnknown(t *testing.T) {
+ routes := []registry.Route{{Host: "rc.example.com", Kind: "instance", App: "myapp", Channel: "canary/x"}}
+ if got := countExpiredCanaries(context.Background(), routes, nil, nil, time.Now()); got != nil {
+ t.Errorf("count = %v, want nil (no store to resolve the pointer against)", *got)
+ }
+}
+
+// A canary route whose channel was never actually promoted (no pointer at
+// that key) is the same "can't answer" case as no store at all — release.
+// Channel returns os.ErrNotExist, and that must null the count too, not
+// silently skip the route and undercount.
+func TestCountExpiredCanariesUnresolvablePointerIsUnknown(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ _, pub := testCanaryKeys(t)
+ routes := []registry.Route{{Host: "rc.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Account: "bab"}}
+ if got := countExpiredCanaries(context.Background(), routes, s, pub, time.Now()); got != nil {
+ t.Errorf("count = %v, want nil (channel was never promoted)", *got)
+ }
+}
+
+// A fresh canary (real Promote, real clock) counts as 0 — not expired, and
+// known.
+func TestCountExpiredCanariesFreshCanaryIsZero(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testCanaryKeys(t)
+ shipVersion(t, s, "bab", "myapp", "v1")
+ if err := release.Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ routes := []registry.Route{{Host: "rc.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Account: "bab"}}
+ got := countExpiredCanaries(ctx, routes, s, pub, time.Now())
+ if got == nil || *got != 0 {
+ t.Errorf("count = %v, want 0 (freshly promoted)", got)
+ }
+}
+
+// The core case: a backdated (expired) canary pointer counts as 1; a second,
+// unexpired canary route on a different channel does not add to it.
+func TestCountExpiredCanariesCountsExpiredOnly(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testCanaryKeys(t)
+ writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/old", "v1",
+ time.Now().Add(-(release.CanaryTTL + 24*time.Hour)))
+ shipVersion(t, s, "bab", "myapp", "v2")
+ if err := release.Promote(ctx, s, priv, "bab", "myapp", "canary/fresh", "v2"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ routes := []registry.Route{
+ {Host: "old.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
+ {Host: "fresh.example.com", Kind: "instance", App: "myapp", Channel: "canary/fresh", Account: "bab"},
+ }
+ got := countExpiredCanaries(ctx, routes, s, pub, time.Now())
+ if got == nil || *got != 1 {
+ t.Errorf("count = %v, want 1 (only the old channel's route is expired)", got)
+ }
+}
+
+// A platform-minted alias is a second NAME for a route already counted, not
+// a second canary route — same convention CollectBox uses to avoid
+// double-counting aliases (host_test.go's own
+// TestCollectBoxSkipsPlatformMintedRoutes in internal/status covers that
+// half; this pins the same rule for the expired-canary count).
+func TestCountExpiredCanariesSkipsPlatformAliases(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ priv, pub := testCanaryKeys(t)
+ writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/old", "v1",
+ time.Now().Add(-(release.CanaryTTL + 24*time.Hour)))
+ routes := []registry.Route{
+ {Host: "alias.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab", Platform: true},
+ }
+ got := countExpiredCanaries(context.Background(), routes, s, pub, time.Now())
+ if got == nil || *got != 0 {
+ t.Errorf("count = %v, want 0 — a platform alias must not be counted", got)
+ }
+}
+
+// getCountingStore wraps a real release.Store and counts Get calls — used to
+// prove countExpiredCanaries' per-call cache actually dedupes store reads,
+// not just channel-pointer resolutions in memory.
+type getCountingStore struct {
+ release.Store
+ gets int
+}
+
+func (s *getCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ s.gets++
+ return s.Store.Get(ctx, key)
+}
+
+// TestCountExpiredCanariesReadsEachChannelOnce pins the caching half of the
+// 2026-08-03 review: statusLoop calls countExpiredCanaries once per
+// 5-minute tick, and this function must resolve a given canary channel's
+// pointer AT MOST ONCE per call however many of this box's routes follow
+// it — N routes sharing one channel costs one store.Get, not N.
+func TestCountExpiredCanariesReadsEachChannelOnce(t *testing.T) {
+ real := release.NewDirStore(t.TempDir())
+ priv, pub := testCanaryKeys(t)
+ writeBackdatedPointer(t, real, priv, "bab", "myapp", "canary/old", "v1",
+ time.Now().Add(-(release.CanaryTTL + 24*time.Hour)))
+ counting := &getCountingStore{Store: real}
+
+ routes := []registry.Route{
+ {Host: "a.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
+ {Host: "b.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
+ {Host: "c.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
+ }
+ got := countExpiredCanaries(context.Background(), routes, counting, pub, time.Now())
+ if got == nil || *got != 3 {
+ t.Fatalf("count = %v, want 3 (all three routes share the one expired channel)", got)
+ }
+ if counting.gets != 1 {
+ t.Errorf("store.Get called %d times for 3 routes on ONE channel, want exactly 1 — the per-tick cache must dedupe by (account, app, channel)", counting.gets)
+ }
+}
+
+// shipVersion ships a minimal one-artifact release so Promote has something
+// real to point at — release.Promote refuses to point a channel at a
+// version nothing shipped.
+func shipVersion(t *testing.T, s release.Store, acct, app, version string) {
+ t.Helper()
+ m := release.Manifest{
+ Schema: 1, App: app, Version: version, Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}},
+ }
+ if err := release.Ship(context.Background(), s, acct, m, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatalf("Ship %s/%s: %v", app, version, err)
+ }
+}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index dfe39e1c9..5f6ef1228 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -9,6 +9,7 @@ import (
"crypto/ed25519"
"errors"
"fmt"
+ "io"
"os"
"runtime"
"time"
@@ -20,6 +21,43 @@ import (
// tests can exercise resolution without faking runtime.GOOS/GOARCH.
var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH
+// PassOption configures one call to Pass or PassFilter beyond their required
+// arguments. Currently the only option is NagExpiredCanaries; the type
+// exists so a call site opts in explicitly and a future option composes
+// the same way, rather than every option becoming its own trailing
+// parameter.
+type PassOption func(*passConfig)
+
+type passConfig struct {
+ // nag is where the canary-TTL nag (spec §5) is printed; nil (the
+ // default — every call site that does not pass NagExpiredCanaries)
+ // means the nag never fires at all, not even to a discarded writer.
+ nag io.Writer
+}
+
+// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
+// line per expired canary/* route the call sees, written to w.
+//
+// DO NOT pass this to a fast/frequent adoption loop. It belongs to the
+// HOURLY ROOT adopt pass ALONE — carlos-adopt.service's RunAdopt
+// (internal/host/agent.go), the only caller that sets it (2026-08-03
+// review), and every future caller must keep it that way. The same split
+// PR #9's config sync uses, for the same reason: the in-process adoptLoop
+// (hourly + jitter, inside the sandboxed carlos-edge.service) and
+// canaryAdoptLoop (every 2-3 SECONDS, canary/* routes only — the whole
+// point of which is fast pickup of a freshly-promoted canary) must NEVER
+// set this. An expired canary sitting still on that fast loop would
+// otherwise nag every 2-3 seconds forever, turning a visibility feature
+// into a journal-flooding one; gating it to the hourly root pass keeps the
+// nag journal-friendly — once an hour, not a poll-storm of log lines. This
+// is enforced by convention (which call sites pass the option), not by the
+// type system — see agent.go's adoptLoop/canaryAdoptLoop for the loud
+// "never add this" comments at the two places it would be easiest to add
+// by accident.
+func NagExpiredCanaries(w io.Writer) PassOption {
+ return func(c *passConfig) { c.nag = w }
+}
+
// Pass is one full adoption sweep: group instance routes by (App, Channel),
// resolve each channel pointer once, verify signatures and artifact sha256s,
// download/stage missing versions, and repoint live/<host> symlinks.
@@ -38,8 +76,8 @@ var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH
// surfaces at wake, recovered by Rollback, which repoints back. Hibernating
// hosts are processed AFTER every awake host, so an awake host discovers a
// boot-broken build (and poisons it) before any sleeper is moved onto it.
-func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error) error {
- return PassFilter(ctx, reg, s, pub, optDir, restart, nil)
+func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, opts ...PassOption) error {
+ return PassFilter(ctx, reg, s, pub, optDir, restart, nil, opts...)
}
// PassFilter is Pass narrowed to the routes extra additionally accepts
@@ -52,7 +90,11 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ
// no window"), so sweeping just them every few seconds instead of hourly
// can't turn into a fleet-wide poll storm the way sweeping every route
// that fast would.
-func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, extra func(registry.Route) bool) error {
+func 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 {
+ var cfg passConfig
+ for _, opt := range opts {
+ opt(&cfg)
+ }
routes, err := reg.List()
if err != nil {
return err
@@ -127,12 +169,12 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
continue
}
ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
- adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now)
+ adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg.nag)
}
for _, rt := range hibernating {
ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
// restart=nil: hibernating hosts get the pure repoint — never woken.
- adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now)
+ adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg.nag)
}
return nil
}
@@ -182,10 +224,35 @@ func RouteAccount(rt registry.Route) string {
// pointer didn't resolve; skip quietly, the group phase already logged).
// restart==nil is the symlink-only path (hibernating hosts, or a caller with
// no proc manager): repoint and record, never wake, never health-gate.
-func adoptHost(ctx context.Context, 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) {
+// nag is passConfig.nag from the call's PassOptions — nil (the ordinary
+// case) means the canary-TTL nag below never fires, never even builds its
+// string; see NagExpiredCanaries for who sets it and why.
+func adoptHost(ctx context.Context, 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, nag io.Writer) {
if ptr == nil {
return
}
+ // The nag (spec §5 "Canaries expire"): visibility only, and deliberately
+ // BEFORE the up-to-date short-circuit below — an expired canary that
+ // isn't moving (the common case: the branch shipped once and nobody
+ // touched it since) must still be seen every pass it's part of, not just
+ // the passes that happen to change something. Nothing here blocks adopt,
+ // restart or promote; it only (optionally) prints. Gated on nag != nil
+ // rather than always printing to os.Stdout: see NagExpiredCanaries — this
+ // must stay silent on the 2-3-second canary fast loop and speak only on
+ // the hourly root adopt pass, or an expired canary sitting still would
+ // nag every few seconds forever instead of once an hour.
+ if nag != nil && CanaryExpired(ptr, now) {
+ // FormatCanaryAge(CanaryAge(...)), NOT a raw now.Sub(ptr.PromotedAt):
+ // that would print the pointer's TOTAL age (CanaryAge + CanaryTTL),
+ // overstating how long it's been expired by exactly one lease length
+ // (2026-08-03 review, IMPORTANT 2) — and disagreeing with the
+ // console's badge, which renders the same CanaryAge/FormatCanaryAge
+ // pair (internal/console/ladder.go's canaryExpiry). One formatter,
+ // shared, so the two operator-facing surfaces of this feature cannot
+ // drift apart on what a duration means.
+ fmt.Fprintf(nag, " %s: canary %s (%s) has been expired %s — still serving, nothing blocked\n",
+ rt.Host, ptr.Channel, ptr.Version, FormatCanaryAge(CanaryAge(ptr, now)))
+ }
channel := routeChannel(rt)
ver := ptr.Version
cur, err := LiveVersion(optDir, rt.Host)
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index 624c67f38..ab541f310 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -1,11 +1,17 @@
package release
import (
+ "bytes"
"context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
"errors"
"os"
"path/filepath"
+ "strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/registry"
)
@@ -370,3 +376,249 @@ func TestPassFilterOnlyTouchesRoutesExtraAccepts(t *testing.T) {
t.Errorf("stable.example.com live = %q, want unmoved — PassFilter must not touch a route extra rejects", v)
}
}
+
+// writeBackdatedPointer signs and writes a channel pointer with an arbitrary
+// PromotedAt, bypassing Promote/signPointer's own "stamp now" behavior — the
+// only way to get an EXPIRED canary pointer into a store under test, since
+// every public promotion path stamps the real clock. Kept in this internal
+// test package (never exported) precisely because forging a pointer's lease
+// is not something production code should ever be able to do.
+func writeBackdatedPointer(t *testing.T, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, promotedAt time.Time) {
+ t.Helper()
+ p := &ChannelPointer{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: promotedAt}
+ payload, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ t.Fatalf("marshal backdated pointer: %v", err)
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ sig := ed25519.Sign(priv, []byte(b64))
+ wrapper, err := json.MarshalIndent(signedWrapper{
+ Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig),
+ }, "", " ")
+ if err != nil {
+ t.Fatalf("marshal backdated wrapper: %v", err)
+ }
+ if _, err := s.Put(context.Background(), ChannelKey(acct, app, channel), wrapper, ""); err != nil {
+ t.Fatalf("write backdated pointer: %v", err)
+ }
+}
+
+// TestPassAdoptsAnExpiredCanary pins spec §5's core promise: expiry is
+// VISIBILITY, not enforcement. A canary/* route whose pointer is well past
+// CanaryTTL must still adopt exactly like a fresh one — nothing about
+// expiry may block a repoint, a restart, or a rollback/promote (the same
+// signed-pointer plumbing every other channel uses; expiry checks nothing
+// about the ladder or the CAS path).
+func TestPassAdoptsAnExpiredCanary(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/x", "v1",
+ time.Now().Add(-(CanaryTTL + 30*24*time.Hour))) // long expired
+ addRoute(t, reg, "old-canary.example.com", "myapp", "canary/x", false)
+
+ var restarted []string
+ restart := func(host string) error { restarted = append(restarted, host); return nil }
+ if err := Pass(ctx, reg, s, pub, opt, restart); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ if v, _ := LiveVersion(opt, "old-canary.example.com"); v != "v1" {
+ t.Fatalf("an expired canary route did not adopt: live = %q, want v1", v)
+ }
+ if len(restarted) != 1 || restarted[0] != "old-canary.example.com" {
+ t.Fatalf("an expired canary route was not restarted: restarted = %v", restarted)
+ }
+ rt, err := reg.Get("old-canary.example.com")
+ if err != nil || rt == nil || rt.Version != "v1" {
+ t.Fatalf("registry version after adopting an expired canary = %+v, %v", rt, err)
+ }
+
+ // Promotion off (or onto) an expired canary channel is unaffected too —
+ // expiry is computed from PromotedAt at read time, never stored, so
+ // nothing about a pointer's own expiry state can gate a write to it.
+ shipRelease(t, s, "bab", "myapp", "v2", adoptPlatform, []byte("build two"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v2"); err != nil {
+ t.Fatalf("promoting a new version onto an expired canary channel: %v", err)
+ }
+ p, err := Channel(ctx, s, pub, "bab", "myapp", "canary/x")
+ if err != nil {
+ t.Fatalf("Channel after re-promoting an expired canary: %v", err)
+ }
+ if p.Version != "v2" {
+ t.Fatalf("channel = %q after promote, want v2", p.Version)
+ }
+ // And the re-promotion refreshed the lease — CanaryExpired.go's whole
+ // point: an actively-updated canary never expires.
+ if CanaryExpired(p, time.Now()) {
+ t.Error("re-promoting an expired canary must refresh its lease, not leave it expired")
+ }
+}
+
+// TestPassFilterDoesNotNagWithoutTheOption pins the fast-loop half of the
+// nag's scoping (2026-08-03 review): internal/host/agent.go's canaryAdoptLoop
+// calls PassFilter every 2-3 seconds for canary/* routes only, and NEVER
+// passes NagExpiredCanaries — so an expired canary that is sitting still
+// must produce ZERO nag lines from that path, not one every few seconds.
+// This is PassFilter called exactly the way canaryAdoptLoop calls it (no
+// options) against the same expired fixture TestPassAdoptsAnExpiredCanary
+// uses.
+func TestPassFilterDoesNotNagWithoutTheOption(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/x", "v1",
+ time.Now().Add(-(CanaryTTL + 30*24*time.Hour)))
+ addRoute(t, reg, "old-canary.example.com", "myapp", "canary/x", false)
+
+ var buf bytes.Buffer
+ withCapturedStdout(t, &buf, func() {
+ if err := PassFilter(ctx, reg, s, pub, opt, func(string) error { return nil }, isCanaryTestRoute); err != nil {
+ t.Fatalf("PassFilter: %v", err)
+ }
+ })
+ if strings.Contains(buf.String(), "expired") {
+ t.Errorf("PassFilter without NagExpiredCanaries printed a nag line:\n%s", buf.String())
+ }
+}
+
+// TestPassNagsOncePerExpiredCanaryRoute pins the root path's half: RunAdopt
+// (carlos-adopt.service, hourly) passes NagExpiredCanaries(os.Stdout) to
+// Pass, and that call must print ONE nag line PER EXPIRED CANARY ROUTE it
+// sees — not one per hourly pass regardless of how many routes follow the
+// expired channel. Three routes (two awake, one hibernating — the awake and
+// hibernating lists are walked separately in Pass/PassFilter, so this is
+// also the regression test for "hibernating routes get skipped" and
+// "a route is visited twice") on ONE expired canary channel must produce
+// exactly three lines, each naming its own host.
+//
+// (2026-08-03 review, MINOR 6: this test used to be named
+// TestPassNagsExactlyOnceWithTheOption and only ever exercised one route,
+// so "not once per host revisit" was asserted by name only, never proven.)
+func TestPassNagsOncePerExpiredCanaryRoute(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/x", "v1",
+ time.Now().Add(-(CanaryTTL + 30*24*time.Hour)))
+ addRoute(t, reg, "awake-a.example.com", "myapp", "canary/x", false)
+ addRoute(t, reg, "awake-b.example.com", "myapp", "canary/x", false)
+ addRoute(t, reg, "sleepy.example.com", "myapp", "canary/x", true)
+
+ var buf bytes.Buffer
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, NagExpiredCanaries(&buf)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ out := buf.String()
+ if got := strings.Count(out, "expired"); got != 3 {
+ t.Errorf("nag lines containing \"expired\" = %d, want exactly 3 (one per route, awake and hibernating alike):\n%s", got, out)
+ }
+ for _, host := range []string{"awake-a.example.com", "awake-b.example.com", "sleepy.example.com"} {
+ if !strings.Contains(out, host) {
+ t.Errorf("nag output does not name %s:\n%s", host, out)
+ }
+ }
+}
+
+// TestNagReportsTimePastTTLNotTotalAge pins the 2026-08-03 review's
+// IMPORTANT 2 fix: the nag must report how long PAST CanaryTTL a canary is,
+// not its total age. A pointer promoted CanaryTTL+1h ago has been EXPIRED
+// for about an hour, not 169 hours (7 days 1 hour, its total age) — the
+// second number is real but answers a different question, and printing it
+// under "has been expired" is the bug the review caught. It must also
+// disagree with nothing the console shows for the same pointer
+// (internal/console/ladder_test.go's TestLadderExpiredCanaryIsBadgedWithAge
+// exercises the same release.CanaryAge/FormatCanaryAge pair).
+func TestNagReportsTimePastTTLNotTotalAge(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/x", "v1",
+ time.Now().Add(-(CanaryTTL + time.Hour)))
+ addRoute(t, reg, "old-canary.example.com", "myapp", "canary/x", false)
+
+ var buf bytes.Buffer
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, NagExpiredCanaries(&buf)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ out := buf.String()
+ if !strings.Contains(out, "1h") {
+ t.Errorf("nag does not say 1h (time past TTL) for a canary expired ~1h ago:\n%s", out)
+ }
+ if strings.Contains(out, "169h") {
+ t.Errorf("nag reports total age (169h) instead of time past TTL:\n%s", out)
+ }
+}
+
+// TestPassDoesNotNagAFreshCanaryEvenWithTheOption: the option opts a CALL
+// into nagging; it does not turn every canary into a nag target. A fresh
+// canary must stay silent whether or not NagExpiredCanaries is set.
+func TestPassDoesNotNagAFreshCanaryEvenWithTheOption(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "fresh-canary.example.com", "myapp", "canary/x", false)
+
+ var buf bytes.Buffer
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, NagExpiredCanaries(&buf)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if strings.Contains(buf.String(), "expired") {
+ t.Errorf("a freshly-promoted canary produced a nag line:\n%s", buf.String())
+ }
+}
+
+// isCanaryTestRoute mirrors host.isCanaryRoute (unexported, different
+// package) for the one fast-loop-shaped test above that needs a canary-only
+// filter without importing internal/host.
+func isCanaryTestRoute(rt registry.Route) bool { return strings.HasPrefix(rt.Channel, "canary/") }
+
+// withCapturedStdout redirects os.Stdout to buf for the duration of fn —
+// used ONLY to prove PassFilter's default (no NagExpiredCanaries) path
+// prints nothing canary-related even via the package's ordinary fmt.Printf
+// progress lines, which is the honest thing to assert against (adoptHost's
+// other progress lines still go to os.Stdout; NagExpiredCanaries is what
+// redirects the NAG line specifically to an injected writer instead).
+func withCapturedStdout(t *testing.T, buf *bytes.Buffer, fn func()) {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("os.Pipe: %v", err)
+ }
+ orig := os.Stdout
+ os.Stdout = w
+ defer func() { os.Stdout = orig }()
+
+ done := make(chan struct{})
+ go func() {
+ buf.ReadFrom(r)
+ close(done)
+ }()
+
+ fn()
+ w.Close()
+ <-done
+}
diff --git a/internal/release/canary_ttl.go b/internal/release/canary_ttl.go
new file mode 100644
index 000000000..9a3425300
--- /dev/null
+++ b/internal/release/canary_ttl.go
@@ -0,0 +1,85 @@
+// Canary TTL — the pipelines spec's second guard rail (§5 "Canaries
+// expire"). Every canary/* channel carries a default 7-day lease, measured
+// from the pointer's own PromotedAt: the pointer already carries that
+// field, so this needs no new storage, and re-promoting a canary refreshes
+// the lease, which is the right semantic — an actively-updated canary never
+// expires, however old the branch it started from.
+//
+// Expiry is visibility, not enforcement, matching §5's "prod-wired" guard
+// rail right above it: nothing here stops a route serving, nothing deletes
+// a pointer, and promotion to or from an expired canary stays allowed. The
+// hazard §5 names is not a canary in production — it's one that outlives
+// the branch it was testing and quietly becomes permanent infrastructure
+// nobody remembers deploying. Every caller that needs to know computes it
+// fresh from the pointer already in hand; nothing persists an "expired" bit
+// anywhere.
+package release
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// CanaryTTL is a canary/* channel's default lease length before it is
+// flagged expired. Spec §5: "default 7 days".
+const CanaryTTL = 7 * 24 * time.Hour
+
+// CanaryExpired reports whether p is a canary/* channel pointer whose lease
+// (PromotedAt + CanaryTTL) has passed as of now. Always false for a channel
+// outside canary/* — edge, beta and stable carry no TTL — and false for a
+// nil pointer or a zero PromotedAt (an unpromoted or not-yet-read pointer):
+// unknown age is never treated as proof of age. Exactly CanaryTTL old is
+// NOT yet expired; the TTL names the lease's length.
+func CanaryExpired(p *ChannelPointer, now time.Time) bool {
+ if p == nil || !strings.HasPrefix(p.Channel, RungCanary+"/") {
+ return false
+ }
+ if p.PromotedAt.IsZero() {
+ return false
+ }
+ return now.Sub(p.PromotedAt) > CanaryTTL
+}
+
+// CanaryAge is how long PAST CanaryTTL an expired canary pointer is — the
+// number both operator-facing surfaces (the adopt pass's nag and the
+// console's badge) render, NOT the pointer's total age. Total age
+// (now.Sub(p.PromotedAt)) is always exactly CanaryAge + CanaryTTL, and
+// reporting IT as "how long has this been expired" overstates the lapse by
+// one full lease length — a canary that missed its lease by an hour would
+// read as a seven-day-old problem (2026-08-03 review, IMPORTANT 2).
+//
+// Meaningful only when CanaryExpired(p, now) is already true; call that
+// first. A caller that skips the check gets a negative or otherwise
+// nonsensical duration back for a fresh or non-canary pointer, the same as
+// any bare Sub on the wrong side of zero.
+func CanaryAge(p *ChannelPointer, now time.Time) time.Duration {
+ return now.Sub(p.PromotedAt) - CanaryTTL
+}
+
+// FormatCanaryAge renders a CanaryAge duration for a human. It is the ONE
+// formatter both the adopt pass's nag (internal/release/adopt.go) and the
+// console's badge (internal/console/ladder.go) call, so the two
+// operator-facing surfaces of this feature can never drift into disagreeing
+// about what a duration means (2026-08-03 review, IMPORTANT 2) — the way
+// they briefly did when the console rounded-and-bucketed a duration the nag
+// printed raw and un-subtracted.
+//
+// "<1h" for anything under a full hour past the TTL — never "0h", which
+// reads as "not actually expired" to whoever sees it first (2026-08-03
+// review, MINOR 7). "Nh" rounded to the nearest hour from one hour up to a
+// day. "Nd" TRUNCATED (not rounded) at a day and beyond: nobody needs
+// minute-level precision on a guard rail whose whole point is that nobody
+// was watching the clock, and losing up to 23 hours off the day count is
+// the deliberate coarseness that buys — nothing here mistakes a 47-hour
+// lapse for two days.
+func FormatCanaryAge(d time.Duration) string {
+ if d < time.Hour {
+ return "<1h"
+ }
+ d = d.Round(time.Hour)
+ if d < 24*time.Hour {
+ return fmt.Sprintf("%dh", int(d.Hours()))
+ }
+ return fmt.Sprintf("%dd", int(d.Hours())/24)
+}
diff --git a/internal/release/canary_ttl_test.go b/internal/release/canary_ttl_test.go
new file mode 100644
index 000000000..f598a079d
--- /dev/null
+++ b/internal/release/canary_ttl_test.go
@@ -0,0 +1,116 @@
+package release
+
+import (
+ "testing"
+ "time"
+)
+
+func TestCanaryExpiredFalseForFreshPromotion(t *testing.T) {
+ p := &ChannelPointer{App: "myapp", Channel: "canary/x", Version: "v1", PromotedAt: time.Now()}
+ if CanaryExpired(p, time.Now()) {
+ t.Error("a canary promoted moments ago must not be expired")
+ }
+}
+
+func TestCanaryExpiredTrueAfterTTL(t *testing.T) {
+ old := time.Now().Add(-(CanaryTTL + time.Hour))
+ p := &ChannelPointer{App: "myapp", Channel: "canary/x", Version: "v1", PromotedAt: old}
+ if !CanaryExpired(p, time.Now()) {
+ t.Error("a canary promoted more than CanaryTTL ago must be expired")
+ }
+}
+
+// Right at the boundary: exactly CanaryTTL old is NOT yet expired — the TTL
+// names the lease's length, not a moment strictly before it.
+func TestCanaryExpiredBoundary(t *testing.T) {
+ now := time.Now()
+ atBoundary := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-CanaryTTL)}
+ if CanaryExpired(atBoundary, now) {
+ t.Error("exactly CanaryTTL old must not be expired yet")
+ }
+ pastBoundary := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-CanaryTTL - time.Second)}
+ if !CanaryExpired(pastBoundary, now) {
+ t.Error("one second past CanaryTTL must be expired")
+ }
+}
+
+// Re-promoting a canary refreshes PromotedAt, which is the whole point of
+// keying age off the pointer's own lease timestamp: an actively-updated
+// canary never expires, however old the channel name itself is.
+func TestCanaryExpiredRefreshesOnRepromotion(t *testing.T) {
+ now := time.Now()
+ stale := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-(CanaryTTL + 24*time.Hour))}
+ if !CanaryExpired(stale, now) {
+ t.Fatal("setup: this pointer should read as expired before the repromotion")
+ }
+ refreshed := &ChannelPointer{Channel: "canary/x", PromotedAt: now}
+ if CanaryExpired(refreshed, now) {
+ t.Error("a freshly re-promoted canary must not be expired, however old the channel is")
+ }
+}
+
+// Only canary/* channels carry a TTL at all — edge, beta and stable are
+// never flagged, however old their PromotedAt is.
+func TestCanaryExpiredFalseForNonCanaryChannels(t *testing.T) {
+ now := time.Now()
+ ancient := now.Add(-365 * 24 * time.Hour)
+ for _, ch := range []string{"stable", "beta", "edge", "canary"} {
+ p := &ChannelPointer{Channel: ch, PromotedAt: ancient}
+ if CanaryExpired(p, now) {
+ t.Errorf("channel %q must never be flagged expired (not a canary/* channel)", ch)
+ }
+ }
+}
+
+// A pointer written before PromotedAt existed (or any zero value) must not
+// be flagged expired — unknown age is not proof of age, and validatePointer
+// already refuses a zero PromotedAt on anything read off the wire, so this
+// is defense for a directly-constructed pointer, not a live-fleet case.
+func TestCanaryExpiredFalseForZeroPromotedAt(t *testing.T) {
+ p := &ChannelPointer{Channel: "canary/x"}
+ if CanaryExpired(p, time.Now()) {
+ t.Error("a zero PromotedAt must not be flagged expired")
+ }
+}
+
+func TestCanaryExpiredNilPointer(t *testing.T) {
+ if CanaryExpired(nil, time.Now()) {
+ t.Error("a nil pointer must not be flagged expired")
+ }
+}
+
+// ---- CanaryAge / FormatCanaryAge (2026-08-03 review, IMPORTANT 2) ---------
+
+// CanaryAge must report time PAST the TTL, not total age — a pointer
+// CanaryTTL+1h old has been expired for about an hour, not 7 days 1 hour.
+func TestCanaryAgeIsTimePastTTLNotTotalAge(t *testing.T) {
+ now := time.Now()
+ p := &ChannelPointer{Channel: "canary/x", PromotedAt: now.Add(-(CanaryTTL + time.Hour))}
+ got := CanaryAge(p, now)
+ if got < 59*time.Minute || got > 61*time.Minute {
+ t.Errorf("CanaryAge = %s, want ~1h (total age minus CanaryTTL, not the 169h total age)", got)
+ }
+}
+
+func TestFormatCanaryAge(t *testing.T) {
+ cases := []struct {
+ d time.Duration
+ want string
+ }{
+ {0, "<1h"}, // the instant it expires
+ {24 * time.Minute, "<1h"}, // MINOR 7: must not read "0h"
+ {59 * time.Minute, "<1h"},
+ {time.Hour, "1h"}, // review's own example: 7d1h old -> "1h"
+ {90 * time.Minute, "2h"}, // rounds to nearest hour
+ {23 * time.Hour, "23h"},
+ {24 * time.Hour, "1d"},
+ {47 * time.Hour, "1d"}, // deliberate coarseness: truncates, not rounds
+ {8 * 24 * time.Hour, "8d"},
+ {30 * 24 * time.Hour, "30d"},
+ }
+ for _, c := range cases {
+ if got := FormatCanaryAge(c.d); got != c.want {
+ t.Errorf("FormatCanaryAge(%s) = %q, want %q", c.d, got, c.want)
+ }
+ }
+}
diff --git a/internal/status/assets/index.html b/internal/status/assets/index.html
index 4c3580849..dab99dfe0 100644
--- a/internal/status/assets/index.html
+++ b/internal/status/assets/index.html
@@ -30,6 +30,10 @@
<h2>Adoption</h2>
<p class="tile-value" id="tile-adoption"></p>
</div>
+ <div class="tile" id="tile-expired-canaries-wrap" hidden>
+ <h2>Expired canaries</h2>
+ <p class="tile-value" id="tile-expired-canaries"></p>
+ </div>
</section>
<section>
@@ -145,6 +149,20 @@
// adoption timers aren't armed is one where nothing picks up a release on
// its own, which used to be invisible from anywhere.
document.getElementById("tile-adoption").replaceChildren(asBool(f.adoption_running, "running", "not running"));
+
+ // The expired-canaries tile is deliberately absent, not zeroed, when
+ // there is nothing to see (pipelines spec §5): a fleet with no expired
+ // canaries — the ordinary, common case — should not carry a permanent
+ // "0" tile competing for attention with the three that always matter.
+ // Unknown (null) also stays hidden: "we couldn't check" is not the same
+ // claim as "there are some", and this tile only ever asserts the latter.
+ var wrap = document.getElementById("tile-expired-canaries-wrap");
+ if (typeof f.expired_canaries === "number" && f.expired_canaries > 0) {
+ wrap.hidden = false;
+ document.getElementById("tile-expired-canaries").replaceChildren(asText(f.expired_canaries));
+ } else {
+ wrap.hidden = true;
+ }
}
function renderBoxes(f) {
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index 9243aa624..02cb6b135 100644
--- a/internal/status/contract_test.go
+++ b/internal/status/contract_test.go
@@ -28,7 +28,11 @@ var allowedBoxKeys = map[string][]string{
// says whether the platform's own release-adoption timer is armed on this
// box. It carries no tenant, hostname or machine identity — only whether
// an opaquely-labelled box is picking up its own releases.
- "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running"},
+ //
+ // "expired_canaries" is likewise deliberate (pipelines spec §5 "Canaries
+ // expire"): a COUNT of this box's own canary/* routes past their TTL,
+ // never which ones — no hostname, no channel name, no tenant identity.
+ "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "expired_canaries"},
"routes": {"static", "resident", "hibernating", "awake", "asleep"},
"replication": {"healthy", "lag_seconds"},
}
@@ -36,8 +40,8 @@ var allowedBoxKeys = map[string][]string{
// allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as
// allowedBoxKeys: every new field is a deliberate publication decision.
var allowedFleetKeys = map[string][]string{
- "": {"generated_at", "boxes", "instances", "capacity", "apps", "apps_unlisted", "services", "replication", "adoption_running"},
- "boxes[]": {"label", "region", "load", "stale", "routes", "adoption_running"},
+ "": {"generated_at", "boxes", "instances", "capacity", "apps", "apps_unlisted", "services", "replication", "adoption_running", "expired_canaries"},
+ "boxes[]": {"label", "region", "load", "stale", "routes", "adoption_running", "expired_canaries"},
"boxes[].routes": {"static", "resident", "hibernating", "awake", "asleep"},
"instances": {"total", "static", "resident", "hibernating", "awake", "asleep"},
"capacity": {"level"},
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go
index 5b75cdaac..288254cab 100644
--- a/internal/status/dryrun.go
+++ b/internal/status/dryrun.go
@@ -87,14 +87,16 @@ func dryRunFixtures() ([]registry.Route, Probes) {
memFrac, diskFrac := 0.73, 0.95
lag := int64(120)
- adopting := true // a known reading, so adoption_running publishes non-null and gets scanned
+ adopting := true // a known reading, so adoption_running publishes non-null and gets scanned
+ expiredCanaries := 2 // ditto for expired_canaries — a known count, not the unexercised nil path
probes := Probes{
- MachineID: "ip-10-0-0-7",
- Region: "us-east-1",
- MemFrac: &memFrac,
- DiskFrac: &diskFrac,
- LagSecs: &lag,
- AdoptRunning: &adopting,
+ MachineID: "ip-10-0-0-7",
+ Region: "us-east-1",
+ MemFrac: &memFrac,
+ DiskFrac: &diskFrac,
+ LagSecs: &lag,
+ AdoptRunning: &adopting,
+ ExpiredCanaries: &expiredCanaries,
// Every hibernating route reports "listening" so Awake/Asleep publish
// non-null — an unexercised null field is a field DryRun can't scan.
Listening: func(string) *bool { v := true; return &v },
diff --git a/internal/status/fleet.go b/internal/status/fleet.go
index f0b9af913..7f69d8e92 100644
--- a/internal/status/fleet.go
+++ b/internal/status/fleet.go
@@ -42,6 +42,14 @@ type FleetBox struct {
// only whether the platform's own timer is armed on an opaquely-labelled
// machine.
AdoptionRunning *bool `json:"adoption_running"`
+ // ExpiredCanaries appears per-box for the same reason AdoptionRunning
+ // does: the fleet-wide sum says something is expired somewhere, this
+ // says on which box, without naming which route. Published in the FEED
+ // only for now — the rendered boxes table deliberately has no column
+ // for it yet (2026-08-03 review, MINOR 4: this comment used to imply
+ // the table showed it; it does not, and adding the column is deferred
+ // until an expired canary has actually been hunted via the feed once).
+ ExpiredCanaries *int `json:"expired_canaries"`
}
// Fleet is the merged feed: status/fleet.json. Every field here is either a
@@ -62,6 +70,13 @@ type Fleet struct {
} `json:"services"`
Replication Replication `json:"replication"`
AdoptionRunning *bool `json:"adoption_running"`
+ // ExpiredCanaries is the fleet-wide sum of every box's own count — see
+ // sumExpiredCanaries. Null-propagating: one box that couldn't check
+ // nulls the whole aggregate, the same rule sumInstances applies to
+ // Awake/Asleep, because a partial sum would read as more certain than
+ // it is. The status page shows this only when it is a known, positive
+ // count — see assets/index.html.
+ ExpiredCanaries *int `json:"expired_canaries"`
}
// capacityRank orders the capacity band words worst-last, so "worst of a
@@ -105,6 +120,7 @@ func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, n
Stale: stale[i],
Routes: b.Routes,
AdoptionRunning: b.AdoptionRunning,
+ ExpiredCanaries: b.ExpiredCanaries,
})
}
@@ -112,6 +128,7 @@ func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, n
f.Capacity.Level = worstCapacity(boxes)
f.Replication = worstReplication(boxes, stale)
f.AdoptionRunning = worstAdoption(boxes, stale)
+ f.ExpiredCanaries = sumExpiredCanaries(boxes)
apps, unlisted := buildApps(routes)
f.Apps = apps
@@ -192,6 +209,25 @@ func sumInstances(boxes []Box) map[string]*int {
return instances
}
+// sumExpiredCanaries adds each box's own ExpiredCanaries count — stale boxes
+// included, same reasoning as sumInstances: a box's last-known count is a
+// "trust this less" signal rendered loudly per-box (FleetBox.Stale), not a
+// reason to silently drop it from the fleet total. Per the null-propagation
+// rule every *int aggregate here follows, ANY box contributing nil (it
+// couldn't check) nils the whole sum — a partial count would read as more
+// certain than it is. No boxes at all sums to 0, not nil: there is nothing
+// unknown about an empty fleet.
+func sumExpiredCanaries(boxes []Box) *int {
+ sum := 0
+ for _, b := range boxes {
+ if b.ExpiredCanaries == nil {
+ return nil
+ }
+ sum += *b.ExpiredCanaries
+ }
+ return intPtr(sum)
+}
+
// worstCapacity picks the worst capacity band among ALL boxes, stale ones
// included: a box that stopped reporting still had a last-known fill level,
// and dropping it from the vote just because its snapshot aged out would
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 534d2aac8..378e55f4d 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -33,6 +33,16 @@ type Probes struct {
// "never asked / couldn't tell", and like every other nil here it must
// publish as null: a box that can't answer is not a box that is fine.
AdoptRunning *bool
+ // ExpiredCanaries is how many of this box's own canary/* routes have
+ // passed release.CanaryTTL (pipelines spec §5 "Canaries expire"),
+ // pre-computed by the caller against the release store — CollectBox
+ // stays pure and never resolves a channel pointer itself, the same
+ // reason every other field here arrives pre-resolved. nil means this
+ // box could not determine the answer (no deployment store configured,
+ // or a channel pointer it needed wouldn't verify), never a guessed
+ // zero; 0 is itself a real, confident answer when every canary route on
+ // the box is fresh, or there are none at all.
+ ExpiredCanaries *int
}
// RouteCounts is aggregate-only: no host, no socket path, just how many.
@@ -76,6 +86,11 @@ type Box struct {
// flagship spent weeks in the false state with no way to see it; this
// field is the way to see it.
AdoptionRunning *bool `json:"adoption_running"`
+ // ExpiredCanaries publishes Probes.ExpiredCanaries straight through — a
+ // count, never hostnames (pipelines spec §5): which canary route expired
+ // is exactly the kind of identifying detail this package's whole design
+ // refuses to carry, but how many is aggregate and safe.
+ ExpiredCanaries *int `json:"expired_canaries"`
}
// replHealthyWithin is the lag under which replication counts as healthy.
@@ -240,6 +255,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
Capacity: bandWord(worst, capacityWords),
Replication: Replication{Healthy: healthy, LagSecs: p.LagSecs},
AdoptionRunning: p.AdoptRunning,
+ ExpiredCanaries: p.ExpiredCanaries,
}
}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index da4830b4c..f38b6d0ab 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -635,6 +635,113 @@ func TestFleetAdoptionWorstOf(t *testing.T) {
// number says SOMETHING is not adopting, the boxes table says which label —
// without it, an operator reading "not running" on a two-box fleet has to go
// SSH into both to find out where the dead timer is.
+// ---- Pipelines Task: canary TTL (spec §5 "Canaries expire") ---------------
+
+// TestExpiredCanariesUnknownIsANull mirrors TestAdoptionUnknownIsANull: a box
+// whose probe never resolved its canary channel pointers (e.g. no deployment
+// store configured) publishes null, exactly like every other unmeasured
+// quantity here. A default 0 would read as "no expired canaries on this
+// box" when the honest answer is "this box couldn't check" — precisely the
+// reassuring-wrong-answer failure the rest of this package refuses to make.
+func TestExpiredCanariesUnknownIsANull(t *testing.T) {
+ b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now()) // nil ExpiredCanaries
+ if b.ExpiredCanaries != nil {
+ t.Errorf("expired_canaries = %v, want null — an unasked question is not a healthy answer", *b.ExpiredCanaries)
+ }
+ for _, want := range []int{0, 3} {
+ got := status.CollectBox(nil, status.Probes{MachineID: "m", ExpiredCanaries: &want}, time.Now()).ExpiredCanaries
+ if got == nil || *got != want {
+ t.Errorf("ExpiredCanaries=%d: expired_canaries = %v, want %d", want, got, want)
+ }
+ }
+}
+
+// TestFleetExpiredCanariesSums proves the fleet-wide count is the sum across
+// boxes when every box's own count is known — the aggregate an operator
+// actually wants ("how many expired canaries are running anywhere"), not a
+// per-box worst-of.
+func TestFleetExpiredCanariesSums(t *testing.T) {
+ s := teststore(t)
+ now := time.Now()
+ two, one := 2, 1
+ for _, b := range []status.Box{
+ status.CollectBox(nil, status.Probes{MachineID: "a", ExpiredCanaries: &two}, now),
+ status.CollectBox(nil, status.Probes{MachineID: "b", ExpiredCanaries: &one}, now),
+ } {
+ if err := status.WriteBox(context.Background(), s, b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.ExpiredCanaries == nil || *f.ExpiredCanaries != 3 {
+ t.Errorf("fleet expired_canaries = %v, want 3", f.ExpiredCanaries)
+ }
+}
+
+// TestFleetExpiredCanariesNullPropagates: one box that couldn't check nulls
+// the fleet-wide sum, the same null-propagation rule sumInstances applies to
+// Awake/Asleep — a partial sum would read as more certain than it is.
+func TestFleetExpiredCanariesNullPropagates(t *testing.T) {
+ s := teststore(t)
+ now := time.Now()
+ two := 2
+ known := status.CollectBox(nil, status.Probes{MachineID: "known", ExpiredCanaries: &two}, now)
+ unknown := status.CollectBox(nil, status.Probes{MachineID: "unknown"}, now) // nil probe
+ for _, b := range []status.Box{known, unknown} {
+ if err := status.WriteBox(context.Background(), s, b); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.ExpiredCanaries != nil {
+ t.Errorf("fleet expired_canaries = %v, want nil (one box could not check)", *f.ExpiredCanaries)
+ }
+}
+
+// TestFleetEmptyExpiredCanariesIsZero: no boxes at all is not "unknown", the
+// same rule TestFleetEmptyIsValid pins for instances — there is nothing
+// unknown about an empty fleet.
+func TestFleetEmptyExpiredCanariesIsZero(t *testing.T) {
+ s := teststore(t)
+ f, err := status.BuildFleet(context.Background(), s, nil, time.Now(), 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.ExpiredCanaries == nil || *f.ExpiredCanaries != 0 {
+ t.Errorf("expired_canaries = %v, want 0 with no boxes", f.ExpiredCanaries)
+ }
+}
+
+// TestFleetBoxCarriesExpiredCanaries keeps the per-box row honest too — the
+// fleet-wide number says something is expired somewhere, the boxes table
+// says which box, the same reasoning TestFleetBoxCarriesAdoption pins for
+// the adoption-timer reading.
+func TestFleetBoxCarriesExpiredCanaries(t *testing.T) {
+ s := teststore(t)
+ now := time.Now()
+ three := 3
+ if err := status.WriteBox(context.Background(), s,
+ status.CollectBox(nil, status.Probes{MachineID: "leaky", ExpiredCanaries: &three}, now)); err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(f.Boxes) != 1 {
+ t.Fatalf("boxes = %d, want 1", len(f.Boxes))
+ }
+ if f.Boxes[0].ExpiredCanaries == nil || *f.Boxes[0].ExpiredCanaries != 3 {
+ t.Errorf("boxes[0].expired_canaries = %v, want 3", f.Boxes[0].ExpiredCanaries)
+ }
+}
+
func TestFleetBoxCarriesAdoption(t *testing.T) {
s := teststore(t)
now := time.Now()