Chio/Docs
LOGIN · JOIN

PlatformLifecycle & Health

Node

Health & Readiness

Liveness is process-only, readiness is dependency-aware, and a tripped health level never self-heals. What each route reports, and when a node denies.

One process answering about itself

This page covers what a single node determines by reading its own memory and its own disk: the flags its supervised workers raise, and the two routes a platform probe reads. What an operator can check on a host before any of this exists is Node Preflight. Degradation across many nodes under one authority is Cluster Overview.

Two questions, two routes

A platform probe asks two different questions and they have different correct answers. "Is this process alive?" is answered by GET /chio/live, which returns 200 while the process runs and consults nothing. "Can this process still serve honestly?" is answered by GET /chio/health, which reads runtime dependencies and returns 503 when they are broken.

Collapsing the two is a real failure mode, in both directions. A dependency-aware liveness probe recycles a container that is serving correctly through a transient blip. A process-only readiness probe keeps an instance in rotation after its receipt database stopped accepting writes, so it accepts traffic it can only deny. Both routes are fixed paths on the sidecar and are not configurable; the deployment detail lives in Sidecar HTTP Service.


The health flag

The routes report. The flags decide. HealthFlag, from crates/core/chio-supervisor, a leaf crate that depends on no other Chio crate, is the primitive a supervised worker carries, and it is what the kernel’s pre-dispatch gates read before admitting a mediated call. Readiness reads a dependency probe of its own, and does not read this flag at all; that gap is covered further down. The flag is a cloneable handle over shared atomics, so level and counter reads are lock-free and cheap enough to run on the pre-dispatch path; only the human-readable reason string sits behind a mutex.

FieldTypeMeaning
levelHealthLevelSeverity. Raised monotonically; see below.
tcb_criticalboolFixed at construction. Marks a worker whose degradation must fail evaluations closed.
consecutive_failuresu32Reset by a completed unit of work. Compared against trip_after and max_restarts.
restart_totalu64Cumulative count of recorded failures, including one that produced no restart at all, such as a thread that could not be spawned. Never reset, not even by an operator clear, so incident history survives recovery.
last_ok_unix_msu64Liveness stamp from the last completed unit of work. Zero renders as None.
last_transition_unix_msu64When the level last changed, in either direction.
reasonOption<String>Last recorded failure text. Behind a mutex whose only invariant is the single Option, so poison recovery cannot leave cross-field state half-mutated.

HealthFlag::snapshot() renders those fields as a serializable HealthSnapshot: camelCase field names, snake_case level values such as "degraded", optional fields #[serde(default)]. It is an operator payload, not a signed artifact. Nothing in the tree serializes the whole snapshot yet: the only reader is the SQLite receipt store, which lifts level and restart_total out of it into writerLevel and writerRestartTotal on its own health report.

Three levels, one downgrade

rendering
HealthLevel transitions. Every arrow away from Healthy follows a recorded failure; the only arrows back are an explicit operator clear.
sourcecrates/core/chio-supervisor/src/health.rs:10-125at fe56570
LevelReached whenSupervisor state
HealthyInitial state, or after an explicit clear.Serving normally.
DegradedConsecutive restart-worthy failures reach trip_after. With trip_after == 0, the first failure trips it.Restarts may still be running. The worker is not necessarily dead.
FailedThe restart budget is exhausted, or the thread could not be spawned at all.Terminal. The supervisor stopped respawning and leaves the flag set to be read.

The rule that makes the flag worth reading is that it never heals itself. A completed unit of work resets the consecutive-failure counter and stamps liveness, and deliberately does not lower the level:

crates/core/chio-supervisor/src/health.rsrust
/// Record a completed unit of work. Resets the consecutive-failure counter and
/// stamps liveness, but never lowers a tripped level.
pub fn record_ok(&self, now_ms: u64) {
    self.0.consecutive_failures.store(0, Ordering::SeqCst);
    self.0.last_ok_unix_ms.store(now_ms, Ordering::SeqCst);
}

