carlos / platform Public

zdd-readiness: claims-on boxes decline CARLOS_SWAP=overlap (host-claims-on)

swapArming now checks eb.Cfg.HostWrites.Enabled first and declines with the code host-claims-on; rolloutwords words it as unavailableDeclined and counts it in UnavailableDeclines, so the deploy watch URL-watches a claims box like any declined box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cd7de723fcc9fb156b6e4bd301c13435240d1f9b parent 774080e
6 files changed, +70 −8
  • deploywatch_test.go +2 −0
  • internal/host/rollout.go +15 −2
  • internal/host/rollout_test.go +39 −0
  • internal/rolloutwords/words.go +2 −1
  • internal/rolloutwords/words_test.go +9 −3
  • internal/status/snapshot.go +3 −2
diff --git a/deploywatch_test.go b/deploywatch_test.go
index 38a4ca0bb..1bd7805ba 100644
--- a/deploywatch_test.go
+++ b/deploywatch_test.go
@@ -557,6 +557,8 @@ func TestWatchRolloutWithNoReportsTimesOutAsTheOldWatch(t *testing.T) {
"flag off": {rec("x.test", nil), rec("y.test", nil)},
// R6: every box declined overlap.
"declined": {rec("x.test", &apiRollout{Box: "x1", Installed: B, Unavailable: "no-cgroup-delegation"}), rec("y.test", nil)},
+ // A claims-on box declines overlap too (#272): URL-watched like any declined box.
+ "claims-on": {rec("x.test", &apiRollout{Box: "x1", Installed: B, Unavailable: "host-claims-on"}), rec("y.test", nil)},
// A report on an instance the deploy does not target is not one.
"untargeted report": {rec("x.test", nil), rec("y.test", nil), rec("z.test", &apiRollout{Box: "x1", Installed: B, Serving: B, Awake: true})},
} {
diff --git a/internal/host/rollout.go b/internal/host/rollout.go
index 4df546508..7fd4ccef9 100644
--- a/internal/host/rollout.go
+++ b/internal/host/rollout.go
@@ -28,6 +28,11 @@ const (
rolloutNoDelegation = "no-cgroup-delegation"
rolloutNoGenControl = "no-generation-control"
rolloutGenerationsOff = "generations-off"
+ // The flag is on but host claims are on: claims-backed launches cannot
+ // yet carry a swap's recovery choice, its execution evidence, the
+ // generations file or the good build (#272, "claims-on swaps"), so the
+ // box declines and stays on today's paths.
+ rolloutHostClaims = "host-claims-on"
// The flag is on and generations are on, but the scheduler's required
// state is unavailable, so every managed start is refused
// (refuseManaged).
@@ -134,8 +139,9 @@ func swapDelegation(eb *activator.ExecBackend) string {
// swapArming decides, before the activator exists, whether CARLOS_SWAP=overlap
// turns generations on (spec §15; plan 3's review: missing delegation must
// fail closed, CODEX-FINDINGS-ZDD-IMPL-3.md:138). With the flag off it is
-// false and says nothing. With the flag on but no usable containment it is
-// false too: generations, generation containment and the scheduler all stay
+// false and says nothing. With the flag on but host claims on, or no usable
+// containment, it is false too (claims-on boxes get no swaps, the owner's
+// ruling, #272): generations, generation containment and the scheduler all stay
// off, every leg keeps today's paths but for the failure records armRollout
// honours (honourDeclinedFailures), and the refusal is logged loudly and
// published as a reason code in the box's status (rolloutStatusProbe).
@@ -143,6 +149,13 @@ func swapArming(getenv func(string) string, eb *activator.ExecBackend) bool {
if !rollout.Enabled(getenv) {
return false
}
+ if eb != nil && eb.Cfg.HostWrites.Enabled {
+ log.Printf("agent: REFUSING %s=overlap (%s): host claims are on, and claims-backed launches cannot yet keep a swap's "+
+ "recovery choice, execution evidence, generations file or good build, so this box stays on today's stop-then-wake paths",
+ rollout.SwapEnv, rolloutHostClaims)
+ declineRollout(rolloutHostClaims)
+ return false
+ }
if why := swapDelegation(eb); why != "" {
log.Printf("agent: REFUSING %s=overlap (%s): generation containment needs the agent's delegated cgroup subtree "+
"and the control channel's generation units, so this box stays on today's stop-then-wake paths", rollout.SwapEnv, why)
diff --git a/internal/host/rollout_test.go b/internal/host/rollout_test.go
index f8d8acb61..ac39aae6d 100644
--- a/internal/host/rollout_test.go
+++ b/internal/host/rollout_test.go
@@ -1,12 +1,15 @@
package host
import (
+ "bytes"
"context"
"errors"
+ "log"
"os"
"path/filepath"
"reflect"
"slices"
+ "strings"
"sync"
"testing"
"time"
@@ -14,6 +17,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -362,6 +366,41 @@ func TestDelegationArmsTheFlag(t *testing.T) {
}
}
+// A box with host claims on declines the swap flag (#272): claims-backed
+// launches cannot yet carry a swap's recovery choice, execution evidence,
+// generations file or good build. The claims check comes before delegation's,
+// so a box that has neither says claims.
+func TestSwapArmingDeclinesWhenHostClaimsAreOn(t *testing.T) {
+ resetRollout(t)
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+ // No cgroups either: the claims check must come first, so the code
+ // says claims, not delegation.
+ eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}}
+ if swapArming(overlapEnv(""), eb) {
+ t.Fatal("armed a box with host claims on")
+ }
+ if got := rolloutUnarmed(); got != rolloutHostClaims {
+ t.Fatalf("decline code %q, want %q", got, rolloutHostClaims)
+ }
+ if !strings.Contains(buf.String(), "REFUSING CARLOS_SWAP=overlap (host-claims-on)") {
+ t.Fatalf("no operator line naming the claims refusal: %q", buf.String())
+ }
+ r := rolloutStatusProbe()
+ if r == nil || r.Unavailable != rolloutHostClaims {
+ t.Fatalf("status %+v: want Unavailable %q", r, rolloutHostClaims)
+ }
+}
+
+func TestSwapArmingIgnoresClaimsWithTheFlagOff(t *testing.T) {
+ resetRollout(t)
+ eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}}
+ if swapArming(noEnv, eb) || rolloutUnarmed() != "" {
+ t.Fatal("flag off: a claims box must say nothing")
+ }
+}
+
func TestRestartsFeedTheSchedulerWithTheirIdentity(t *testing.T) {
routes := []registry.Route{
{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true},
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go
index 75c594990..6652d0901 100644
--- a/internal/rolloutwords/words.go
+++ b/internal/rolloutwords/words.go
@@ -84,6 +84,7 @@ var unavailablePhrases = map[string]string{
"no-cgroup-delegation": unavailableDeclined,
"no-generation-control": unavailableDeclined,
"generations-off": unavailableDeclined,
+ "host-claims-on": 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",
@@ -107,7 +108,7 @@ func Unavailable(code string) string {
// are safe to wake.
func UnavailableDeclines(code string) bool {
switch code {
- case "no-cgroup-delegation", "no-generation-control", "generations-off":
+ case "no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on":
return true
default:
return false
diff --git a/internal/rolloutwords/words_test.go b/internal/rolloutwords/words_test.go
index 0c5a22f29..06e5aaf74 100644
--- a/internal/rolloutwords/words_test.go
+++ b/internal/rolloutwords/words_test.go
@@ -83,7 +83,7 @@ func TestEveryCauseAndReasonHasAPhrase(t *testing.T) {
func TestEveryUnavailableCodeHasAPhrase(t *testing.T) {
// Declined codes
- for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off"} {
+ for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on"} {
phrase := Unavailable(code)
if phrase == "" {
t.Errorf("Unavailable(%q) = empty string", code)
@@ -117,12 +117,12 @@ func TestEveryUnavailableCodeHasAPhrase(t *testing.T) {
}
}
-// UnavailableDeclines is true for exactly the three codes under which a box
+// UnavailableDeclines is true for exactly the four codes under which a box
// runs today's stop-then-start paths. A refusing code is not one, and nor is
// a code this binary does not know: a reader cannot assume a newer box's
// problem leaves its instances safe to wake.
func TestUnavailableDeclinesOnlyTheKnownDeclinedCodes(t *testing.T) {
- for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off"} {
+ for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on"} {
if !UnavailableDeclines(code) {
t.Errorf("UnavailableDeclines(%q) = false, want true", code)
}
@@ -133,3 +133,9 @@ func TestUnavailableDeclinesOnlyTheKnownDeclinedCodes(t *testing.T) {
}
}
}
+
+func TestHostClaimsDeclineReadsAsADecline(t *testing.T) {
+ if Unavailable("host-claims-on") != unavailableDeclined || !UnavailableDeclines("host-claims-on") || UnavailableRefuses("host-claims-on") {
+ t.Fatal("host-claims-on must read and act as a decline")
+ }
+}
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 89255afb0..77aa2a6e0 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -259,13 +259,14 @@ type RolloutReport struct {
// Unavailable, when set, is a reason code (not member-facing text) for
// why CARLOS_SWAP=overlap is on but the scheduler could not arm. Either
// the box cannot contain generations ("no-cgroup-delegation",
- // "no-generation-control", "generations-off"), and stays on today's
+ // "no-generation-control", "generations-off") or has host claims on
+ // ("host-claims-on"), and stays on today's
// paths, or its required state is unavailable ("no-deploy-store",
// "no-release-key", "failure-book-unreadable"), and every managed start
// is refused meanwhile (Task 14, internal/host's swapArming and
// refuseManaged). The rest of the report is then empty. Plan 6 words it.
Unavailable string `json:"unavailable,omitempty"`
- // FailureBook is set only beside one of the first three codes: what a
+ // FailureBook is set only beside a decline code (the first four): what a
// box that cannot contain generations does with the failure records an
// earlier armed run left (final review F6). "honoured": a managed wake,
// or a bounce, onto a build they hold for that instance is refused.