carlos / platform Public

status: show a host waiting for a dev proof in status --live

ProofWaits is private Metrics (it names hosts), with the same leak guards as ReleaseRefusals: dry-run needle and scope split, contract allowlist and fixture, and a CollectBox/BuildFleet test. A wait with no prover is a policy problem and prints the load error as given.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1b6339e390c00d32872218f270cc9989f5e9ea70 parent 51f641a
8 files changed, +192 −2
  • cli.go +17 −0
  • internal/host/agent.go +2 −0
  • internal/host/proofstatus.go +15 −0
  • internal/status/contract_test.go +10 −1
  • internal/status/dryrun.go +29 −0
  • internal/status/snapshot.go +23 −1
  • internal/status/status_test.go +48 −0
  • statuslive_test.go +48 −0
diff --git a/cli.go b/cli.go
index fb212becf..c71b79fbd 100644
--- a/cli.go
+++ b/cli.go
@@ -3470,6 +3470,23 @@ func printLiveBoxes(w io.Writer, boxes []status.Box, now time.Time) {
}
fmt.Fprintf(w, " refused %s %s since %s: %s%s\n", r.Host, r.Version, r.RefusedAt.UTC().Format(time.RFC3339), r.Reason, failed)
}
+ for _, pw := range m.ProofWaits {
+ sha := pw.SHA256
+ if len(sha) > 12 {
+ sha = sha[:12]
+ }
+ // No prover means the wait is the policy file's, not a missing
+ // proof: say so, with the load error exactly as given.
+ what := "a " + pw.Prover + " proof"
+ if pw.Prover == "" {
+ what = "a proof (policy: " + pw.Reason + ")"
+ }
+ line := fmt.Sprintf(" waiting for %s: %s %s (sha256 %s) since %s", what, pw.Host, pw.Version, orDash(sha), pw.Since.UTC().Format(time.RFC3339))
+ if pw.Prover != "" && pw.Reason != "" {
+ line += ": " + pw.Reason
+ }
+ fmt.Fprintln(w, line)
+ }
}
}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index cf1d9a6a2..be5a2eec4 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -3033,6 +3033,8 @@ func statusLoop(reg *registry.Registry) {
// Builds the root pass rolled back (D1), as the metrics loop's
// refusal watch last read them. Private Metrics only: hosts.
p.ReleaseRefusals = releaseRefusalProbe()
+ // Hosts held for a dev proof (D3): private Metrics only.
+ p.ProofWaits = proofWaitProbe()
// A fleet box (CARLOS_FLEET set) never gets here as a contender: it
// self-reports only, never attempting the election at all — no
diff --git a/internal/host/proofstatus.go b/internal/host/proofstatus.go
new file mode 100644
index 000000000..edbdae3c6
--- /dev/null
+++ b/internal/host/proofstatus.go
@@ -0,0 +1,15 @@
+package host
+
+import "github.com/carlosframework/platform/internal/status"
+
+// proofWaitProbe feeds status.Probes.ProofWaits from the agent's proof gate:
+// the hosts held on their current build until a dev proof arrives, or until
+// the proof policy file loads. Private Metrics only, because they name hosts.
+func proofWaitProbe() []status.ProofWait {
+ var out []status.ProofWait
+ for _, w := range agentProofGate().Waits() {
+ out = append(out, status.ProofWait{Host: w.Host, Version: w.Version, SHA256: w.SHA256,
+ Prover: w.Prover, Reason: w.Reason, Since: w.Since})
+ }
+ return out
+}
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index 0acd2099f..5edff033d 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", "release_refusals", "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", "proof_waits", "config_guard"},
"metrics.route_versions": {wildcardAnyKey},
"metrics.upstreams": {wildcardAnyKey},
"metrics.upstreams.*[]": {"addr", "up", "since", "fails", "last_error"},
@@ -86,6 +86,9 @@ var allowedBoxKeys = map[string][]string{
// 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", "kind", "recovery_error"},
+ // proof_waits (D3) names the host and build the box is holding for a dev
+ // proof, in the private block only, pinned to the field level too.
+ "metrics.proof_waits[]": {"host", "version", "sha256", "prover", "reason", "since"},
}
// allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as
@@ -311,6 +314,12 @@ func fixtureProbes() status.Probes {
{Host: "contract-rolled-back.internal", Version: "v2", Reason: "exited: status=2", RefusedAt: time.Now(),
Kind: "wrong-program", RecoveryError: "rollback did not come up either"},
},
+ // One host held for a dev proof, so metrics.proof_waits is exercised
+ // by the allowlist walk too.
+ ProofWaits: []status.ProofWait{
+ {Host: "contract-proof-wait.internal", Version: "v3", SHA256: "e47fcc1e0123456789abcdef", Prover: "dev",
+ Reason: "no proof from dev", Since: time.Now().Add(-time.Hour)},
+ },
ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)",
MemFrac: &memFrac,
DiskFrac: &diskFrac,
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go
index 5ccc610d6..377af3410 100644
--- a/internal/status/dryrun.go
+++ b/internal/status/dryrun.go
@@ -124,6 +124,14 @@ const dryRunReleaseRefusalHost = "refused-build.internal"
var dryRunReleaseRefusalNeedles = []string{dryRunReleaseRefusalHost}
+// dryRunProofWaitHost is the proof wait needle (D3): Box.Metrics.ProofWaits
+// names the hosts this box is holding for a dev proof, so the same scope
+// split applies. REQUIRED in status/boxes/*.json and BANNED in every other
+// published object.
+const dryRunProofWaitHost = "proof-wait.internal"
+
+var dryRunProofWaitNeedles = []string{dryRunProofWaitHost}
+
// 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
@@ -215,6 +223,10 @@ func dryRunFixtures() ([]registry.Route, Probes) {
ReleaseRefusals: []ReleaseRefusal{
{Host: dryRunReleaseRefusalHost, Version: "v-dryrun", Reason: "exited: status=2", RefusedAt: time.Now(), Kind: "wrong-program"},
},
+ // One host held for a dev proof (D3), for the same scope check.
+ ProofWaits: []ProofWait{
+ {Host: dryRunProofWaitHost, Version: "v-dryrun", SHA256: "dryrunproofsha", Prover: "dev", Reason: "no proof from dev", Since: 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.
@@ -373,6 +385,9 @@ func DryRun(dir string, out io.Writer) error {
// releaseRefusalKeySeen is the same positive half for D1's release
// refusal list (dryRunReleaseRefusalNeedles).
releaseRefusalKeySeen := false
+ // proofWaitKeySeen is the same positive half for D3's proof wait list
+ // (dryRunProofWaitNeedles).
+ proofWaitKeySeen := false
for _, key := range keys {
body, _, err := s.Get(ctx, key)
@@ -454,6 +469,17 @@ func DryRun(dir string, out io.Writer) error {
return fmt.Errorf("dry run: %s leaked a release_refusals fixture %v — ReleaseRefusals must never reach the public feed", key, leaks)
}
+ // dryRunProofWaitNeedles: the sixth scope split, on the same
+ // pattern. The hosts held for a dev proof are real route names.
+ if strings.HasPrefix(key, "status/boxes/") {
+ if leaks := ScanForLeaks(body, dryRunProofWaitNeedles); len(leaks) == 0 {
+ return fmt.Errorf("dry run: %s: expected proof_waits fixtures %v, found none — ProofWaits not attached?", key, dryRunProofWaitNeedles)
+ }
+ proofWaitKeySeen = true
+ } else if leaks := ScanForLeaks(body, dryRunProofWaitNeedles); len(leaks) > 0 {
+ return fmt.Errorf("dry run: %s leaked a proof_waits fixture %v — ProofWaits 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
@@ -484,6 +510,9 @@ func DryRun(dir string, out io.Writer) error {
if !releaseRefusalKeySeen {
return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — release_refusals scope check never ran")
}
+ if !proofWaitKeySeen {
+ return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — proof_waits scope check never ran")
+ }
fmt.Fprintln(out, "dry-run: no leaks")
return nil
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 0b95e9efb..03f592fae 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -144,6 +144,9 @@ type Probes struct {
// ReleaseRefusals is the builds this box rolled back and is now refusing
// to re-adopt (D1); nil when there are none. Private Metrics only.
ReleaseRefusals []ReleaseRefusal
+ // ProofWaits is the hosts held on their current build waiting for a dev
+ // proof (D3). Private Metrics only: they name hosts.
+ ProofWaits []ProofWait
// ConfigGuard is "" in the normal mode and a words-only note when the
// config guard's explicit bypass is in effect on this box (amadan #213).
ConfigGuard string
@@ -185,6 +188,20 @@ type ReleaseRefusal struct {
RecoveryError string `json:"recovery_error,omitempty"`
}
+// ProofWait is a host this box is holding on its current build because its
+// channel head has no dev proof yet (D3, release.ProofGate), or because the
+// box's proof policy file does not load. PRIVATE like the rest of
+// Box.Metrics: Host is a real route name. With no Prover the wait is about
+// the policy, and Reason is the policy's own load error.
+type ProofWait struct {
+ Host string `json:"host"`
+ Version string `json:"version"`
+ SHA256 string `json:"sha256,omitempty"`
+ Prover string `json:"prover,omitempty"`
+ Reason string `json:"reason"`
+ Since time.Time `json:"since"`
+}
+
// UpstreamState is one upstream's health as the edge's prober currently sees
// it: what it believes, when that belief last changed, and why.
//
@@ -322,6 +339,10 @@ type BoxMetrics struct {
// did not come up (D1), one per host. Hostname-bearing, so private like
// ConfigRefusals. Omitted when empty.
ReleaseRefusals []ReleaseRefusal `json:"release_refusals,omitempty"`
+ // ProofWaits is the hosts this box is holding on their current build
+ // until a dev proof for the new head arrives (D3). Hostname-bearing, so
+ // it lives in this private block only. Omitted when empty.
+ ProofWaits []ProofWait `json:"proof_waits,omitempty"`
// ConfigGuard says the box's admission guard is bypassed, e.g.
// "bypassed (CARLOS_NO_SEALED_CONFIG)": it admits placed exec instances
// without checking their sealed config. Omitted in the normal mode, so an
@@ -720,7 +741,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" ||
p.InstanceID != "" || p.Platform != "" || p.SystemChannel != "" || p.System != nil ||
len(routeVersions) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 ||
- len(routeDBBytes) > 0 || len(p.ConfigRefusals) > 0 || len(p.ReleaseRefusals) > 0 || p.ConfigGuard != "" ||
+ len(routeDBBytes) > 0 || len(p.ConfigRefusals) > 0 || len(p.ReleaseRefusals) > 0 || len(p.ProofWaits) > 0 || p.ConfigGuard != "" ||
p.Alerts != nil {
box.Metrics = &BoxMetrics{
MemTotalBytes: p.MemTotalBytes, MemAvailBytes: p.MemAvailBytes,
@@ -742,6 +763,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
ConfigRefusals: append([]ConfigRefusal(nil), p.ConfigRefusals...),
ConfigRefusalsTotal: p.ConfigRefusalsTotal,
ReleaseRefusals: append([]ReleaseRefusal(nil), p.ReleaseRefusals...),
+ ProofWaits: append([]ProofWait(nil), p.ProofWaits...),
ConfigGuard: p.ConfigGuard,
}
}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index 45db1778d..57c0d07eb 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -1544,6 +1544,54 @@ func TestCollectBoxCarriesReleaseRefusals(t *testing.T) {
}
}
+// TestCollectBoxCarriesProofWaits (D3): a box holding a host on its current
+// build for a dev proof says so in its private Metrics block. The hostname
+// and build never reach the public fleet.json, and a box with no wait carries
+// no key at all.
+func TestCollectBoxCarriesProofWaits(t *testing.T) {
+ at := time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC)
+ waits := []status.ProofWait{
+ {Host: "waiting.example.com", Version: "v3-secret-build", SHA256: "e47fcc1e0123456789abcdef", Prover: "dev",
+ Reason: "no proof from dev", Since: at},
+ }
+ routes := []registry.Route{{Host: "waiting.example.com", Kind: "instance", Hibernate: true}}
+ b := status.CollectBox(routes, status.Probes{MachineID: "m", ProofWaits: waits}, time.Now())
+ if b.Metrics == nil {
+ t.Fatal("metrics block missing: a proof wait alone should attach it")
+ }
+ if got := b.Metrics.ProofWaits; len(got) != 1 || got[0] != waits[0] {
+ t.Fatalf("proof_waits = %+v, want %+v", got, waits)
+ }
+
+ 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{"waiting.example.com", "v3-secret-build", "e47fcc1e", "no proof from dev", "proof_waits"} {
+ 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 wait, want absent", quiet.Metrics)
+ }
+ raw, _ := json.Marshal(status.CollectBox(routes, status.Probes{MachineID: "m", MemTotalBytes: new(int64)}, time.Now()))
+ if strings.Contains(string(raw), "proof_waits") {
+ t.Errorf("a box with no wait publishes proof_waits: %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())
diff --git a/statuslive_test.go b/statuslive_test.go
index 574666806..d76d22a5e 100644
--- a/statuslive_test.go
+++ b/statuslive_test.go
@@ -227,6 +227,54 @@ func TestPrintLiveBoxesShowsANotProvenRefusal(t *testing.T) {
}
}
+// D3: a box holding a host on its current build for a dev proof says so, with
+// the short sha and when the wait began.
+func TestPrintLiveBoxesShowsAProofWait(t *testing.T) {
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ var buf bytes.Buffer
+ printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{
+ ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2",
+ SHA256: "e47fcc1e0123456789abcdef", Prover: "dev", Since: now.Add(-time.Hour)}},
+ }}}, now)
+ want := " waiting for a dev proof: console.example.com v2 (sha256 e47fcc1e0123) since 2026-10-06T09:00:00Z"
+ if !strings.Contains(buf.String(), want) {
+ t.Fatalf("missing %q in:\n%s", want, buf.String())
+ }
+}
+
+// Ruling 7: with no prover named the wait is a policy problem. The line says
+// so and prints the policy's own error exactly as given, never "a proof".
+func TestPrintLiveBoxesShowsAPolicyProofWait(t *testing.T) {
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ var buf bytes.Buffer
+ printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{
+ ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2",
+ Reason: "proof policy: line 3: unknown prover", Since: now.Add(-time.Hour)}},
+ }}}, now)
+ out := buf.String()
+ want := " waiting for a proof (policy: proof policy: line 3: unknown prover): console.example.com v2 (sha256 -) since 2026-10-06T09:00:00Z"
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q in:\n%s", want, out)
+ }
+ if strings.Contains(out, "a proof") || strings.Contains(out, "a - proof") {
+ t.Fatalf("an empty prover printed as a blank or dash:\n%s", out)
+ }
+}
+
+// A named prover's wait carries the gate's reason through unchanged.
+func TestPrintLiveBoxesShowsAProofWaitReason(t *testing.T) {
+ now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC)
+ var buf bytes.Buffer
+ printLiveBoxes(&buf, []status.Box{{GeneratedAt: now, Label: "ada-stargazing", Metrics: &status.BoxMetrics{
+ ProofWaits: []status.ProofWait{{Host: "console.example.com", Version: "v2", SHA256: "abc",
+ Prover: "dev", Reason: "proof from dev: signature does not verify", Since: now}},
+ }}}, now)
+ want := "since 2026-10-06T10:00:00Z: proof from dev: signature does not verify\n"
+ if !strings.Contains(buf.String(), want) {
+ t.Fatalf("missing %q in:\n%s", want, buf.String())
+ }
+}
+
func TestHumanBytesAndFloat(t *testing.T) {
for _, c := range []struct {
in *int64