A supervisor that restarted its worker but lost work in the gap must still report the gap. Raising is a compare-and-swap loop, so a concurrent escalation to Failed can never be lost behind a lower-severity raise, and clear is the only path back to Healthy. The unit suite pins each edge (record_ok_resets_consecutive_failures_without_lowering_level, raise_is_monotonic_and_ignores_lower_severity, clear_is_the_only_downgrade_and_preserves_restart_total), and a property test over random operation sequences asserts the level is non-decreasing between clears while restart_total equals the number of recorded failures. A nightly loom model covers one worker recording a failure against one concurrent reader and asserts the reader never observes a torn or invalid level. That is the whole model: it is a two-thread interleaving check, not a proof about the supervisor loop.


tcb_critical: denying at the door

tcb_critical is the difference between a flag that informs an operator and a flag that changes verdicts. It is a single predicate:

crates/core/chio-supervisor/src/health.rsrust
/// True when the surface must fail closed: TCB-critical and not
/// [`HealthLevel::Healthy`]. Read on the pre-dispatch path.
#[must_use]
pub fn is_serving_closed(&self) -> bool {
    self.0.tcb_critical && !matches!(self.level(), HealthLevel::Healthy)
}

There is no grace band. The instant the level leaves Healthy, a TCB-critical flag reports serving-closed, and the callers that read it deny before dispatch rather than after. Two flags do this today.

The lock-poison flag. The kernel holds one HealthFlag::new(true) for trusted state. When a lock guarding multi-field state is found poisoned, meaning a panic unwound while it was held, record_tcb_lock_poison records a failure with trip_after = 0, so the flag is degraded and serving-closed on the first occurrence. The pre-dispatch gate ensure_tcb_locks_healthy then returns a fail-closed deny for every subsequent evaluation. Every in-tree call site is a budget-registry lock, where continuing on half-mutated state would be silent monetary accounting corruption.

The receipt commit writer. The SQLite receipt store supervises its writer thread with tcb_critical: true and trip_after: 1, so any writer fault is immediately visible. ensure_receipt_persistence_ready consults it before dispatch. This ordering is the whole point: the kernel persists receipts after the tool executes, so a writer discovered dead afterwards means a side effect already happened with no durable evidence. Denying at the door means the tool never runs.

The writer flag is not the only closed condition

writer_serving_closed() is true when the supervised thread’s flag has left Healthy, when the store has no writer handle at all, or when the writer’s verified head is poisoned. The head starts poisoned: ReceiptCommitWriterHealth::default sets it that way and the actor clears it only after it seeds a head, so a still-attaching or corrupt store cannot admit one governed action before its first append could reject. Any store-wide append fault poisons it again, and every append is then rejected until an operator reseeds. The thread flag alone cannot see that, because the writer thread is alive.

Restart budget and backoff

SupervisorConfig carries the whole policy. Every field is public, and SupervisorConfig::new(name, tcb_critical) supplies a conventional default. Read the convention column as the crate’s documented starting point and the receipt commit writer column as what a running node uses: the writer, the one supervised worker in the tree, builds the struct field by field rather than calling ::new.

Field::new conventionReceipt commit writerEffect
trip_after31Consecutive restarts before the flag trips to Degraded.
max_restarts105Consecutive restarts before terminal Failed. The loop returns and stops respawning.
base_backoff100ms100msDelay before the first re-entry of the worker body.
max_backoff60s30sCeiling on the doubling.

The delay for the count-th consecutive restart is min(max_backoff, base_backoff * 2^(count - 1)), computed with checked_shl and saturating multiplication, so a large count saturates at the ceiling instead of overflowing.

Two details matter for reading the counters. First, a worker whose iteration completes returns SupervisedOutcome::Continue, which resets the consecutive counter; isolated faults separated by healthy work therefore never accumulate into a trip. Second, the workspace release profile sets panic = "unwind", so the catch_unwind restart path is live in release builds and not only under test profiles.

The crate also ships an async supervisor, SupervisedTask, behind an optional async cargo feature. No workspace crate enables that feature, so it is compiled into nothing that ships. Where it is enabled it runs each iteration in its own child task, because a panic across an .await point is not something catch_unwind captures cleanly.


What readiness actually checks

On the sidecar, readiness is one method on ProxyState:

