Chio/Docs
LOGIN · JOIN

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.

Deployment chio@5f63e6e

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.

Run doctor
chio doctorChoose a probe.
networkcargog1toolchainerrorHEAD /v2/registryg2ociwarningbundleg3cosignwarningOTLP URLg4otelwarningGET /metricsmetrics URLg5kernel_runtimewarningchio.yamlg6chio_yamlerrormaxok, info, warningexit 0errorexit 1fatalexit 2worstnetworkcargog1toolchainfails as error/v2/registryg2ocifails as warningbundleg3cosignfails as warningOTLP URLg4otelfails as warning/metricsmetrics URLg5kernel_runtimefails as warningchio.yamlg6chio_yamlfails as errormaxexit 0ok, info, warningexit 1errorexit 2fatal

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.

The standard probe set in run order, with the input each reads and the worst severity it can report. Help text, context keys, and the one repair --fix performs are left out.

1. Run doctor and read every report

Run it with nothing configured to see the baseline:

doctor · unconfiguredtranscript
$ chio doctor
chio 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 = 0
exit 0

You 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".

SeverityMeaningExit
okPassed, or skipped with a resolved target0
infoNothing configured to check0
warningChecked and failed, non-blocking0
errorChecked and failed, blocking1
fatalReserved; no standard probe emits it2

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:

ci.shbash
set -o pipefail
chio doctor --json --skip-network | jq -e '[.reports[]
  | select(.probe == "toolchain" or .probe == "chio_yaml")
  | .severity] | all(. == "ok")'
Output
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:

doctor · jsontranscript
$ 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"
}
exit 0

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, with registry in context for oci, and metrics_url and expected_gauge for kernel_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.

chio.yaml (scaffolded by --fix)yaml
version: 1
policy: ./policy.yaml

You 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

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.

crates/products/chio-cli/src/doctor/probe.rsrust
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.

FieldTypeMeaning
probe
&'static str
Stable identifier. The six names are asserted in order by doctor_skeleton.rs.
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.

crates/products/chio-cli/src/cli/doctor.rsrust
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 entrySeverityStabilityRegistry summary
urn:chio:error:cli:doctor-chio-yaml-invalid
error
unstable
chio doctor schema probe found chio.yaml validation errors.
urn:chio:error:cli:doctor-cosign-stale
warning
unstable
chio doctor cosign freshness probe found a stale or unverifiable guard-bundle signature.
urn:chio:error:cli:doctor-oci-unreachable
warning
unstable
chio doctor OCI registry reachability probe could not reach the configured registry.
urn:chio:error:cli:doctor-otel-unresolved
warning
unstable
chio doctor OTEL probe could not resolve the OTLP endpoint or kernel runtime metrics.
urn:chio:error:cli:doctor-probe-failed
error
unstable
chio doctor reported one or more failing probes.
urn:chio:error:cli:doctor-toolchain-mismatch
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.

Next: start the service and check it running