Skip to main content
Status: Draft Type: Standards Track Category: Core (Operations) Created: 2026-08-11 Related: CIP-9 (runner-backed storage — the relay registry this CIP’s chain view reads), CIP-24 (CBSS), CIP-39 (CBQS)

Abstract

This CIP specifies GET /statusz: one JSON self-report with a shared envelope and a system-specific drill-down payload, to be served by every Cowboy daemon — validator RPC, indexer, CBFS relay, cbssd, cbqsd, runner. The canonical schema is Rust, in cowboy-protocol-types::statusz (cowboy-protocol#79, pending): a daemon that compiles against it serves the same shape every consumer parses. cowboy top (node#1307, pending) is the first consumer. See Implementation status for the full PR stack; nothing named by this CIP is merged yet.

Background

Cluster health lives in per-system formats: the node’s /health/* family, cbqsd’s former /healthz + Prometheus text, cbfs and cbssd metrics registries, and nothing at all from the runner. The gateway (/_cowboy/health) and the RAS write-relayer (its own /healthz) are two more dialects again. Any tool that wants a topological view must speak all of them, and each new daemon invents another. (The gateway and write-relayer are not in this CIP’s initial SystemKind set; adding them is the compatible-extension path described under Compatibility.) Two sources of truth exist and both matter. The chain view (the node RPC’s read surface: /ras/stats, /runners/active, /cbss/proxies, /cbqs/broker-state — the explorer-class aggregates are unauthenticated and per-IP rate-limited) is what the network believes about a component and is the only view a public user can reach. The self-report is what the daemon knows about itself — version, uptime, live counters — and is an operator surface. This CIP standardizes the second; it does not replace the first. Consumers merge them; see the precedence rule under Envelope. Goal: one endpoint, one schema, so status consumers scale with the contract instead of with the daemon count.

Specification

The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119. The rustdoc of cowboy-protocol-types::statusz is normative alongside this document; where they disagree, the Rust module wins and this CIP has a bug.

Endpoint

GET /statusz200 with Content-Type: application/json, on the listener and exposure each daemon’s row specifies: Exposure is a per-row requirement, not a property inherited from /metrics (the node’s /metrics, for instance, has a different posture than its /statusz). Where a row says “loopback only”, the stated mechanism is the requirement an implementation carries. A poller MUST treat “no /statusz” as a rendered state (fall back to the chain view), never an error.

Envelope

Canonical definition: cowboy-protocol-types::statusz::StatuszReport.
  • system — one of node, indexer, cbfs-relay, cbssd, cbqsd, runner. Reports self-identify: a consumer polls a URL list and buckets by this field, so fleets (relays, runners) need no per-instance config.
  • version — build version string; display-only, daemon’s choice of format.
  • network — the network the daemon believes it is on (e.g. mesa, canyon, local). Empty string is the defined sentinel for “this daemon does not know its network”; consumers display it as absent, never as a network named "".
  • status — the daemon’s judgment of its own duty: healthy | degraded | unhealthy are the only values a daemon emits (a cbqsd that cannot read finalized chain state is unhealthy; a runner missing three heartbeats is degraded). unreachable is a poller judgment and never appears on the wire. A fourth wire value, unknown, exists as reader vocabulary: both wire enums carry a #[serde(other)] Unknown fallback, so an unrecognized string buckets instead of failing the report — and a bucketed report re-serialized through the typed struct legally emits the literal "unknown". Strict validators MUST accept it. Consumers MUST NOT re-serialize a bucketed report as if it were the original (the source string is not preserved); forward raw bytes instead. When merging judgments from multiple sources, the worse one wins, in the order healthy < degraded < unhealthy; an unrecognized status MUST be treated as at least as severe as degraded.
  • uptime_seconds — seconds since the daemon started.
  • boot_id — random per-process identity, generated once at startup. Counter deltas are only meaningful within one boot_id: a restart that re-reaches its old count, or a load balancer swapping the instance behind the URL, presents as an increase that no decrease rule can catch. Pollers MUST skip the delta interval whenever boot_id changes. 0 is the sentinel for a daemon that predates the field; pollers then fall back to decrease-only detection.
  • counters — monotonic series: never decreasing within one boot_id. Most are process-lifetime totals; an externally monotonic series (e.g. chain height) is also a valid counter — it just keeps climbing across boots. Daemons never compute rates for this endpoint; pollers derive activity from deltas between their own ticks, scoped to one boot_id. A decrease within one boot_id MUST be treated as a reset (skip the interval, never compute an underflowed delta).
  • gaugesinteger (u64) point-in-time values, possibly the daemon’s most recent cached observation (e.g. a chain probe on its own cadence) — the poll interval, not the envelope, bounds staleness. Every declared gauge is a discrete integer quantity (counts, bytes, seconds, heights, epochs); f64 is excluded because a non-finite value serializes to JSON null and poisons the report parse exactly when a daemon is in trouble, and byte counters lose precision above 2^53.
  • detail — free-form system-specific JSON for drill-downs; it may evolve without an envelope change. Omitted from the JSON when the daemon has none (readers of the typed struct see Value::Null either way).
Well-known counter/gauge keys live in statusz::keys; the example above uses only registered keys. Daemons populate what they have and SHOULD register new keys rather than emit bare literals (a registered key is covered by the rename-guard test; a literal is not); consumers tolerate absent and unknown keys. Envelope evolution: new fields land as #[serde(default)]wire-compatible (old readers ignore them, old payloads default) but source-coordinated: producers construct the struct by literal, so any new field is a compile error in every daemon repo until its pin bump adds it; that error is the coordination mechanism. Adding a field without #[serde(default)] is a breaking wire rev and needs the full stack to move together.

Transport

The endpoint is served over HTTP GET — the verb is part of the contract. A successfully generated report is always 200 with Content-Type: application/json, regardless of the health state it carriesunhealthy is a self-report from a daemon that can still answer, not a 5xx. An implementation that returns 5xx for unhealthy makes pollers misclassify the daemon as unreachable, the opposite of this endpoint’s purpose. Non-2xx means the report could not be produced.

Compatibility

Adding a SystemKind variant or a well-known key is a compatible change (consumers ignore unknown keys, bucket unknown systems via the Unknown fallback, and must be rebuilt to place a new system in a UI — the intended pin-bump discipline). Renaming any contract literal is breaking and pinned by test: enum strings and the route by wire_strings_are_the_contract, the key registry by key_strings_are_the_contract. cbqsd’s former /healthz is removed, not deprecated: pre-launch there is no installed base and no external prober depended on it (aws-infrastructure was checked), per the workspace’s no-back-compat-shims convention. Post-launch, removing an operational endpoint would require a deprecation window; that machinery is deliberately out of scope now.

Security considerations

/statusz is an unauthenticated read endpoint by design; its security model is exposure control, not authentication.
  • Information disclosure. The envelope necessarily carries version and uptime_seconds — exactly the pair COW-762 withholds from the validator’s public surface, because build fingerprints and restart timing aid targeting of consensus nodes. Hence the validator row is dev-mode-only: in production the route is not registered at all, and public consumers use the chain view. For non-consensus daemons the same pair is less sensitive but not free: the default posture is loopback (cbfs relay, cbssd, runner). The two deliberate exceptions are the indexer, whose entire surface is already public read and whose operator accepts version disclosure as part of running a public archive, and cbqsd, whose broker listener is an operator-run, TLS-terminated service endpoint — its report adds traffic counters, never queue contents or key material. cbssd’s gauges (shares_held, committee_epoch) reveal committee participation shape, which is why its row is loopback-only with a MUST-refuse bind check.
  • Denial of service. Handlers MUST be cheap reads of in-process state (atomic counters, cached observations, a small directory listing) and MUST NOT perform network calls on the request path. Public rows inherit whatever rate limiting their listener already carries.
  • Fail-closed bind checks. Where the endpoint table says loopback only, the implementation MUST enforce it in code (refuse the bind or conditionally mount), not in deployment convention; cbssd’s existing refusal is the reference behavior.

Implementation status

Nothing below is merged as of this draft; the CIP describes the target state of an open PR stack, in merge order: Merge order is a hard constraint, not a preference: consumers that depend on each other must pin one cowboy-protocol rev or cargo builds two instances of the types crate (each repo’s check-cowboy-protocol-revisions.py gate enforces this). The statusz branch is therefore based on the protocol devnet lineage (the union that also carries the CBQS client crates, which never landed on main), and cowboy-protocol#79 targets devnet. Order: (1) protocol#79; (2) cbfs#135, cbss#84, cbqs#10 — independent of each other; (3) node#1307, which pins cbfs’s merged rev; (4) runner#214 last — it pins node, cbfs, and cbss revs and its gate only converges once those exist. Until the stack lands, consumers pin the branch-head union rev; each PR re-pins to the durable merged rev of its parents immediately before merging.