crates/products/chio-api-protect/src/proxy/state.rsrust
pub(crate) async fn readiness_status(&self) -> SidecarStatus {
    if let Some(store) = &self.receipt_store {
        let store = store.lock().await;
        if !store.is_reachable() {
            return SidecarStatus::Unhealthy;
        }
    }
    SidecarStatus::Healthy
}

is_reachable is not a connection ping. A bare SELECT 1 still answers over dropped tables, a read-only mount, or a full disk, which would hold an instance in rotation while every append fails after an already-allowed upstream call. Instead it inserts into http_receipts and tool_receipts under the reserved primary key __chio_readiness_probe__ inside a transaction that is always rolled back, so all three fail readiness and no probe row is ever persisted. The test reachability_probe_touches_the_write_path_and_persists_nothing drops http_receipts out of band and asserts the store stops being reachable.

Readiness does not read the commit writer

The receipt_store field above is the sidecar’s own receipt-log connection over http_receipts and tool_receipts, a different object from the kernel’s durable receipt store that owns the supervised commit writer. Readiness never calls writer_serving_closed(), is_serving_closed(), or the writer liveness verdict. A tripped writer flag, a poisoned verified head, or a wedged writer therefore denies every mediated call fail-closed while /chio/health still answers 200. File-level faults do show up in both, because both connections sit on the same database.

The handler maps that status to a code: Healthy to 200, anything else to 503, alongside the version and the embedded kernel’s receipt_backend and revocation_backend. Liveness returns 200 with those two backend fields left empty, because a process-only answer does not inspect storage.

Both routes against a sidecar started with chio api protect and a durable receipt store. The difference is visible in the payload, not the status line:

curl /chio/health and /chio/livebash
$ curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:9090/chio/health
{"status":"healthy","version":"0.1.0","receipt_backend":"durable","revocation_backend":"durable"}
HTTP 200

$ curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:9090/chio/live
{"status":"healthy","version":"0.1.0","receipt_backend":"","revocation_backend":""}
HTTP 200
ProbeRouteReference manifests
Startup/chio/healthCloud Run service.yaml, Azure container-app.bicep
Readiness/chio/healthCloud Run, Azure
Liveness/chio/liveCloud Run, Azure, and the distroless sidecar image HEALTHCHECK
ECS container healthCheck/chio/healthdeploy/ecs/task-definition.json, curled with a 15s start period

Writer liveness verdicts

A dead worker is the easy case. The harder one is a worker that is alive and making no progress, which the supervisor flag alone cannot see because the thread never exited. The store classifies that case from its own in-memory writer counters, and the kernel gates on the verdict:

ReceiptWriterLivenessClassified whenGate admits?
DeadThe writer’s last recorded error contains the substring unavailable. The store writes sqlite receipt commit actor is unavailable when the commit-actor channel is found disconnected at enqueue.No
WedgedA caller-timed-out command is still owned by the actor, or a backlog exists and nothing has progressed for receipt_writer_stall_ms (default 10,000). The stall clock anchors to the later of the last commit and the current backlog’s start, so an idle writer is not judged wedged the instant fresh work arrives; with neither anchor set it is never classified stalled.No
SaturatedChannel occupancy has reached the commit-actor channel capacity.No
HealthyNone of the above.Yes
UnknownNo store installed, no async writer, or a read-only observer that cannot see the owning process.Yes

An opt-in watchdog polls this every receipt_writer_poll_ms (default 1,000) and publishes the verdict the gate reads. If no watchdog is running the published verdict stays Unknown, which is permissive, so the gate falls back to sampling the store inline on the pre-dispatch path. A host that never started the watchdog still fails closed on a wedged writer; it just pays the sample per evaluation.

chio receipt health reads the same counters out of a store on disk, which is how an operator sees the verdict without a running edge. On a store that has committed two entries and never checkpointed:

chio receipt health stdoutbash
$ chio --receipt-db ./chio.db receipt health
status: healthy
committed_entry_seq: 2
checkpoint_seq: none
checkpointed_entry_seq: 0
writer_level: healthy
writer_restart_total: 0
uncheckpointed_range: 1..=2
writer_accepted_total: 0
writer_committed_total: 0
writer_failed_total: 0
writer_timed_out_total: 0
writer_timed_out_inflight: 0
writer_saturated_total: 0
writer_inflight: 0
writer_last_commit_unix_ms: none
writer_last_error: none
db_size_bytes: none
retention_watermark_entry_seq: none

