Node Preflight
Run chio doctor on the host a service will use, read each of its six reports, and gate CI on the ones you need.
On this page
chio doctor checks a host before a Chio service runs there: the Rust toolchain, the guard registry and bundle, the telemetry endpoint, a kernel metrics route, and the doctor’s chio.yaml marker file. One run prints six reports and an exit code a CI job can gate on.
Run it from the directory whose files you want checked, with Chio and Cargo on PATH: a build host or a CI runner. Doctor reads files, environment variables, and two endpoints; a running service reports its own state through Health & Readiness.
Six probes, one rail, one exit code
Every probe runs on every invocation, in this order, whatever a previous probe reported. The runner keeps the worst severity: ok, info, and warning exit 0, error exits 1, fatal exits 2. Run doctor.
--fix performs are left out.1. Run doctor and read every report
Run it with nothing configured to see the baseline:
$ chio doctorchio doctor
[ok] toolchain: Rust toolchain 1.96.0 satisfies workspace MSRV.
cargo_version: cargo 1.96.0 (30a34c682 2026-05-25)
[info] oci: No guard OCI registry configured. Skipping reachability probe.
help: Set CHIO_GUARD_REGISTRY or add a `registry:` line to chio.yaml to enable the OCI reachability probe.
[info] cosign: No guard bundle configured. Skipping cosign freshness probe.
help: Set CHIO_GUARD_BUNDLE or place a sigstore bundle at .chio/guard-bundle.sigstore.json to enable this probe.
[info] otel: No OTLP endpoint configured. Skipping OTEL probe.
help: Set OTEL_EXPORTER_OTLP_ENDPOINT (or the receipt-specific CHIO_OTEL_RECEIPT_EXPORTER_ENDPOINT) to enable the probe.
[info] kernel_runtime: No kernel metrics URL configured. Skipping chio_kernel_dispatch_inflight gauge probe.
help: Set CHIO_KERNEL_METRICS_URL to the chio-tower /metrics URL to enable this probe.
[info] chio_yaml: No chio.yaml found at ~/chio/chio.yaml.
help: Run `chio doctor --fix` to scaffold a minimal chio.yaml, or create one by hand.
summary: worst severity = info, exit code = 0You should see exit code 0 with one ok and five info results. All six probes run on every invocation, in the order the figure draws, whatever a previous probe reported. Each info means the probe had nothing to check: no registry, no bundle, no endpoint, no marker file. A run like this says the toolchain matches and little else, so configure the inputs your deployment uses and run it again.
2. Gate on severities, because warnings exit 0
Each report carries a severity, and the runner keeps the worst. info sorts above ok, so an unconfigured run reports "worst": "info".
| Severity | Meaning | Exit |
|---|---|---|
ok | Passed, or skipped with a resolved target | 0 |
info | Nothing configured to check | 0 |
warning | Checked and failed, non-blocking | 0 |
error | Checked and failed, blocking | 1 |
fatal | Reserved; no standard probe emits it | 2 |
Only toolchain and chio_yaml can report error. An unreachable registry, an old bundle, a malformed OTLP endpoint, or a missing gauge is a warning, so a zero exit can still carry findings. In CI, select the reports your deployment requires and branch on their severity:
set -o pipefail
chio doctor --json --skip-network | jq -e '[.reports[]
| select(.probe == "toolchain" or .probe == "chio_yaml")
| .severity] | all(. == "ok")'true
You should see true and exit 0 only when every selected probe passed; jq -e exits 1 on false. Keep pipefail, or capture doctor’s own status before parsing, so a doctor that fails before printing an envelope still fails the job.
3. Parse the JSON envelope in automation
--json prints one envelope with schema chio.doctor.v1, worst, exit_code, and reports. Here the registry comes from the environment and the network is skipped:
$ CHIO_GUARD_REGISTRY=ghcr.io/example/chio \
chio doctor --json --skip-network | jq .{
"exit_code": 0,
"reports": [
{
"code": null,
"context": [
{
"key": "cargo_version",
"value": "cargo 1.96.0 (30a34c682 2026-05-25)"
}
],
"help": null,
"message": "Rust toolchain 1.96.0 satisfies workspace MSRV.",
"probe": "toolchain",
"repaired": false,
"severity": "ok"
},
{
"code": null,
"context": [
{
"key": "registry",
"value": "ghcr.io/example/chio"
}
],
"help": null,
"message": "Registry endpoint ghcr.io/example/chio resolved. Network probe skipped.",
"probe": "oci",
"repaired": false,
"severity": "ok"
},
{
"code": null,
"help": "Set CHIO_GUARD_BUNDLE or place a sigstore bundle at .chio/guard-bundle.sigstore.json to enable this probe.",
"message": "No guard bundle configured. Skipping cosign freshness probe.",
"probe": "cosign",
"repaired": false,
"severity": "info"
},
{
"code": null,
"help": "Set OTEL_EXPORTER_OTLP_ENDPOINT (or the receipt-specific CHIO_OTEL_RECEIPT_EXPORTER_ENDPOINT) to enable the probe.",
"message": "No OTLP endpoint configured. Skipping OTEL probe.",
"probe": "otel",
"repaired": false,
"severity": "info"
},
{
"code": null,
"help": "Set CHIO_KERNEL_METRICS_URL to the chio-tower /metrics URL to enable this probe.",
"message": "No kernel metrics URL configured. Skipping chio_kernel_dispatch_inflight gauge probe.",
"probe": "kernel_runtime",
"repaired": false,
"severity": "info"
},
{
"code": null,
"help": "Run `chio doctor --fix` to scaffold a minimal chio.yaml, or create one by hand.",
"message": "No chio.yaml found at ~/chio/chio.yaml.",
"probe": "chio_yaml",
"repaired": false,
"severity": "info"
}
],
"schema": "chio.doctor.v1",
"worst": "info"
}You should see exit_code equal to the process’s own exit status, and the oci report at ok because its target resolved and the request was skipped. Branch on probe and severity. Messages are human text. A report at ok or info carries "code": null; a warning or error carries the registry URN of the failure.
Read more in the envelope reference.
4. Assert configuration without network traffic
In a sandbox with no egress, add --skip-network, or export CHIO_DOCTOR_SKIP_NETWORK. The variable counts when present, so setting it to 0 still skips. All six probes run; the two that would send a request resolve their target and report it instead.
- Configured and skipped.
ok, withregistryin context foroci, andmetrics_urlandexpected_gaugeforkernel_runtime. - Unconfigured.
info, with empty context.
That difference makes the skip a configuration assertion: the environment resolves a registry and a metrics URL without either one answering. The skip saves up to five seconds per network probe and leaves the verdict unchanged, because every network failure is a warning. It governs doctor’s own requests only, not those of a child program such as a toolchain shim.
5. Scaffold a missing marker file
chio doctor --fix performs one repair: when ./chio.yaml is absent, it creates the doctor’s two-key marker file and reports ok with repaired: true.
version: 1
policy: ./policy.yamlYou should see the file and the help line Review the scaffolded policy path before running production workloads. The repair only creates, never overwrites, and it writes no policy, toolchain, or bundle. On a read-only directory or a full disk the create fails, and the run exits 1 under urn:chio:error:cli:doctor-probe-failed where it would otherwise have reported info.
A failing probe names the input to fix
toolchain reports error
Cargo is missing from PATH, or its version is below the workspace MSRV or outside the pinned channel. Install or select the workspace toolchain and rerun from the workspace root; on a host that runs a prebuilt chio with no Rust, run doctor on the build host instead.
chio_yaml reports error
The marker file failed to parse, lacks version or policy, or cannot be read; the report carries line, column, and missing in context. The marker file is the doctor’s; a serving node reads its runtime configuration from the chio.yaml schema.
A network or bundle probe reports warning
Investigate the named target separately; the run still exits 0. A reachable registry proves the host resolves and answers, and a fresh bundle file proves its age. Signature checks on a bundle happen at load, through chio attest and chio bind.
With every relevant report accepted, start the service and verify its health route, one governed call, and that call’s receipt.
Probes, reports, and the envelope
Try CHIO_GUARD_BUNDLE, exit_code, or deny_unknown_fields.
1. toolchain
Shells out to cargo --version, parses the semver from the second whitespace-delimited field, and compares major and minor only. It then line-scans the root Cargo.toml for rust-version and rust-toolchain.toml for channel. Below the MSRV is an error; a pinned channel whose major and minor differ from the active Cargo is an error. All four failure paths carry urn:chio:error:cli:doctor-toolchain-mismatch.
No Cargo on PATH, a cargo --version that exits nonzero, and output the parser cannot read collapse to one error. The first two report Failed to invoke `cargo --version`. and exit 1. Run against the Chio workspace root, the probe finds rust-version = "1.94" and channel = "1.94.1", so a Cargo outside 1.94 fails the pin comparison and one below 1.94 fails the MSRV comparison.
channel = "stable" yields no numeric pair, so the pin comparison is skipped. Both file reads root at the working directory: from a subdirectory the probe finds neither file and passes on the active toolchain alone, which is the captured run, whose only context key is cargo_version. Read the msrv and pinned_channel keys to know whether a comparison happened; the unparseable-output branch attaches neither help nor context.
2. oci
Resolves a registry from CHIO_GUARD_REGISTRY, then CHIO_GUARD_REGISTRY_REFERENCE, then a registry: line in chio.yaml. An empty variable counts as unset. The file fallback is a line scan: the first line whose trimmed text starts with registry:, at any nesting depth. With nothing resolved it reports info.
Otherwise it sends one HEAD with a 5-second timeout, to the reference itself when it starts with http and to https://{registry}/v2/ otherwise, and accepts 2xx, 401, or 404. It sends no credential, which is why 401 counts. A pass establishes that the host resolves, terminates TLS, and answers. A reference with a repository path (ghcr.io/example/chio) gains /v2/ after the path, which is not the registry API root. Every failure is a warning.
The guard pull path reads its own variables, such as CHIO_GUARD_REGISTRY_PASSWORD in cli/types/runtime.rs, and registry is not a key in the runtime schema.
3. cosign
Resolves a bundle path from CHIO_GUARD_BUNDLE or, failing that, .chio/guard-bundle.sigstore.json under the working directory if it exists. A CHIO_GUARD_BUNDLE pointing at a missing file is a warning, Cannot read guard bundle {path}: {err}, under doctor-cosign-stale. Once a path resolves, the probe reads modified() with a created() fallback, converts the elapsed time to whole days, and warns above 90. Exactly 90 days passes.
The probe stats the file and reads its age; it checks no signature. When the elapsed time cannot be computed, from a future mtime, a clock moved backwards, or a filesystem with neither timestamp, age_days is 0 and the probe passes. Signature, identity, and inclusion checks run in chio-attest-verify’s verify_bundle, reached through chio attest and the bundle checks on chio bind; Guard Supply Chain covers them at load time.
4. otel
Reads CHIO_OTEL_RECEIPT_EXPORTER_ENDPOINT, then OTEL_EXPORTER_OTLP_ENDPOINT, records which won in a source context key, and parses the value as a URL, prefixing http:// when it carries no http:// or https:// scheme. Surrounding whitespace is rejected, which catches a pasted trailing space. An empty value counts as unset and reports info.
It sends no span and opens no socket, so --skip-network leaves it unchanged; its parameter is named _config. Anything url::Url::parse accepts passes, so a typo that still parses passes here. Both variables are doctor-only; a node’s OTLP destination is telemetry.endpoint in the runtime chio.yaml schema.
5. kernel_runtime
With CHIO_KERNEL_METRICS_URL unset, the probe reports info and names the variable. With it set, the probe sends one GET with a 5-second timeout and substring-matches the body for chio_kernel_dispatch_inflight, without reading the HTTP status. A 401, 404, or 500 whose body lacks the string, a connection failure, and an unreadable body all report the warning Metrics endpoint did not expose the chio_kernel_dispatch_inflight gauge.
The kernel’s metric families are served at /metrics on the chio api protect router and on the control plane. That route sits behind require_sidecar_control_middleware, the admin gate the approval routes use, and the probe sends no credential. handle_metrics composes the body from the guard families (chio_guard_verdict_total, chio_guard_deny_total, and the rest), the OTEL-drop, signing-queue-block, settlement-unresolved, and ambiguous-dispatch families, the receipt watchdog gauges, the HTTP mediation-edge families, and the alert pack. Node Observability lists them.
All four failure branches carry urn:chio:error:cli:doctor-otel-unresolved, which the registry scopes to the OTLP endpoint or kernel runtime metrics, so a code-based alert cannot tell probe 4 from probe 5. Split on the probe field.
6. chio_yaml
Reads ./chio.yaml, parses it with serde_yml, requires a top-level mapping, and requires the keys version and policy to be present. Values are not inspected, extra top-level keys are ignored, and the file policy names is not opened.
A parse failure carries the parser’s line and column into line and column, falling back to 0, 0; missing keys report line: 1, column: 1 and list the names in missing. Only ErrorKind::NotFound is info. Any other read failure, a directory at that path or a permission denial, is an error and exits 1.
The runtime loader chio_config::load_from_file parses a different contract under the same filename. ChioConfig knows kernel, adapters, edges, receipts, logging, telemetry, guards, and wasm_guards, and the eleven structs in chio-config/src/schema.rs carry deny_unknown_fields. Where a runtime configuration lives at ./chio.yaml, gate CI on the other five probes.
Probe contract and report fields
A probe has a stable lowercase name and one method that consumes a read-only config and returns one report. Probes must not panic: a failure comes back as a report carrying a registry code.
pub trait Probe {
/// Stable probe identifier. Must be lowercase ASCII, no spaces.
fn name(&self) -> &'static str;
/// Execute the probe.
fn run(&self, config: &ProbeConfig) -> ProbeReport;
}ProbeConfig has three fields, the same for every probe: skip_network from --skip-network or the environment, fix_enabled from --fix, and workdir, a test-only override the binary leaves as None, so every path-resolving probe roots at the working directory. DoctorRunner::standard fixes the probe set and order, DoctorArgs is three booleans, and the doctor never opens a receipt or authority database or reads a HealthFlag.
| Field | Type | Meaning |
|---|---|---|
probe | &'static str | Stable identifier. The six names are asserted in order by doctor_. |
severity | ProbeSeverity | Lowercased in JSON. Drives the aggregate. |
code | Option<&'static str> | A registry URN for a failing report. Serialized as null at ok and info; branch on severity. |
message | String | Human text, outside the stable contract. |
help | Option<String> | Remediation. Serialized as null when absent. |
context | Vec<ProbeContext> | Ordered string pairs, omitted when empty: cargo_version, msrv, pinned_channel, registry, bundle, age_days, endpoint, source, metrics_url, expected_gauge, path, missing, line, column, created. No key is guaranteed. |
repaired | bool | True only when --fix wrote something; one probe can set it. |
worst_severity_drives_exit_code and warnings_do_not_drive_non_zero_exit test the aggregate, and doctor_json_envelope_carries_schema_and_reports in tests/doctor_skeleton.rs tests the envelope. Each doctor environment variable, CHIO_GUARD_REGISTRY, CHIO_GUARD_REGISTRY_REFERENCE, CHIO_GUARD_BUNDLE, CHIO_KERNEL_METRICS_URL, and both OTLP variables, has the doctor as its only reader.
Envelope and rendering
--json, or the global --json or --format json, prints one compact line on stdout. cmd_doctor calls std::process::exit directly, so exit_code and the process status agree.
let payload = serde_json::json!({
"schema": "chio.doctor.v1",
"worst": run.worst,
"exit_code": run.exit_code(),
"reports": run.reports,
});Keys come out alphabetical: the workspace leaves serde_json’s preserve_order feature off, so serde_json::Map is a BTreeMap. Parse the payload. context is omitted on reports with none; help is null on passing reports.
The human renderer prints a leading chio doctor line, one [ok] probe (code): message line per report with indented help: and context lines, a repaired: yes line where it applies, and summary: worst severity = ..., exit code = .... Its warning marker is [warn], where JSON says warning.
| Registry entry | Severity | Stability | Registry summary |
|---|---|---|---|
urn:chio:error:cli:doctor- | error | unstable | chio doctor schema probe found chio.yaml validation errors. |
urn:chio:error:cli:doctor- | warning | unstable | chio doctor cosign freshness probe found a stale or unverifiable guard-bundle signature. |
urn:chio:error:cli:doctor- | warning | unstable | chio doctor OCI registry reachability probe could not reach the configured registry. |
urn:chio:error:cli:doctor- | warning | unstable | chio doctor OTEL probe could not resolve the OTLP endpoint or kernel runtime metrics. |
urn:chio:error:cli:doctor- | error | unstable | chio doctor reported one or more failing probes. |
urn:chio:error:cli:doctor- | error | unstable | chio doctor toolchain probe found a Rust toolchain mismatch. |
Repair behavior
One probe reads fix_enabled, in one branch: chio_yaml, on ErrorKind::NotFound. scaffold_missing writes with create_new(true), and if the file appeared between the read and the create, the AlreadyExists arm re-reads and validates it. On success the report is ok with repaired: true and a created: true context key.
The repair runs inline, inside the sixth probe, in place of the report it would have emitted. An existing chio.yaml with missing keys never consults the flag, and on a host where every probe passes the flag does nothing.
Network skip resolution
The environment check is presence-only (std::env::var(...).is_ok()), so an exported empty value counts. cmd_doctor folds both switches into ProbeConfig::skip_network before building the runner, and the two network probes re-check the variable, so a library caller passing skip_network: false still gets the skip. Only oci and kernel_runtime build an HTTP client, each with a 5-second timeout and an egress allowance comment marking it a diagnostic probe against a configured endpoint. tests/doctor_oci.rs and tests/doctor_otel.rs assert the skipped and unconfigured signals.