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>
8 files changed,
+192
−2
cli.go+17 −0internal/host/agent.go+2 −0internal/host/proofstatus.go+15 −0internal/status/contract_test.go+10 −1internal/status/dryrun.go+29 −0internal/status/snapshot.go+23 −1internal/status/status_test.go+48 −0statuslive_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 |