carlos / platform Public

plan: console release defences, plan 1 of 4 (D1)

Nine tasks: refusal records, the adoptHost refusal gate, the process-group
rollback, the settle check, readiness on the effective upstream, wiring
the checked restart and rollback restart, promote --retry, the alert and
status --live line, and the full gate with an operator note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2b6287efb1f67c14678279197ffe841719891372 parent 2922945
1 file changed, +1952 −0
  • docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md +1952 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md
new file mode 100644
index 000000000..3953c4dbe
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-1-d1.md
@@ -0,0 +1,1952 @@
+# Console release defences, Plan 1 of 4: D1 (roll back a named unit that will not come up)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A box that adopts a named-unit build (today: the console) checks that
+the new process actually comes up. If it doesn't, the box rolls back, refuses
+that promotion until `carlos promote --retry`, and alerts.
+
+**Architecture:** `adoptHost` (internal/release/adopt.go) already rolls back
+when its restart callback errors. This plan makes the named-unit restart
+return an honest error by settling the unit after `try-restart`, through a
+small `unitSystem` seam over systemctl. It adds a durable refusal record in the
+bake directory, scoped to the routes that share one process. It also adds a
+`--retry` promote, and an alert plus a `status --live` line fed from the
+refusal records.
+
+**Tech Stack:** Go (stdlib, `os/exec` systemctl, `net/http` over TCP or Unix
+sockets), the existing release/registry/host/status packages.
+
+**Spec:** `docs/superpowers/specs/2026-10-06-console-release-defences-design.md`
+(rev 5): §2 (D1) and §7's D1 and retry tests. Plans 2 (D2), 3 (D3) and 4 (D4,
+process and scripts) follow; this plan stands alone.
+
+## Global Constraints
+
+- Settle window `CARLOS_UNIT_SETTLE`: default 30 s, floor 5 s, poll every 500 ms (spec §2.3).
+- Readiness follows the effective upstream: `Socket` if set, else `Addr` (spec §2.4).
+- No-probe readiness: the unit stays `active` within one invocation for 5 s (spec §2.4).
+- A unit not running before the restart is never checked or started (spec §2.3).
+- A binding mismatch (`ExecStart` not `live/<host>`) skips the settle check only; the `try-restart` error still propagates (spec §2.2, codex 12).
+- D1 rollback runs `reset-failed` then `restart`; every other `makeStartRoute` caller keeps `start` (spec §2.3, codex 1).
+- A rollback group is the bound route plus unstamped same-app, same-account routes with the same non-empty upstream; two empty upstreams never match (spec §2.3).
+- A refusal blocks while the pointer's `Version` and `PromotedAt` equal the record's (spec §2.5). See Task 1's note on keying.
+- If `systemctl show` fails (for example, the sandboxed agent may not query the unit), behave exactly as today: plain `try-restart`, return its error.
+- Gate locally with `GOFLAGS=-buildvcs=false`; in this sandbox set `GOCACHE=$TMPDIR/gocache` per command (AGENTS.md path is read-only here).
+- Commits go on branch `design-console-release-defences`'s implementation branch (named at execution), as the agent identity, ending with the Co-Authored-By line.
+
+## Review Focus
+
+1. **A console that takes longer than the window to start** (cold registry, slow disk). Expect: it is rolled back only after the full window, the window is configurable, and the log says "not ready after 30s", not "crashed". Test in Task 4 (`TestSettleDeadlineSaysNotReady`).
+2. **The sandboxed agent cannot run `systemctl show`.** Expect: today's behaviour exactly, with no false rollback. Test in Task 6 (`TestNamedRestartShowFailsFallsBack`).
+3. **An operator stopped the console on purpose during an adoption.** Expect: no check, no start, and it stays stopped. Test in Task 4 (`TestSettleSkipsAUnitThatWasNotRunning`).
+4. **A corrupt or half-written refusal file.** Expect: treated as absent and logged, never a permanent block or a panic. Test in Task 1 (`TestReadRefusalCorruptIsAbsent`).
+5. **Two instances of one app on different upstreams.** Expect: a rollback of one never touches the other. Test in Task 3 (`TestRollbackGroupLeavesOtherUpstreamAlone`).
+
+---
+
+### Task 1: Refusal records
+
+**Files:**
+- Create: `internal/release/refusal.go`
+- Test: `internal/release/refusal_test.go`
+
+**Interfaces:**
+- Produces:
+ - `type Refusal struct { Schema int; Host, App, Version, SHA256, Platform string; PointerPromotedAt, RefusedAt time.Time; Reason string }` (JSON keys below)
+ - `func RefusalDir(bakeDir string) string`
+ - `func WriteRefusal(bakeDir string, r Refusal) error`
+ - `func ReadRefusal(bakeDir, host string) (*Refusal, error)` — `(nil, nil)` when absent or unparseable
+ - `func ListRefusals(bakeDir string) ([]Refusal, error)`
+ - `func ClearRefusal(bakeDir, host string) error` — nil when absent
+ - `func (r *Refusal) Blocks(p *ChannelPointer) bool`
+
+**Keying note (for the reviewer):** spec §2.5 keys on the artifact sha256. This
+plan blocks on `(Version, PointerPromotedAt)` and records `SHA256` for display.
+That is equivalent: a version's manifest is immutable while a pointer names
+it, and reusing a name for new bytes needs a new promotion, which changes
+`PromotedAt` and clears the block. It also avoids a manifest GET on every
+fast-loop tick for a refused route.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestRefusalRoundTripAndBlocks(t *testing.T) {
+ dir := t.TempDir()
+ at := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC)
+ r := Refusal{Host: "console.example.com", App: "console", Version: "v2",
+ SHA256: "abc", Platform: "linux-arm64", PointerPromotedAt: at,
+ RefusedAt: at.Add(10 * time.Second), Reason: "exited: status=2"}
+ if err := WriteRefusal(dir, r); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadRefusal(dir, "console.example.com")
+ if err != nil || got == nil {
+ t.Fatalf("ReadRefusal = %+v, %v", got, err)
+ }
+ if got.Schema != 1 || got.Version != "v2" || !got.PointerPromotedAt.Equal(at) {
+ t.Fatalf("round trip = %+v", got)
+ }
+ if !got.Blocks(&ChannelPointer{Version: "v2", PromotedAt: at}) {
+ t.Fatal("the same promotion is not blocked")
+ }
+ if got.Blocks(&ChannelPointer{Version: "v2", PromotedAt: at.Add(time.Second)}) {
+ t.Fatal("a re-signed pointer (retry) is still blocked")
+ }
+ if got.Blocks(&ChannelPointer{Version: "v3", PromotedAt: at}) {
+ t.Fatal("a different version is blocked")
+ }
+ var nilR *Refusal
+ if nilR.Blocks(&ChannelPointer{Version: "v2", PromotedAt: at}) {
+ t.Fatal("a nil refusal blocks")
+ }
+}
+
+func TestReadRefusalAbsentIsNil(t *testing.T) {
+ got, err := ReadRefusal(t.TempDir(), "nobody.example.com")
+ if got != nil || err != nil {
+ t.Fatalf("absent = %+v, %v; want nil, nil", got, err)
+ }
+}
+
+func TestReadRefusalCorruptIsAbsent(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(RefusalDir(dir), "console.example.com.json")
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(`{"schema":1,"host":`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadRefusal(dir, "console.example.com")
+ if got != nil || err != nil {
+ t.Fatalf("corrupt = %+v, %v; want nil, nil (a torn file must never block)", got, err)
+ }
+}
+
+func TestRefusalRejectsAPathInTheHost(t *testing.T) {
+ if err := WriteRefusal(t.TempDir(), Refusal{Host: "../etc/x"}); err == nil {
+ t.Fatal("a host with a path separator was written")
+ }
+}
+
+func TestListAndClearRefusals(t *testing.T) {
+ dir := t.TempDir()
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := WriteRefusal(dir, Refusal{Host: h, Version: "v2"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := ClearRefusal(dir, "a.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := ClearRefusal(dir, "never.example.com"); err != nil {
+ t.Fatalf("clearing an absent record: %v", err)
+ }
+ all, err := ListRefusals(dir)
+ if err != nil || len(all) != 1 || all[0].Host != "b.example.com" {
+ t.Fatalf("ListRefusals = %+v, %v", all, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Refusal' -count=1`
+Expected: FAIL, `undefined: Refusal`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// Refusal records — D1 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §2.5).
+//
+// When a named unit does not come up on a new build, the root pass rolls it
+// back and writes one of these per host in the rolled-back process group.
+// Every later pass, the agent's fast loop and root alike, reads it before
+// staging and skips the promotion it names. Without it the very next pass
+// would adopt the same build and bounce the service again.
+//
+// The record blocks a PROMOTION, not a version name: it matches while the
+// channel pointer still has the version and PromotedAt it had when the build
+// was refused. `carlos promote --retry` re-signs the pointer with a fresh
+// PromotedAt, and so does any move to another version; either clears it.
+//
+// Root writes these under the bake directory, which the sandboxed agent can
+// read. A file that does not parse reads as absent: a torn write must never
+// become a permanent block.
+package release
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+const refusalSchema = 1
+
+type Refusal struct {
+ Schema int `json:"schema"`
+ Host string `json:"host"`
+ App string `json:"app"`
+ Version string `json:"version"`
+ SHA256 string `json:"sha256,omitempty"`
+ Platform string `json:"platform,omitempty"`
+ PointerPromotedAt time.Time `json:"pointer_promoted_at"`
+ RefusedAt time.Time `json:"refused_at"`
+ Reason string `json:"reason"`
+}
+
+func RefusalDir(bakeDir string) string { return filepath.Join(bakeDir, "refusals") }
+
+func refusalPath(bakeDir, host string) (string, error) {
+ if host == "" || strings.ContainsAny(host, `/\`) || host == "." || host == ".." {
+ return "", fmt.Errorf("refusal: invalid host %q", host)
+ }
+ return filepath.Join(RefusalDir(bakeDir), host+".json"), nil
+}
+
+// WriteRefusal writes atomically (temp file + rename) and world-readable,
+// because the reader is the sandboxed agent.
+func WriteRefusal(bakeDir string, r Refusal) error {
+ p, err := refusalPath(bakeDir, r.Host)
+ if err != nil {
+ return err
+ }
+ r.Schema = refusalSchema
+ b, err := json.MarshalIndent(r, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(p), ".refusal-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if _, err := tmp.Write(append(b, '\n')); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Chmod(0o644); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), p)
+}
+
+func ReadRefusal(bakeDir, host string) (*Refusal, error) {
+ p, err := refusalPath(bakeDir, host)
+ if err != nil {
+ return nil, err
+ }
+ b, err := os.ReadFile(p)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var r Refusal
+ if err := json.Unmarshal(b, &r); err != nil || r.Schema != refusalSchema {
+ log.Printf("refusal: %s does not parse (%v), treating it as absent", p, err)
+ return nil, nil
+ }
+ return &r, nil
+}
+
+func ListRefusals(bakeDir string) ([]Refusal, error) {
+ ents, err := os.ReadDir(RefusalDir(bakeDir))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var out []Refusal
+ for _, e := range ents {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".json") || strings.HasPrefix(name, ".") {
+ continue
+ }
+ r, err := ReadRefusal(bakeDir, strings.TrimSuffix(name, ".json"))
+ if err != nil || r == nil {
+ continue
+ }
+ out = append(out, *r)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out, nil
+}
+
+func ClearRefusal(bakeDir, host string) error {
+ p, err := refusalPath(bakeDir, host)
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return nil
+}
+
+// Blocks reports whether this record still refuses the promotion p names.
+func (r *Refusal) Blocks(p *ChannelPointer) bool {
+ return r != nil && p != nil && r.Version == p.Version && r.PointerPromotedAt.Equal(p.PromotedAt)
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Refusal' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/refusal.go internal/release/refusal_test.go
+git commit -m "release: refusal records for builds a box rolled back (console defences D1)"
+```
+
+---
+
+### Task 2: adoptHost honours a refusal before it stages or relays
+
+**Files:**
+- Modify: `internal/release/adopt.go` (in `adoptHost`, after the `poisoned[ver]` check and before `ensureStaged`; and after the final `SetVersions` success)
+- Test: `internal/release/adopt_test.go` (append)
+
+**Interfaces:**
+- Consumes: Task 1's `ReadRefusal`, `ClearRefusal`, `(*Refusal).Blocks`; `passConfig.bakeDir` (set by `BakeDir(dir)`).
+- Produces: nothing new; the printed line `refused: <ver> did not come up on this box at <time> (<reason>); retry with: carlos promote --retry <ver> <channel>` is what Task 8 and operators grep.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// TestRefusedPromotionIsNeitherStagedNorRelayed: a refusal naming the current
+// pointer stops adoptHost before ensureStaged, so the agent never relays a
+// root pass for it and nothing restarts.
+func TestRefusedPromotionIsNeitherStagedNorRelayed(t *testing.T) {
+ host := "console.example.com"
+ ctx, reg, s, pub, opt := startUnitFixture(t, host)
+ bake := t.TempDir()
+ ptr, err := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteRefusal(bake, Refusal{Host: host, App: "console", Version: ptr.Version,
+ PointerPromotedAt: ptr.PromotedAt, Reason: "exited: status=2"}); err != nil {
+ t.Fatal(err)
+ }
+ restart := func(h string) error { t.Fatalf("restart consulted for refused %s", h); return nil }
+ relayed := false
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake),
+ OnStageBlocked(func(string, string, error) { relayed = true })); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ })
+ if v, _ := LiveVersion(opt, host); v != "old" {
+ t.Fatalf("live = %q, want old", v)
+ }
+ if relayed {
+ t.Fatal("a refused promotion was relayed to root")
+ }
+ if !strings.Contains(out.String(), "promote --retry") {
+ t.Fatalf("printed %q, want the retry hint", out.String())
+ }
+}
+
+// TestRePromotionClearsTheBlock: a pointer re-signed for the same version
+// (what promote --retry does) is adopted, and the stale record is removed.
+func TestRePromotionClearsTheBlock(t *testing.T) {
+ host := "console.example.com"
+ ctx, reg, s, pub, opt := startUnitFixture(t, host)
+ bake := t.TempDir()
+ if err := WriteRefusal(bake, Refusal{Host: host, App: "console", Version: "new",
+ PointerPromotedAt: time.Unix(1, 0).UTC(), Reason: "older promotion"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, host); v != "new" {
+ t.Fatalf("live = %q, want new (the record names an older promotion)", v)
+ }
+ if r, _ := ReadRefusal(bake, host); r != nil {
+ t.Fatalf("stale refusal survived a successful adoption: %+v", r)
+ }
+}
+```
+
+`Channel` (internal/release/release.go:1047) is the verified pointer read.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'TestRefusedPromotion|TestRePromotionClears' -count=1`
+Expected: FAIL. The first test's restart fatals, and the second still finds the record.
+
+- [ ] **Step 3: Implement the gate**
+
+In `adoptHost`, immediately after:
+
+```go
+ if poisoned[ver] {
+ fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver)
+ return
+ }
+```
+
+insert:
+
+```go
+ // D1's refusal (refusal.go): this box rolled this exact promotion back
+ // because the unit did not come up. Checked before ensureStaged so a
+ // refused build is neither downloaded nor relayed to a root pass.
+ if cfg.bakeDir != "" {
+ if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err != nil {
+ fmt.Printf(" %s: reading refusal record: %v\n", rt.Host, err)
+ } else if r.Blocks(ptr) {
+ fmt.Printf(" %s: refused: %s did not come up on this box at %s (%s); retry with: carlos promote --retry %s %s\n",
+ rt.Host, ver, r.RefusedAt.Format(time.RFC3339), r.Reason, ver, channel)
+ return
+ }
+ }
+```
+
+and immediately after the final successful `reg.SetVersions(rt.Host, ver, cur)`
+(the one followed by the `updated %s -> %s` print), insert:
+
+```go
+ // A record left from an older promotion is stale once a build is live.
+ // Best-effort: the agent cannot remove root's file, the root pass can.
+ if cfg.bakeDir != "" {
+ _ = ClearRefusal(cfg.bakeDir, rt.Host)
+ }
+```
+
+- [ ] **Step 4: Run the release package tests**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -count=1`
+Expected: PASS (new tests and every existing adopt test).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/adopt.go internal/release/adopt_test.go
+git commit -m "release: adoptHost skips a refused promotion before staging or relaying"
+```
+
+---
+
+### Task 3: The process-group rollback when a unit does not come up
+
+**Files:**
+- Create: `internal/release/rollback.go`
+- Modify: `internal/release/adopt.go` (`passConfig`, new `WithRollbackRestart`, the `rerr != nil` branch of `adoptHost`)
+- Test: `internal/release/rollback_test.go`
+
+**Interfaces:**
+- Produces:
+ - `type UnitNotUpError struct { Unit, Reason, Journal string }` with `Error() string`. The host package's restart returns this (Task 6) and adoptHost tests it with `errors.As`.
+ - `func upstream(rt registry.Route) string`: `rt.Socket` if non-empty, else `rt.Addr`.
+ - `func processGroup(reg *registry.Registry, bound registry.Route) []registry.Route`: excludes `bound`.
+ - `func WithRollbackRestart(fn func(host string) error) PassOption`: replaces `startUnit` on the D1 path only.
+- Consumes: Task 1's `WriteRefusal`; `LiveVersion`, `PointLive`, `RouteAccount`, `reg.List`, `reg.SetVersions`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func groupFixtureRoute(t *testing.T, reg *registry.Registry, host, socket, addr, unit string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", App: "console",
+ Channel: "canary/x", Socket: socket, Addr: addr, Unit: unit}); err != nil {
+ t.Fatalf("upsert %s: %v", host, err)
+ }
+}
+
+func TestProcessGroupKeysOnTheEffectiveUpstream(t *testing.T) {
+ reg := testRegistry(t)
+ groupFixtureRoute(t, reg, "platform.example.com", "", "127.0.0.1:8090", "console.service")
+ groupFixtureRoute(t, reg, "console.example.com", "", "127.0.0.1:8090", "")
+ groupFixtureRoute(t, reg, "other.example.com", "", "127.0.0.1:9000", "")
+ groupFixtureRoute(t, reg, "sock.example.com", "/run/x.sock", "127.0.0.1:8090", "")
+ bound, _ := reg.Get("platform.example.com")
+ got := processGroup(reg, *bound)
+ if len(got) != 1 || got[0].Host != "console.example.com" {
+ t.Fatalf("group = %+v, want only console.example.com (socket wins over addr; 9000 is another process)", got)
+ }
+}
+
+func TestProcessGroupNeverMatchesEmptyUpstreams(t *testing.T) {
+ reg := testRegistry(t)
+ groupFixtureRoute(t, reg, "a.example.com", "", "", "a.service")
+ groupFixtureRoute(t, reg, "b.example.com", "", "", "")
+ bound, _ := reg.Get("a.example.com")
+ if got := processGroup(reg, *bound); len(got) != 0 {
+ t.Fatalf("group = %+v, want none: two empty upstreams are not one process", got)
+ }
+}
+
+// The flagship shape: three hosts, one process, the unit runs the LAST
+// host's link. Hosts before it were repointed and stamped; the bound host's
+// restart reports the unit did not come up.
+func TestNotUpRollsTheWholeGroupBackAndRefusesEveryHost(t *testing.T) {
+ ctx, reg, s, pub, opt := startUnitFixture(t, "console.example.com")
+ if err := reg.SetUnit("console.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ groupFixtureRoute(t, reg, "platform.example.com", "", "127.0.0.1:8090", "console.service")
+ if err := reg.SetUpstreams("console.example.com", "127.0.0.1:8090", "", ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := PointLive(opt, "platform.example.com", "bab", "old", "console"); err != nil {
+ t.Fatal(err)
+ }
+ bake := t.TempDir()
+ restart := func(h string) error {
+ if h == "platform.example.com" {
+ return &UnitNotUpError{Unit: "console.service", Reason: "exited: status=2"}
+ }
+ return nil
+ }
+ var rolledBackHost string
+ rb := func(h string) error { rolledBackHost = h; return nil }
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake), WithRollbackRestart(rb)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ })
+ for _, h := range []string{"console.example.com", "platform.example.com"} {
+ if v, _ := LiveVersion(opt, h); v != "old" {
+ t.Fatalf("%s live = %q, want old", h, v)
+ }
+ rt, _ := reg.Get(h)
+ if rt.Version == "new" {
+ t.Fatalf("%s still stamped new: %+v", h, rt)
+ }
+ if r, _ := ReadRefusal(bake, h); r == nil || r.Version != "new" {
+ t.Fatalf("%s refusal = %+v, want one naming new", h, r)
+ }
+ }
+ if rolledBackHost != "platform.example.com" {
+ t.Fatalf("rollback restart ran for %q, want the bound host", rolledBackHost)
+ }
+}
+
+func TestRollbackGroupLeavesOtherUpstreamAlone(t *testing.T) {
+ ctx, reg, s, pub, opt := startUnitFixture(t, "console.example.com")
+ if err := reg.SetUnit("console.example.com", "console.service"); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetUpstreams("console.example.com", "127.0.0.1:8090", "", ""); err != nil {
+ t.Fatal(err)
+ }
+ groupFixtureRoute(t, reg, "zz-other.example.com", "", "127.0.0.1:9000", "")
+ if err := StageArtifact(opt, "bab", "new", "console", []byte("bad build")); err != nil {
+ t.Fatal(err)
+ }
+ if err := PointLive(opt, "zz-other.example.com", "bab", "new", "console"); err != nil {
+ t.Fatal(err)
+ }
+ restart := func(h string) error {
+ if h == "console.example.com" {
+ return &UnitNotUpError{Unit: "console.service", Reason: "not ready after 30s"}
+ }
+ return nil
+ }
+ bake := t.TempDir()
+ if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake), WithRollbackRestart(func(string) error { return nil })); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, "zz-other.example.com"); v != "new" {
+ t.Fatalf("another process's route was rolled back to %q", v)
+ }
+ if r, _ := ReadRefusal(bake, "zz-other.example.com"); r != nil {
+ t.Fatalf("another process's route was refused: %+v", r)
+ }
+}
+```
+
+Check the fixture's assumptions before running: `reg.SetUnit` and
+`reg.SetUpstreams(host, addr, addrs, health)` exist at
+`internal/registry/registry.go:1231` and `:1315`, and `startUnitFixture`'s route
+starts with no `Unit`.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'ProcessGroup|NotUpRolls|RollbackGroup' -count=1`
+Expected: FAIL, `undefined: UnitNotUpError`.
+
+- [ ] **Step 3: Implement `rollback.go`**
+
+```go
+// The D1 rollback (spec 2026-10-06-console-release-defences-design.md §2.3):
+// what adoptHost does when a named unit's restart reports that the new build
+// did not come up, as opposed to systemctl itself failing.
+package release
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// UnitNotUpError is a named unit that was restarted onto a new build and did
+// not settle: it failed, restarted again, or was not ready by the deadline.
+// The host package's settle check returns it (internal/host/unitsettle.go).
+type UnitNotUpError struct {
+ Unit string
+ Reason string
+ Journal string // the unit's last journal lines, for the rollback line and the alert
+}
+
+func (e *UnitNotUpError) Error() string {
+ return fmt.Sprintf("%s did not come up: %s", e.Unit, e.Reason)
+}
+
+// upstream is a route's effective upstream: Socket wins over Addr, as at the
+// edge (registry.Route.Socket's doc).
+func upstream(rt registry.Route) string {
+ if rt.Socket != "" {
+ return rt.Socket
+ }
+ return rt.Addr
+}
+
+// processGroup is every OTHER route served by bound's process: unstamped,
+// same account and app, same non-empty upstream. A bound route with no
+// upstream is a group of one.
+func processGroup(reg *registry.Registry, bound registry.Route) []registry.Route {
+ up := upstream(bound)
+ if up == "" {
+ return nil
+ }
+ all, err := reg.List()
+ if err != nil {
+ fmt.Printf(" %s: listing routes for the rollback group: %v (rolling back this host only)\n", bound.Host, err)
+ return nil
+ }
+ var out []registry.Route
+ for _, r := range all {
+ if r.Host == bound.Host || r.Unit != "" || r.App != bound.App ||
+ RouteAccount(r) != RouteAccount(bound) || upstream(r) != up {
+ continue
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
+// rollbackGroup puts every group route whose link names the rejected version
+// onto restored (what bound now runs), stamps it to match, and writes a
+// refusal for bound and every group host.
+func rollbackGroup(reg *registry.Registry, optDir string, cfg passConfig, bound registry.Route,
+ ptr *ChannelPointer, rejected, restored, sha, platform string, nu *UnitNotUpError, now time.Time) {
+ hosts := []string{bound.Host}
+ for _, g := range processGroup(reg, bound) {
+ hosts = append(hosts, g.Host)
+ v, err := LiveVersion(optDir, g.Host)
+ if err != nil || v != rejected || restored == "" {
+ continue
+ }
+ if err := PointLive(optDir, g.Host, RouteAccount(g), restored, g.App); err != nil {
+ fmt.Printf(" %s: could not roll the symlink back to %s with its process: %v\n", g.Host, restored, err)
+ continue
+ }
+ if err := reg.SetVersions(g.Host, restored, g.PrevVersion); err != nil {
+ fmt.Printf(" %s: rolled back to %s but recording the version failed: %v\n", g.Host, restored, err)
+ }
+ }
+ if cfg.bakeDir == "" {
+ return
+ }
+ reason := nu.Reason
+ if j := strings.TrimSpace(nu.Journal); j != "" {
+ lines := strings.Split(j, "\n")
+ reason += " — " + lines[len(lines)-1]
+ }
+ for _, h := range hosts {
+ r := Refusal{Host: h, App: bound.App, Version: rejected, SHA256: sha, Platform: platform,
+ PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(), Reason: reason}
+ if err := WriteRefusal(cfg.bakeDir, r); err != nil {
+ fmt.Printf(" %s: writing refusal record: %v\n", h, err)
+ }
+ }
+}
+```
+
+`now` is the `now time.Time` that `adoptHost` already receives. Add `"time"`
+to `rollback.go`'s imports.
+
+- [ ] **Step 4: Wire it into `adoptHost` and `passConfig`**
+
+In `passConfig` (adopt.go:34) add:
+
+```go
+ // rollbackRestart replaces startUnit on D1's path only — a named unit
+ // whose restart returned *UnitNotUpError. It must REPLACE whatever is
+ // running (reset-failed + restart), because a deadline failure can leave
+ // the rejected build running unready and `start` is a no-op on it.
+ rollbackRestart func(host string) error
+```
+
+Below `WithStartUnit`, add:
+
+```go
+// WithRollbackRestart sets D1's rollback restart (see passConfig).
+func WithRollbackRestart(fn func(host string) error) PassOption {
+ return func(c *passConfig) { c.rollbackRestart = fn }
+}
+```
+
+In the `rerr != nil` branch of `adoptHost`, right after
+`poisoned[ver] = true`, record whether this is D1's case:
+
+```go
+ var notUp *UnitNotUpError
+ isNotUp := errors.As(rerr, &notUp)
+```
+
+Replace the final start block
+
+```go
+ if cfg.startUnit != nil && rolledBack && !daemonRestart {
+ if serr := cfg.startUnit(rt.Host); serr != nil {
+```
+
+with:
+
+```go
+ start := cfg.startUnit
+ if isNotUp && cfg.rollbackRestart != nil {
+ start = cfg.rollbackRestart
+ }
+ if isNotUp {
+ sha := ""
+ if m != nil {
+ sha = m.Artifacts[adoptPlatform].SHA256
+ }
+ rollbackGroup(reg, optDir, cfg, rt, ptr, ver, cur, sha, adoptPlatform, notUp, now)
+ if notUp.Journal != "" {
+ fmt.Printf(" %s: %s's last lines:\n%s\n", rt.Host, notUp.Unit, indent(notUp.Journal, " "))
+ }
+ }
+ if start != nil && rolledBack && !daemonRestart {
+ if serr := start(rt.Host); serr != nil {
+```
+
+Keep the rest of that block (the "could not start it" print) unchanged. Add
+`indent` to `rollback.go` if the package has no such helper:
+
+```go
+func indent(s, pre string) string {
+ return pre + strings.ReplaceAll(strings.TrimRight(s, "\n"), "\n", "\n"+pre)
+}
+```
+
+`adoptPlatform` (adopt.go:26, `runtime.GOOS + "-" + runtime.GOARCH`) is the
+box's own artifact key.
+
+- [ ] **Step 5: Run the release package tests**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -count=1`
+Expected: PASS, including every pre-existing restart-failure test. Those
+return plain errors, so `isNotUp` is false and their behaviour is unchanged.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/release/rollback.go internal/release/rollback_test.go internal/release/adopt.go
+git commit -m "release: roll back a unit's whole process group and refuse the build when it does not come up"
+```
+
+---
+
+### Task 4: The settle check
+
+**Files:**
+- Create: `internal/host/unitsettle.go`
+- Test: `internal/host/unitsettle_test.go`
+
+**Interfaces:**
+- Produces:
+ - `type unitProps struct { ActiveState, InvocationID string; MainPID, NRestarts int; ExecPath string }`
+ - `type unitSystem interface { Show(unit string) (unitProps, error); TryRestart(unit string) error; Restart(unit string) error; ResetFailed(unit string) error; JournalTail(unit string, n int) string; Exe(pid int) (string, error) }`
+ - `type systemdUnits struct{}` implementing it with `systemctl`/`journalctl`/`os.Readlink("/proc/<pid>/exe")`
+ - `func parseShow(out string) unitProps`
+ - `type settleOpts struct { Timeout, Poll, Hold time.Duration; WantDir string; Ready func() error; Now func() time.Time; Sleep func(time.Duration) }`
+ - `func settleUnit(sys unitSystem, unit, beforeInvocation string, o settleOpts) error`: nil or `*release.UnitNotUpError`
+ - `func unitSettleTimeout(getenv func(string) string) time.Duration`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type fakeUnits struct {
+ shows []unitProps // returned in order; the last repeats
+ n int
+ exe map[int]string
+ journal string
+ restarts []string
+}
+
+func (f *fakeUnits) Show(string) (unitProps, error) {
+ p := f.shows[min(f.n, len(f.shows)-1)]
+ f.n++
+ return p, nil
+}
+func (f *fakeUnits) TryRestart(u string) error { f.restarts = append(f.restarts, "try-restart "+u); return nil }
+func (f *fakeUnits) Restart(u string) error { f.restarts = append(f.restarts, "restart "+u); return nil }
+func (f *fakeUnits) ResetFailed(u string) error { f.restarts = append(f.restarts, "reset-failed "+u); return nil }
+func (f *fakeUnits) JournalTail(string, int) string { return f.journal }
+func (f *fakeUnits) Exe(pid int) (string, error) {
+ if e, ok := f.exe[pid]; ok {
+ return e, nil
+ }
+ return "", errors.New("no such pid")
+}
+
+func fakeClock() (func() time.Time, func(time.Duration)) {
+ t := time.Unix(0, 0)
+ return func() time.Time { return t }, func(d time.Duration) { t = t.Add(d) }
+}
+
+func opts(ready func() error) settleOpts {
+ now, sleep := fakeClock()
+ return settleOpts{Timeout: 30 * time.Second, Poll: 500 * time.Millisecond, Hold: 5 * time.Second,
+ WantDir: "/opt/carlos/versions/new", Ready: ready, Now: now, Sleep: sleep}
+}
+
+func TestParseShow(t *testing.T) {
+ p := parseShow("ActiveState=active\nInvocationID=abc\nMainPID=42\nNRestarts=0\n" +
+ "ExecStart={ path=/opt/carlos/live/platform.example.com ; argv[]=/opt/carlos/live/platform.example.com serve ; ignore_errors=no }\n")
+ if p.ActiveState != "active" || p.InvocationID != "abc" || p.MainPID != 42 ||
+ p.ExecPath != "/opt/carlos/live/platform.example.com" {
+ t.Fatalf("parseShow = %+v", p)
+ }
+}
+
+func TestSettleHealthyNewInvocation(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{
+ {ActiveState: "activating", InvocationID: "new"},
+ {ActiveState: "active", InvocationID: "new", MainPID: 7},
+ }, exe: map[int]string{7: "/opt/carlos/versions/new/console"}}
+ if err := settleUnit(f, "console.service", "old", opts(func() error { return nil })); err != nil {
+ t.Fatalf("settle = %v, want healthy", err)
+ }
+}
+
+// systemd resets NRestarts on a manual start: a healthy unit that had
+// auto-restarted before must still pass (spec §2.3, codex 4).
+func TestSettleIgnoresThePreviousInvocationsCounter(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7, NRestarts: 0}},
+ exe: map[int]string{7: "/opt/carlos/versions/new/console"}}
+ if err := settleUnit(f, "console.service", "old", opts(func() error { return nil })); err != nil {
+ t.Fatalf("settle = %v", err)
+ }
+}
+
+// Today's outage: the process exits in 55 ms and systemd cycles it.
+func TestSettleCatchesARestartLoop(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{
+ {ActiveState: "active", InvocationID: "new1", MainPID: 7},
+ {ActiveState: "activating", InvocationID: "new2", NRestarts: 1},
+ }, exe: map[int]string{7: "/opt/carlos/versions/new/console"}, journal: "usage: carlos <command> [flags]"}
+ err := settleUnit(f, "console.service", "old", opts(func() error { return errors.New("connection refused") }))
+ var nu *release.UnitNotUpError
+ if !errors.As(err, &nu) || !strings.Contains(nu.Journal, "usage: carlos") {
+ t.Fatalf("settle = %v, want UnitNotUpError carrying the journal", err)
+ }
+}
+
+func TestSettleCatchesFailed(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "failed", InvocationID: "new"}}}
+ var nu *release.UnitNotUpError
+ if err := settleUnit(f, "console.service", "old", opts(nil)); !errors.As(err, &nu) {
+ t.Fatalf("settle = %v, want UnitNotUpError", err)
+ }
+}
+
+func TestSettleDeadlineSaysNotReady(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}},
+ exe: map[int]string{7: "/opt/carlos/versions/new/console"}}
+ err := settleUnit(f, "console.service", "old", opts(func() error { return errors.New("dial: refused") }))
+ var nu *release.UnitNotUpError
+ if !errors.As(err, &nu) || !strings.Contains(nu.Reason, "not ready after 30s") {
+ t.Fatalf("settle = %v, want a not-ready deadline", err)
+ }
+}
+
+func TestSettleRejectsTheOldBinary(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}},
+ exe: map[int]string{7: "/opt/carlos/versions/old/console"}}
+ var nu *release.UnitNotUpError
+ if err := settleUnit(f, "console.service", "old", opts(func() error { return nil })); !errors.As(err, &nu) {
+ t.Fatalf("settle = %v, want not-up: the process runs the old build", err)
+ }
+}
+
+func TestSettleWithoutReadyHoldsFiveSeconds(t *testing.T) {
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}},
+ exe: map[int]string{7: "/opt/carlos/versions/new/console"}}
+ o := opts(nil)
+ start := o.Now()
+ if err := settleUnit(f, "console.service", "old", o); err != nil {
+ t.Fatalf("settle = %v", err)
+ }
+ if got := o.Now().Sub(start); got < 5*time.Second {
+ t.Fatalf("settled after %v, want at least the 5s hold", got)
+ }
+}
+
+func TestSettleSkipsAUnitThatWasNotRunning(t *testing.T) {
+ // beforeInvocation "" means the caller saw the unit inactive; settleUnit
+ // is not called at all in that case (Task 6). This pins the caller's
+ // contract from this side: an inactive unit that stays inactive after
+ // try-restart is "nothing started", not a failure.
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "inactive"}}}
+ if err := settleUnit(f, "console.service", "", opts(nil)); err != nil {
+ t.Fatalf("settle on a stopped unit = %v, want nil", err)
+ }
+}
+
+func TestUnitSettleTimeoutFloor(t *testing.T) {
+ env := func(v string) func(string) string { return func(string) string { return v } }
+ for in, want := range map[string]time.Duration{"": 30 * time.Second, "1s": 5 * time.Second,
+ "45s": 45 * time.Second, "junk": 30 * time.Second} {
+ if got := unitSettleTimeout(env(in)); got != want {
+ t.Fatalf("CARLOS_UNIT_SETTLE=%q -> %v, want %v", in, got, want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'Settle|ParseShow' -count=1`
+Expected: FAIL, `undefined: unitProps`.
+
+- [ ] **Step 3: Implement `unitsettle.go`**
+
+```go
+// The settle check — D1 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §2.2-§2.4).
+//
+// `systemctl try-restart` of a Type=simple unit returns 0 as soon as the start
+// job is queued, so it says nothing about whether the new build runs. On
+// 2026-10-06 the console's new process printed the CLI's usage and exited 2
+// after 55 ms while try-restart reported success, and adoptHost's rollback
+// never fired. settleUnit watches the unit after the restart and returns
+// *release.UnitNotUpError when the new build does not come up, which drives
+// that rollback.
+package host
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type unitProps struct {
+ ActiveState string
+ InvocationID string
+ MainPID int
+ NRestarts int
+ ExecPath string // ExecStart's executable path
+}
+
+type unitSystem interface {
+ Show(unit string) (unitProps, error)
+ TryRestart(unit string) error
+ Restart(unit string) error
+ ResetFailed(unit string) error
+ JournalTail(unit string, n int) string
+ Exe(pid int) (string, error)
+}
+
+type systemdUnits struct{}
+
+func (systemdUnits) Show(unit string) (unitProps, error) {
+ out, err := exec.Command("systemctl", "show", unit,
+ "-p", "ActiveState", "-p", "InvocationID", "-p", "MainPID", "-p", "NRestarts", "-p", "ExecStart").Output()
+ if err != nil {
+ return unitProps{}, fmt.Errorf("systemctl show %s: %w", unit, err)
+ }
+ return parseShow(string(out)), nil
+}
+
+func (systemdUnits) TryRestart(unit string) error { return systemctlTryRestartUnit(unit) }
+func (systemdUnits) Restart(unit string) error { return systemctlVerb("restart", unit) }
+func (systemdUnits) ResetFailed(unit string) error { return systemctlVerb("reset-failed", unit) }
+
+func (systemdUnits) JournalTail(unit string, n int) string {
+ out, _ := exec.Command("journalctl", "-u", unit, "-o", "cat", "-n", strconv.Itoa(n), "--no-pager").Output()
+ return strings.TrimSpace(string(out))
+}
+
+func (systemdUnits) Exe(pid int) (string, error) { return os.Readlink(fmt.Sprintf("/proc/%d/exe", pid)) }
+
+func systemctlVerb(verb, unit string) error {
+ out, err := exec.Command("systemctl", verb, unit).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("systemctl %s %s: %w: %s", verb, unit, err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+var execPathRE = regexp.MustCompile(`path=([^ ;]+)`)
+
+func parseShow(out string) unitProps {
+ var p unitProps
+ for _, line := range strings.Split(out, "\n") {
+ k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
+ if !ok {
+ continue
+ }
+ switch k {
+ case "ActiveState":
+ p.ActiveState = v
+ case "InvocationID":
+ p.InvocationID = v
+ case "MainPID":
+ p.MainPID, _ = strconv.Atoi(v)
+ case "NRestarts":
+ p.NRestarts, _ = strconv.Atoi(v)
+ case "ExecStart":
+ if m := execPathRE.FindStringSubmatch(v); m != nil {
+ p.ExecPath = m[1]
+ }
+ }
+ }
+ return p
+}
+
+type settleOpts struct {
+ Timeout time.Duration // the whole window (CARLOS_UNIT_SETTLE)
+ Poll time.Duration // 500ms
+ Hold time.Duration // no-probe readiness: active this long in one invocation
+ WantDir string // the new version's directory; MainPID's exe must be under it
+ Ready func() error // nil = no upstream to probe; Hold applies
+ Now func() time.Time
+ Sleep func(time.Duration)
+}
+
+// settleUnit watches unit after a try-restart. beforeInvocation is the
+// InvocationID before the restart; "" means the unit was not running, and an
+// inactive unit is then "nothing started", not a failure.
+func settleUnit(sys unitSystem, unit, beforeInvocation string, o settleOpts) error {
+ deadline := o.Now().Add(o.Timeout)
+ var inv string // the new invocation, once seen active
+ var activeSince time.Time
+ var lastReady error
+ notUp := func(reason string) error {
+ return &release.UnitNotUpError{Unit: unit, Reason: reason, Journal: sys.JournalTail(unit, 20)}
+ }
+ for {
+ p, err := sys.Show(unit)
+ if err != nil {
+ return notUp(err.Error())
+ }
+ if beforeInvocation == "" && p.ActiveState == "inactive" {
+ return nil
+ }
+ switch {
+ case p.ActiveState == "failed":
+ return notUp("unit entered failed state")
+ case inv != "" && p.InvocationID != inv:
+ return notUp("systemd restarted it (new invocation)")
+ case inv != "" && p.NRestarts > 0:
+ return notUp(fmt.Sprintf("systemd restarted it %d time(s)", p.NRestarts))
+ case inv == "" && p.ActiveState == "active" && p.InvocationID != beforeInvocation:
+ inv, activeSince = p.InvocationID, o.Now()
+ }
+ if inv != "" && p.ActiveState == "active" {
+ exe, err := sys.Exe(p.MainPID)
+ if err == nil && o.WantDir != "" && !strings.HasPrefix(exe, strings.TrimSuffix(o.WantDir, "/")+"/") {
+ return notUp(fmt.Sprintf("running %s, not the new build in %s", exe, o.WantDir))
+ }
+ if o.Ready != nil {
+ if lastReady = o.Ready(); lastReady == nil {
+ return nil
+ }
+ } else if o.Now().Sub(activeSince) >= o.Hold {
+ return nil
+ }
+ }
+ if !o.Now().Before(deadline) {
+ if lastReady != nil {
+ return notUp(fmt.Sprintf("not ready after %s: %v", o.Timeout, lastReady))
+ }
+ return notUp(fmt.Sprintf("not ready after %s", o.Timeout))
+ }
+ o.Sleep(o.Poll)
+ }
+}
+
+func unitSettleTimeout(getenv func(string) string) time.Duration {
+ d, err := time.ParseDuration(strings.TrimSpace(getenv("CARLOS_UNIT_SETTLE")))
+ switch {
+ case err != nil || d <= 0:
+ return 30 * time.Second
+ case d < 5*time.Second:
+ return 5 * time.Second
+ }
+ return d
+}
+```
+
+`systemctlTryRestartUnit` already exists in agent.go and is reused, not
+duplicated.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'Settle|ParseShow' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/unitsettle.go internal/host/unitsettle_test.go
+git commit -m "host: settle check for a named unit after try-restart"
+```
+
+---
+
+### Task 5: Readiness on the effective upstream
+
+**Files:**
+- Modify: `internal/host/unitsettle.go` (append)
+- Test: `internal/host/unitsettle_test.go` (append)
+
+**Interfaces:**
+- Produces: `func routeReady(rt registry.Route, timeout time.Duration) func() error`, which returns nil when the route has no upstream.
+- Consumes: `provision.Health` and `.WithDefaults()` (internal/provision/provision.go:189).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestRouteReadyProbesTheUnixSocket(t *testing.T) {
+ sock := filepath.Join(t.TempDir(), "s.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/healthz" && r.Host == "console.example.com" {
+ w.WriteHeader(200)
+ return
+ }
+ w.WriteHeader(500)
+ })}
+ go srv.Serve(ln)
+ t.Cleanup(func() { srv.Close() })
+ rt := registry.Route{Host: "console.example.com", Socket: sock, Addr: "127.0.0.1:1",
+ Health: `{"path":"/healthz","status":200}`}
+ if err := routeReady(rt, time.Second)(); err != nil {
+ t.Fatalf("ready over the socket = %v (Addr must be ignored when Socket is set)", err)
+ }
+}
+
+func TestRouteReadyWrongStatus(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) }))
+ t.Cleanup(srv.Close)
+ rt := registry.Route{Host: "x.example.com", Addr: strings.TrimPrefix(srv.URL, "http://"),
+ Health: `{"path":"/healthz","status":200}`}
+ if err := routeReady(rt, time.Second)(); err == nil || !strings.Contains(err.Error(), "503") {
+ t.Fatalf("ready = %v, want a 503 failure", err)
+ }
+}
+
+func TestRouteReadyNoProbeIsAConnect(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ if err := routeReady(registry.Route{Host: "x", Addr: ln.Addr().String()}, time.Second)(); err != nil {
+ t.Fatalf("connect readiness = %v", err)
+ }
+}
+
+func TestRouteReadyNoUpstreamIsNil(t *testing.T) {
+ if routeReady(registry.Route{Host: "x"}, time.Second) != nil {
+ t.Fatal("a route with no upstream got a ready check; settle must use the hold instead")
+ }
+}
+```
+
+Add `net`, `net/http`, `net/http/httptest`, `path/filepath` and
+`github.com/carlosframework/platform/internal/registry` to the test imports.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'RouteReady' -count=1`
+Expected: FAIL, `undefined: routeReady`.
+
+- [ ] **Step 3: Implement**
+
+Append to `unitsettle.go` (add `context`, `encoding/json`, `io`, `net`,
+`net/http`, `registry` and `provision` imports):
+
+```go
+// routeReady is the readiness check for rt's effective upstream (spec §2.4):
+// Socket wins over Addr. With a configured probe it is an HTTP GET; without
+// one, a connect. nil when the route has no upstream: settle then requires
+// the unit to stay active for its Hold.
+func routeReady(rt registry.Route, timeout time.Duration) func() error {
+ network, address := "unix", rt.Socket
+ if address == "" {
+ network, address = "tcp", rt.Addr
+ }
+ if address == "" {
+ return nil
+ }
+ if rt.Health == "" {
+ return func() error {
+ c, err := net.DialTimeout(network, address, timeout)
+ if err != nil {
+ return fmt.Errorf("connect %s: %w", address, err)
+ }
+ return c.Close()
+ }
+ }
+ var h provision.Health
+ if err := json.Unmarshal([]byte(rt.Health), &h); err != nil {
+ return func() error { return fmt.Errorf("route health probe does not parse: %w", err) }
+ }
+ h = h.WithDefaults()
+ hostHdr := h.HostHeader
+ if hostHdr == "" {
+ hostHdr = rt.Host
+ }
+ client := &http.Client{Timeout: timeout, Transport: &http.Transport{
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, network, address)
+ },
+ }}
+ return func() error {
+ req, err := http.NewRequest(http.MethodGet, "http://upstream"+h.Path, nil)
+ if err != nil {
+ return err
+ }
+ req.Host = hostHdr
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("dial: %v", err)
+ }
+ io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
+ resp.Body.Close()
+ if resp.StatusCode != h.Status {
+ return fmt.Errorf("status %d (want %d)", resp.StatusCode, h.Status)
+ }
+ return nil
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'RouteReady|Settle' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/unitsettle.go internal/host/unitsettle_test.go
+git commit -m "host: readiness on a route's effective upstream, socket before addr"
+```
+
+---
+
+### Task 6: Wire the checked restart and the rollback restart
+
+**Files:**
+- Modify: `internal/host/agent.go`: `makeRestartRoute` (3101) gets `tryRestartUnit func(rt registry.Route) error`; new `checkedUnitRestart` and `rollbackUnitRestart`; callers at 820, 1931, 3243; `RunAdopt` pass options at 3660; the agent loop options at 871 and 1500.
+- Modify: `internal/host/agent_test.go`: the seven `makeRestartRoute(` call sites wrap their unit fake with `unitOnly(...)`.
+- Test: `internal/host/unitsettle_test.go` (append)
+
+**Interfaces:**
+- Consumes: Tasks 4 and 5 (`unitSystem`, `settleUnit`, `settleOpts`, `routeReady`, `unitSettleTimeout`); Task 3's `release.WithRollbackRestart`.
+- Produces:
+ - `func unitOnly(f func(unit string) error) func(rt registry.Route) error`
+ - `func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string) func(rt registry.Route) error`
+ - `func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string) func(host string) error`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func boundRoute() registry.Route {
+ return registry.Route{Host: "platform.example.com", App: "console", Unit: "console.service", Addr: "127.0.0.1:1"}
+}
+
+func liveTo(t *testing.T, opt, host, ver string) {
+ t.Helper()
+ dir := filepath.Join(opt, "versions", ver)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "console"), []byte("x"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(filepath.Join(opt, "live", host))
+ if err := os.Symlink(filepath.Join(dir, "console"), filepath.Join(opt, "live", host)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNamedRestartBindingMismatchKeepsTheError(t *testing.T) {
+ opt := t.TempDir()
+ liveTo(t, opt, "platform.example.com", "new")
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "old",
+ ExecPath: filepath.Join(opt, "live", "console.example.com")}}}
+ failing := &failingTry{fakeUnits: f}
+ err := checkedUnitRestart(failing, opt, func(string) string { return "" })(boundRoute())
+ if err == nil || !strings.Contains(err.Error(), "try-restart failed") {
+ t.Fatalf("err = %v, want the try-restart error propagated (codex 12)", err)
+ }
+}
+
+type failingTry struct{ *fakeUnits }
+
+func (failingTry) TryRestart(string) error { return errors.New("try-restart failed") }
+
+func TestNamedRestartShowFailsFallsBack(t *testing.T) {
+ opt := t.TempDir()
+ sys := &showFails{fakeUnits: &fakeUnits{}}
+ if err := checkedUnitRestart(sys, opt, func(string) string { return "" })(boundRoute()); err != nil {
+ t.Fatalf("err = %v, want today's behaviour (plain try-restart, its nil)", err)
+ }
+ if len(sys.restarts) != 1 || sys.restarts[0] != "try-restart console.service" {
+ t.Fatalf("restarts = %v", sys.restarts)
+ }
+}
+
+type showFails struct{ *fakeUnits }
+
+func (showFails) Show(string) (unitProps, error) { return unitProps{}, errors.New("Access denied") }
+
+func TestNamedRestartStoppedUnitIsNotChecked(t *testing.T) {
+ opt := t.TempDir()
+ liveTo(t, opt, "platform.example.com", "new")
+ f := &fakeUnits{shows: []unitProps{{ActiveState: "inactive", ExecPath: filepath.Join(opt, "live", "platform.example.com")}}}
+ if err := checkedUnitRestart(f, opt, func(string) string { return "" })(boundRoute()); err != nil {
+ t.Fatalf("err = %v, want nil for an operator-stopped unit", err)
+ }
+}
+
+func TestRollbackRestartResetsThenRestarts(t *testing.T) {
+ opt := t.TempDir()
+ liveTo(t, opt, "platform.example.com", "old")
+ reg := restartTestRegistry(t)
+ if err := reg.Upsert(boundRoute()); err != nil {
+ t.Fatal(err)
+ }
+ f := &fakeUnits{shows: []unitProps{
+ {ActiveState: "active", InvocationID: "bad"},
+ {ActiveState: "active", InvocationID: "restored", MainPID: 9},
+ }, exe: map[int]string{9: filepath.Join(opt, "versions", "old", "console")}}
+ rb := rollbackUnitRestart(reg, f, opt, func(string) string { return "" })
+ if err := rb("platform.example.com"); err != nil {
+ t.Fatalf("rollback = %v", err)
+ }
+ if strings.Join(f.restarts, ",") != "reset-failed console.service,restart console.service" {
+ t.Fatalf("restarts = %v, want reset-failed then restart (never start)", f.restarts)
+ }
+}
+```
+
+`restartTestRegistry` is agent_test.go's existing constructor (line 539).
+In `TestRollbackRestartResetsThenRestarts`, upsert `boundRoute()` with
+`Addr` cleared (`r := boundRoute(); r.Addr = ""`). With no upstream,
+`routeReady` returns nil and settle uses the hold instead of dialling.
+`rollbackUnitRestart` uses the real clock, so this test takes about 5 s,
+which is acceptable for one test. The binding test keeps `Addr` because it
+returns before readiness.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'NamedRestart|RollbackRestart' -count=1`
+Expected: FAIL, `undefined: checkedUnitRestart`.
+
+- [ ] **Step 3: Implement**
+
+In agent.go, change `makeRestartRoute`'s signature and its first arm:
+
+```go
+func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), tryRestartUnit func(rt registry.Route) error, bounce func(host string) bool) func(host string) error {
+ return func(host string) error {
+ rt, err := reg.Get(host)
+ if err != nil {
+ log.Printf("adopt: registry lookup %s: %v (restarting as a unit)", host, err)
+ }
+ if err == nil && rt != nil && rt.Unit != "" {
+ return tryRestartUnit(*rt)
+ }
+```
+
+(the rest unchanged). Add, next to `systemctlTryRestartUnit`:
+
+```go
+// unitOnly adapts a unit-name restart to makeRestartRoute's route-aware arm.
+func unitOnly(f func(unit string) error) func(rt registry.Route) error {
+ return func(rt registry.Route) error { return f(rt.Unit) }
+}
+
+// checkedUnitRestart is the named-unit arm with D1's settle check (spec
+// §2.2-§2.3). The binding is verified first: the check judges a restart only
+// when the unit's ExecStart runs THIS route's live link. Anything it cannot
+// establish (show denied, wrong binding, unit stopped) falls back to today's
+// plain try-restart and returns that command's own error.
+func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string) func(rt registry.Route) error {
+ return func(rt registry.Route) error {
+ before, err := sys.Show(rt.Unit)
+ if err != nil {
+ log.Printf("adopt: %s: cannot read %s (%v); restart not checked", rt.Host, rt.Unit, err)
+ return sys.TryRestart(rt.Unit)
+ }
+ link := filepath.Join(optDir, "live", rt.Host)
+ if before.ExecPath != link {
+ log.Printf("adopt: binding: %s runs %s, but the unit stamp is on %s; restart not checked", rt.Unit, before.ExecPath, rt.Host)
+ return sys.TryRestart(rt.Unit)
+ }
+ if before.ActiveState != "active" {
+ return sys.TryRestart(rt.Unit) // stopped by an operator: stays a no-op
+ }
+ want, err := filepath.EvalSymlinks(link)
+ if err != nil {
+ return sys.TryRestart(rt.Unit)
+ }
+ if err := sys.TryRestart(rt.Unit); err != nil {
+ return err
+ }
+ timeout := unitSettleTimeout(getenv)
+ err = settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{
+ Timeout: timeout, Poll: 500 * time.Millisecond, Hold: 5 * time.Second,
+ WantDir: filepath.Dir(want), Ready: routeReady(rt, 2*time.Second),
+ Now: time.Now, Sleep: time.Sleep})
+ if err == nil {
+ log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want))
+ }
+ return err
+ }
+}
+
+// rollbackUnitRestart is D1's rollback restart (release.WithRollbackRestart):
+// the live link already names the restored build; replace whatever is
+// running (a deadline failure can leave the rejected build up) and check
+// the restored build comes up too.
+func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string) func(host string) error {
+ return func(host string) error {
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil || rt.Unit == "" {
+ return nil
+ }
+ before, _ := sys.Show(rt.Unit)
+ if err := sys.ResetFailed(rt.Unit); err != nil {
+ log.Printf("adopt: %s: reset-failed %s: %v (continuing)", host, rt.Unit, err)
+ }
+ if err := sys.Restart(rt.Unit); err != nil {
+ return err
+ }
+ want, err := filepath.EvalSymlinks(filepath.Join(optDir, "live", host))
+ if err != nil {
+ return err
+ }
+ if err := settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{
+ Timeout: unitSettleTimeout(getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second,
+ WantDir: filepath.Dir(want), Ready: routeReady(*rt, 2*time.Second),
+ Now: time.Now, Sleep: time.Sleep}); err != nil {
+ return fmt.Errorf("rollback did not come up either: %w", err)
+ }
+ return nil
+ }
+}
+```
+
+`settleUnit` treats `beforeInvocation == ""` with an inactive unit as nothing
+started. In `rollbackUnitRestart` the unit has just been `restart`ed, so
+`before.InvocationID` is whatever ran before. A failed unit keeps its last
+InvocationID, and a new one appears on restart, which is what settle keys on.
+
+Callers:
+- line 820 and line 1931: replace `systemctlTryRestartUnit` with
+ `checkedUnitRestart(systemdUnits{}, optDir, os.Getenv)`, using whatever
+ opt-dir variable is in scope there (`d.optDir` on the fast path).
+- line 3243 `rootRestartRoute`: the same, with the opt dir its caller uses
+ (`RunAdopt`'s). Add an `optDir string` parameter to `rootRestartRoute` and
+ pass it from its one caller.
+- `RunAdopt`'s `passOpts` (3660): append
+ `release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))`.
+- the agent loop options at 871 and 1500: append the same option. The
+ sandboxed agent's `reset-failed`/`restart` may be denied; that is logged
+ and the rollback line says "could not start it", the same as today's start
+ path there.
+
+In agent_test.go, change each `makeRestartRoute(reg, X, Y, Z)` whose third
+argument is a `func(string) error` to `makeRestartRoute(reg, X, unitOnly(Y), Z)`.
+
+- [ ] **Step 4: Run the host and release suites**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host ./internal/release -count=1`
+Expected: PASS. Per `[[cpu-budget-docs-t1]]`, run them niced and one at a time if the box is busy: `nice -n 10`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/agent.go internal/host/agent_test.go internal/host/unitsettle_test.go
+git commit -m "host: named-unit restarts settle and roll back by restart (console defences D1)"
+```
+
+---
+
+### Task 7: `carlos promote --retry`
+
+**Files:**
+- Modify: `internal/release/release.go`: `PromoteOptions` (786) adds `Retry bool`; `Promotion` (144) adds `Retry bool \`json:"retry,omitempty"\``; `PromoteOnLadder`'s repeat branch.
+- Modify: `cli.go` (2356: flag; 2397 and 2417: pass it)
+- Modify: `apimode.go:947` (`promote` payload)
+- Modify: `internal/console/apicli.go` (743: request field; 899: option)
+- Test: `internal/release/release_test.go` (append), `internal/console/apicli_test.go` (append)
+
+**Interfaces:**
+- Produces: `PromoteOptions.Retry`. With Retry, a repeat is re-signed with a fresh `PromotedAt`. Without a repeat it is an error: `promote --retry renews a promotion: <channel> names <cur>, not <version>`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestPromoteRetryRenewsPromotedAt(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("b"))
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ time.Sleep(1100 * time.Millisecond) // PromotedAt is truncated to the second
+ res, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{})
+ if err != nil || !res.NoOp {
+ t.Fatalf("plain repeat = %+v, %v; want the existing no-op", res, err)
+ }
+ same, _ := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ if !same.PromotedAt.Equal(before.PromotedAt) {
+ t.Fatal("a plain repeat rewrote the pointer")
+ }
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{Retry: true}); err != nil {
+ t.Fatalf("retry: %v", err)
+ }
+ after, _ := Channel(ctx, s, pub, "bab", "console", "canary/x")
+ if after.Version != "v1" || !after.PromotedAt.After(before.PromotedAt) {
+ t.Fatalf("after retry = %+v, want v1 with a later PromotedAt", after)
+ }
+}
+
+func TestPromoteRetryNeverChangesTheVersion(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, _ := testKeys(t)
+ shipRelease(t, s, "bab", "console", "v1", adoptPlatform, []byte("a"))
+ shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("b"))
+ if _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v1", PromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ _, err := PromoteOnLadder(ctx, s, priv, "bab", "console", "canary/x", "v2", PromoteOptions{Retry: true})
+ if err == nil || !strings.Contains(err.Error(), "renews a promotion") {
+ t.Fatalf("retry onto a different version = %v, want a refusal", err)
+ }
+}
+```
+
+For the API door, add one test beside the existing `handleAPIPromote` tests in
+`internal/console/apicli_test.go`. Copy the nearest existing promote test's
+setup verbatim, send `{"version":"v1","channel":"canary/x","retry":true}`
+after a first promote, and assert 200 and that the stored pointer's
+`PromotedAt` moved.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'PromoteRetry' -count=1`
+Expected: FAIL, `unknown field Retry`.
+
+- [ ] **Step 3: Implement**
+
+`PromoteOptions`: add
+
+```go
+ // Retry re-signs a pointer that already names this version, with a
+ // fresh PromotedAt: the way past a box's D1 refusal (refusal.go), which
+ // blocks one promotion, not a version. Never changes the version.
+ Retry bool
+```
+
+`Promotion`: add `Retry bool \`json:"retry,omitempty"\``.
+
+In `PromoteOnLadder`'s repeat branch, before `res.NoOp = true`, insert:
+
+```go
+ if opt.Retry {
+ res.From = res.To
+ rec := Promotion{From: res.To, To: res.To, Actor: opt.Actor, Retry: true}
+ if !time.Now().UTC().Truncate(time.Second).After(p.PromotedAt) {
+ time.Sleep(time.Until(p.PromotedAt.Add(time.Second)))
+ }
+ if err := sg.promote(ctx, s, acct, app, channel, version, opt.Authorize, rec); err != nil {
+ return res, err
+ }
+ return res, nil
+ }
+```
+
+and after the repeat branch's closing brace (so it is reached only when the
+pointer is absent or names another version), insert:
+
+```go
+ if opt.Retry {
+ cur := "nothing"
+ if c, _, err := s.Get(ctx, ChannelKey(acct, app, channel)); err == nil {
+ if p, verr := verifyPointer(pub, c, acct, app, channel); verr == nil {
+ cur = p.Version
+ }
+ }
+ return res, fmt.Errorf("promote --retry renews a promotion: %s names %s, not %s", channel, cur, version)
+ }
+```
+
+`cli.go`: next to `hotfix := fs.Bool("hotfix", ...)` (2356) add
+
+```go
+ retry := fs.Bool("retry", false, "re-sign the channel's current promotion of this version with a fresh time, so a box that refused it tries again")
+```
+
+and pass it: `release.PromoteOptions{Hotfix: *hotfix, Retry: *retry, Actor: promoteActor()}`
+(2417), and in API mode `sess.promote(..., *hotfix, *retry, message)`.
+
+`apimode.go:947`: add a `retry bool` parameter after `hotfix`, and a
+`Retry bool \`json:"retry,omitempty"\`` field in the payload. Update its one
+other caller if any (grep `sess.promote(` and `.promote(ctx`).
+
+`internal/console/apicli.go`: add `Retry bool \`json:"retry"\`` to the request
+struct (743), and `Retry: req.Retry` to the `PromoteOptions` at 899. The retry
+goes through the same `Authorize` and scope checks as any promote.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release ./internal/console -run 'Promote' -count=1 && GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...`
+Expected: PASS, and the build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/release.go internal/release/release_test.go cli.go apimode.go internal/console/apicli.go internal/console/apicli_test.go
+git commit -m "promote --retry: re-sign a promotion so a box retries a refused build"
+```
+
+---
+
+### Task 8: The alert and the `status --live` line
+
+**Files:**
+- Create: `internal/host/refusalwatch.go`
+- Modify: `internal/host/alertsink.go` (add `deliverRelease`)
+- Modify: `internal/status/snapshot.go` (`ReleaseRefusal` type; `Probes.ReleaseRefusals`; `BoxMetrics.ReleaseRefusals`; the copy at 719 and the emptiness check at 700)
+- Modify: `internal/host/agent.go:2939` (feed the probe), and the metrics loop (`internal/host/metrics_loop.go:511` neighbourhood) to call the watcher each tick
+- Modify: `cli.go:3418` `printLiveBoxes`
+- Test: `internal/host/refusalwatch_test.go`, `cli_test.go` (the existing `printLiveBoxes` test, extended)
+
+**Interfaces:**
+- Consumes: Task 1's `release.ListRefusals`.
+- Produces:
+ - `type ReleaseRefusal struct { Host, Version, Reason string; RefusedAt time.Time }` (status package, JSON `host`, `version`, `reason`, `refused_at`)
+ - `type refusalWatch struct { seen map[string]time.Time }` with `func (w *refusalWatch) tick(ctx context.Context, bakeDir string, sinks *alertSinks, now time.Time) []status.ReleaseRefusal`
+ - `func (a *alertSinks) deliverRelease(ctx context.Context, r release.Refusal)`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestRefusalWatchAlertsOncePerRecord(t *testing.T) {
+ bake := t.TempDir()
+ now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC)
+ if err := release.WriteRefusal(bake, release.Refusal{Host: "console.example.com", App: "console",
+ Version: "v2", RefusedAt: now.Add(-time.Minute), Reason: "exited: status=2"}); err != nil {
+ t.Fatal(err)
+ }
+ var posts []string
+ sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{})
+ w := &refusalWatch{}
+ got := w.tick(context.Background(), bake, sinks, now)
+ w.tick(context.Background(), bake, sinks, now.Add(time.Minute))
+ if len(posts) != 1 || !strings.Contains(posts[0], `"release_refused"`) || !strings.Contains(posts[0], "v2") {
+ t.Fatalf("posts = %v, want exactly one release_refused alert", posts)
+ }
+ if len(got) != 1 || got[0].Host != "console.example.com" {
+ t.Fatalf("status rows = %+v", got)
+ }
+}
+
+func TestRefusalWatchSkipsOldRecordsAtStartup(t *testing.T) {
+ bake := t.TempDir()
+ now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
+ if err := release.WriteRefusal(bake, release.Refusal{Host: "a.example.com", Version: "v2",
+ RefusedAt: now.Add(-2 * time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+ var posts int
+ sinks := newAlertSinks("https://hook.example", func(string, []byte) error { posts++; return nil }, "", nil, &alertCounters{})
+ (&refusalWatch{}).tick(context.Background(), bake, sinks, now)
+ if posts != 0 {
+ t.Fatalf("an agent restart re-alerted a refusal from two hours ago")
+ }
+}
+```
+
+In `cli_test.go`, extend the existing `printLiveBoxes` test (grep
+`printLiveBoxes(` in `*_test.go`) with a box whose `Metrics.ReleaseRefusals`
+has one row. Assert that the output contains
+`refused console.example.com v2 since 2026-10-06T09:30:40Z: exited: status=2`.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host -run 'RefusalWatch' -count=1`
+Expected: FAIL, `undefined: refusalWatch`.
+
+- [ ] **Step 3: Implement**
+
+`internal/status/snapshot.go`, beside `ConfigRefusal`:
+
+```go
+// ReleaseRefusal is a build this box rolled back because its unit did not
+// come up (D1, release.Refusal). PRIVATE like the rest of Box.Metrics: Host
+// is a real route name.
+type ReleaseRefusal struct {
+ Host string `json:"host"`
+ Version string `json:"version"`
+ Reason string `json:"reason"`
+ RefusedAt time.Time `json:"refused_at"`
+}
+```
+
+Add `ReleaseRefusals []ReleaseRefusal` to `Probes` and to the metrics struct
+that `ConfigRefusals` is copied into at line 719, with JSON key
+`release_refusals,omitempty`. Copy it there the same way, and add
+`len(p.ReleaseRefusals) > 0 ||` to the emptiness check at 700.
+
+`internal/host/alertsink.go`, after `deliver`:
+
+```go
+// deliverRelease sends a D1 refusal to every configured sink. The webhook
+// body is the record with kind "release_refused"; SNS gets a sentence.
+func (a *alertSinks) deliverRelease(ctx context.Context, r release.Refusal) {
+ if a == nil {
+ return
+ }
+ if a.webhookURL != "" && a.post != nil {
+ body, err := json.Marshal(struct {
+ Kind string `json:"kind"`
+ release.Refusal
+ }{"release_refused", r})
+ if err == nil {
+ if err := a.post(a.webhookURL, body); err != nil {
+ a.counts.webhook.Add(1)
+ log.Printf("host: alert webhook post (release refusal %s): %v", r.Host, err)
+ }
+ }
+ }
+ if a.topicARN != "" && a.pub != nil {
+ subject := fmt.Sprintf("%s: %s did not start, rolled back", r.Host, r.Version)
+ if len(subject) > 100 { // SNS subject limit
+ subject = subject[:100]
+ }
+ msg := fmt.Sprintf("%s refused %s at %s: %s\n\nRetry with: carlos promote --retry %s <channel>",
+ r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), r.Reason, r.Version)
+ pctx, cancel := context.WithTimeout(ctx, alertPublishTimeout)
+ defer cancel()
+ if _, err := a.pub.Publish(pctx, &sns.PublishInput{TopicArn: &a.topicARN, Subject: &subject, Message: &msg}); err != nil {
+ a.counts.sns.Add(1)
+ log.Printf("host: alert sns publish (release refusal %s): %v", r.Host, err)
+ }
+ }
+}
+```
+
+`internal/host/refusalwatch.go`:
+
+```go
+// refusalWatch turns D1's refusal records (release.Refusal, written by the
+// root pass) into one alert each and a status row each. It runs on the
+// agent's metrics tick. A record older than refusalAlertWindow when first
+// seen is not alerted, so an agent restart does not re-send old news.
+package host
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+const refusalAlertWindow = 30 * time.Minute
+
+type refusalWatch struct{ seen map[string]time.Time }
+
+func (w *refusalWatch) tick(ctx context.Context, bakeDir string, sinks *alertSinks, now time.Time) []status.ReleaseRefusal {
+ if w.seen == nil {
+ w.seen = map[string]time.Time{}
+ }
+ recs, err := release.ListRefusals(bakeDir)
+ if err != nil {
+ log.Printf("agent: refusals: %v", err)
+ return nil
+ }
+ var rows []status.ReleaseRefusal
+ for _, r := range recs {
+ rows = append(rows, status.ReleaseRefusal{Host: r.Host, Version: r.Version, Reason: r.Reason, RefusedAt: r.RefusedAt})
+ if last, ok := w.seen[r.Host]; ok && !r.RefusedAt.After(last) {
+ continue
+ }
+ w.seen[r.Host] = r.RefusedAt
+ if now.Sub(r.RefusedAt) <= refusalAlertWindow {
+ sinks.deliverRelease(ctx, r)
+ }
+ }
+ return rows
+}
+```
+
+Wire it. In the metrics loop, keep one `refusalWatch` for the life of the
+loop. Each tick, call `rows := watch.tick(ctx, dataDir(), sinks, now)` and
+store the rows where the probe at agent.go:2939 can read them. Follow the
+`configRefusalProbe` pattern: a package-level `atomic.Pointer[[]status.ReleaseRefusal]`
+written by the tick and read by a `releaseRefusalProbe()` that fills
+`p.ReleaseRefusals`.
+
+`cli.go` `printLiveBoxes`: after the box's main `Fprintf`, add
+
+```go
+ for _, r := range m.ReleaseRefusals {
+ fmt.Fprintf(w, " refused %s %s since %s: %s\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), r.Reason)
+ }
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/host ./internal/status . -run 'RefusalWatch|LiveBoxes|Snapshot' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/refusalwatch.go internal/host/refusalwatch_test.go internal/host/alertsink.go internal/host/agent.go internal/host/metrics_loop.go internal/status/snapshot.go cli.go cli_test.go
+git commit -m "host: alert on a refused build and show it in status --live"
+```
+
+---
+
+### Task 9: Full gate and the README operator note
+
+**Files:**
+- Modify: `README.md`: under "Releasing the platform", in the release-notes area that lists operator checks, add the note below.
+
+- [ ] **Step 1: Add the note**
+
+```markdown
+**Named units now settle (console release defences, D1).** After adopting a
+build for a route with a named unit (the console), the box waits up to 30 s
+(`CARLOS_UNIT_SETTLE`) for the unit to stay up on the new build and pass its
+health probe. If it does not, the box rolls back, refuses that promotion and
+alerts. Retry with `carlos promote --retry <version> <channel>`. The check
+only runs when the unit's `ExecStart` names the stamped route's own live
+link; `adopt` logs `binding: … restart not checked` where it does not, and
+that box needs its stamp moved (2026-08-23 console design §4c).
+```
+
+This is operator documentation, not member-facing copy. Keep its sentences
+plain and avoid em dashes in new prose (`[[copy-voice-no-em-dashes]]`).
+
+- [ ] **Step 2: Run the full gate**
+
+Run, niced and serial (`[[cpu-budget-docs-t1]]`):
+
+```bash
+GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go vet ./... && \
+GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go test ./... -count=1 && \
+GOOS=darwin GOARCH=arm64 GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./... && \
+GOOS=windows GOARCH=amd64 GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...
+```
+
+Expected: all pass. `unitsettle.go` uses no linux-only syscalls (exec and
+`/proc` reads only), so it needs no build tag (`[[cross-compile-gate]]`). If
+the cross build fails on it anyway, split the `systemdUnits` methods into
+`unitsettle_linux.go` with a `!linux` stub returning an error from `Show`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "README: operator note for the named-unit settle check"
+```