carlos / platform Public

zdd-6 task 2: rolloutwords, the one home for rollout phrases

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1f48e2a9e24227e34b3e52990e32cf0da8b79003 parent d905a67
2 files changed, +235 −0
  • internal/rolloutwords/words.go +124 −0
  • internal/rolloutwords/words_test.go +111 −0
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go
new file mode 100644
index 000000000..4d33e8e3e
--- /dev/null
+++ b/internal/rolloutwords/words.go
@@ -0,0 +1,124 @@
+// Package rolloutwords holds every member-facing phrase about a rollout:
+// the causes of an attempt (zero-downtime spec §6.3), the reasons a build
+// 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 was copy-reviewed; approved_copy_zdd_test.go
+// pins it against 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.
+package rolloutwords
+
+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
+}
+
+// Cause renders an attempt's cause. One this binary does not know is shown
+// as its own word, never dropped.
+func Cause(c string) string {
+ if p, ok := causes[c]; ok {
+ return p
+ }
+ return c
+}
+
+// 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
+}
+
+// TODO-COPY zdd.reason.unknown
+const reasonUnknown = "it failed (%s)"
+
+// Reason renders a failure word. One this binary does not know still says
+// the build failed, and carries the box's own word for it.
+func Reason(r string) string {
+ if p, ok := reasons[r]; ok {
+ return p
+ }
+ return fmt.Sprintf(reasonUnknown, r)
+}
+
+// Event formats (spec §9.4), one per text function in internal/rollout/events.go
+// plus the bounce. TestVerbsAreWhereTheCallersPutThem pins each verb count.
+const (
+ // TODO-COPY zdd.event.swapping
+ EventSwapping = "starting build %s beside the running one (%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
+ 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
+ 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)"
+)
+
+// 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
+ StateConfigPending = "config delivered, not active"
+)
+
+// 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": "its box cannot run two builds side by side, so it restarts instances the old way", // TODO-COPY zdd.unavailable.declined
+ "no-generation-control": "its box cannot run two builds side by side, so it restarts instances the old way", // TODO-COPY zdd.unavailable.declined
+ "generations-off": "its box cannot run two builds side by side, so it restarts instances the old way", // 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
+}
+
+// TODO-COPY zdd.unavailable.unknown
+const unavailableUnknown = "its box reports a rollout problem (%s)"
+
+// Unavailable is the member-facing phrase for a box's
+// RolloutReport.Unavailable code. An unknown code (a newer box) reads
+// through UnavailableUnknown with the code itself.
+func Unavailable(code string) string {
+ if p, ok := unavailablePhrases[code]; ok {
+ return p
+ }
+ return fmt.Sprintf(unavailableUnknown, code)
+}
+
+// UnavailableRefuses reports whether code is one under which the box
+// refuses every managed start (as opposed to running today's paths).
+func UnavailableRefuses(code string) bool {
+ switch code {
+ case "no-deploy-store", "no-release-key", "failure-book-unreadable":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/rolloutwords/words_test.go b/internal/rolloutwords/words_test.go
new file mode 100644
index 000000000..e183d24e8
--- /dev/null
+++ b/internal/rolloutwords/words_test.go
@@ -0,0 +1,111 @@
+package rolloutwords
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func allWords() map[string]string {
+ out := map[string]string{
+ "EventSwapping": EventSwapping, "EventSwapped": EventSwapped, "EventSwapFailed": EventSwapFailed,
+ "EventHoldDown": EventHoldDown, "EventCohortHalted": EventCohortHalted,
+ "EventFallbackServed": EventFallbackServed, "EventConfigPending": EventConfigPending,
+ "EventStagingRefused": EventStagingRefused, "EventBounceInstance": EventBounceInstance,
+ "EventBounceSidecar": EventBounceSidecar, "StateHeld": StateHeld, "StateRollingOut": StateRollingOut,
+ "StateAsleep": StateAsleep, "StateConfigPending": StateConfigPending, "reasonUnknown": reasonUnknown,
+ }
+ for k, v := range causes {
+ out["cause "+k] = v
+ }
+ for k, v := range reasons {
+ out["reason "+k] = v
+ }
+ // Add unavailability phrases
+ for k, v := range unavailablePhrases {
+ out["unavailable "+k] = v
+ }
+ out["unavailableUnknown"] = unavailableUnknown
+ return out
+}
+
+func TestNoEmDashInAnyWord(t *testing.T) {
+ for name, s := range allWords() {
+ if strings.ContainsRune(s, '—') {
+ t.Errorf("%s carries an em dash: %q", name, s)
+ }
+ }
+}
+
+// The emit sites fill these verbs in this order; an approved rewording must
+// keep them, and carry no other verb.
+func TestVerbsAreWhereTheCallersPutThem(t *testing.T) {
+ words := allWords()
+ for name, want := range map[string]int{
+ "EventSwapping": 2, "EventSwapped": 2, "EventSwapFailed": 3, "EventHoldDown": 1,
+ "EventCohortHalted": 3, "EventFallbackServed": 2, "EventConfigPending": 2,
+ "EventStagingRefused": 1, "EventBounceInstance": 1, "EventBounceSidecar": 1,
+ "StateHeld": 1, "StateRollingOut": 0, "StateAsleep": 1, "StateConfigPending": 0, "reasonUnknown": 1,
+ } {
+ s := words[name]
+ if strings.Count(s, "%s") != want || strings.Count(s, "%") != want {
+ t.Errorf("%s = %q: want exactly %d %%s verbs and no other verb", name, s, want)
+ }
+ }
+ for name, s := range words {
+ if (strings.HasPrefix(name, "cause ") || strings.HasPrefix(name, "reason ")) && strings.Contains(s, "%") {
+ t.Errorf("%s = %q: a phrase is filled into other strings and carries no verb", name, s)
+ }
+ }
+}
+
+func TestEveryCauseAndReasonHasAPhrase(t *testing.T) {
+ for _, c := range []string{"release", "install", "retry", "restart", "config"} {
+ if causes[c] == "" {
+ t.Errorf("cause %q has no phrase", c)
+ }
+ }
+ for _, r := range []string{status.ReasonHealth, status.ReasonSettle, status.ReasonExit, status.ReasonStaging, status.ReasonReused} {
+ if reasons[r] == "" {
+ t.Errorf("reason %q (plan 5's word) has no phrase", r)
+ }
+ }
+ if got := Cause("future"); got != "future" {
+ t.Errorf("Cause(future) = %q, want the word itself", got)
+ }
+ if got, want := Reason("future"), fmt.Sprintf(reasonUnknown, "future"); got != want {
+ t.Errorf("Reason(future) = %q, want %q", got, want)
+ }
+}
+
+func TestEveryUnavailableCodeHasAPhrase(t *testing.T) {
+ // Declined codes
+ for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off"} {
+ phrase := Unavailable(code)
+ if phrase == "" {
+ t.Errorf("Unavailable(%q) = empty string", code)
+ }
+ if UnavailableRefuses(code) {
+ t.Errorf("UnavailableRefuses(%q) = true, want false", code)
+ }
+ }
+ // Refusing codes
+ for _, code := range []string{"no-deploy-store", "no-release-key", "failure-book-unreadable"} {
+ phrase := Unavailable(code)
+ if phrase == "" {
+ t.Errorf("Unavailable(%q) = empty string", code)
+ }
+ if !UnavailableRefuses(code) {
+ t.Errorf("UnavailableRefuses(%q) = false, want true", code)
+ }
+ }
+ // Unknown code
+ if got := Unavailable("future"); got != fmt.Sprintf(unavailableUnknown, "future") {
+ t.Errorf("Unavailable(future) = %q, want %q", got, fmt.Sprintf(unavailableUnknown, "future"))
+ }
+ if UnavailableRefuses("future") {
+ t.Errorf("UnavailableRefuses(future) = true, want false")
+ }
+}