carlos / platform Public

plan: carlos deploy — 10 tasks, spec corrected with 4 planning findings

Corrections folded into the spec: canary/* name-scope stays (script
promotes never bump); warm exec children need activator Bounce on adopt;
the header requires a content-accurate registry fingerprint (closes #15);
deploy is API-mode-only with an additive held field on promote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell bb6281551c5aa27323129e0a3df18d61bbc56061 parent c933ebd
2 files changed, +938 −19
  • docs/superpowers/plans/2026-08-08-carlos-deploy.md +894 −0
  • docs/superpowers/specs/2026-08-08-carlos-deploy-design.md +44 −19
diff --git a/docs/superpowers/plans/2026-08-08-carlos-deploy.md b/docs/superpowers/plans/2026-08-08-carlos-deploy.md
new file mode 100644
index 000000000..2bd8e34b8
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-carlos-deploy.md
@@ -0,0 +1,894 @@
+# carlos deploy Implementation Plan
+
+> **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:** `carlos deploy` gets a build serving on its URL in single-digit seconds: ship → promote → watch, backed by a bucket deploy signal, a generalized fast-converge loop, on-demand cold-wake staging, and an `X-Carlos-Version` proof header.
+
+**Architecture:** Pull-only. The console bumps one bucket object (`control/serial`) on writes that create box work; boxes poll that single object every 2s and run the ordinary verified pass set on change. A fast set (canary/* by name ∪ unstaged routes, with per-host backoff) sweeps at 2s until drained. `ExecBackend.Materialize` stages the channel head at cold wake. The edge stamps `X-Carlos-Version` from the route table's `Version` column; the registry fingerprint becomes content-accurate so version swaps actually reload the edge (closes #15).
+
+**Tech Stack:** Go, sqlite registry, S3/dir release store, systemd units, console HTTP API.
+
+**Spec:** `docs/superpowers/specs/2026-08-08-carlos-deploy-design.md` — read it before starting; it explains every "why" below.
+
+## Global Constraints
+
+- Build gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` — all packages clean; `gofmt -l` clean for files you touch (three pre-existing unformatted files elsewhere stay alone).
+- Sandbox: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; if the toolchain reports "read-only file system", run with the sandbox disabled.
+- `GOOS=darwin` must compile: `internal/host` links into the CLI people run on Macs — no Linux-only types on any path you touch (CI enforces).
+- Wire shapes are additive: live pointers/manifests in production buckets must still parse. The two NEW shapes (serial object, response header) are ignored-by-old-parsers by construction. Never change existing pointer/manifest fields.
+- `internal/release/laddercheck_test.go` pins production channel names and must stay green untouched.
+- Any test that shells the carlos binary MUST build env via `carlosTestEnv` (defined in `e2e_test.go:81`) AND set `cmd.Dir` to a scratch dir — the binary walks cwd ancestors for `.carlos/config`; a miss mints real accounts on the production console.
+- Console security-gate order (session → CSRF → owner 404 → freshAuth) is untouched by every task here; bump calls go AFTER a handler's successful write, never before its gates.
+- The in-process agent loops run inside `carlos-edge.service` (ProtectSystem=strict, ReadWritePaths=/data /run/carlos): never add `release.NagExpiredCanaries` or `release.WithCapabilities` to any in-process pass — carry the existing DO-NOT comments from `canaryAdoptLoop` into its successor verbatim.
+- Delegation note (CLAUDE.md): Tasks 1, 2, 7 are Sonnet-grade from this brief; Tasks 3, 4, 5 are converge-grade/root-adjacent (Opus/Fable per policy) and their review is Opus minimum; Task 9 is Sonnet with Opus review (it mints a user-facing verb).
+
+---
+
+### Task 1: `control/serial` — the deploy signal object
+
+**Files:**
+- Create: `internal/release/serial.go`
+- Test: `internal/release/serial_test.go`
+
+**Interfaces:**
+- Consumes: `release.Store` (Get/Put semantics: Get not-found is `os.ErrNotExist`-wrapped; Put ifMatch "" = create-only, non-empty = replace-only, race = `ErrCASConflict`).
+- Produces: `const SerialKey = "control/serial"`, `type SerialDoc struct { Schema int; Serial int64; BumpedAt string }` (JSON `schema`, `serial`, `bumped_at`), `func ReadSerial(ctx context.Context, s Store) (SerialDoc, string, error)` (doc, etag; missing object → zero doc, "" etag, nil error), `func BumpSerial(ctx context.Context, s Store, now time.Time) error`. Tasks 5 and 6 call these.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestSerialBumpFromMissing(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ doc, etag, err := ReadSerial(ctx, s)
+ if err != nil || doc.Serial != 0 || etag != "" {
+ t.Fatalf("missing serial should read as zero: %+v %q %v", doc, etag, err)
+ }
+ if err := BumpSerial(ctx, s, time.Unix(1754640000, 0).UTC()); err != nil {
+ t.Fatal(err)
+ }
+ doc, etag, err = ReadSerial(ctx, s)
+ if err != nil || doc.Serial != 1 || doc.Schema != 1 || etag == "" {
+ t.Fatalf("first bump: %+v %q %v", doc, etag, err)
+ }
+ if doc.BumpedAt == "" {
+ t.Fatal("bumped_at not stamped")
+ }
+}
+
+func TestSerialBumpIncrements(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ for i := 0; i < 3; i++ {
+ if err := BumpSerial(ctx, s, time.Unix(1754640000, 0).UTC()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ doc, _, _ := ReadSerial(ctx, s)
+ if doc.Serial != 3 {
+ t.Fatalf("serial = %d, want 3", doc.Serial)
+ }
+}
+
+func TestSerialGarbageObjectStillBumps(t *testing.T) {
+ // A bucket-writer can corrupt the object; a bump must recover, not wedge.
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := s.Put(ctx, SerialKey, []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := BumpSerial(ctx, s, time.Unix(1754640000, 0).UTC()); err != nil {
+ t.Fatal(err)
+ }
+ doc, _, err := ReadSerial(ctx, s)
+ if err != nil || doc.Serial != 1 {
+ t.Fatalf("recovery bump: %+v %v", doc, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/release -run TestSerial -count=1` — expect compile failure (undefined: SerialKey).
+
+- [ ] **Step 3: Implement `internal/release/serial.go`**
+
+```go
+// Package-file doc: control/serial is the deploy signal (carlos-deploy spec
+// §3): an UNSIGNED counter the console bumps after any bucket write a box
+// loop consumes. It carries no authority — it can only trigger verified work
+// sooner — which is why it is the one object in the bucket outside the
+// Ed25519 envelope. Boxes react to any CHANGE, not only increments, so a
+// reset or a lost-increment race can never wedge a box into ignoring bumps.
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+)
+
+// SerialKey deliberately sits at the bucket top level: nothing that lists or
+// parses app shapes (ChannelsPrefix, appPrefix) ever sees it.
+const SerialKey = "control/serial"
+
+type SerialDoc struct {
+ Schema int `json:"schema"`
+ Serial int64 `json:"serial"`
+ BumpedAt string `json:"bumped_at"`
+}
+
+// ReadSerial returns the current serial, its etag for CAS, and nil for a
+// missing object (zero doc, "" etag) — absence is the ordinary pre-first-bump
+// state, not an error. A present-but-unparseable object also reads as the
+// zero doc WITH its real etag, so the next bump overwrites the garbage.
+func ReadSerial(ctx context.Context, s Store) (SerialDoc, string, error) {
+ body, etag, err := s.Get(ctx, SerialKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return SerialDoc{}, "", nil
+ }
+ if err != nil {
+ return SerialDoc{}, "", err
+ }
+ var doc SerialDoc
+ if json.Unmarshal(body, &doc) != nil {
+ return SerialDoc{}, etag, nil // garbage: recoverable, next bump replaces it
+ }
+ return doc, etag, nil
+}
+
+// BumpSerial increments the serial with a small CAS retry loop. Callers must
+// treat failure as log-only: a lost bump costs latency (the hourly floor
+// still converges everything), never correctness, and must never fail the
+// user-facing write that triggered it.
+func BumpSerial(ctx context.Context, s Store, now time.Time) error {
+ var lastErr error
+ for attempt := 0; attempt < 4; attempt++ {
+ doc, etag, err := ReadSerial(ctx, s)
+ if err != nil {
+ return err
+ }
+ next := SerialDoc{Schema: 1, Serial: doc.Serial + 1, BumpedAt: now.UTC().Format(time.RFC3339)}
+ body, err := json.Marshal(next)
+ if err != nil {
+ return err
+ }
+ if _, err = s.Put(ctx, SerialKey, body, etag); err == nil {
+ return nil
+ } else if errors.Is(err, ErrCASConflict) {
+ lastErr = err
+ continue // someone else bumped — their change signal serves ours too,
+ // but retry so OUR write's announcement isn't silently coalesced away
+ } else {
+ return err
+ }
+ }
+ return fmt.Errorf("bump %s: lost CAS race %d times: %w", SerialKey, 4, lastErr)
+}
+```
+
+(If `ErrCASConflict` has a different exported name in `internal/release`, use the store's actual sentinel — check `go doc ./internal/release | grep -i cas`.)
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/release -run TestSerial -count=1` — PASS.
+- [ ] **Step 5: Full gate, then commit** — `git add internal/release/serial.go internal/release/serial_test.go && git commit -m "release: control/serial — the deploy signal object (spec §3)"`
+
+---
+
+### Task 2: content-accurate registry fingerprint (closes #15)
+
+**Files:**
+- Modify: `internal/registry/registry.go:986-1017` (the `Fingerprint` method)
+- Test: `internal/registry/registry_test.go` (append)
+
+**Interfaces:**
+- Produces: same signature `func (r *Registry) Fingerprint() (string, error)` — an opaque string the edge watcher (`internal/edge/edge.go:822-831`, 2s poll) compares for change. Only the guarantee strengthens: any change to a reload-relevant column changes the string, including same-length rewrites.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestFingerprintSeesSameLengthVersionSwap(t *testing.T) {
+ reg := mustOpen(t) // use this file's existing open/seed helper naming
+ rt := Route{Host: "fp.example.test", Kind: "instance", App: "echo", Status: "ready", Version: "abc1234"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetVersions("fp.example.test", "def5678", "abc1234"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatal("same-length version swap invisible to Fingerprint (issue #15)")
+ }
+}
+
+func TestFingerprintSeesSameLengthChannelSwap(t *testing.T) {
+ reg := mustOpen(t)
+ rt := Route{Host: "fp2.example.test", Kind: "instance", App: "echo", Status: "ready", Channel: "canary/1"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := reg.Fingerprint()
+ rt.Channel = "canary/2"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ after, _ := reg.Fingerprint()
+ if before == after {
+ t.Fatal("same-length channel swap invisible to Fingerprint (issue #15)")
+ }
+}
+```
+
+Adapt the registry-open helper to whatever `registry_test.go` actually uses (`New(t.TempDir()+"/r.db")` shape) — read the file's first test before writing.
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/registry -run TestFingerprintSees -count=1` — FAIL on `before == after`.
+
+- [ ] **Step 3: Reimplement Fingerprint** — same fresh-connection + busy_timeout preamble, then select values and hash in Go. Keep `grants` deliberately excluded (registry.go:93's rule: grants change unit shape, not routing). Replace the aggregate query with:
+
+```go
+ rows, err := db.Query(`
+SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel
+FROM routes ORDER BY rowid`)
+ if err != nil {
+ return "", err
+ }
+ defer rows.Close()
+ h := sha256.New()
+ n := 0
+ for rows.Next() {
+ var rowid, hibernate int64
+ var status, socket, addr, version, channel, backing, tunnel string
+ if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate, &channel, &backing, &tunnel); err != nil {
+ return "", err
+ }
+ fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1e",
+ rowid, status, socket, addr, version, hibernate, channel, backing, tunnel)
+ n++
+ }
+ if err := rows.Err(); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
+```
+
+Rewrite the doc comment: delete the "LENGTH … deliberate cost" paragraph and state the new contract — content-accurate over the reload-relevant columns, still a fresh connection per poll, row count folded in so an empty table and a never-opened one differ from populated ones. Note it closes #15 and why the deploy watch needs it (spec §7: the `X-Carlos-Version` header reads `Version`, and git short shas are same-length).
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/registry -count=1` — all PASS (existing fingerprint tests must still hold: insert/delete visibility).
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "registry: content-accurate Fingerprint — same-length rewrites reload the edge (closes #15)"`
+
+---
+
+### Task 3: `release.MaterializeHost` + activator wiring — cold wake stages on demand
+
+**Files:**
+- Create: `internal/release/materialize.go`
+- Test: `internal/release/materialize_test.go`
+- Modify: `internal/host/agent.go:146-152` (the `ExecBackend` literal) and add `materializeHook` nearby
+
+**Interfaces:**
+- Consumes: `PassFilter(ctx, reg, s, pub, optDir, restart, extra, opts...)` (restart nil = symlink-only, never wakes), `LiveVersion(optDir, host)`, `BakeDir(dir) PassOption`, `activator.ExecBackend.Materialize func(ctx context.Context, host string) error` (called at wake when the live link is missing; backend_exec.go:364-379).
+- Produces: `func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string) error` — stages host's channel head via the REAL adopt pass scoped to one host, so every invariant (signature verification, bake hold, Version stamp) is the pass's own, not a second staging path.
+
+- [ ] **Step 1: Write the failing test** — build the fixture the way `internal/release/adopt_test.go` builds one (dir store seeded with a shipped version + promoted channel pointer, temp registry with one exec-backed ready route, temp optDir). Read that file first and reuse its helpers; the assertions:
+
+```go
+func TestMaterializeHostStagesChannelHead(t *testing.T) {
+ // fixture: route "mat.test" on channel "canary/dev", version "abc1234"
+ // shipped and promoted, nothing staged yet.
+ env := newAdoptFixture(t) // whatever adopt_test.go's fixture builder is named
+ err := MaterializeHost(context.Background(), env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ v, err := LiveVersion(env.optDir, "mat.test")
+ if err != nil || v != "abc1234" {
+ t.Fatalf("live = %q, %v; want abc1234", v, err)
+ }
+}
+
+func TestMaterializeHostRefusesHeldHead(t *testing.T) {
+ // fixture: production flag TRUE for the app, channel "beta" (24h hold),
+ // version promoted moments ago → the pass must not stage it.
+ env := newAdoptFixtureProductionBeta(t)
+ err := MaterializeHost(context.Background(), env.reg, env.store, env.pub, env.optDir, env.dataDir, "mat.test")
+ if err == nil {
+ t.Fatal("materialize of a bake-held head must refuse")
+ }
+ if v, _ := LiveVersion(env.optDir, "mat.test"); v != "" {
+ t.Fatalf("held head was staged anyway: %q", v)
+ }
+}
+
+func TestMaterializeHostUnknownHost(t *testing.T) {
+ env := newAdoptFixture(t)
+ if err := MaterializeHost(context.Background(), env.reg, env.store, env.pub, env.optDir, env.dataDir, "nope.test"); err == nil {
+ t.Fatal("unknown host must error")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/release -run TestMaterializeHost -count=1`.
+
+- [ ] **Step 3: Implement `materialize.go`**
+
+```go
+// MaterializeHost stages host's channel head and points its live symlink —
+// the cold-wake, on-demand form of the adopt pass (carlos-deploy spec §5).
+// It IS the adopt pass, filtered to one host with restart=nil (the host is
+// cold; the activator execing it is the "restart"): bake holds, signature
+// verification, staging, and the Version stamp are all the pass's own —
+// a second staging path is exactly the drift this refuses to create.
+// Success is judged by the symlink, not the pass error: Pass logs per-route
+// trouble rather than returning it, and "held by a bake window" and "nothing
+// promoted" both correctly leave the link absent.
+func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string) error {
+ only := func(rt registry.Route) bool { return rt.Host == host }
+ if err := PassFilter(ctx, reg, s, pub, optDir, nil, only, BakeDir(dataDir)); err != nil {
+ return fmt.Errorf("materialize %s: %w", host, err)
+ }
+ v, err := LiveVersion(optDir, host)
+ if err != nil {
+ return fmt.Errorf("materialize %s: %w", host, err)
+ }
+ if v == "" {
+ return fmt.Errorf("materialize %s: no adoptable head (nothing promoted for its channel, or the head is bake-held)", host)
+ }
+ return nil
+}
+```
+
+- [ ] **Step 4: Run to verify pass**, then wire the agent. In `agent.go`, add:
+
+```go
+// materializeHook builds the ExecBackend's cold-wake staging hook, or nil
+// when this box has no release store/pubkey — nil keeps today's behavior
+// ("no live binary" wake failure), the safe degrade.
+func materializeHook(reg *registry.Registry, dd string) func(context.Context, string) error {
+ store, err := openDeployStore(context.Background())
+ if err != nil {
+ return nil
+ }
+ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ return nil
+ }
+ optDir := release.OptDir()
+ return func(ctx context.Context, host string) error {
+ return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host)
+ }
+}
+```
+
+and in the `ExecBackend` literal add `Materialize: materializeHook(reg, dd),`.
+
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "release+host: Materialize stages the channel head at cold wake (closes #77)"`
+
+---
+
+### Task 4: backing-dispatched restart — warm exec children bounce on adopt
+
+**Files:**
+- Modify: `internal/host/agent.go:1138-1144` (`restartRoute`) and the call sites `adoptLoop` / `canaryAdoptLoop` (which Task 5 replaces — coordinate: do this task first)
+- Test: `internal/host/agent_test.go` (append)
+
+**Interfaces:**
+- Consumes: `registry.Registry.Get(host) (*Route, error)`, `registry.BackingUnit`, `activator.Service.Bounce(host string) bool`, `activator.UnitName(host)`.
+- Produces: `func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), bounce func(host string) bool) func(host string) error` — injectable for tests; agent wires `systemctlTryRestart` and `svc.Bounce` (nil-safe). Task 5's loops take this closure. `adoptLoop` gains the `svc *activator.Service` parameter to build it.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestMakeRestartRouteDispatchesOnBacking(t *testing.T) {
+ reg := newTestRegistry(t) // agent_test.go's existing registry helper
+ if err := reg.Upsert(registry.Route{Host: "unit.test", Kind: "instance", App: "a", Status: "ready", Backing: registry.BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "exec.test", Kind: "instance", App: "a", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ var restarted, bounced []string
+ restart := makeRestartRoute(reg,
+ func(h string) { restarted = append(restarted, h) },
+ func(h string) bool { bounced = append(bounced, h); return true })
+ if err := restart("unit.test"); err != nil {
+ t.Fatal(err)
+ }
+ if err := restart("exec.test"); err != nil {
+ t.Fatal(err)
+ }
+ if len(restarted) != 1 || restarted[0] != "unit.test" {
+ t.Fatalf("unit path: %v", restarted)
+ }
+ if len(bounced) != 1 || bounced[0] != "exec.test" {
+ t.Fatalf("exec path: %v", bounced)
+ }
+}
+
+func TestMakeRestartRouteUnknownHostFallsBackToUnit(t *testing.T) {
+ // A host the registry can't answer for gets the OLD behavior (try-restart,
+ // a safe no-op for a nonexistent unit) — never a bounce it might not own.
+ reg := newTestRegistry(t)
+ var restarted []string
+ restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, nil)
+ if err := restart("ghost.test"); err != nil {
+ t.Fatal(err)
+ }
+ if len(restarted) != 1 {
+ t.Fatalf("fallback: %v", restarted)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/host -run TestMakeRestartRoute -count=1`.
+
+- [ ] **Step 3: Implement.** Replace `restartRoute` with:
+
+```go
+// makeRestartRoute builds the adopt pass's restart callback, dispatched on
+// the route's backing (spec §4 correction): a unit tenant is try-restarted
+// (no-op when not running — hibernators are never woken), an exec child is
+// Bounced through the activator, the same graceful cycle config sync uses —
+// systemctl cannot reach a child of THIS process, which is why exec-backed
+// warm instances kept serving old binaries until they happened to hibernate.
+// Unknown-to-the-registry hosts take the unit path: try-restart of a
+// nonexistent unit is a logged no-op, a bounce of a process we might not own
+// is not. Errors are logged, never returned — a failed restart must not
+// poison the version for other hosts (adopt.go:352's rule).
+func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), bounce func(host string) bool) func(host string) error {
+ return func(host string) error {
+ rt, err := reg.Get(host)
+ if err == nil && rt != nil && rt.Backing != registry.BackingUnit {
+ if bounce != nil && bounce(host) {
+ log.Printf("adopt: bounced exec instance %s onto its new version", host)
+ }
+ return nil
+ }
+ tryRestart(host)
+ return nil
+ }
+}
+
+// systemctlTryRestart is makeRestartRoute's production unit path — the body
+// of the old restartRoute, unchanged.
+func systemctlTryRestart(host string) {
+ out, err := exec.Command("systemctl", "try-restart", activator.UnitName(host)).CombinedOutput()
+ if err != nil {
+ log.Printf("adopt: try-restart %s: %v: %s (continuing)", activator.UnitName(host), err, strings.TrimSpace(string(out)))
+ }
+}
+```
+
+Wire: `adoptLoop(reg)` becomes `adoptLoop(reg, svc)`; inside, `restart := makeRestartRoute(reg, systemctlTryRestart, bounceFn(svc))` where `bounceFn(svc)` returns nil for nil svc, else `svc.Bounce`. Update the `go adoptLoop(reg)` call at agent.go:158 (and `canaryAdoptLoop` identically — Task 5 replaces it, but keep it compiling now). `RunAdopt` (root, separate process) is NOT changed — it keeps the pure systemctl restart: another process's children are not its to bounce.
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/host -count=1`.
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "host: adopt restart dispatches on backing — warm exec children bounce (spec §4)"`
+
+---
+
+### Task 5: `fastConvergeLoop` — serial poll + fast set, replacing `canaryAdoptLoop`
+
+**Files:**
+- Modify: `internal/host/agent.go` — delete `canaryAdoptLoop` (lines ~565-613), add `fastConvergeLoop` + `fastTick`; change the spawn at line 160; keep `isCanaryRoute` (it stays, spec §4 correction)
+- Test: `internal/host/agent_test.go` (append)
+
+**Interfaces:**
+- Consumes: Task 1's `release.ReadSerial`, Task 4's `makeRestartRoute`, `release.PassFilter`, `release.Pass`, `provision.Reconcile(ctx, store, reg, provisionTickConfig(os.Getenv))`, `syncExecSerialized(ctx, reg, store, priv, dd, svc)` (agent.go:304 — the ONLY way to run SyncExec, mutex inside), `release.LiveVersion(optDir, host)`.
+- Produces: `fastConvergeLoop(reg *registry.Registry, svc *activator.Service)` goroutine; `type fastDeps struct` + `func fastTick(ctx context.Context, d *fastDeps, now time.Time)` — the testable single tick.
+
+**Design (from spec §3-§4):** every 2s+jitter tick does two independent things:
+1. **Serial check** — one `ReadSerial`. First successful read only SEEDS `lastSerial` (boot must not replay history; `adoptLoop`'s 1-minute first pass covers boot). Afterward, any change → if ≥5s since last full pass-set: run **reconcile → adopt pass → exec config sync** in that order (mint routes, then stage them, then their config), record the serial as acted; if clamped, do NOT record — next tick re-fires. Config sync piece is skipped when `CARLOS_SECRETS_KEY` doesn't parse (same degrade as `configSyncLoop`).
+2. **Fast sweep** — membership per route: `isCanaryRoute(rt)` (always — direct-bucket operator promotes never bump, spec §4 correction) OR (`release.LiveVersion(optDir, rt.Host)` == "" AND host's backoff window has passed). Compute the member set FIRST; skip `PassFilter` entirely when empty (an empty sweep must cost zero store reads). After the sweep, hosts still unstaged double their backoff 2s→5min cap; staged hosts clear their backoff state. Held routes cost nothing here: the pass's own bake logic refuses them and the backoff keeps re-checks rare; the serial/hourly passes re-notice them when the window expires.
+
+- [ ] **Step 1: Write the failing tests** (dir store + temp registry, like existing agent tests; `fastDeps` carries every seam so no env vars needed):
+
+```go
+func TestFastTickActsOnSerialChangeNotBoot(t *testing.T) {
+ env := newFastFixture(t) // store+reg+pub+optDir fixture; build like Task 3's
+ var passes int
+ d := env.deps(func() { passes++ }) // deps with the full-pass-set hook counted
+ now := time.Unix(1754640000, 0)
+ fastTick(context.Background(), d, now) // no serial object at all: seeds nothing, runs nothing
+ release.BumpSerial(context.Background(), env.store, now)
+ fastTick(context.Background(), d, now.Add(2*time.Second)) // first sight: SEED only
+ if passes != 0 {
+ t.Fatalf("boot seed must not run a pass, got %d", passes)
+ }
+ release.BumpSerial(context.Background(), env.store, now)
+ fastTick(context.Background(), d, now.Add(4*time.Second))
+ if passes != 1 {
+ t.Fatalf("serial change must run the pass set once, got %d", passes)
+ }
+ fastTick(context.Background(), d, now.Add(6*time.Second))
+ if passes != 1 {
+ t.Fatalf("unchanged serial must not re-run, got %d", passes)
+ }
+}
+
+func TestFastTickClampsFullPasses(t *testing.T) {
+ env := newFastFixture(t)
+ var passes int
+ d := env.deps(func() { passes++ })
+ now := time.Unix(1754640000, 0)
+ release.BumpSerial(context.Background(), env.store, now)
+ fastTick(context.Background(), d, now) // seed
+ release.BumpSerial(context.Background(), env.store, now)
+ fastTick(context.Background(), d, now.Add(2*time.Second)) // pass 1
+ release.BumpSerial(context.Background(), env.store, now)
+ fastTick(context.Background(), d, now.Add(4*time.Second)) // inside 5s clamp: deferred
+ if passes != 1 {
+ t.Fatalf("clamp: got %d passes", passes)
+ }
+ fastTick(context.Background(), d, now.Add(8*time.Second)) // clamp expired: fires
+ if passes != 2 {
+ t.Fatalf("deferred bump must fire after clamp, got %d", passes)
+ }
+}
+
+func TestFastSweepMembership(t *testing.T) {
+ env := newFastFixture(t)
+ // three routes: a canary (always member), a staged edge route (not a
+ // member), an unstaged edge route (member until staged, then drops out).
+ env.addRoute(t, "canary.test", "canary/x")
+ env.addRoute(t, "staged.test", "edge")
+ env.stageLive(t, "staged.test", "abc1234") // mint its live symlink
+ env.addRoute(t, "cold.test", "edge")
+ members := env.deps(nil).fastMembers(time.Unix(1754640000, 0))
+ sort.Strings(members)
+ if want := []string{"canary.test", "cold.test"}; !slices.Equal(members, want) {
+ t.Fatalf("members = %v, want %v", members, want)
+ }
+}
+
+func TestFastSweepBackoffDoubles(t *testing.T) {
+ env := newFastFixture(t)
+ env.addRoute(t, "broken.test", "edge") // nothing promoted: staging can never succeed
+ d := env.deps(nil)
+ now := time.Unix(1754640000, 0)
+ d.noteUnstaged("broken.test", now)
+ if m := d.fastMembers(now.Add(1 * time.Second)); len(m) != 0 {
+ t.Fatalf("inside backoff window, want no members, got %v", m)
+ }
+ if m := d.fastMembers(now.Add(3 * time.Second)); len(m) != 1 {
+ t.Fatalf("past first backoff, want member, got %v", m)
+ }
+ for i := 0; i < 20; i++ { // drive to the cap
+ d.noteUnstaged("broken.test", now)
+ }
+ if d.backoffFor("broken.test") != 5*time.Minute {
+ t.Fatalf("backoff cap = %v, want 5m", d.backoffFor("broken.test"))
+ }
+}
+```
+
+Shape `fastDeps`/`fastMembers`/`noteUnstaged`/`backoffFor` to make exactly these tests natural; the loop is a thin wrapper (sleep-jitter, build deps once, call `fastTick`).
+
+- [ ] **Step 2: Run to verify failure.**
+- [ ] **Step 3: Implement.** Structure:
+
+```go
+// fastDeps is one fastConvergeLoop's state and seams — a struct so the tick
+// is testable without goroutines, env vars, or sleeps.
+type fastDeps struct {
+ reg *registry.Registry
+ store release.Store
+ pub ed25519.PublicKey
+ optDir string
+ restart func(string) error
+ fullPass func(ctx context.Context) // reconcile → adopt → config sync, or a test hook
+ lastSerial int64
+ seeded bool
+ lastFull time.Time
+ notBefore map[string]time.Time
+ step map[string]time.Duration
+}
+```
+
+`fastTick`: serial check per the design block above, then `members := d.fastMembers(now)`; if non-empty run `release.PassFilter(ctx, d.reg, d.store, d.pub, d.optDir, d.restart, memberPredicate, release.BakeDir(dataDir()))`, then re-scan members: still-unstaged → `d.noteUnstaged(host, now)`, staged → clear both maps for host. `fastConvergeLoop` mirrors `canaryAdoptLoop`'s setup (openDeployStore/ParsePublicKey silent-return degrade, 2s+jitter sleep) and its armed log line becomes `"agent: fast converge loop armed (2s + jitter: serial poll + canary/* + unstaged routes)"`. The production `fullPass` runs `provision.Reconcile(ctx, store, reg, provisionTickConfig(os.Getenv))`, then `release.Pass(ctx, reg, store, pub, optDir, restart, release.BakeDir(dataDir()))`, then — only if `CARLOS_SECRETS_KEY` parses — `syncExecSerialized(...)`; each error is one log line, never fatal. **Carry the two DO-NOT comment blocks (NagExpiredCanaries / WithCapabilities) from `canaryAdoptLoop` verbatim onto `fastConvergeLoop`** — same sandbox, same 2s cadence, same reasons. Update the spawn: `go fastConvergeLoop(reg, svc)`.
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/host -count=1`; also `go test ./internal/release -run TestLadder -count=1` (channel names untouched).
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "host: fastConvergeLoop — serial poll + outstanding-work sweep replaces canary-only fast adopt (#101)"`
+
+---
+
+### Task 6: console bumps the serial; promote API says `held`
+
+**Files:**
+- Create: `internal/console/serialbump.go`
+- Modify: `internal/console/apicli.go` (`handleAPIPromote:289`, `handleAPIRollback:377`), `internal/console/apiinstances.go` (`handleAPIInstanceCreate:214`, `handleAPIInstanceEnable:440`, `handleAPIInstanceDelete:331`), `internal/console/apiconfig.go` (`handleAPIConfigPut:274`), `internal/console/server.go` (the web promote handler containing `s.audit(acct, app, addr, "promote", ...)` at :1740, the web rollback handler `handleRollback`, `handleProduction:1872`, and the fast-track write if it is a separate handler — find every successful `release.PromoteOnLadder` / `release.SetProduction` / instance-record / config write with `grep -n "PromoteOnLadder\|SetProduction\|instancerecords\.\|appconfig\." internal/console/*.go` and cover each)
+- Test: `internal/console/serialbump_test.go` + one assertion added to the existing promote-API test
+
+**Interfaces:**
+- Consumes: Task 1's `release.BumpSerial`; `s.cfg.Releases` (the console's release store), `s.cfg.Key` (root signing key; its `.Public().(ed25519.PublicKey)` verifies production flags), `release.GetProduction(ctx, s, pub, acct, app) (*ProductionFlag, error)`, `release.Rung(channel)` / `release.RungBeta`.
+- Produces: `func (s *Server) bumpSerial(ctx context.Context)` — log-only on failure, 2s timeout, called after each successful box-consumed write; promote API response gains additive `"held": true|false`; client task 9 reads it.
+
+- [ ] **Step 1: Write the failing tests.** `serialbump_test.go`: build a `Server` the way the smallest existing console API test does (read one first — there is an established fixture), call the promote API against a seeded app/version, then `release.ReadSerial` on the test store and assert `Serial == 1`. Second test: flip the app to production (write the flag with `release.SetProduction` using the test key), promote to `beta`, assert response JSON carries `"held": true`; promote to a `canary/*` channel asserts `"held": false` (or absent — decode into `struct{ Held bool }`).
+- [ ] **Step 2: Run to verify failure.**
+- [ ] **Step 3: Implement.** `serialbump.go`:
+
+```go
+// bumpSerial announces "the bucket has news" (carlos-deploy spec §3) after a
+// write a box loop consumes. Log-only on failure and clamped to 2s: a lost
+// bump costs latency (the hourly floor still converges), and no user-facing
+// write may fail or stall because its announcement did.
+func (s *Server) bumpSerial(ctx context.Context) {
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ if err := release.BumpSerial(ctx, s.cfg.Releases, time.Now()); err != nil {
+ log.Printf("console: serial bump: %v (boxes fall back to the hourly pass)", err)
+ }
+}
+```
+
+Call it right after each handler's successful write (next to the existing `s.audit` calls — after every gate, per the security-order constraint). In `handleAPIPromote`, before the final `writeJSON`, compute `held`:
+
+```go
+ held := false
+ if rung, _ := release.Rung(channel); rung == release.RungBeta {
+ pub := s.cfg.Key.Public().(ed25519.PublicKey)
+ if f, err := release.GetProduction(ctx, s.cfg.Releases, pub, acct.Sqid, app); err == nil && f != nil && f.Production {
+ held = true
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "result": "promoted", "from": res.From, "to": res.To, "noop": false, "held": held,
+ })
+```
+
+(Stable never reaches here — the rung gate 403s it to the console; beta is the only bakeable rung this door can move. Verify `ProductionFlag`'s bool field name with `go doc ./internal/release ProductionFlag` and adjust.)
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/console -count=1`.
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "console: bump control/serial on box-consumed writes; promote API reports held"`
+
+---
+
+### Task 7: `X-Carlos-Version` response header
+
+**Files:**
+- Modify: `internal/edge/edge.go` — in `Reload()`'s per-route switch (lines ~236-296), after `proxy` is chosen
+- Test: `internal/edge/edge_test.go` (append)
+
+**Interfaces:**
+- Consumes: `registry.Route.Version` (stamped by the adopt pass after restart — serving truth; kept fresh by Task 2's fingerprint).
+- Produces: `X-Carlos-Version: <version>` on every response for routes with non-empty `Version` (all backings: proxied, static, status). Task 9's watch parses it; header absent = "cannot confirm", never a match.
+
+- [ ] **Step 1: Write the failing test** — follow the existing pattern in `edge_test.go` that builds an `Edge` over a registry and drives a request through it (find one with `httptest`); assert:
+
+```go
+func TestVersionHeaderOnVersionedRoute(t *testing.T) {
+ // route with Version "abc1234" over a local httptest backend
+ // → response carries X-Carlos-Version: abc1234
+}
+
+func TestNoVersionHeaderOnUnversionedRoute(t *testing.T) {
+ // route with Version "" → header absent
+}
+```
+
+Write both bodies concretely against the fixture pattern the file already uses (do not invent a new harness).
+
+- [ ] **Step 2: Run to verify failure.**
+- [ ] **Step 3: Implement.** In `Reload()`, after the `switch` assigns `proxy` (and before the map insert), wrap:
+
+```go
+ // The deploy watch's proof and the human "verify with the binary you
+ // built" tell (carlos-deploy spec §7): the version this route is
+ // serving, from the row the adopt pass stamps AFTER the restart.
+ // Server:-header-grade disclosure, ruled public 2026-08-08. Reload
+ // rebuilds these handlers on every registry change — Fingerprint is
+ // content-accurate since #15 closed, so the header can't go stale.
+ if t.Version != "" {
+ proxy = versionHeader(t.Version, proxy)
+ }
+```
+
+with, near the other small handler builders:
+
+```go
+func versionHeader(ver string, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", ver)
+ next.ServeHTTP(w, r)
+ })
+}
+```
+
+- [ ] **Step 4: Run to verify pass** — `go test ./internal/edge -count=1`.
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "edge: X-Carlos-Version on versioned routes — the deploy watch's proof (spec §7)"`
+
+---
+
+### Task 8: `staged_version` in the instances API (chips at #78)
+
+**Files:**
+- Modify: `internal/status/status.go` (the raw `Metrics` block `CollectBox` fills — read the type first; it is the deliberate bucket-private exception to the banding rule), `internal/console/apiinstances.go:157-178` (`handleAPIInstanceList`), root `instances_cli.go:255-280` (`runInstancesList` gains a VERSION column), `apimode.go` (`apiInstanceRecord` gains `StagedVersion string \`json:"staged_version"\``)
+- Test: `internal/status/status_test.go`, `internal/console/apiinstances_test.go` (append to each)
+
+**Interfaces:**
+- Consumes: `registry.Route.Version`; `status/boxes/<label>.json` objects (bucket-private, raw Metrics allowed); the console's `s.cfg.Releases` store reads them via `List(ctx, "status/boxes/")` + `Get`.
+- Produces: `Metrics.RouteVersions map[string]string` (host → Version, non-platform routes with Version set) in the box snapshot; instances API records gain additive `staged_version` (empty when no box has reported — freshness is the 5-minute status cadence, explicitly best-effort; the deploy watch does NOT use it).
+
+- [ ] **Step 1: status side, TDD** — test that `CollectBox` over a registry with a versioned route lands `RouteVersions{"h.test": "abc1234"}` in the written `status/boxes/<label>.json`, and — critically — extend the existing `DryRun`/needle privacy test's expectations ONLY if it fails: `RouteVersions` lives in `Metrics`, the block `BuildFleet` never copies, so the public `fleet.json` must NOT contain any hostname from it (the existing needle test should already enforce this — run it and confirm it passes untouched; if it flags the new field, the field is in the wrong place, fix the placement, not the test).
+- [ ] **Step 2: console side, TDD** — seed the console fixture's store with a `status/boxes/box1.json` carrying `RouteVersions`, call the instances list API, assert the matching record has `staged_version: "abc1234"` and a record with no box entry has `""`. Missing/garbage box files: skipped with a log line, never a 500 — the list must serve without status objects at all.
+- [ ] **Step 3: CLI column** — `runInstancesList` header becomes `HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tCREATED\tBY`, printing `r.StagedVersion`. No test beyond compile (table printing has no harness); the API-side test covers the data.
+- [ ] **Step 4: Full gate, then commit** — `git commit -m "status+console: staged_version surfaces through the instances API (#78)"`
+
+---
+
+### Task 9: the `carlos deploy` verb
+
+**Files:**
+- Create: `deploy.go` (root package), `deploy_test.go`
+- Modify: `main.go` (dispatch case + usage line, in the "shipping your own app" group), `apimode.go` (`apiPromoteResult` gains `Held bool \`json:"held"\``)
+
+**Interfaces:**
+- Consumes: `resolveMode`/`requireAPISession("deploy", *consoleFlag)`, `accountFlags(fs)`, `apiAccount(acctRaw, acctIDRaw)`, `readCapabilities(*capsFile, *app)`, `runShipAPI(sess, acct, fs, app, ver, kind, target, capsSet) error` (cli.go:1592 — reuse, do not duplicate), `sess.promote(ctx, acct, app, version, channel) (apiPromoteResult, error)`, `sess.instanceList(ctx, acct, app) ([]apiInstanceRecord, bool, error)` (records carry `Host`, `Channel`), Task 6's `held` field, Task 7's header, `printUsing(stdout, sess)`, `colorFor(stdout)`/`mutedf`.
+- Produces: `carlos deploy` — see spec §6 for the exact contract. Helper seams for tests: `resolveDeployChannel(recs []apiInstanceRecord, hostFlag string) (channel string, hosts []string, err error)` and `watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, interval time.Duration) error` with `type watchTarget struct{ Host, Origin string }`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestResolveDeployChannel(t *testing.T) {
+ recs := []apiInstanceRecord{
+ {Host: "a.test", Channel: "canary/dev"},
+ {Host: "b.test", Channel: "canary/dev"},
+ }
+ ch, hosts, err := resolveDeployChannel(recs, "")
+ if err != nil || ch != "canary/dev" || len(hosts) != 2 {
+ t.Fatalf("%q %v %v", ch, hosts, err)
+ }
+ recs[1].Channel = "edge"
+ if _, _, err := resolveDeployChannel(recs, ""); err == nil {
+ t.Fatal("two channels with no -channel/-host must error")
+ }
+ ch, hosts, err = resolveDeployChannel(recs, "b.test")
+ if err != nil || ch != "edge" || len(hosts) != 1 || hosts[0] != "b.test" {
+ t.Fatalf("host-scoped: %q %v %v", ch, hosts, err)
+ }
+ if _, _, err := resolveDeployChannel(nil, ""); err == nil {
+ t.Fatal("no instances must error (hint: carlos instances create)")
+ }
+}
+
+func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) {
+ var mu sync.Mutex
+ ver := "old1234"
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ w.Header().Set("X-Carlos-Version", ver)
+ mu.Unlock()
+ }))
+ defer srv.Close()
+ go func() { time.Sleep(30 * time.Millisecond); mu.Lock(); ver = "new5678"; mu.Unlock() }()
+ var out bytes.Buffer
+ err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 2*time.Second, 10*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "x.test") {
+ t.Fatalf("live line missing: %q", out.String())
+ }
+}
+
+func TestWatchVersionTimeoutDiagnostics(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "old1234")
+ }))
+ defer srv.Close()
+ var out bytes.Buffer
+ err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 50*time.Millisecond, 10*time.Millisecond)
+ if err == nil {
+ t.Fatal("must time out")
+ }
+ if !strings.Contains(err.Error(), "old1234") {
+ t.Fatalf("diagnostic must name the version still serving: %v", err)
+ }
+}
+
+func TestWatchVersionNoHeaderDiagnostic(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ defer srv.Close()
+ var out bytes.Buffer
+ err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 50*time.Millisecond, 10*time.Millisecond)
+ if err == nil || !strings.Contains(err.Error(), "no X-Carlos-Version") {
+ t.Fatalf("old-edge diagnostic: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test . -run 'TestResolveDeployChannel|TestWatchVersion' -count=1`.
+
+- [ ] **Step 3: Implement `deploy.go`.** Flags: `-app`, account flags, `-console`, `-kind binary|static`, `-version` (default: `git rev-parse --short HEAD` via a `gitShortHead()` helper that `log.Fatal`s with `"not in a git repository — pass -version"` on failure), `-target linux-arm64`, `-capabilities`, `-channel`, `-host`, `-timeout 120s`, positional files-or-site-dir (required). Flow, straight from spec §6:
+
+```go
+ sess := requireAPISession("deploy", *consoleFlag) // API-mode only: member verb
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ ctx := context.Background()
+ recs, _, err := sess.instanceList(ctx, acct, *app)
+ if err != nil {
+ log.Fatalf("deploy: list instances: %v", err)
+ }
+ var channel string
+ var hosts []string
+ if *channelFlag != "" {
+ // Explicit channel: no ambiguity to resolve. Watch the instances that
+ // follow it (none is fine — promote still lands; the watch is skipped
+ // with a note, since there is nothing that will serve this channel).
+ channel = *channelFlag
+ for _, r := range recs {
+ if r.Channel == channel && (*hostFlag == "" || r.Host == *hostFlag) {
+ hosts = append(hosts, r.Host)
+ }
+ }
+ } else {
+ channel, hosts, err = resolveDeployChannel(recs, *hostFlag)
+ if err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+ }
+ if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, capsSet); err != nil {
+ log.Fatalf("deploy: ship: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), *app, *ver)
+ res, err := sess.promote(ctx, acct, *app, *ver, channel)
+ if err != nil {
+ log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err
+ }
+ fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, *ver, channel)
+ if res.Held {
+ mutedf(stdout, "%s is a production app: the %s bake window applies — the fleet adopts when it expires; console fast-track if this is an emergency\n", *app, channel)
+ return // exit 0: the writes succeeded, the hold is policy (spec §6.6)
+ }
+ if len(hosts) == 0 {
+ mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel)
+ return
+ }
+ targets := make([]watchTarget, 0, len(hosts))
+ for _, h := range hosts {
+ targets = append(targets, watchTarget{Host: h, Origin: "https://" + h})
+ }
+ if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second); err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+```
+
+`resolveDeployChannel`: error text for the ambiguous case lists each channel with its hosts and says `"pass -channel or -host"`; the empty case says `"no instances — create one: carlos instances create -app <a> ..."`. `watchVersion`: GET `Origin + "/"`, drain ≤4KiB and close, compare `X-Carlos-Version`; on match print `live https://<host>` (ok color) and drop the target; sleep `interval` between rounds; on deadline, one error naming each pending host's last observation — `still serving <v>`, `no X-Carlos-Version header (edge predates this feature — the hourly pass will adopt it)`, or `unreachable: <err>` (route not minted yet / wake failing; point at `carlos instances -app <a>`). Add the `main.go` case (`case "deploy": runDeploy(args)`) and a usage line directly under `ship`/`promote`: `deploy ship + promote + wait until the URL serves the new version`.
+
+- [ ] **Step 4: Run to verify pass** — `go test . -count=1`. This package's tests shell the binary in places — if you add any test that does, `carlosTestEnv` + scratch `cmd.Dir` are mandatory (Global Constraints).
+- [ ] **Step 5: Full gate, then commit** — `git commit -m "cli: carlos deploy — ship, promote, and watch until the URL serves it (spec §6)"`
+
+---
+
+### Task 10: whole-branch verification
+
+- [ ] **Step 1:** `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1` — every package, clean.
+- [ ] **Step 2:** `GOOS=darwin go build ./...` — the Mac CLI gate.
+- [ ] **Step 3:** `gofmt -l .` — nothing new (the three known pre-existing files only).
+- [ ] **Step 4:** Spec sweep — reread `docs/superpowers/specs/2026-08-08-carlos-deploy-design.md` §2-§7 and confirm each shape landed: serial object + console bumps + `held`; fast set (canary ∪ unstaged, backoff, clamp, seed-not-replay); Materialize refusing held heads; header + fingerprint; deploy verb incl. bake-held exit and diagnostics; `staged_version`. Confirm `internal/release/laddercheck_test.go` is untouched (`git diff main --stat -- internal/release/laddercheck_test.go` is empty).
+- [ ] **Step 5:** Commit anything outstanding; the branch is ready for review (superpowers:requesting-code-review, then superpowers:finishing-a-development-branch).
+
+## Follow-ups deliberately NOT in this plan
+
+- pinfra `ship-app.sh`/`promote-app.sh` learning to bump the serial (spec §8 non-goal; flagship script deploys stay hourly-floor + canary-fast).
+- Website/tap batch for the new verb (standing rule: same-batch on release, owed at deploy time).
+- Deploy rollout to the boxes (spec §10: ship → adopt → restart `carlos-edge.service`, both boxes, verify via the header itself) — operator work after merge.
+- Issue #95 verification (serial bump on config writes may cover it — test live, then close or keep).
diff --git a/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md b/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md
index 743a54365..42384b34e 100644
--- a/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md
+++ b/docs/superpowers/specs/2026-08-08-carlos-deploy-design.md
@@ -115,12 +115,14 @@ bump triggers. `bumped_at` uses the console clock and is informational.
outstanding work — it is *held*, drops out of the fast set, and re-enters
when the window expires (the hourly pass or a serial bump re-notices it).
Fast convergence never polls a held route at 2 s for 72 h.
-- `canary/*` routes lose their special case: a canary promote bumps the
- serial like any other, the pass notices the unstaged head, the route joins
- the fast set, converges, drains. Behavior is equal or better; the
- channel-name check (`isCanaryRoute`) is deleted. Production channel names
- pinned in `internal/release/laddercheck_test.go` are untouched — this
- changes when pointers are *polled*, never which channels are *legal*.
+- `canary/*` routes KEEP their name-based fast membership, unioned with the
+ outstanding-work set (planning correction, 2026-08-08): operator promotes
+ that write pointers directly to the bucket (pinfra `ship-app.sh` /
+ `promote-app.sh`) never bump the serial, and dropping the name scope would
+ regress exactly those flows from 2 s to hourly. `isCanaryRoute` stays.
+ Production channel names pinned in `internal/release/laddercheck_test.go`
+ are untouched — this changes when pointers are *polled*, never which
+ channels are *legal*.
- The sandbox rules move with the code: the fast loop stays inside
`carlos-edge.service` (ProtectSystem=strict), so the standing DO-NOTs on
`canaryAdoptLoop` — no `NagExpiredCanaries`, no `WithCapabilities` —
@@ -128,10 +130,17 @@ bump triggers. `bumped_at` uses the console clock and is informational.
The capability-converge consequence recorded there (new binary runs
against the previous unit shape until the hourly root pass) is unchanged.
-Restart semantics are already correct and untouched: the adopt pass
-try-restarts awake units onto the new symlink; hibernating instances get the
-pure repoint and pick the new binary up on wake. Once adoption is fast,
-"new version on next refresh" follows for both warm and cold instances.
+Restart semantics need one correction (found in planning, 2026-08-08):
+today's restart callback shells `systemctl try-restart carlos-app@<host>`,
+which reaches only unit-backed tenants — a warm **exec-backed** child keeps
+serving the old binary until it happens to hibernate. The in-process
+adopt/fast passes gain a backing-dispatched restart: unit-backed →
+`try-restart` as today; exec-backed → the activator's `Bounce` (the same
+graceful cycle config sync already uses on config change). The root
+`RunAdopt` pass keeps units-only — another process's children are not its
+to bounce. Hibernating instances still get the pure repoint and pick the
+new binary up on wake. With that, "new version on next refresh" holds for
+warm and cold instances alike.
## 5. Materialize: cold wake stages on demand
@@ -159,9 +168,15 @@ assumes a user with no access to the underlying infra and no box commands;
the command alone must complete and verify the job. One command:
```
-carlos deploy [-target linux-arm64] [-channel <ch>] [-host <host>] [-timeout 120s]
+carlos deploy [-app <a>] [-version <sha>] [-target linux-arm64] [-kind binary|static]
+ [-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>
```
+Deploy is **API-mode only** — it is a member verb; direct-bucket operators
+keep `ship` + `promote`. `-version` defaults to `git rev-parse --short HEAD`
+of the working directory (ship's own required flag, defaulted where the
+fast path lives).
+
1. **Resolve** app from `.carlos/config` / `-app` like every other verb.
2. **Resolve channel**: the channel the app's instances follow (instances
API). Exactly one distinct channel → use it. Several → error listing
@@ -176,11 +191,14 @@ carlos deploy [-target linux-arm64] [-channel <ch>] [-host <host>] [-timeout 120
reports, or "no header" (edge predates this feature → say so and suggest
the hourly window), or connection state (route not minted yet / wake
failing).
-6. **Bake-held channel** (production app promoting to beta/stable inside a
- window): ship and promote complete, then deploy prints the hold ("held
- until ~T, use the console fast-track if this is an emergency") and exits
- 0 **without watching** — the requested writes succeeded and the hold is
- policy, not failure. Deploy never bypasses or waives bake; fast-track
+6. **Bake-held channel** (production app promoting to beta/stable): ship
+ and promote complete, then deploy prints the hold ("bake window applies;
+ use the console fast-track if this is an emergency") and exits 0
+ **without watching** — the requested writes succeeded and the hold is
+ policy, not failure. The console's promote API response gains an
+ additive `held: true` field (production flag on AND the target rung
+ bakes) so deploy knows without a second call; exact expiry is box-local
+ first-seen truth the console deliberately does not fake. Deploy never bypasses or waives bake; fast-track
stays a console/passkey action (bake-controls spec §5).
Step-by-step verbs remain first-class; deploy is composition, not
@@ -189,9 +207,15 @@ replacement.
## 7. The version tell: `X-Carlos-Version`
The edge sets `X-Carlos-Version: <version>` on every proxied response for
-versioned routes, read from the route's live symlink target — the edge
-already knows it; this is a `Server:`-header-grade disclosure, approved as
-public. It is the deploy watch's proof and doubles as the human
+versioned routes, read from the route table's `Version` column — stamped by
+the adopt pass after the restart, so the header reports the version actually
+serving, not merely staged. This is a `Server:`-header-grade disclosure,
+approved as public. Prerequisite (planning finding, 2026-08-08): the edge's
+registry-reload fingerprint is length-weighted (issue #15), and git-short-sha
+version swaps are same-length — invisible, which would leave the header and
+the deploy watch permanently stale. The fingerprint becomes content-accurate
+(the reload-relevant columns hashed in Go over the same fresh-connection
+poll), closing #15. It is the deploy watch's proof and doubles as the human
"verify with the binary you built" tell (`curl -sI | grep -i carlos`).
Consumer list for the header (everything that PARSES it): the deploy watch,
@@ -250,6 +274,7 @@ itself from the edge (`--resolve` rule), not a cached DNS answer.
- #101 — implements its two suggested shapes plus the serial signal for the
notice-a-promote gap; comment left linking this spec.
+- #15 — closed by §7's content-accurate registry fingerprint.
- #77 — closed by §5 (Materialize, its preferred fix).
- #78 — advanced by §7's `staged_version` field (not fully closed).
- #95 — env-set first-sighting absorption: the serial bump on config writes