carlos / platform Public

status: leak guards for ReleaseRefusals

The dry-run needle, the contract allowlist and a CollectBox/BuildFleet test now cover Metrics.ReleaseRefusals the way they cover ConfigRefusals: required in status/boxes/*.json, banned everywhere else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8f5ae1d64275cbdf97fd4deebff2d07c3425cfd4 parent 6abd12d
3 files changed, +97 −13
  • internal/status/contract_test.go +21 −13
  • internal/status/dryrun.go +29 −0
  • internal/status/status_test.go +47 −0

CI passed — run details

diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index 445720e6f..4b7f0fe04 100644
--- a/internal/status/contract_test.go
+++ b/internal/status/contract_test.go
@@ -75,7 +75,7 @@ var allowedBoxKeys = map[string][]string{
"": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "restart_path_running", "expired_canaries", "metrics"},
"routes": {"static", "resident", "hibernating", "awake", "asleep"},
"replication": {"healthy", "lag_seconds"},
- "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "config_refusals", "config_refusals_total", "config_guard"},
+ "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "config_refusals", "config_refusals_total", "release_refusals", "config_guard"},
"metrics.route_versions": {wildcardAnyKey},
"metrics.upstreams": {wildcardAnyKey},
"metrics.upstreams.*[]": {"addr", "up", "since", "fails", "last_error"},
@@ -83,6 +83,9 @@ var allowedBoxKeys = map[string][]string{
// the private block only. Pinned to the field level so a future field on
// the refusal is a publication decision too.
"metrics.config_refusals[]": {"host", "reason", "since", "last", "count"},
+ // release_refusals (D1) names the host and build the box rolled back, in
+ // the private block only, pinned to the field level for the same reason.
+ "metrics.release_refusals[]": {"host", "version", "reason", "refused_at"},
}
// allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as
@@ -302,18 +305,23 @@ func fixtureProbes() status.Probes {
{Host: "contract-refused.internal", Reason: "cannot_open", Since: time.Now().Add(-time.Hour), Last: time.Now(), Count: 2},
},
ConfigRefusalsTotal: 1,
- ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)",
- MemFrac: &memFrac,
- DiskFrac: &diskFrac,
- Listening: listening,
- LagSecs: &lagSecs,
- AdoptRunning: &adopting,
- MemTotalBytes: &memTotal,
- MemAvailBytes: &memAvail,
- Load1: &load1,
- DiskFreeBytes: &diskFree,
- Release: "contract-fixture-sha", // distinct from dryrun.go's "dryrun4242sha"
- Tunnel: "connected", // distinct from dryrun.go's "reconnecting"
+ // One rolled-back build, so metrics.release_refusals is exercised
+ // by the allowlist walk too.
+ ReleaseRefusals: []status.ReleaseRefusal{
+ {Host: "contract-rolled-back.internal", Version: "v2", Reason: "exited: status=2", RefusedAt: time.Now()},
+ },
+ ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)",
+ MemFrac: &memFrac,
+ DiskFrac: &diskFrac,
+ Listening: listening,
+ LagSecs: &lagSecs,
+ AdoptRunning: &adopting,
+ MemTotalBytes: &memTotal,
+ MemAvailBytes: &memAvail,
+ Load1: &load1,
+ DiskFreeBytes: &diskFree,
+ Release: "contract-fixture-sha", // distinct from dryrun.go's "dryrun4242sha"
+ Tunnel: "connected", // distinct from dryrun.go's "reconnecting"
}
}
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go
index 25e02899f..fd9026ae5 100644
--- a/internal/status/dryrun.go
+++ b/internal/status/dryrun.go
@@ -116,6 +116,14 @@ const dryRunConfigRefusalHost = "refused-secret.internal"
var dryRunConfigRefusalNeedles = []string{dryRunConfigRefusalHost}
+// dryRunReleaseRefusalHost is the release refusal needle (D1):
+// Box.Metrics.ReleaseRefusals names the hosts whose build this box rolled
+// back, so the same scope split applies. REQUIRED in status/boxes/*.json and
+// BANNED in every other published object.
+const dryRunReleaseRefusalHost = "refused-build.internal"
+
+var dryRunReleaseRefusalNeedles = []string{dryRunReleaseRefusalHost}
+
// ScanForLeaks reports which of banned's strings appear anywhere in j. It is
// the single implementation both DryRun's self-check and
// contract_test.go's scanForLeaks test helper run — the test package can't
@@ -203,6 +211,10 @@ func dryRunFixtures() ([]registry.Route, Probes) {
{Host: dryRunConfigRefusalHost, Reason: "no_key", Since: time.Now().Add(-time.Hour), Last: time.Now(), Count: 2},
},
ConfigRefusalsTotal: 1,
+ // One rolled-back build (D1), for the same scope check.
+ ReleaseRefusals: []ReleaseRefusal{
+ {Host: dryRunReleaseRefusalHost, Version: "v-dryrun", Reason: "exited: status=2", RefusedAt: time.Now()},
+ },
// The probe table, keyed by a route host and carrying a backend
// address — see dryRunUpstreamNeedles. One up and one down upstream,
// so the down one's reason string is exercised too.
@@ -358,6 +370,9 @@ func DryRun(dir string, out io.Writer) error {
// configRefusalKeySeen is the same positive half for the config guard's
// refusal list (dryRunConfigRefusalNeedles), amadan #213.
configRefusalKeySeen := false
+ // releaseRefusalKeySeen is the same positive half for D1's release
+ // refusal list (dryRunReleaseRefusalNeedles).
+ releaseRefusalKeySeen := false
for _, key := range keys {
body, _, err := s.Get(ctx, key)
@@ -428,6 +443,17 @@ func DryRun(dir string, out io.Writer) error {
return fmt.Errorf("dry run: %s leaked a config_refusals fixture %v — ConfigRefusals must never reach the public feed", key, leaks)
}
+ // dryRunReleaseRefusalNeedles: the fifth scope split, on the same
+ // pattern. The rolled-back builds name real hosts.
+ if strings.HasPrefix(key, "status/boxes/") {
+ if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) == 0 {
+ return fmt.Errorf("dry run: %s: expected release_refusals fixtures %v, found none — ReleaseRefusals not attached?", key, dryRunReleaseRefusalNeedles)
+ }
+ releaseRefusalKeySeen = true
+ } else if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) > 0 {
+ return fmt.Errorf("dry run: %s leaked a release_refusals fixture %v — ReleaseRefusals must never reach the public feed", key, leaks)
+ }
+
// Page assets (index.html, status.css) aren't JSON and simply fail
// to decode here — silently skipped, since the band-digit check
// only means anything for objects that actually carry Load/Capacity
@@ -455,6 +481,9 @@ func DryRun(dir string, out io.Writer) error {
if !configRefusalKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — config_refusals scope check never ran")
}
+ if !releaseRefusalKeySeen {
+ return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — release_refusals scope check never ran")
+ }
fmt.Fprintln(out, "dry-run: no leaks")
return nil
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index c9f6c9035..45db1778d 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -1497,6 +1497,53 @@ func TestCollectBoxCarriesConfigRefusals(t *testing.T) {
}
}
+// TestCollectBoxCarriesReleaseRefusals (D1): a box that rolled a build back
+// says so in its private Metrics block, naming the host, version and reason.
+// The hostname never reaches the public fleet.json, and a box with no
+// refusal carries no key at all.
+func TestCollectBoxCarriesReleaseRefusals(t *testing.T) {
+ at := time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC)
+ refusals := []status.ReleaseRefusal{
+ {Host: "rolled-back.example.com", Version: "v2-secret-build", Reason: "exited: status=2", RefusedAt: at},
+ }
+ routes := []registry.Route{{Host: "rolled-back.example.com", Kind: "instance", Hibernate: true}}
+ b := status.CollectBox(routes, status.Probes{MachineID: "m", ReleaseRefusals: refusals}, time.Now())
+ if b.Metrics == nil {
+ t.Fatal("metrics block missing: a release refusal alone should attach it")
+ }
+ if got := b.Metrics.ReleaseRefusals; len(got) != 1 || got[0] != refusals[0] {
+ t.Fatalf("release_refusals = %+v, want %+v", got, refusals)
+ }
+
+ s := teststore(t)
+ ctx := context.Background()
+ if err := status.WriteBox(ctx, s, b); err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(ctx, s, routes, time.Now(), 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fj, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, needle := range []string{"rolled-back.example.com", "v2-secret-build", "exited: status=2", "release_refusals"} {
+ if strings.Contains(string(fj), needle) {
+ t.Errorf("fleet.json leaked %q: %s", needle, fj)
+ }
+ }
+
+ quiet := status.CollectBox(routes, status.Probes{MachineID: "m"}, time.Now())
+ if quiet.Metrics != nil {
+ t.Errorf("metrics = %+v with no refusal, want absent", quiet.Metrics)
+ }
+ raw, _ := json.Marshal(status.CollectBox(routes, status.Probes{MachineID: "m", MemTotalBytes: new(int64)}, time.Now()))
+ if strings.Contains(string(raw), "release_refusals") {
+ t.Errorf("a box with no refusal publishes release_refusals: %s", raw)
+ }
+}
+
// The config guard's bypass is visible in the private block, and only there.
func TestCollectBoxCarriesTheConfigGuardBypass(t *testing.T) {
b := status.CollectBox(nil, status.Probes{MachineID: "m", ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)"}, time.Now())