carlos / platform Public

zdd-6 task 16: apply and pin the approved zero-downtime copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 90c8feda7934d10789e706758a40b5176ddd9f10 parent 05dce64
29 files changed, +386 −326
  • apimode.go +0 −1
  • approved_copy_test.go +15 −8
  • approved_copy_zdd_test.go +73 −0
  • apps.go +0 −2
  • apps_swap.go +14 −30
  • apps_swap_markers_test.go => apps_swap_constants_test.go +6 −32
  • cli.go +0 −4
  • deploy.go +0 −2
  • deploywatch.go +25 −51
  • docs/site/cli.md +0 −7
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json +155 −0
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md +10 −15
  • instances_cli.go +2 −8
  • internal/activator/activator.go +1 −2
  • internal/activator/logsink.go +0 −2
  • internal/activator/wakeplan.go +1 −1
  • internal/console/apiretry.go +2 −7
  • internal/console/apiship_swap.go +1 −3
  • internal/console/apiswap.go +2 −7
  • internal/console/apiswap_test.go +7 −6
  • internal/console/swapcopy.go +26 −55
  • internal/console/templates/app_settings.html +9 −9
  • internal/host/rollout.go +2 −7
  • internal/host/rolloutdeclined.go +0 −2
  • internal/host/rolloutrefusaldrafts_test.go +1 −1
  • internal/rollout/events.go +1 −3
  • internal/rollout/wakeplan.go +3 −14
  • internal/rollout/wakeplan_test.go +2 −2
  • internal/rolloutwords/words.go +28 −45
