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 specifiesGET /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 ofcowboy-protocol-types::statusz is normative
alongside this document; where they disagree, the Rust module wins and this
CIP has a bug.
Endpoint
GET /statusz → 200 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 ofnode,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|unhealthyare the only values a daemon emits (a cbqsd that cannot read finalized chain state isunhealthy; a runner missing three heartbeats isdegraded).unreachableis 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)]Unknownfallback, 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 orderhealthy < degraded < unhealthy; an unrecognized status MUST be treated as at least as severe asdegraded.uptime_seconds— seconds since the daemon started.boot_id— random per-process identity, generated once at startup. Counter deltas are only meaningful within oneboot_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 wheneverboot_idchanges.0is the sentinel for a daemon that predates the field; pollers then fall back to decrease-only detection.counters— monotonic series: never decreasing within oneboot_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 oneboot_id. A decrease within oneboot_idMUST be treated as a reset (skip the interval, never compute an underflowed delta).gauges— integer (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);f64is excluded because a non-finite value serializes to JSONnulland 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 seeValue::Nulleither way).
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 always200 with
Content-Type: application/json, regardless of the health state it
carries — unhealthy 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 aSystemKind 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
versionanduptime_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.