The writer counters all read zero here: this invocation opened the file and did not own the commit writer. The --json form separates the two readings that the human form collapses, carrying "writerLevel": "healthy" beside "writerLiveness": "unknown" under schema chio.cli.receipt.health.v1, which is the permissive Unknown row of the table above.


Guarantees and limits

StatusClaimEvidence
ShippedThe level is monotonic and only an explicit clear lowers it; restart_total survives the clear.crates/core/chio-supervisor/src/health.rs
Proved by testNon-decreasing severity between clears under random operation sequences; no torn level under one concurrent reader.tests/health_properties.rs, tests/loom_health.rs (loom is nightly-only, run behind --cfg loom)
ShippedA degraded TCB-critical flag denies before dispatch, so no tool executes without a durable receipt path.chio-kernel/src/kernel/construction.rs, evaluation/async_evaluation_core.rs, evaluation/nested_flow_evaluation.rs
ShippedReadiness exercises the sidecar receipt log’s write path and rolls it back; liveness consults nothing.chio-api-protect/src/proxy/state.rs, proxy/sidecar.rs
Not coveredReadiness does not read any HealthFlag. A tripped commit writer denies every mediated call while /chio/health still answers 200. Watch the kernel deny reason, not the probe, for that condition.readiness_status consults only SqliteReceiptStore::is_reachable in proxy/state.rs
Not wiredThe async supervisor SupervisedTask sits behind an optional async cargo feature that no workspace crate enables, and has no in-tree caller either way. Neither the cluster sync loop nor the SIEM export loop is wrapped in one, so neither carries a HealthFlag.chio-supervisor/src/task.rs behind #[cfg(feature = "async")]; no consumer outside the crate
Spec onlychio_task_health, chio_task_restart_total, chio_lock_poison_total and chio_cluster_sync_staleness_seconds are named in the design record with no emitter in the tree. No exporter reads a HealthFlag today.docs/architecture/reliability/RFC-0008-task-supervision-honest-health.md
UnsupportedNo CLI command or route calls HealthFlag::clear. Recovery from a tripped flag in a running process is a restart.No caller outside the crate’s own tests

Three more boundaries are easy to read past. Readiness consults the sidecar receipt log and nothing else: capability-authority reachability and policy load state are not part of the verdict, so a node can report ready with a dependency the probe does not model. SidecarStatus::Degraded exists in the type and maps to 503 in the handler, but no code path returns it, so readiness is binary in practice. And chio receipt health always opens the database read-only from outside the serving process. It rejects --control-url outright, and it cannot see the serving process’s in-memory supervisor, so it prints writer_level: healthy and serializes writerLiveness: "unknown" whatever that process is actually doing, alongside zeroed writer counters. Those are structural defaults, not observations, and the human renderer does not print the liveness field at all, so read it from --json if you need it. Read writer health from the serving node.

Claim: an unready node leaves rotation

FieldValue
StatusShipped, deployment-dependent.
ClaimA node whose sidecar receipt database can no longer accept a write returns 503 from /chio/health, and the platform pulls it from routing rather than sending it traffic it can only deny.
SubjectOne sidecar process with receipt_db set.
Evidencereadiness_status and sidecar_health_handler; readiness probes on /chio/health in the Cloud Run and Azure reference manifests, and the ECS container healthCheck.
LimitScoped to the write path, not to writer health: a degraded or wedged commit writer does not turn this probe red. Chio returns the status code; removing the instance is the orchestrator’s action, so it only happens where a readiness probe is actually configured, and a startup probe alone never re-checks after the gate opens. With no receipt_db configured there is no store to fail and readiness is always healthy.

Next Steps

  • Cluster Overview · per-subsystem degradation once more than one node answers under the same authority
  • Sidecar HTTP Service · the two routes in deployment context, the response body, and probe wiring
  • Node Observability · what to watch beyond the probe, and why a silent exporter looks like a quiet one
  • Failure & Recovery · what the guard runtime does when a dependency is the thing that broke
Health & Readiness · Chio Docs