diff --git a/apimode.go b/apimode.go
index 748074e86..18cbddfc0 100644
--- a/apimode.go
+++ b/apimode.go
@@ -4419,7 +4419,6 @@ func checkCapabilitiesEcho(sent *caps.Set, body []byte) error {
return nil
}
-// TODO-COPY zdd.ship.no-echo
const shipSwapNoEcho = "ship: this console predates --swap and stored the release without it, so it would swap in overlap mode. Nothing was promoted. Upgrade the console, then ship under a new version"
// checkSwapEcho is checkCapabilitiesEcho's twin for the swap demand: a
diff --git a/approved_copy_test.go b/approved_copy_test.go
index 9d6aae50c..e44fb2c6d 100644
--- a/approved_copy_test.go
+++ b/approved_copy_test.go
@@ -62,11 +62,13 @@ func TestApprovedCopyIsApplied(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- checkApprovedCopy(t, raw, ".")
+ checkApprovedCopy(t, raw, ".", approvedCopyIDs, approvedCopyFragments)
}
-// checkApprovedCopy checks a record against the files under root.
-func checkApprovedCopy(t *testing.T, raw []byte, root string) {
+// checkApprovedCopy checks a record against the files under root. ids is the
+// pinned inventory the record must name; fragments are the entries that sit
+// inside a longer literal.
+func checkApprovedCopy(t *testing.T, raw []byte, root string, ids []string, fragments map[string]bool) {
t.Helper()
var rec struct {
ApprovedBy string `json:"approved_by"`
@@ -85,7 +87,7 @@ func checkApprovedCopy(t *testing.T, raw []byte, root string) {
seen[e.ID]++
}
want := map[string]bool{}
- for _, id := range approvedCopyIDs {
+ for _, id := range ids {
want[id] = true
if seen[id] == 0 {
t.Errorf("the record has no entry %s, which the plan's copy inventory names", id)
@@ -102,7 +104,7 @@ func checkApprovedCopy(t *testing.T, raw []byte, root string) {
}
sort.Strings(extra)
for _, id := range extra {
- t.Errorf("the record has entry %s, which the pinned inventory does not name (add it to approvedCopyIDs)", id)
+ t.Errorf("the record has entry %s, which the pinned inventory does not name (add it to the pinned inventory)", id)
}
for _, e := range rec.Entries {
@@ -115,7 +117,7 @@ func checkApprovedCopy(t *testing.T, raw []byte, root string) {
t.Errorf("%s: %v", e.ID, err)
continue
}
- if !copyApplied(e.ID, e.File, e.Text, body) {
+ if !copyAppliedWith(fragments, e.ID, e.File, e.Text, body) {
t.Errorf("%s: %s does not contain the approved text %q", e.ID, e.File, e.Text)
}
}
@@ -126,11 +128,11 @@ func checkApprovedCopy(t *testing.T, raw []byte, root string) {
// value of a string literal token, so a word cannot pass by appearing in a
// comment, an identifier or a longer literal. U1 to U3 are pieces of a larger
// literal, so they need only be inside some literal's decoded value.
-func copyApplied(id, file, text string, body []byte) bool {
+func copyAppliedWith(fragments map[string]bool, id, file, text string, body []byte) bool {
if !strings.HasSuffix(file, ".go") {
return bytes.Contains(body, []byte(text))
}
- if approvedCopyFragments[id] {
+ if fragments[id] {
return goLiteralMatches(body, func(v string) bool { return strings.Contains(v, text) })
}
if len(text) >= shortCopyBytes {
@@ -139,6 +141,11 @@ func copyApplied(id, file, text string, body []byte) bool {
return goLiteralMatches(body, func(v string) bool { return v == text })
}
+// copyApplied is the nearest-region record's check, kept for its own test.
+func copyApplied(id, file, text string, body []byte) bool {
+ return copyAppliedWith(approvedCopyFragments, id, file, text, body)
+}
+
// goLiteralMatches reports whether the decoded value of any string literal in
// the Go source satisfies match. The scanner skips comments, so text that
// appears only in a comment never matches.
diff --git a/approved_copy_zdd_test.go b/approved_copy_zdd_test.go
new file mode 100644
index 000000000..fffdb3d0e
--- /dev/null
+++ b/approved_copy_zdd_test.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "os"
+ "testing"
+)
+
+// zddCopyIDs is the zero-downtime plan 6 copy inventory, pinned as a literal:
+// the 149 strings Paul approved on 2026-10-11. The record must name every one,
+// once, and nothing else. A record that loses an entry fails here even though
+// every entry left in it is applied.
+var zddCopyIDs = []string{
+ "zdd.settings.heading", "zdd.settings.intro", "zdd.settings.swap-label",
+ "zdd.settings.overlap-option", "zdd.settings.hold-option", "zdd.settings.concurrency-label",
+ "zdd.settings.migrations-note", "zdd.settings.save", "zdd.settings.saved", "zdd.settings.invalid",
+ "zdd.settings.store-failed", "zdd.settings.rate-limited", "zdd.settings.unread",
+ "zdd.settings.no-bucket", "zdd.instance.installed", "zdd.instance.serving", "zdd.instance.hold",
+ "zdd.instance.hold-text", "zdd.instance.hold-down-text", "zdd.instance.promote-retry",
+ "zdd.instance.retry-button", "zdd.instance.rolling", "zdd.instance.asleep",
+ "zdd.instance.retry-title", "zdd.instance.retry-line", "zdd.instance.retry-done",
+ "zdd.instance.retry-failed", "zdd.instance.retry-limited", "zdd.instance.not-held",
+ "zdd.instance.audit", "zdd.api.swap-nothing", "zdd.api.swap-bad", "zdd.api.concurrency-bad",
+ "zdd.api.swap-limited", "zdd.api.swap-audit", "zdd.api.ship-swap-bad",
+ "zdd.api.ship-swap-conflict", "zdd.api.retry-store", "zdd.api.retry-host-audit",
+ "zdd.api.retry-not-held", "zdd.api.retry-no-bucket", "zdd.api.retry-limited",
+ "zdd.apps.usage-line", "zdd.apps.usage-set", "zdd.apps-set.usage", "zdd.apps-set.flag-swap",
+ "zdd.apps-set.flag-concurrency", "zdd.apps-set.flag-app", "zdd.apps-set.nothing",
+ "zdd.apps-set.bad-swap", "zdd.apps-set.bad-concurrency", "zdd.apps-set.two-apps",
+ "zdd.apps-set.direct-mode", "zdd.apps-set.done", "zdd.apps-set.overlap-note",
+ "zdd.apps-set.hold-note", "zdd.apps-set.mismatch", "zdd.apps-set.old-console",
+ "zdd.apps-set.error", "zdd.apps-set.unreadable", "zdd.ship.flag-swap", "zdd.ship.usage-flag",
+ "zdd.ship.bad-swap", "zdd.ship.hold-note", "zdd.ship.no-echo", "zdd.deploy.usage-flag",
+ "zdd.deploy.held-until", "zdd.instances-retry.usage-line", "zdd.instances-retry.usage-para",
+ "zdd.instances-retry.done", "zdd.instances-retry.old-console", "zdd.list.header",
+ "zdd.list.retry-hint", "zdd.state.held", "zdd.state.rolling-out", "zdd.state.asleep",
+ "zdd.state.config-pending", "zdd.cause.release", "zdd.cause.install", "zdd.cause.retry",
+ "zdd.cause.restart", "zdd.cause.config", "zdd.reason.health", "zdd.reason.settle",
+ "zdd.reason.exit", "zdd.reason.staging", "zdd.reason.reused", "zdd.reason.unknown",
+ "zdd.unavailable.declined", "zdd.unavailable.store", "zdd.unavailable.release-key",
+ "zdd.unavailable.failure-book", "zdd.unavailable.unknown", "zdd.event.swapping",
+ "zdd.event.swapped", "zdd.event.swap-failed", "zdd.event.hold-down", "zdd.event.cohort-halted",
+ "zdd.event.fallback-served", "zdd.event.config-pending", "zdd.event.staging-refused",
+ "zdd.event.bounce-instance", "zdd.event.bounce-sidecar", "zdd.refuse.not-installed",
+ "zdd.refuse.held-down", "zdd.refuse.not-staged", "zdd.refuse.attempt-running",
+ "zdd.refuse.not-installable", "zdd.refuse.held-since-plan", "zdd.refuse.no-store",
+ "zdd.refuse.no-release-key", "zdd.refuse.book-unreadable", "zdd.refuse.no-swaps",
+ "zdd.refuse.restart", "zdd.wake.not-published", "zdd.sidecar.restart-retrying", "zdd.watch.intro",
+ "zdd.watch.word-swapped", "zdd.watch.word-installed", "zdd.watch.asleep-tail",
+ "zdd.watch.word-failed", "zdd.watch.word-held", "zdd.watch.held-tail", "zdd.watch.box",
+ "zdd.watch.count-swapped", "zdd.watch.count-asleep", "zdd.watch.count-failed",
+ "zdd.watch.count-held", "zdd.watch.failed-summary", "zdd.watch.hold-down-summary",
+ "zdd.watch.timeout", "zdd.watch.pending-not-installed", "zdd.watch.pending-other",
+ "zdd.watch.pending-queued", "zdd.watch.pending-running", "zdd.watch.pending-settling",
+ "zdd.watch.pending-waiting", "zdd.watch.pending-dark", "zdd.watch.box-busy",
+ "zdd.watch.pending-unlisted", "zdd.watch.pending-unread", "zdd.watch.list-failed",
+ "zdd.docs.apps-set", "zdd.docs.deploy-watch", "zdd.docs.deploy-watch-lead",
+ "zdd.docs.deploy-swap-and-retry", "zdd.docs.migrations", "zdd.docs.instances",
+ "zdd.docs.sidecars",
+}
+
+// zddCopyFragments are entries shorter than shortCopyBytes that sit inside a
+// longer literal.
+var zddCopyFragments = map[string]bool{"zdd.ship.usage-flag": true, "zdd.deploy.usage-flag": true}
+
+// Every string Paul approved in the zero-downtime plan 6 copy review is
+// present, byte for byte, in the file the record names.
+func TestZeroDowntimeCopyIsApplied(t *testing.T) {
+ raw, err := os.ReadFile("docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ checkApprovedCopy(t, raw, ".", zddCopyIDs, zddCopyFragments)
+}
diff --git a/apps.go b/apps.go
index 54d0ecc04..48246d3e6 100644
--- a/apps.go
+++ b/apps.go
@@ -34,8 +34,6 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// TODO-COPY zdd.apps.usage-line
-// TODO-COPY zdd.apps.usage-set
const appsUsage = `usage: carlos apps <create|place|build-probe|set|delete|restore|ttl|keep|list> [flags] <app>
create --app <name> [flags] claim a new app name in an account, through
diff --git a/apps_swap.go b/apps_swap.go
index 1b6bed8d0..aa2dfe84b 100644
--- a/apps_swap.go
+++ b/apps_swap.go
@@ -15,7 +15,6 @@ import (
"strings"
)
-// TODO-COPY zdd.apps-set.usage
const appsSetUsage = `usage: carlos apps set <app> [--swap overlap|hold] [--swap-concurrency <n>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]
--swap overlap|hold how a running instance takes a new build. overlap
@@ -32,36 +31,21 @@ Any member of the app may change these. They reach the app's boxes with its
config, within seconds.`
const (
- // TODO-COPY zdd.apps-set.flag-app
- appsSetFlagApp = "app whose swap policy to set"
- // TODO-COPY zdd.apps-set.flag-swap
- appsSetFlagSwap = "overlap or hold: how a running instance takes a new build"
- // TODO-COPY zdd.apps-set.flag-concurrency
+ appsSetFlagApp = "app whose swap policy to set"
+ appsSetFlagSwap = "overlap or hold: how a running instance takes a new build"
appsSetFlagConcurrency = "how many instances of the app may swap at once on one box (1 or more)"
- // TODO-COPY zdd.apps-set.nothing
- appsSetNothing = "apps set: nothing to set. Pass --swap, --swap-concurrency, or both"
- // TODO-COPY zdd.apps-set.bad-swap
- appsSetBadSwap = "apps set: --swap is overlap or hold, not %q"
- // TODO-COPY zdd.apps-set.bad-concurrency
- appsSetBadConcurrency = "apps set: --swap-concurrency must be 1 or more"
- // TODO-COPY zdd.apps-set.two-apps
- appsSetTwoApps = "apps set: name the app once, either first or with --app"
- // TODO-COPY zdd.apps-set.direct-mode
- appsSetDirectMode = "apps set works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>"
- // TODO-COPY zdd.apps-set.done
- appsSetDone = "%s swaps in %s mode, %d at a time on each box"
- // TODO-COPY zdd.apps-set.overlap-note
- appsSetOverlapNote = " the old and new builds run side by side for a few seconds and share the database, so keep migrations backward-compatible"
- // TODO-COPY zdd.apps-set.hold-note
- appsSetHoldNote = " each instance stops before its new build starts, and requests wait until it is up"
- // TODO-COPY zdd.apps-set.mismatch
- appsSetMismatch = "apps set: the change did not stick (the console stored %s, %d at a time). Run it again"
- // TODO-COPY zdd.apps-set.error
- appsSetError = "apps set: %v"
- // TODO-COPY zdd.apps-set.unreadable
- appsSetUnreadable = "apps set: unreadable response: %w"
- // TODO-COPY zdd.apps-set.old-console
- appsSetOldConsole = "not found: either %s is not an app in this account, or this console predates carlos apps set (deploy a console built from current main)"
+ appsSetNothing = "apps set: nothing to set. Pass --swap, --swap-concurrency, or both"
+ appsSetBadSwap = "apps set: --swap is overlap or hold, not %q"
+ appsSetBadConcurrency = "apps set: --swap-concurrency must be 1 or more"
+ appsSetTwoApps = "apps set: name the app once, either first or with --app"
+ appsSetDirectMode = "apps set works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>"
+ appsSetDone = "%s swaps in %s mode, %d at a time on each box"
+ appsSetOverlapNote = " the old and new builds run side by side for a few seconds and share the database, so keep migrations backward-compatible"
+ appsSetHoldNote = " each instance stops before its new build starts, and requests wait until it is up"
+ appsSetMismatch = "apps set: the change did not stick (the console stored %s, %d at a time). Run it again"
+ appsSetError = "apps set: %v"
+ appsSetUnreadable = "apps set: unreadable response: %w"
+ appsSetOldConsole = "not found: either %s is not an app in this account, or this console predates carlos apps set (deploy a console built from current main)"
)
// swapPolicyReq is the door's body: an absent half is left as it is.
diff --git a/apps_swap_markers_test.go b/apps_swap_constants_test.go
similarity index 63%
rename from apps_swap_markers_test.go
rename to apps_swap_constants_test.go
index 428109905..e89c30af7 100644
--- a/apps_swap_markers_test.go
+++ b/apps_swap_constants_test.go
@@ -5,17 +5,16 @@ import (
"go/parser"
"go/token"
"strconv"
- "strings"
"testing"
)
// Astra plan 6 R1 #9: every member-facing string `carlos apps set` adds is a
-// marked draft. In apps_swap.go, the help text of a flag and the text of an
-// error it prints must be a constant whose declaration carries a
-// "TODO-COPY zdd." marker. The only literals allowed there are the help
+// named constant, so the approved copy record can pin it. In apps_swap.go, the
+// help text of a flag and the text of an error it prints must be a constant,
+// never a bare literal. The only literals allowed there are the help
// strings shared, word for word, with the other verbs' --account,
// --account-id and --console flags: existing copy, not new.
-func TestAppsSetCopyIsMarkedDrafts(t *testing.T) {
+func TestAppsSetCopyIsNamedConstants(t *testing.T) {
shared := map[string]bool{
"account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)": true,
"account sqid, matched as a sqid ONLY — supersedes --account": true,
@@ -26,26 +25,6 @@ func TestAppsSetCopyIsMarkedDrafts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- marked := map[string]bool{}
- for _, d := range f.Decls {
- g, ok := d.(*ast.GenDecl)
- if !ok || g.Tok != token.CONST {
- continue
- }
- for _, sp := range g.Specs {
- vs := sp.(*ast.ValueSpec)
- doc := ""
- if vs.Doc != nil {
- doc = vs.Doc.Text()
- }
- if len(g.Specs) == 1 && g.Doc != nil {
- doc += g.Doc.Text()
- }
- for _, n := range vs.Names {
- marked[n.Name] = strings.Contains(doc, "TODO-COPY zdd.")
- }
- }
- }
// copyArg is which argument of each call is member-facing text.
copyArg := map[string]int{"fs.String": 2, "fs.Int": 2, "appFlag": 1, "consoleOption": 1,
"fmt.Errorf": 0, "errors.New": 0, "log.Fatal": 0, "log.Fatalf": 0}
@@ -70,14 +49,9 @@ func TestAppsSetCopyIsMarkedDrafts(t *testing.T) {
}
checked++
pos := fset.Position(call.Pos())
- switch a := call.Args[i].(type) {
- case *ast.BasicLit:
+ if a, ok := call.Args[i].(*ast.BasicLit); ok {
if s, _ := strconv.Unquote(a.Value); !shared[s] {
- t.Errorf("%s: %s(%s): new copy as a bare literal; make it a marked draft constant", pos, name, a.Value)
- }
- case *ast.Ident:
- if m, known := marked[a.Name]; known && !m {
- t.Errorf("%s: %s(%s): its constant has no TODO-COPY marker", pos, name, a.Name)
+ t.Errorf("%s: %s(%s): new copy as a bare literal; make it a named constant", pos, name, a.Value)
}
}
return true
diff --git a/cli.go b/cli.go
index 85bfd7b5b..44ed783de 100644
--- a/cli.go
+++ b/cli.go
@@ -2127,9 +2127,6 @@ func publicKey() ed25519.PublicKey {
// stored manifest, BuiltAt included, so a test of the re-ship path pins it.
var shipNow = time.Now
-// TODO-COPY zdd.ship.flag-swap
-// TODO-COPY zdd.ship.bad-swap
-// TODO-COPY zdd.ship.hold-note
const (
shipSwapFlagHelp = "hold: this release stops each instance before starting it, for a migration the previous build cannot share the database with"
shipBadSwap = "--swap takes hold, or leave it off (%q is not a swap demand)"
@@ -2167,7 +2164,6 @@ func runShip(args []string) {
// the target (release versioning spec, 2026-08-08). Everything else about
// this check is unchanged — an app and at least one file are still the
// minimum a ship can mean.
- // TODO-COPY zdd.ship.usage-flag
usage := "usage: carlos ship --app <a> [--version <sha>] [--label <one line>] [--notes <text> | --notes-file <file>] [--kind binary|static] [--account <sqid-or-name>] [--account-id <sqid>] [--console <origin>] [--capabilities <file.json>] [--program-change] [--swap hold] <files-or-site-dir>"
if fs.NArg() == 0 {
log.Fatal(usage)
diff --git a/deploy.go b/deploy.go
index 8353510c6..edcf2f4b7 100644
--- a/deploy.go
+++ b/deploy.go
@@ -91,7 +91,6 @@ func runDeploy(args []string) {
fs.Parse(args)
usage := "usage: carlos deploy --app <a> [--version <sha>] [--kind binary|static] [--target linux-arm64] " +
"[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " +
- // TODO-COPY zdd.deploy.usage-flag
"[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--swap hold] [--temporary] [--ttl 7d] [--timeout 120s] <files-or-site-dir>\n" + // C11.23
"or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]"
temp, ttl, terr := temporaryDeployFlags(*temporary, flagWasSet(fs, "ttl"), *ttlFlag, *hostFlag, flagWasSet(fs, "kind"), *kind, fs.NArg())
@@ -433,7 +432,6 @@ func runDeploy(args []string) {
}
}
-// TODO-COPY zdd.deploy.held-until
const deployHeldUntil = "%s is held by the %s bake window until about %s, then the fleet installs it. Fast-track it in the console if this is an emergency"
// deployHeldLine is what `carlos deploy` says when the promote it made is
diff --git a/deploywatch.go b/deploywatch.go
index 56f00ccf4..2ca946652 100644
--- a/deploywatch.go
+++ b/deploywatch.go
@@ -26,58 +26,32 @@ import (
)
const (
- // TODO-COPY zdd.watch.intro
- watchIntro = "watching %s roll out box by box: an awake instance counts once the new build has taken over, an asleep one once its box has installed it"
- // TODO-COPY zdd.watch.word-swapped
- watchWordSwapped = "swapped"
- // TODO-COPY zdd.watch.word-installed
- watchWordInstalled = "installed"
- // TODO-COPY zdd.watch.asleep-tail
- watchAsleepTail = "starts on its next request"
- // TODO-COPY zdd.watch.word-failed
- watchWordFailed = "failed"
- // TODO-COPY zdd.watch.word-held
- watchWordHeld = "held"
- // TODO-COPY zdd.watch.held-tail
- watchHeldTail = "stayed on %s because %s failed on this box"
- // TODO-COPY zdd.watch.box
- watchBox = "box %s: %s"
- // TODO-COPY zdd.watch.count-swapped
- watchCountSwapped = "%d swapped"
- // TODO-COPY zdd.watch.count-asleep
- watchCountAsleep = "%d asleep with it installed"
- // TODO-COPY zdd.watch.count-failed
- watchCountFailed = "%d failed"
- // TODO-COPY zdd.watch.count-held
- watchCountHeld = "%d stayed on their last good build"
- // TODO-COPY zdd.watch.failed-summary
- watchFailedSummary = "build %s did not reach every instance: %d failed and %d stayed on their last good build. Fix it and deploy a new build, or try this one again: carlos promote --app %s --retry %s %s"
- // TODO-COPY zdd.watch.hold-down-summary
- watchHoldDownSummary = "an instance that swaps in hold mode is down, with nothing to fall back to: carlos rollback --app %s"
- // TODO-COPY zdd.watch.timeout
- watchTimeout = "timed out waiting for build %s:"
- // TODO-COPY zdd.watch.pending-not-installed
+ watchIntro = "watching %s roll out box by box: an awake instance counts once the new build has taken over, an asleep one once its box has installed it"
+ watchWordSwapped = "swapped"
+ watchWordInstalled = "installed"
+ watchAsleepTail = "starts on its next request"
+ watchWordFailed = "failed"
+ watchWordHeld = "held"
+ watchHeldTail = "stayed on %s because %s failed on this box"
+ watchBox = "box %s: %s"
+ watchCountSwapped = "%d swapped"
+ watchCountAsleep = "%d asleep with it installed"
+ watchCountFailed = "%d failed"
+ watchCountHeld = "%d stayed on their last good build"
+ watchFailedSummary = "build %s did not reach every instance: %d failed and %d stayed on their last good build. Fix it and deploy a new build, or try this one again: carlos promote --app %s --retry %s %s"
+ watchHoldDownSummary = "an instance that swaps in hold mode is down, with nothing to fall back to: carlos rollback --app %s"
+ watchTimeout = "timed out waiting for build %s:"
watchPendingNotInstalled = "its box has not installed this build yet"
- // TODO-COPY zdd.watch.pending-other
- watchPendingOther = "its box has %s installed, not this build"
- // TODO-COPY zdd.watch.pending-queued
- watchPendingQueued = "queued"
- // TODO-COPY zdd.watch.pending-running
- watchPendingRunning = "starting beside the running build"
- // TODO-COPY zdd.watch.pending-settling
- watchPendingSettling = "settling: making sure it stays up for 10 seconds"
- // TODO-COPY zdd.watch.pending-waiting
- watchPendingWaiting = "waiting for its box to start it"
- // TODO-COPY zdd.watch.pending-dark
- watchPendingDark = "its box stopped reporting"
- // TODO-COPY zdd.watch.pending-unlisted (new: no plan draft; controller ruling R3)
- watchPendingUnlisted = "the console's instance list was cut short before it, so its state is unknown"
- // TODO-COPY zdd.watch.box-busy
- watchBoxBusy = "box %s is still working through this app's instances"
- // TODO-COPY zdd.watch.list-failed
- watchListFailed = "the console's instance list could not be read, so the rollout could not be followed"
- // TODO-COPY zdd.watch.pending-unread (new: no plan draft; fix wave 1, W2)
- watchPendingUnread = "the console could not read every box's report, so its state is unknown"
+ watchPendingOther = "its box has %s installed, not this build"
+ watchPendingQueued = "queued"
+ watchPendingRunning = "starting beside the running build"
+ watchPendingSettling = "settling: making sure it stays up for 10 seconds"
+ watchPendingWaiting = "waiting for its box to start it"
+ watchPendingDark = "its box stopped reporting"
+ watchPendingUnlisted = "the console's instance list was cut short before it, so its state is unknown"
+ watchBoxBusy = "box %s is still working through this app's instances"
+ watchListFailed = "the console's instance list could not be read, so the rollout could not be followed"
+ watchPendingUnread = "the console could not read every box's report, so its state is unknown"
)
// watchList is one answer of the console's instance list, as the watch
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 4bca898c3..9c20f3e1a 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -184,7 +184,6 @@ carlos apps ttl --app tmp-docs 5d
carlos apps keep --app tmp-docs
```
-<!-- TODO-COPY zdd.docs.apps-set -->
`carlos apps set` changes how the app's running instances take a new build:
```sh
@@ -252,7 +251,6 @@ Ship, promote, and then wait until the app's URL actually serves the new
build. Reach for this one by default; ship and promote separately when you
want the gap between them.
-<!-- TODO-COPY zdd.docs.deploy-watch -->
Deploy then watches each box roll the build out. An awake instance counts
once the new build has passed its health check and taken over; on each box
the first one must also stay up for ten seconds before the rest follow. An
@@ -261,7 +259,6 @@ its next request. If the build fails on a box, that box stops rolling it out,
its other instances stay on the build they were serving, and deploy names the
failure and exits with an error.
-<!-- TODO-COPY zdd.docs.deploy-watch-lead -->
Where a box does not report its rollout yet, deploy watches the app's URL
instead, and what it watches there is worth knowing, because the two answers
differ. If the app
@@ -294,7 +291,6 @@ having no instances to resolve a channel from.
carlos deploy --app website --kind static --host www.example.com ./dist
```
-<!-- TODO-COPY zdd.docs.deploy-swap-and-retry -->
`--swap hold` ships a release that stops each instance before starting the
new build, for a migration the previous build cannot share the database
with. It applies to that release only; `carlos apps set --swap hold` makes it
@@ -326,7 +322,6 @@ app is served only at its own platform address, so `--host` and
carlos deploy --temporary --ttl 3d ./dist
```
-<!-- TODO-COPY zdd.docs.migrations -->
#### Two builds at once
When a new build reaches an awake instance, it starts beside the old one,
@@ -731,7 +726,6 @@ every few minutes, and asleep instances are measured too. A dash means no box
has measured it: the instance is not running anywhere yet, it has not created
its database, or it keeps its data somewhere CARLOS does not manage.
-<!-- TODO-COPY zdd.docs.instances -->
`carlos instances list` shows two builds for each instance: the one installed
on its box, and the one serving its requests. They differ while a new build
rolls out, while an asleep instance waits for its next request, and after a
@@ -909,7 +903,6 @@ record asks for it, independently and on purpose: one is a fact about the
network, the other a requirement of the work. Where they disagree the more
restrictive reading wins.
-<!-- TODO-COPY zdd.docs.sidecars -->
Because a sidecar has no URL, there is no `X-Carlos-Version` header to read
it back from, so `carlos sidecars ls` shows the build its box last started
for each sidecar, beside the version its channel points at. One that has not
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json
new file mode 100644
index 000000000..497c285cf
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json
@@ -0,0 +1,155 @@
+{
+ "approved_by": "Paul",
+ "approved_on": "2026-10-11",
+ "entries": [
+ {"id": "zdd.settings.heading", "file": "internal/console/swapcopy.go", "text": "Deploys"},
+ {"id": "zdd.settings.intro", "file": "internal/console/swapcopy.go", "text": "When a new build reaches a running instance, the old instance keeps running until the new one is ready."},
+ {"id": "zdd.settings.swap-label", "file": "internal/console/swapcopy.go", "text": "Swap mode:"},
+ {"id": "zdd.settings.overlap-option", "file": "internal/console/swapcopy.go", "text": "overlap (default)"},
+ {"id": "zdd.settings.hold-option", "file": "internal/console/swapcopy.go", "text": "hold"},
+ {"id": "zdd.settings.concurrency-label", "file": "internal/console/swapcopy.go", "text": "Instances that swap at once on each box:"},
+ {"id": "zdd.settings.migrations-note", "file": "internal/console/swapcopy.go", "text": "With overlap, the old and new builds share the instance's database for a few seconds. Migrations need to be backward compatible to ensure the old version keeps working. Use hold when a release cannot do that."},
+ {"id": "zdd.settings.save", "file": "internal/console/templates/app_settings.html", "text": "Save"},
+ {"id": "zdd.settings.saved", "file": "internal/console/swapcopy.go", "text": "Swap policy saved: %s, %d at a time on each box."},
+ {"id": "zdd.settings.invalid", "file": "internal/console/swapcopy.go", "text": "Choose overlap or hold, and a whole number of 1 or more."},
+ {"id": "zdd.settings.store-failed", "file": "internal/console/swapcopy.go", "text": "Could not save the swap policy. Try again."},
+ {"id": "zdd.settings.rate-limited", "file": "internal/console/swapcopy.go", "text": "Too many changes. Try again in a minute."},
+ {"id": "zdd.settings.unread", "file": "internal/console/swapcopy.go", "text": "This app's swap policy could not be read just now, so it cannot be changed here. Reload before concluding anything."},
+ {"id": "zdd.settings.no-bucket", "file": "internal/console/swapcopy.go", "text": "No deployment bucket on this host, so the swap policy cannot be changed here."},
+ {"id": "zdd.instance.installed", "file": "internal/console/swapcopy.go", "text": "Installed build"},
+ {"id": "zdd.instance.serving", "file": "internal/console/swapcopy.go", "text": "Serving build"},
+ {"id": "zdd.instance.hold", "file": "internal/console/swapcopy.go", "text": "Failure hold"},
+ {"id": "zdd.instance.hold-text", "file": "internal/console/swapcopy.go", "text": "Build %s failed on this box because %s. This instance stays on %s until you retry it or ship a newer build."},
+ {"id": "zdd.instance.hold-down-text", "file": "internal/console/swapcopy.go", "text": "Build %s failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it."},
+ {"id": "zdd.instance.promote-retry", "file": "internal/console/swapcopy.go", "text": "To try it again on every box: carlos promote --app %s --retry %s %s"},
+ {"id": "zdd.instance.retry-button", "file": "internal/console/swapcopy.go", "text": "Retry"},
+ {"id": "zdd.instance.rolling", "file": "internal/console/swapcopy.go", "text": "Build %s is installed and rolling out. Build %s serves until the new one passes its health check."},
+ {"id": "zdd.instance.asleep", "file": "internal/console/swapcopy.go", "text": "Build %s is installed and starts on the next request."},
+ {"id": "zdd.instance.retry-title", "file": "internal/console/swapcopy.go", "text": "Retry build {build} on {host}?"},
+ {"id": "zdd.instance.retry-line", "file": "internal/console/swapcopy.go", "text": "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again."},
+ {"id": "zdd.instance.retry-done", "file": "internal/console/swapcopy.go", "text": "Retry requested for {host}."},
+ {"id": "zdd.instance.retry-failed", "file": "internal/console/swapcopy.go", "text": "The retry request failed. Try again."},
+ {"id": "zdd.instance.retry-limited", "file": "internal/console/swapcopy.go", "text": "Too many retry requests. Try again later."},
+ {"id": "zdd.instance.not-held", "file": "internal/console/swapcopy.go", "text": "{host} is no longer held back by a failed build, so there is nothing to retry."},
+ {"id": "zdd.instance.audit", "file": "internal/console/swapcopy.go", "text": "Retry of build {build} requested for {host}."},
+ {"id": "zdd.api.swap-nothing", "file": "internal/console/apiswap.go", "text": "nothing to set: send swap, swap_concurrency, or both"},
+ {"id": "zdd.api.swap-bad", "file": "internal/console/apiswap.go", "text": "swap must be \"overlap\" or \"hold\""},
+ {"id": "zdd.api.concurrency-bad", "file": "internal/console/apiswap.go", "text": "swap_concurrency must be a whole number, 1 or more"},
+ {"id": "zdd.api.swap-limited", "file": "internal/console/apiswap.go", "text": "too many requests. wait a minute and try again"},
+ {"id": "zdd.api.swap-audit", "file": "internal/console/apiswap.go", "text": "set the swap policy for %s to %s, %d at a time (%s)"},
+ {"id": "zdd.api.ship-swap-bad", "file": "internal/console/apiship_swap.go", "text": "swap must be \"hold\" or left out"},
+ {"id": "zdd.api.ship-swap-conflict", "file": "internal/console/apiship_swap.go", "text": "version %s was already shipped with a different swap demand. Versions are immutable: ship under a new version"},
+ {"id": "zdd.api.retry-store", "file": "internal/console/apiretry.go", "text": "could not write the retry request. Try again"},
+ {"id": "zdd.api.retry-host-audit", "file": "internal/console/apiretry.go", "text": "requested a retry of %s on %s (%s)"},
+ {"id": "zdd.api.retry-not-held", "file": "internal/console/apiretry.go", "text": "%s is not held back by a failed build, as far as its box last reported, so there is nothing to retry"},
+ {"id": "zdd.api.retry-no-bucket", "file": "internal/console/apiretry.go", "text": "no deployment bucket on this host. this console cannot request a retry"},
+ {"id": "zdd.api.retry-limited", "file": "internal/console/apiretry.go", "text": "too many requests. wait a minute and try again"},
+ {"id": "zdd.apps.usage-line", "file": "apps.go", "text": "usage: carlos apps <create|place|build-probe|set|delete|restore|ttl|keep|list> [flags] <app>"},
+ {"id": "zdd.apps.usage-set", "file": "apps.go", "text": " set <app> [--swap overlap|hold] [--swap-concurrency <n>] how the app's\n running instances take a new build: see carlos apps set -h."},
+ {"id": "zdd.apps-set.usage", "file": "apps_swap.go", "text": "usage: carlos apps set <app> [--swap overlap|hold] [--swap-concurrency <n>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]\n\n --swap overlap|hold how a running instance takes a new build. overlap\n starts the new build beside the old one and switches\n once it answers its health check, so no request\n waits. hold stops the old one first, and requests\n wait until the new one is up. Use hold when two\n builds cannot share the database.\n --swap-concurrency <n> how many instances of the app may swap at once on\n one box, after the first has stayed up for ten\n seconds. 1 or more; it starts at 1.\n\nAny member of the app may change these. They reach the app's boxes with its\nconfig, within seconds."},
+ {"id": "zdd.apps-set.flag-swap", "file": "apps_swap.go", "text": "overlap or hold: how a running instance takes a new build"},
+ {"id": "zdd.apps-set.flag-concurrency", "file": "apps_swap.go", "text": "how many instances of the app may swap at once on one box (1 or more)"},
+ {"id": "zdd.apps-set.flag-app", "file": "apps_swap.go", "text": "app whose swap policy to set"},
+ {"id": "zdd.apps-set.nothing", "file": "apps_swap.go", "text": "apps set: nothing to set. Pass --swap, --swap-concurrency, or both"},
+ {"id": "zdd.apps-set.bad-swap", "file": "apps_swap.go", "text": "apps set: --swap is overlap or hold, not %q"},
+ {"id": "zdd.apps-set.bad-concurrency", "file": "apps_swap.go", "text": "apps set: --swap-concurrency must be 1 or more"},
+ {"id": "zdd.apps-set.two-apps", "file": "apps_swap.go", "text": "apps set: name the app once, either first or with --app"},
+ {"id": "zdd.apps-set.direct-mode", "file": "apps_swap.go", "text": "apps set works through a console, and this shell is in direct bucket mode (CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Log in instead: carlos auth login --console <url>"},
+ {"id": "zdd.apps-set.done", "file": "apps_swap.go", "text": "%s swaps in %s mode, %d at a time on each box"},
+ {"id": "zdd.apps-set.overlap-note", "file": "apps_swap.go", "text": " the old and new builds run side by side for a few seconds and share the database, so keep migrations backward-compatible"},
+ {"id": "zdd.apps-set.hold-note", "file": "apps_swap.go", "text": " each instance stops before its new build starts, and requests wait until it is up"},
+ {"id": "zdd.apps-set.mismatch", "file": "apps_swap.go", "text": "apps set: the change did not stick (the console stored %s, %d at a time). Run it again"},
+ {"id": "zdd.apps-set.old-console", "file": "apps_swap.go", "text": "not found: either %s is not an app in this account, or this console predates carlos apps set (deploy a console built from current main)"},
+ {"id": "zdd.apps-set.error", "file": "apps_swap.go", "text": "apps set: %v"},
+ {"id": "zdd.apps-set.unreadable", "file": "apps_swap.go", "text": "apps set: unreadable response: %w"},
+ {"id": "zdd.ship.flag-swap", "file": "cli.go", "text": "hold: this release stops each instance before starting it, for a migration the previous build cannot share the database with"},
+ {"id": "zdd.ship.usage-flag", "file": "cli.go", "text": "[--swap hold]"},
+ {"id": "zdd.ship.bad-swap", "file": "cli.go", "text": "--swap takes hold, or leave it off (%q is not a swap demand)"},
+ {"id": "zdd.ship.hold-note", "file": "cli.go", "text": " this release swaps in hold mode: each instance stops before the new build starts"},
+ {"id": "zdd.ship.no-echo", "file": "apimode.go", "text": "ship: this console predates --swap and stored the release without it, so it would swap in overlap mode. Nothing was promoted. Upgrade the console, then ship under a new version"},
+ {"id": "zdd.deploy.usage-flag", "file": "deploy.go", "text": "[--swap hold]"},
+ {"id": "zdd.deploy.held-until", "file": "deploy.go", "text": "%s is held by the %s bake window until about %s, then the fleet installs it. Fast-track it in the console if this is an emergency"},
+ {"id": "zdd.instances-retry.usage-line", "file": "instances_cli.go", "text": " carlos instances retry --app <a> <host>"},
+ {"id": "zdd.instances-retry.usage-para", "file": "instances_cli.go", "text": "retry clears the failure hold on one instance: the build that failed on its\nbox is tried again there, as a canary. \"carlos promote --retry <version>\n<channel>\" does the same on every box."},
+ {"id": "zdd.instances-retry.done", "file": "instances_cli.go", "text": "retry requested for %s: its next attempt at %s runs as a canary"},
+ {"id": "zdd.instances-retry.old-console", "file": "instances_cli.go", "text": "not found: either %s has no instance %s, or this console predates carlos instances retry (deploy a console built from current main)"},
+ {"id": "zdd.list.header", "file": "instances_cli.go", "text": "HOST\\tREGION\\tCHANNEL\\tPOOL\\tINSTALLED\\tSERVING\\tHEALTH\\tUPSTREAMS\\tDB\\tCREATED\\tBY\\tREASON"},
+ {"id": "zdd.list.retry-hint", "file": "instances_cli.go", "text": "a build that failed is tried again on every box with carlos promote --app %s --retry <version> <channel>, or on one instance with carlos instances retry --app %s <host>"},
+ {"id": "zdd.state.held", "file": "internal/rolloutwords/words.go", "text": "held: build %s failed on this box"},
+ {"id": "zdd.state.rolling-out", "file": "internal/rolloutwords/words.go", "text": "rolling out"},
+ {"id": "zdd.state.asleep", "file": "internal/rolloutwords/words.go", "text": "starts %s on its next request"},
+ {"id": "zdd.state.config-pending", "file": "internal/rolloutwords/words.go", "text": "config delivered, not active"},
+ {"id": "zdd.cause.release", "file": "internal/rolloutwords/words.go", "text": "new release"},
+ {"id": "zdd.cause.install", "file": "internal/rolloutwords/words.go", "text": "installed by hand"},
+ {"id": "zdd.cause.retry", "file": "internal/rolloutwords/words.go", "text": "retry"},
+ {"id": "zdd.cause.restart", "file": "internal/rolloutwords/words.go", "text": "carlos restart"},
+ {"id": "zdd.cause.config", "file": "internal/rolloutwords/words.go", "text": "config change"},
+ {"id": "zdd.reason.health", "file": "internal/rolloutwords/words.go", "text": "it did not answer its health check within 30 seconds"},
+ {"id": "zdd.reason.settle", "file": "internal/rolloutwords/words.go", "text": "it stopped within 10 seconds of answering its health check"},
+ {"id": "zdd.reason.exit", "file": "internal/rolloutwords/words.go", "text": "it exited before it was ready"},
+ {"id": "zdd.reason.staging", "file": "internal/rolloutwords/words.go", "text": "the copy on the box did not match the release"},
+ {"id": "zdd.reason.reused", "file": "internal/rolloutwords/words.go", "text": "this version name was shipped twice with different content, so ship it under a new name"},
+ {"id": "zdd.reason.unknown", "file": "internal/rolloutwords/words.go", "text": "it failed (%s)"},
+ {"id": "zdd.unavailable.declined", "file": "internal/rolloutwords/words.go", "text": "its box cannot run two builds side by side, so it restarts instances the old way"},
+ {"id": "zdd.unavailable.store", "file": "internal/rolloutwords/words.go", "text": "its box cannot start instances: the deploy store is unavailable"},
+ {"id": "zdd.unavailable.release-key", "file": "internal/rolloutwords/words.go", "text": "its box cannot start instances: the release key is unavailable"},
+ {"id": "zdd.unavailable.failure-book", "file": "internal/rolloutwords/words.go", "text": "its box cannot start instances: its record of failed builds cannot be read"},
+ {"id": "zdd.unavailable.unknown", "file": "internal/rolloutwords/words.go", "text": "its box reports a rollout problem (%s)"},
+ {"id": "zdd.event.swapping", "file": "internal/rolloutwords/words.go", "text": "starting build %s (%s)"},
+ {"id": "zdd.event.swapped", "file": "internal/rolloutwords/words.go", "text": "now serving build %s. The previous build finished its requests and stopped in %s"},
+ {"id": "zdd.event.swap-failed", "file": "internal/rolloutwords/words.go", "text": "build %s did not take over, because %s. Still serving build %s"},
+ {"id": "zdd.event.hold-down", "file": "internal/rolloutwords/words.go", "text": "build %s failed and nothing is serving. This app swaps in hold mode, so nothing falls back by itself. Roll back with carlos rollback"},
+ {"id": "zdd.event.cohort-halted", "file": "internal/rolloutwords/words.go", "text": "rollout of build %s stopped on box %s, because %s. Instances on that box that have not served it stay on their last good build"},
+ {"id": "zdd.event.fallback-served", "file": "internal/rolloutwords/words.go", "text": "build %s failed to start, so the last good build, %s, is serving instead"},
+ {"id": "zdd.event.config-pending", "file": "internal/rolloutwords/words.go", "text": "new config reached this box but is not in use yet, because %s. Trying again in %s"},
+ {"id": "zdd.event.staging-refused", "file": "internal/rolloutwords/words.go", "text": "build %s is not staged correctly on this box, so the box asked for it to be staged again"},
+ {"id": "zdd.event.bounce-instance", "file": "internal/rolloutwords/words.go", "text": "instance restarting (%s)"},
+ {"id": "zdd.event.bounce-sidecar", "file": "internal/rolloutwords/words.go", "text": "sidecar restarting (%s)"},
+ {"id": "zdd.refuse.not-installed", "file": "internal/rollout/wakeplan.go", "text": "no build is installed for this instance yet"},
+ {"id": "zdd.refuse.held-down", "file": "internal/rollout/wakeplan.go", "text": "build %s failed in hold mode on this instance, so it stays down until you roll back or retry the build"},
+ {"id": "zdd.refuse.not-staged", "file": "internal/rollout/wakeplan.go", "text": "build %s is not staged correctly on this box"},
+ {"id": "zdd.refuse.attempt-running", "file": "internal/rollout/wakeplan.go", "text": "this instance is still moving to another build"},
+ {"id": "zdd.refuse.not-installable", "file": "internal/rollout/wakeplan.go", "text": "its build could not be installed"},
+ {"id": "zdd.refuse.held-since-plan", "file": "internal/rollout/wakeplan.go", "text": "build %s failed on this box while this instance was starting, so it was not started"},
+ {"id": "zdd.refuse.no-store", "file": "internal/host/rollout.go", "text": "this box cannot start instances right now: the deploy store is unavailable"},
+ {"id": "zdd.refuse.no-release-key", "file": "internal/host/rollout.go", "text": "this box cannot start instances right now: the release key is unavailable"},
+ {"id": "zdd.refuse.book-unreadable", "file": "internal/host/rollout.go", "text": "this box cannot start instances right now: its record of failed builds cannot be read"},
+ {"id": "zdd.refuse.no-swaps", "file": "internal/host/rolloutdeclined.go", "text": "this box cannot run two builds side by side, which this instance's build needs"},
+ {"id": "zdd.refuse.restart", "file": "internal/host/rollout.go", "text": "restart refused: this box cannot restart instances right now"},
+ {"id": "zdd.wake.not-published", "file": "internal/activator/logsink.go", "text": "wake failed: the box could not record which build this instance runs, so it did not start it. the next request tries again"},
+ {"id": "zdd.sidecar.restart-retrying", "file": "internal/activator/activator.go", "text": "sidecar could not be restarted. retrying"},
+ {"id": "zdd.watch.intro", "file": "deploywatch.go", "text": "watching %s roll out box by box: an awake instance counts once the new build has taken over, an asleep one once its box has installed it"},
+ {"id": "zdd.watch.word-swapped", "file": "deploywatch.go", "text": "swapped"},
+ {"id": "zdd.watch.word-installed", "file": "deploywatch.go", "text": "installed"},
+ {"id": "zdd.watch.asleep-tail", "file": "deploywatch.go", "text": "starts on its next request"},
+ {"id": "zdd.watch.word-failed", "file": "deploywatch.go", "text": "failed"},
+ {"id": "zdd.watch.word-held", "file": "deploywatch.go", "text": "held"},
+ {"id": "zdd.watch.held-tail", "file": "deploywatch.go", "text": "stayed on %s because %s failed on this box"},
+ {"id": "zdd.watch.box", "file": "deploywatch.go", "text": "box %s: %s"},
+ {"id": "zdd.watch.count-swapped", "file": "deploywatch.go", "text": "%d swapped"},
+ {"id": "zdd.watch.count-asleep", "file": "deploywatch.go", "text": "%d asleep with it installed"},
+ {"id": "zdd.watch.count-failed", "file": "deploywatch.go", "text": "%d failed"},
+ {"id": "zdd.watch.count-held", "file": "deploywatch.go", "text": "%d stayed on their last good build"},
+ {"id": "zdd.watch.failed-summary", "file": "deploywatch.go", "text": "build %s did not reach every instance: %d failed and %d stayed on their last good build. Fix it and deploy a new build, or try this one again: carlos promote --app %s --retry %s %s"},
+ {"id": "zdd.watch.hold-down-summary", "file": "deploywatch.go", "text": "an instance that swaps in hold mode is down, with nothing to fall back to: carlos rollback --app %s"},
+ {"id": "zdd.watch.timeout", "file": "deploywatch.go", "text": "timed out waiting for build %s:"},
+ {"id": "zdd.watch.pending-not-installed", "file": "deploywatch.go", "text": "its box has not installed this build yet"},
+ {"id": "zdd.watch.pending-other", "file": "deploywatch.go", "text": "its box has %s installed, not this build"},
+ {"id": "zdd.watch.pending-queued", "file": "deploywatch.go", "text": "queued"},
+ {"id": "zdd.watch.pending-running", "file": "deploywatch.go", "text": "starting beside the running build"},
+ {"id": "zdd.watch.pending-settling", "file": "deploywatch.go", "text": "settling: making sure it stays up for 10 seconds"},
+ {"id": "zdd.watch.pending-waiting", "file": "deploywatch.go", "text": "waiting for its box to start it"},
+ {"id": "zdd.watch.pending-dark", "file": "deploywatch.go", "text": "its box stopped reporting"},
+ {"id": "zdd.watch.box-busy", "file": "deploywatch.go", "text": "box %s is still working through this app's instances"},
+ {"id": "zdd.watch.pending-unlisted", "file": "deploywatch.go", "text": "the console's instance list was cut short before it, so its state is unknown"},
+ {"id": "zdd.watch.pending-unread", "file": "deploywatch.go", "text": "the console could not read every box's report, so its state is unknown"},
+ {"id": "zdd.watch.list-failed", "file": "deploywatch.go", "text": "the console's instance list could not be read, so the rollout could not be followed"},
+ {"id": "zdd.docs.apps-set", "file": "docs/site/cli.md", "text": "`carlos apps set` changes how the app's running instances take a new build:\n\n```sh\ncarlos apps set hello --swap hold\ncarlos apps set hello --swap-concurrency 3\n```\n\nWith `--swap overlap`, the default, the new build starts beside the old one\nand takes over once it answers its health check, so no request waits. With\n`--swap hold`, the old one stops first, and requests wait until the new one\nis up. `--swap-concurrency` is how many instances may swap at once on one\nbox, after the first one has stayed up for ten seconds; it starts at 1. Any\nmember of the app can change both, here or on the app's settings page in the\nconsole."},
+ {"id": "zdd.docs.deploy-watch", "file": "docs/site/cli.md", "text": "Deploy then watches each box roll the build out. An awake instance counts\nonce the new build has passed its health check and taken over; on each box\nthe first one must also stay up for ten seconds before the rest follow. An\nasleep instance counts once its box has installed the build, and starts it on\nits next request. If the build fails on a box, that box stops rolling it out,\nits other instances stay on the build they were serving, and deploy names the\nfailure and exits with an error."},
+ {"id": "zdd.docs.deploy-watch-lead", "file": "docs/site/cli.md", "text": "Where a box does not report its rollout yet, deploy watches the app's URL\ninstead, and what it watches there is worth knowing, because the two answers\ndiffer."},
+ {"id": "zdd.docs.deploy-swap-and-retry", "file": "docs/site/cli.md", "text": "`--swap hold` ships a release that stops each instance before starting the\nnew build, for a migration the previous build cannot share the database\nwith. It applies to that release only; `carlos apps set --swap hold` makes it\nthe rule for the app.\n\nTo try a build that failed again, once you have fixed what it depended on,\npromote it again with `--retry`. Every box where it failed tries it again:\n\n```sh\ncarlos promote --app hello --retry a1b2c3d edge\n```\n\nWithout `--retry`, promoting a build that is already on the channel does\nnothing."},
+ {"id": "zdd.docs.migrations", "file": "docs/site/cli.md", "text": "#### Two builds at once\n\nWhen a new build reaches an awake instance, it starts beside the old one,\nand for a few seconds both run. They share the instance's database and its\nscheduled work. The new build's migrations run while the old build is still\nserving, so both builds must work with the database in between:\n\n- Add in one release and remove in a later one. A new column, table or index\n goes in one release; dropping the old one waits for the next.\n- Keep the names of scheduled work (`carlos schedule`) the same across the\n two builds.\n\nWhen a release cannot follow these rules, ship it with `--swap hold`, or set\nthe app to `hold` with `carlos apps set`. Each instance then stops before the\nnew build starts, and requests wait until it is up."},
+ {"id": "zdd.docs.instances", "file": "docs/site/cli.md", "text": "`carlos instances list` shows two builds for each instance: the one installed\non its box, and the one serving its requests. They differ while a new build\nrolls out, while an asleep instance waits for its next request, and after a\nbuild has failed on that box. The REASON column says which.\n\n`carlos instances retry` clears the failure hold on one instance, so the\nbuild that failed there is tried again, as a canary:\n\n```sh\ncarlos instances retry --app hello shop-1.hello.example.com\n```"},
+ {"id": "zdd.docs.sidecars", "file": "docs/site/cli.md", "text": "Because a sidecar has no URL, there is no `X-Carlos-Version` header to read\nit back from, so `carlos sidecars ls` shows the build its box last started\nfor each sidecar, beside the version its channel points at. One that has not\nstarted on any box reads as unknown rather than as current."}
+ ]
+}
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md
index d2caa4fa3..2ca6faa77 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-roll-notes.md
@@ -129,21 +129,16 @@ passing nil.
The discussion text predates plan 6 ("not started"). On this branch the
`carlos sidecars ls` change is built (task 13), drafts for the refusal and
-wake wording exist behind `TODO-COPY` markers (task 14b), and this file is
-task 17. None of those closes its item: the copy items wait for Paul's
-review, and only Paul closes the discussion's items.
+wake wording is built (task 14b), and this file is task 17. None of those
+closes its item: only Paul closes the discussion's items.
## What plan 6 changed that the roll should check
-- **Copy is still pending.** No approved copy exists yet. Every new
- user-facing string is the plan's draft text with a `TODO-COPY zdd.<id>`
- marker beside it (Go: a `// TODO-COPY` comment; templates:
- `{{/* TODO-COPY ... */}}`; docs: `<!-- TODO-COPY ... -->`). The strings sit
- in `copy-review/` for review. Task 16's pin of the approved copy
- (`approved_copy_zdd_test.go`, reading `6-copy.json`) is deferred until Paul
- approves. **Do not turn the flag on until the approved copy is applied and
- pinned.** Where this file says "the approved line", it means the line after
- that review. Until then the draft is what prints.
+- **The copy is approved and pinned.** Paul approved every new user-facing
+ string on 2026-10-11. The record is
+ `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json`, and
+ `approved_copy_zdd_test.go` fails if any string in it drifts from the code.
+ Where this file says "the approved line", it means the text in that record.
- **`carlos sidecars ls` changes with the flag off.** It reads
`ServingVersion` and prints "unknown" for a sidecar that has never started
anywhere. Plan 4 already changed what the registry `Version` means for a
@@ -206,7 +201,7 @@ review, and only Paul closes the discussion's items.
agent log line `REFUSING CARLOS_SWAP=overlap`
(`internal/host/rollout.go:150`).
- **The migrations rule.** The new "Two builds at once" section of the public
- CLI page (`docs/site/cli.md:330`, marked `TODO-COPY zdd.docs.migrations`)
+ CLI page (`docs/site/cli.md`)
and the release notes carry it: add in one release and remove in a later
one, and keep the names of scheduled work the same across the two builds.
`--swap hold` and `carlos apps set --swap hold` are the way out for a
@@ -264,7 +259,7 @@ carlos apps set adopt-probe --account bab --swap overlap --console https://conso
```
Expected: the approved `zdd.apps-set.done` line for `adopt-probe`, overlap,
-1. Until the copy is approved the draft prints:
+1:
`adopt-probe swaps in overlap mode, 1 at a time on each box`, then a muted
note about backward-compatible migrations. Setting the default writes
nothing (see the Q3 note above).
@@ -504,7 +499,7 @@ and twice the baseline's slowest. Each deploy's watch ends with the approved
slowest is clearly above the baseline. If it is not, the probe cannot see a
pause and the overlap result means nothing.
-The watch prints `swapped https://<host>` (draft word `swapped`,
+The watch prints `swapped https://<host>` (the approved word `swapped`,
`deploywatch.go:569`) and `box <label>: ...` (`deploywatch.go:44`). With the
flag off it prints `live https://<host>` instead (`deploywatch.go:564`), so a
`live` line during the overlap phase means that box is not armed.
diff --git a/instances_cli.go b/instances_cli.go
index da4bc7aed..6db56d655 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -209,8 +209,6 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p
return provision.PutManifest(ctx, s, acct, app, m)
}
-// TODO-COPY zdd.instances-retry.usage-line
-// TODO-COPY zdd.instances-retry.usage-para
const instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> [--region <r>] [--channel <c>]
[--kind instance|service|static] [--environment <e>]
[--addr <a:p,...>] [--health-path <p>] [--health-status <n>]
@@ -429,9 +427,7 @@ func upstreamsCell(ups []apiUpstreamState) string {
}
const (
- // TODO-COPY zdd.list.header
- instancesListHeader = "HOST\tREGION\tCHANNEL\tPOOL\tINSTALLED\tSERVING\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY\tREASON"
- // TODO-COPY zdd.list.retry-hint
+ instancesListHeader = "HOST\tREGION\tCHANNEL\tPOOL\tINSTALLED\tSERVING\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY\tREASON"
instancesListRetryHint = "a build that failed is tried again on every box with carlos promote --app %s --retry <version> <channel>, or on one instance with carlos instances retry --app %s <host>"
)
@@ -907,9 +903,7 @@ func runInstancesSetEnvironment(args []string) {
}
const (
- // TODO-COPY zdd.instances-retry.done
- instancesRetryDone = "retry requested for %s: its next attempt at %s runs as a canary"
- // TODO-COPY zdd.instances-retry.old-console
+ instancesRetryDone = "retry requested for %s: its next attempt at %s runs as a canary"
instancesRetryOldConsole = "not found: either %s has no instance %s, or this console predates carlos instances retry (deploy a console built from current main)"
)
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 9808e1fa0..f5246fcb1 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -2962,8 +2962,7 @@ func (a *Service) respawnWorkerLocked(e *actEntry, stopOld Proc, why string, ont
// on its own terms, moves the worker onto another build.
a.mu.Unlock()
log.Printf("activator: WORKER %s failed to respawn (%s): %v — the sweep's crash restart takes it from here", host, why, err)
- // TODO-COPY zdd.sidecar.restart-retrying (main's text, em dash and all: flag-off bytes stay as they are)
- emitEvent(a.Logs, rt, "error", "sidecar could not be restarted — retrying")
+ emitEvent(a.Logs, rt, "error", "sidecar could not be restarted. retrying")
default:
e.proc = proc
e.workerToken = tok
diff --git a/internal/activator/logsink.go b/internal/activator/logsink.go
index 49c84827f..faa705ba2 100644
--- a/internal/activator/logsink.go
+++ b/internal/activator/logsink.go
@@ -77,8 +77,6 @@ func memberSafeWakeReason(err error) string {
// The member gets a reason in their terms; the write error is the
// operator's (the sweep retries the write). errStagedBuildNotPublished
// is a distinct sentinel and was mapped to the fence above.
- //
- // TODO-COPY zdd.wake.not-published
return "wake failed: the box could not record which build this instance runs, so it did not start it. the next request tries again"
}
s := err.Error()
diff --git a/internal/activator/wakeplan.go b/internal/activator/wakeplan.go
index bd255fe02..fb035f276 100644
--- a/internal/activator/wakeplan.go
+++ b/internal/activator/wakeplan.go
@@ -273,7 +273,7 @@ func (a *Service) BounceFor(host string, cause Cause) bool {
}
// bounceText is the one place BounceFor words its event. The words are
-// internal/rolloutwords, drafts pending copy review. The flag-off literal in
+// internal/rolloutwords, approved copy. The flag-off literal in
// bounceLocked is never edited; restart's cause phrase keeps the two
// identical.
func bounceText(worker bool, cause Cause) string {
diff --git a/internal/console/apiretry.go b/internal/console/apiretry.go
index 3977bc4da..3f60f889d 100644
--- a/internal/console/apiretry.go
+++ b/internal/console/apiretry.go
@@ -23,12 +23,9 @@ import (
)
const (
- // TODO-COPY zdd.api.retry-store
- apiRetryStore = "could not write the retry request. Try again"
- // TODO-COPY zdd.api.retry-host-audit
+ apiRetryStore = "could not write the retry request. Try again"
apiRetryHostAudit = "requested a retry of %s on %s (%s)"
- // TODO-COPY zdd.api.retry-not-held
- apiRetryNotHeld = "%s is not held back by a failed build, as far as its box last reported, so there is nothing to retry"
+ apiRetryNotHeld = "%s is not held back by a failed build, as far as its box last reported, so there is nothing to retry"
)
var (
@@ -80,12 +77,10 @@ func (s *Server) handleAPIInstanceRetry(w http.ResponseWriter, r *http.Request)
}
p, _ := principalFrom(r.Context())
if !s.allowWrite(p, time.Now()) {
- // TODO-COPY zdd.api.retry-limited
jsonError(w, http.StatusTooManyRequests, "too many requests. wait a minute and try again")
return
}
if s.cfg.Releases == nil {
- // TODO-COPY zdd.api.retry-no-bucket
jsonError(w, http.StatusServiceUnavailable,
"no deployment bucket on this host. this console cannot request a retry")
return
diff --git a/internal/console/apiship_swap.go b/internal/console/apiship_swap.go
index 1b728c3d9..1c1a6e975 100644
--- a/internal/console/apiship_swap.go
+++ b/internal/console/apiship_swap.go
@@ -10,9 +10,7 @@ import (
// The ship door's swap-demand sentences live here, not in apicli.go: the
// moves copy gate pins the jsonError text of that file.
const (
- // TODO-COPY zdd.api.ship-swap-bad
- apiShipSwapBad = `swap must be "hold" or left out`
- // TODO-COPY zdd.api.ship-swap-conflict
+ apiShipSwapBad = `swap must be "hold" or left out`
apiShipSwapConflict = "version %s was already shipped with a different swap demand. Versions are immutable: ship under a new version"
)
diff --git a/internal/console/apiswap.go b/internal/console/apiswap.go
index 5e73d8a76..6f1b3d7a7 100644
--- a/internal/console/apiswap.go
+++ b/internal/console/apiswap.go
@@ -19,18 +19,13 @@ import (
)
const (
- // TODO-COPY zdd.api.swap-nothing
- apiSwapNothing = "nothing to set: send swap, swap_concurrency, or both"
- // TODO-COPY zdd.api.swap-bad
- apiSwapBad = `swap must be "overlap" or "hold"`
- // TODO-COPY zdd.api.concurrency-bad
+ apiSwapNothing = "nothing to set: send swap, swap_concurrency, or both"
+ apiSwapBad = `swap must be "overlap" or "hold"`
apiSwapConcurrencyBad = "swap_concurrency must be a whole number, 1 or more"
// apiSwapLimited is this door's own draft: the sibling doors' sentence
// carries an em dash (Astra plan 6 R1 #9).
- // TODO-COPY zdd.api.swap-limited
apiSwapLimited = "too many requests. wait a minute and try again"
// apiSwapAudit is app, mode, number, then "cli" or "console".
- // TODO-COPY zdd.api.swap-audit
apiSwapAudit = "set the swap policy for %s to %s, %d at a time (%s)"
)
diff --git a/internal/console/apiswap_test.go b/internal/console/apiswap_test.go
index eb9ec97dd..8ac072af5 100644
--- a/internal/console/apiswap_test.go
+++ b/internal/console/apiswap_test.go
@@ -90,19 +90,20 @@ func TestAPISwapRateLimitIsAMarkedDraftWithNoEmDash(t *testing.T) {
t.Fatalf("the swap door was never rate-limited in %d attempts", cliWriteBurst+3)
}
-// Astra plan 6 R1 #9: the Deploys card's Save button is new copy, so it
-// carries its draft marker like the card's other strings.
-func TestDeploysCardSaveButtonIsMarked(t *testing.T) {
+// Astra plan 6 R1 #9: the Deploys card's Save button is new copy. It is a
+// literal in the template, so the pinned copy record names the template for it
+// (zdd.settings.save), and this keeps the button inside the Deploys card.
+func TestDeploysCardSaveButtonIsInTheCard(t *testing.T) {
raw, err := os.ReadFile("templates/app_settings.html")
if err != nil {
t.Fatal(err)
}
- _, card, ok := strings.Cut(string(raw), "{{/* TODO-COPY zdd.settings.heading */}}")
+ _, card, ok := strings.Cut(string(raw), "{{.SwapCopy.Heading}}")
if !ok {
t.Fatal("no Deploys card in app_settings.html")
}
card, _, _ = strings.Cut(card, "</form>")
- if !strings.Contains(card, `<button type="submit" class="btn-sm">{{/* TODO-COPY zdd.settings.save */}}Save</button>`) {
- t.Fatalf("the Deploys card's Save button has no zdd.settings.save marker:\n%s", card)
+ if !strings.Contains(card, `<button type="submit" class="btn-sm">Save</button>`) {
+ t.Fatalf("the Deploys card has no Save button:\n%s", card)
}
}
diff --git a/internal/console/swapcopy.go b/internal/console/swapcopy.go
index d21fc5c99..6d2264b52 100644
--- a/internal/console/swapcopy.go
+++ b/internal/console/swapcopy.go
@@ -3,7 +3,7 @@ package console
// swapCopyStrings is the console's copy for the zero-downtime surfaces (spec
// §9.2): the Deploys card on the app's settings page, and the installed and
// serving facts, failure hold and Retry action on an instance page. Every
-// string is a draft until the copy review approves it (see the markers).
+// string is approved copy, pinned by the zero-downtime copy record.
type swapCopyStrings struct {
Heading string
Intro string
@@ -37,67 +37,38 @@ type swapCopyStrings struct {
}
var swapCopy = swapCopyStrings{
- // TODO-COPY zdd.settings.heading
- Heading: "Deploys",
- // TODO-COPY zdd.settings.intro
- Intro: "When a new build reaches a running instance, overlap starts it beside the old one and switches once it answers its health check, so no request waits. Hold stops the old one first, and requests wait until the new one is up.",
- // TODO-COPY zdd.settings.swap-label
- SwapLabel: "Swap mode:",
- // TODO-COPY zdd.settings.overlap-option
- OverlapOption: "overlap (default)",
- // TODO-COPY zdd.settings.hold-option
- HoldOption: "hold",
- // TODO-COPY zdd.settings.concurrency-label
+ Heading: "Deploys",
+ Intro: "When a new build reaches a running instance, the old instance keeps running until the new one is ready.",
+ SwapLabel: "Swap mode:",
+ OverlapOption: "overlap (default)",
+ HoldOption: "hold",
ConcurrencyLabel: "Instances that swap at once on each box:",
- // TODO-COPY zdd.settings.migrations-note
- MigrationsNote: "With overlap, the old and new builds share the instance's database for a few seconds. Keep migrations backward-compatible: add in one release and remove in a later one. Use hold when a release cannot do that.",
- // TODO-COPY zdd.settings.saved
- Saved: "Swap policy saved: %s, %d at a time on each box.",
- // TODO-COPY zdd.settings.invalid
- Invalid: "Choose overlap or hold, and a whole number of 1 or more.",
- // TODO-COPY zdd.settings.store-failed
- StoreFailed: "Could not save the swap policy. Try again.",
- // TODO-COPY zdd.settings.rate-limited
- RateLimited: "Too many changes. Try again in a minute.",
- // TODO-COPY zdd.settings.unread
- Unread: "This app's swap policy could not be read just now, so it cannot be changed here. Reload before concluding anything.",
- // TODO-COPY zdd.settings.no-bucket
- NoBucket: "No deployment bucket on this host, so the swap policy cannot be changed here.",
- // TODO-COPY zdd.instance.installed
- Installed: "Installed build",
- // TODO-COPY zdd.instance.serving
- Serving: "Serving build",
- // TODO-COPY zdd.instance.hold
- Hold: "Failure hold",
- // TODO-COPY zdd.instance.hold-text
- HoldText: "Build %s failed on this box because %s. This instance stays on %s until you retry it or ship a newer build.",
+ MigrationsNote: "With overlap, the old and new builds share the instance's database for a few seconds. Migrations need to be backward compatible to ensure the old version keeps working. Use hold when a release cannot do that.",
+ Saved: "Swap policy saved: %s, %d at a time on each box.",
+ Invalid: "Choose overlap or hold, and a whole number of 1 or more.",
+ StoreFailed: "Could not save the swap policy. Try again.",
+ RateLimited: "Too many changes. Try again in a minute.",
+ Unread: "This app's swap policy could not be read just now, so it cannot be changed here. Reload before concluding anything.",
+ NoBucket: "No deployment bucket on this host, so the swap policy cannot be changed here.",
+ Installed: "Installed build",
+ Serving: "Serving build",
+ Hold: "Failure hold",
+ HoldText: "Build %s failed on this box because %s. This instance stays on %s until you retry it or ship a newer build.",
// HoldDownText is the hold row when the failed attempt left the instance
// down (hold mode, attempt down): nothing serves it, so it stays on
// nothing (Astra plan 6 R1 #8).
- // TODO-COPY zdd.instance.hold-down-text
HoldDownText: "Build %s failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it.",
- // TODO-COPY zdd.instance.promote-retry
PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s",
- // TODO-COPY zdd.instance.retry-button
- RetryButton: "Retry",
- // TODO-COPY zdd.instance.rolling
- Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.",
- // TODO-COPY zdd.instance.asleep
- Asleep: "Build %s is installed and starts on the next request.",
- // TODO-COPY zdd.instance.retry-title
- RetryTitle: "Retry build {build} on {host}?",
- // TODO-COPY zdd.instance.retry-line
- RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again.",
- // TODO-COPY zdd.instance.retry-done
- RetryDone: "Retry requested for {host}.",
- // TODO-COPY zdd.instance.retry-failed
- RetryFailed: "The retry request failed. Try again.",
- // TODO-COPY zdd.instance.retry-limited
+ RetryButton: "Retry",
+ Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.",
+ Asleep: "Build %s is installed and starts on the next request.",
+ RetryTitle: "Retry build {build} on {host}?",
+ RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again.",
+ RetryDone: "Retry requested for {host}.",
+ RetryFailed: "The retry request failed. Try again.",
RetryLimited: "Too many retry requests. Try again later.",
- // TODO-COPY zdd.instance.not-held
- NotHeld: "{host} is no longer held back by a failed build, so there is nothing to retry.",
- // TODO-COPY zdd.instance.audit
- Audit: "Retry of build {build} requested for {host}.",
+ NotHeld: "{host} is no longer held back by a failed build, so there is nothing to retry.",
+ Audit: "Retry of build {build} requested for {host}.",
}
func (p page) SwapCopy() swapCopyStrings { return swapCopy }
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 58f2a42cb..7c36cd1cb 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -361,25 +361,25 @@ JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes eve
joined across days. Whether that needs consent where your visitors are is your call, which is why it
is off until you turn it on.</p>
-<h2>{{/* TODO-COPY zdd.settings.heading */}}{{.SwapCopy.Heading}}</h2>
+<h2>{{.SwapCopy.Heading}}</h2>
{{if .SwapUnread}}
-<p class="small">{{/* TODO-COPY zdd.settings.unread */}}{{.SwapCopy.Unread}}</p>
+<p class="small">{{.SwapCopy.Unread}}</p>
{{else}}
-<p class="small">{{/* TODO-COPY zdd.settings.intro */}}{{.SwapCopy.Intro}}</p>
+<p class="small">{{.SwapCopy.Intro}}</p>
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/swap" class="settings-row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
- <label>{{/* TODO-COPY zdd.settings.swap-label */}}{{.SwapCopy.SwapLabel}}
+ <label>{{.SwapCopy.SwapLabel}}
<select name="swap">
- <option value="overlap"{{if eq .SwapPolicy.Swap "overlap"}} selected{{end}}>{{/* TODO-COPY zdd.settings.overlap-option */}}{{.SwapCopy.OverlapOption}}</option>
- <option value="hold"{{if eq .SwapPolicy.Swap "hold"}} selected{{end}}>{{/* TODO-COPY zdd.settings.hold-option */}}{{.SwapCopy.HoldOption}}</option>
+ <option value="overlap"{{if eq .SwapPolicy.Swap "overlap"}} selected{{end}}>{{.SwapCopy.OverlapOption}}</option>
+ <option value="hold"{{if eq .SwapPolicy.Swap "hold"}} selected{{end}}>{{.SwapCopy.HoldOption}}</option>
</select>
</label>
- <label>{{/* TODO-COPY zdd.settings.concurrency-label */}}{{.SwapCopy.ConcurrencyLabel}}
+ <label>{{.SwapCopy.ConcurrencyLabel}}
<input type="number" name="swap_concurrency" min="1" step="1" value="{{.SwapPolicy.Concurrency}}" required>
</label>
- <button type="submit" class="btn-sm">{{/* TODO-COPY zdd.settings.save */}}Save</button>
+ <button type="submit" class="btn-sm">Save</button>
</form>
-<p class="small">{{/* TODO-COPY zdd.settings.migrations-note */}}{{.SwapCopy.MigrationsNote}}</p>
+<p class="small">{{.SwapCopy.MigrationsNote}}</p>
{{end}}
{{end}}
diff --git a/internal/host/rollout.go b/internal/host/rollout.go
index 27648f441..4df546508 100644
--- a/internal/host/rollout.go
+++ b/internal/host/rollout.go
@@ -40,11 +40,8 @@ const (
// a member through the activator's "wake failed:" event, so it names the
// missing fact and never the raw error, which goes to the agent's log.
var refuseTexts = map[string]string{
- // TODO-COPY zdd.refuse.no-store
- rolloutNoStore: "this box cannot start instances right now: the deploy store is unavailable",
- // TODO-COPY zdd.refuse.no-release-key
- rolloutNoReleaseKey: "this box cannot start instances right now: the release key is unavailable",
- // TODO-COPY zdd.refuse.book-unreadable
+ rolloutNoStore: "this box cannot start instances right now: the deploy store is unavailable",
+ rolloutNoReleaseKey: "this box cannot start instances right now: the release key is unavailable",
rolloutBookUnreadable: "this box cannot start instances right now: its record of failed builds cannot be read",
}
@@ -503,8 +500,6 @@ func rolloutStatusProbe() *status.RolloutReport {
// rolloutRestartRefused is the RestartError an awake managed host's config
// ack carries while managed starts are refused. It is fixed text: the cause
// goes to the agent's log, never to the member (plan 6 words it).
-//
-// TODO-COPY zdd.refuse.restart
const rolloutRestartRefused = "restart refused: this box cannot restart instances right now"
// managedAwake reports whether host is a managed exec instance the activator
diff --git a/internal/host/rolloutdeclined.go b/internal/host/rolloutdeclined.go
index 498174206..af7b06f68 100644
--- a/internal/host/rolloutdeclined.go
+++ b/internal/host/rolloutdeclined.go
@@ -22,8 +22,6 @@ const rolloutBookHonoured = "honoured"
// refuseNoSwaps is the wake refusal for a managed instance whose build needs
// the scheduler this box cannot run: refuseManaged's text for a reason it has
// no words for, and a declined box's for a build a failure record holds.
-//
-// TODO-COPY zdd.refuse.no-swaps
const refuseNoSwaps = "this box cannot run two builds side by side, which this instance's build needs"
// failureHolds is the failure book a box that declined CARLOS_SWAP=overlap
diff --git a/internal/host/rolloutrefusaldrafts_test.go b/internal/host/rolloutrefusaldrafts_test.go
index c864b6d47..0a8f4f54c 100644
--- a/internal/host/rolloutrefusaldrafts_test.go
+++ b/internal/host/rolloutrefusaldrafts_test.go
@@ -2,7 +2,7 @@ package host
import "testing"
-// The member-facing refusal texts are drafts pending copy review (zdd.refuse.*
+// The member-facing refusal texts are approved copy (zdd.refuse.*
// and zdd.refuse.restart in the plan 6 copy list). The other tests compare
// against the constants, so this one pins the words themselves: a change here
// is a copy change and goes through review.
diff --git a/internal/rollout/events.go b/internal/rollout/events.go
index c331d0719..d0b97dc80 100644
--- a/internal/rollout/events.go
+++ b/internal/rollout/events.go
@@ -12,9 +12,7 @@ import (
// EventCode is a rollout event's stable internal name (spec §9.4). The codes
// are what the journal and tests key on. The member-facing texts below are
-// worded by internal/rolloutwords, and are drafts pending copy review (the
-// TODO-COPY markers live there); the CARLOS_SWAP flag stays off outside dev
-// until they are reviewed.
+// worded by internal/rolloutwords, and are approved copy.
type EventCode string
const (
diff --git a/internal/rollout/wakeplan.go b/internal/rollout/wakeplan.go
index 94c3007a8..6ca56c0ad 100644
--- a/internal/rollout/wakeplan.go
+++ b/internal/rollout/wakeplan.go
@@ -44,8 +44,7 @@ type wakeInputs struct {
// so it names the build only. A box that declined CARLOS_SWAP=overlap but
// honours the failure records refuses with the same words (internal/host).
func HeldDownRefusal(build string) string {
- // TODO-COPY zdd.refuse.held-down
- return "build " + build + " failed in hold mode on this instance, so it stays down until you roll back or retry the build"
+ return fmt.Sprintf("build %s failed in hold mode on this instance, so it stays down until you roll back or retry the build", build)
}
// decideWake is spec §8.2's "when the wake picks a build", as a pure
@@ -65,7 +64,6 @@ func decideWake(in wakeInputs) (activator.WakePlan, error) {
if installed == "" {
// Plan installs a cold host first; a plan for a host with nothing
// installed refuses rather than let the live link run unplanned.
- // TODO-COPY zdd.refuse.not-installed
return activator.WakePlan{Refuse: "no build is installed for this instance yet"}, nil
}
hasGood := rt.GoodVersion != "" && rt.GoodDigest != ""
@@ -98,8 +96,7 @@ func decideWake(in wakeInputs) (activator.WakePlan, error) {
if hasGood {
return recovery, err
}
- // TODO-COPY zdd.refuse.not-staged
- return activator.WakePlan{Refuse: "build " + installed + " is not staged correctly on this box"}, err
+ return activator.WakePlan{Refuse: fmt.Sprintf("build %s is not staged correctly on this box", installed)}, err
}
mode := AttemptMode(in.Policy, demand)
plan := activator.WakePlan{Boot: release.StagedIdentity{Version: installed, StagedPath: path, Digest: digest, Demand: demand},
@@ -144,8 +141,7 @@ func decideHoldRan(in wakeInputs, mark HoldRan, hasGood bool, recovery activator
if err != nil {
// A staging refusal cannot boot the good build instead here, as it
// does for an instance with no marker: that would be the fallback.
- // TODO-COPY zdd.refuse.not-staged
- return activator.WakePlan{Refuse: "build " + installed + " is not staged correctly on this box"}, err
+ return activator.WakePlan{Refuse: fmt.Sprintf("build %s is not staged correctly on this box", installed)}, err
}
mode := AttemptMode(in.Policy, demand)
if installed == mark.Build {
@@ -201,7 +197,6 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
// hold that went down, or a swap whose entry stopped under it, is
// recorded by done() before anything wakes the instance, so no wake
// can boot the good build ahead of a hold-down record.
- // TODO-COPY zdd.refuse.attempt-running
return activator.WakePlan{Refuse: "this instance is still moving to another build"}
}
if rt.Version == "" {
@@ -211,12 +206,10 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
// Nothing managed ever runs through an empty plan: the live-link
// wake would materialize the channel head and exec it unplanned.
if m.d.Materialize == nil {
- // TODO-COPY zdd.refuse.not-installed
return activator.WakePlan{Refuse: "no build is installed for this instance yet"}
}
if err := m.d.Materialize(ctx, rt.Host); err != nil {
m.d.Logf("rollout: %s: install before its wake: %v", rt.Host, err)
- // TODO-COPY zdd.refuse.not-installable
return activator.WakePlan{Refuse: "its build could not be installed"}
}
fresh, err := m.d.Routes.Get(rt.Host)
@@ -224,7 +217,6 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
if err != nil {
m.d.Logf("rollout: %s: read the row after its install: %v", rt.Host, err)
}
- // TODO-COPY zdd.refuse.not-installed
return activator.WakePlan{Refuse: "no build is installed for this instance yet"}
}
rt = *fresh
@@ -239,7 +231,6 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
if err != nil {
m.d.Logf("rollout: %s: read the row after its plan: %v", rt.Host, err)
}
- // TODO-COPY zdd.refuse.not-installed
return activator.WakePlan{Refuse: "no build is installed for this instance yet"}
}
if fresh.Version == rt.Version {
@@ -247,7 +238,6 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
}
if again {
m.d.Logf("rollout: %s: its installed build moved twice while its wake was planned (%s, now %s); refusing the wake", rt.Host, rt.Version, fresh.Version)
- // TODO-COPY zdd.refuse.not-installed
return activator.WakePlan{Refuse: "no build is installed for this instance yet"}
}
m.d.Logf("rollout: %s: its installed build moved from %s to %s while its wake was planned; planning again", rt.Host, rt.Version, fresh.Version)
@@ -314,7 +304,6 @@ func (m *Manager) planCheck(rt registry.Route, plan activator.WakePlan) func(bui
// The texts reach the member's "wake failed" event (memberSafeWakeReason
// passes a planned wake's error through).
held := func(build string) error {
- // TODO-COPY zdd.refuse.held-since-plan
return fmt.Errorf("build %s failed on this box while this instance was starting, so it was not started", build)
}
return func(build string) error {
diff --git a/internal/rollout/wakeplan_test.go b/internal/rollout/wakeplan_test.go
index 9d1fbc3d1..dcb0a8eab 100644
--- a/internal/rollout/wakeplan_test.go
+++ b/internal/rollout/wakeplan_test.go
@@ -455,7 +455,7 @@ func TestAPlansCheckRefusesABuildHeldSinceThePlan(t *testing.T) {
if err == nil {
t.Fatal("v2 failed on a sibling after the plan, and the check still let it boot")
}
- // Draft wording (zdd.refuse.held-since-plan), pending copy review.
+ // Approved wording (zdd.refuse.held-since-plan).
if want := "build v2 failed on this box while this instance was starting, so it was not started"; err.Error() != want {
t.Fatalf("refusal %q, want the draft %q", err, want)
}
@@ -508,7 +508,7 @@ func TestAForeignRowsFailedHoldIsHeldDown(t *testing.T) {
h.install("v2", "a")
h.pass()
waitFor(t, "the attempt to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
- // Draft wording (zdd.refuse.attempt-running, zdd.refuse.held-down), pending copy review.
+ // Approved wording (zdd.refuse.attempt-running, zdd.refuse.held-down).
if p := h.m.Plan(context.Background(), h.rows.get("a")); p.Refuse != "this instance is still moving to another build" {
t.Fatalf("a wake was planned while the instance's attempt ran: %+v", p)
}
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go
index 58a213a1e..75c594990 100644
--- a/internal/rolloutwords/words.go
+++ b/internal/rolloutwords/words.go
@@ -3,8 +3,8 @@
// failed, the short states `carlos instances list` and the console show
// (§9.1, §9.2), and the events a box writes to the platform log stream
// (§9.4). One home, so the CLI, the console and the box never word one fact
-// two ways. Every string here is a draft pending copy review; TODO-COPY
-// markers identify the texts that will be pinned by an approved copy test.
+// two ways. Every string here is approved copy, pinned by the zero-downtime
+// copy record (docs/superpowers/plans/2026-10-07-zero-downtime-deploys-6-copy.json).
//
// A leaf: it imports nothing of this repo's, so the rollout manager and the
// activator can use it without an import cycle.
@@ -14,11 +14,11 @@ import "fmt"
// causes are the attempt causes of spec §6.3, as a member reads them.
var causes = map[string]string{
- "release": "new release", // TODO-COPY zdd.cause.release
- "install": "installed by hand", // TODO-COPY zdd.cause.install
- "retry": "retry", // TODO-COPY zdd.cause.retry
- "restart": "carlos restart", // TODO-COPY zdd.cause.restart
- "config": "config change", // TODO-COPY zdd.cause.config
+ "release": "new release",
+ "install": "installed by hand",
+ "retry": "retry",
+ "restart": "carlos restart",
+ "config": "config change",
}
// Cause renders an attempt's cause. One this binary does not know is shown
@@ -32,14 +32,13 @@ func Cause(c string) string {
// reasons are the box's failure words, read after "because".
var reasons = map[string]string{
- "health": "it did not answer its health check within 30 seconds", // TODO-COPY zdd.reason.health
- "settle": "it stopped within 10 seconds of answering its health check", // TODO-COPY zdd.reason.settle
- "exit": "it exited before it was ready", // TODO-COPY zdd.reason.exit
- "staging": "the copy on the box did not match the release", // TODO-COPY zdd.reason.staging
- "reused": "this version name was shipped twice with different content, so ship it under a new name", // TODO-COPY zdd.reason.reused
+ "health": "it did not answer its health check within 30 seconds",
+ "settle": "it stopped within 10 seconds of answering its health check",
+ "exit": "it exited before it was ready",
+ "staging": "the copy on the box did not match the release",
+ "reused": "this version name was shipped twice with different content, so ship it under a new name",
}
-// TODO-COPY zdd.reason.unknown
const reasonUnknown = "it failed (%s)"
// Reason renders a failure word. One this binary does not know still says
@@ -57,55 +56,39 @@ const (
// Emitted for every attempt, overlap, hold and a socketless worker's
// stop-then-start alike (rollout's execute and executeWorker), so it
// says nothing about the mode (Astra plan 6 R1 #8).
- // TODO-COPY zdd.event.swapping
- EventSwapping = "starting build %s (%s)"
- // TODO-COPY zdd.event.swapped
- EventSwapped = "now serving build %s. The previous build finished its requests and stopped in %s"
- // TODO-COPY zdd.event.swap-failed
- EventSwapFailed = "build %s did not take over, because %s. Still serving build %s"
- // TODO-COPY zdd.event.hold-down
- EventHoldDown = "build %s failed and nothing is serving. This app swaps in hold mode, so nothing falls back by itself. Roll back with carlos rollback"
- // TODO-COPY zdd.event.cohort-halted
- EventCohortHalted = "rollout of build %s stopped on box %s, because %s. Instances on that box that have not served it stay on their last good build"
- // TODO-COPY zdd.event.fallback-served
+ EventSwapping = "starting build %s (%s)"
+ EventSwapped = "now serving build %s. The previous build finished its requests and stopped in %s"
+ EventSwapFailed = "build %s did not take over, because %s. Still serving build %s"
+ EventHoldDown = "build %s failed and nothing is serving. This app swaps in hold mode, so nothing falls back by itself. Roll back with carlos rollback"
+ EventCohortHalted = "rollout of build %s stopped on box %s, because %s. Instances on that box that have not served it stay on their last good build"
EventFallbackServed = "build %s failed to start, so the last good build, %s, is serving instead"
- // TODO-COPY zdd.event.config-pending
- EventConfigPending = "new config reached this box but is not in use yet, because %s. Trying again in %s"
- // TODO-COPY zdd.event.staging-refused
+ EventConfigPending = "new config reached this box but is not in use yet, because %s. Trying again in %s"
EventStagingRefused = "build %s is not staged correctly on this box, so the box asked for it to be staged again"
- // TODO-COPY zdd.event.bounce-instance
EventBounceInstance = "instance restarting (%s)"
- // TODO-COPY zdd.event.bounce-sidecar
- EventBounceSidecar = "sidecar restarting (%s)"
+ EventBounceSidecar = "sidecar restarting (%s)"
)
// States: the REASON column of `carlos instances list` (spec §9.1).
const (
- // TODO-COPY zdd.state.held
- StateHeld = "held: build %s failed on this box"
- // TODO-COPY zdd.state.rolling-out
- StateRollingOut = "rolling out"
- // TODO-COPY zdd.state.asleep
- StateAsleep = "starts %s on its next request"
- // TODO-COPY zdd.state.config-pending
+ StateHeld = "held: build %s failed on this box"
+ StateRollingOut = "rolling out"
+ StateAsleep = "starts %s on its next request"
StateConfigPending = "config delivered, not active"
)
-// TODO-COPY zdd.unavailable.declined
const unavailableDeclined = "its box cannot run two builds side by side, so it restarts instances the old way"
// unavailablePhrases are the member-facing reasons a box cannot run zero-downtime deploys,
// keyed by the codes in status.RolloutReport.Unavailable.
var unavailablePhrases = map[string]string{
- "no-cgroup-delegation": unavailableDeclined, // TODO-COPY zdd.unavailable.declined
- "no-generation-control": unavailableDeclined, // TODO-COPY zdd.unavailable.declined
- "generations-off": unavailableDeclined, // TODO-COPY zdd.unavailable.declined
- "no-deploy-store": "its box cannot start instances: the deploy store is unavailable", // TODO-COPY zdd.unavailable.store
- "no-release-key": "its box cannot start instances: the release key is unavailable", // TODO-COPY zdd.unavailable.release-key
- "failure-book-unreadable": "its box cannot start instances: its record of failed builds cannot be read", // TODO-COPY zdd.unavailable.failure-book
+ "no-cgroup-delegation": unavailableDeclined,
+ "no-generation-control": unavailableDeclined,
+ "generations-off": unavailableDeclined,
+ "no-deploy-store": "its box cannot start instances: the deploy store is unavailable",
+ "no-release-key": "its box cannot start instances: the release key is unavailable",
+ "failure-book-unreadable": "its box cannot start instances: its record of failed builds cannot be read",
}
-// TODO-COPY zdd.unavailable.unknown
const unavailableUnknown = "its box reports a rollout problem (%s)"
// Unavailable is the member-facing phrase for a box's