PlatformGuard Runtime
Node
Guard Supply Chain
Getting a signed guard module from a registry onto one node, and replacing it without dropping an evaluation already in flight.
The guest contract is next door
evaluate entry point, the host imports, the fuel meter, the memory cap, and the guard-manifest.yaml that binds a module to its Ed25519 signer. This page owns either side of that contract: the OCI artifact, the cache, the verification gate, the epoch swap, the rollback watchdog, and the digest blocklist.Two crates, three roots, one process
A guard is Kernel content: it returns a verdict on one mediated call, and running two nodes does not change what it decides (Core & Shell). Where its bytes came from is not. The cache, the blocklist, and the incident directory are plain files under one process’s home, and no delta stream replicates any of them, so every node fetches, verifies, and blocks guard artifacts on its own. That puts this page on the Node rung beside Node State on Disk, which covers the SQLite half.
The work splits across two crates. crates/guards/chio-guard-registry owns OCI transport, the cache, and the delegation to Sigstore. crates/guards/chio-wasm-guards owns the epoch swap, the watchdog, the incident writer, and the blocklist. Shipped status differs inside each of them, and the split falls at the cache write. chio guard publish and chio guard pull run from the CLI and are the only production callers in crates/; sign, verify, and blocklist remove run beside them. Everything after the cache write is library code with no production caller. load_guard_with_policy and hot_reload::Engine are constructed only by test binaries, and nothing in crates/ loads a cached artifact into a running kernel. Read the verification and reload sections as mechanisms that work and are tested, not as a running feature you can switch on.
The artifact and the two shapes of reference
A guard artifact is an OCI image manifest with artifactType application/vnd.chio.guard.v1+wasm, one JSON config blob, and exactly three layers in a fixed order.
| Position | Media type | Role annotation | Cache file |
|---|---|---|---|
| config | application/vnd.chio.guard.config.v1+json | n/a | config.json |
| layer 0 | application/vnd.chio.guard.wit.v1 | wit | wit.bin |
| layer 1 | application/vnd.chio.guard.module.v1+wasm | wasm | module.wasm |
| layer 2 | application/vnd.chio.guard.manifest.v1+json | manifest | guard-manifest.json |
The config blob carries six fields: a schema_version of chio.guard.config.v1, the pinned wit_world, an ed25519:<base64> signer key, fuel_limit, memory_limit_bytes, and an operator-supplied epoch_id_seed. The CLI defaults the two limits to 5000000 and 16777216 and requires --epoch-id-seed explicitly. None of it is enforced anywhere. GuardArtifactConfig is constructed at publish and never deserialized again: the pull path checks the config descriptor’s media type, digest, and size, then writes the bytes to config.json without parsing them. Runtime fuel comes from the wasm_guards entry in chio.yaml and the memory cap from a compile-time constant, and nothing reconciles either against the blob.
The two directions use mutually exclusive reference shapes, and both refusals happen while parsing the string:
| Direction | Accepted | Refused |
|---|---|---|
GuardPublishRef | oci://ghcr.io/acme/tool-gate:v1 | A digest (PublishReferencePinnedByDigest) or no tag (PublishReferenceMissingTag) |
GuardOciRef | oci://ghcr.io/acme/tool-gate@sha256:<64 hex> | Neither tag nor digest (MissingDigest), any tag at all, with or without a digest beside it (TaggedDigestReference), uppercase or short hex (InvalidSha256Digest) |
Both require the oci:// scheme and an explicit registry, meaning a first path component that is localhost or contains a dot or a colon. Transport is HTTPS unless a host is named in --allow-http-registry, and accept_invalid_certificates is hard-coded false. Referrer, blob, and bearer-token reads leave the process through an HttpEgressContract built per URL (Node Egress Contract), so a denied egress is a typed ReferrersEgress error rather than a socket failure. That contract is narrow on purpose: one scheme, one authority, link-local and IPv6 ULA denied, loopback denied unless the host is HTTP-allowlisted, at most three redirects, at most 16 MiB of response. The bulk manifest and layer pulls go through oci-distribution instead and are not covered by that contract.
The three on-disk roots:
| Root | Default path | Written by |
|---|---|---|
| Artifact cache | ${XDG_CACHE_HOME:-~/.cache}/chio/guards/<sha256:digest>/ | GuardCache::write_artifact |
| Digest blocklist | ${XDG_STATE_HOME:-~/.local/state}/chio/guards/blocklist.json | GuardDigestBlocklist::store |
| Reload incidents | ${XDG_STATE_HOME}/chio/incidents/<utc>-<guard_id>-<reload_seq>/ | IncidentWriter::write_reload_incident |
With no XDG variable and no HOME, the cache refuses with CacheRootUnavailable and chio guard pull exits. The blocklist does something else, covered in the callout at the end of this page. The incident root is whatever the caller passed into WatchdogConfig.
Publish builds an artifact; it does not sign one
GuardPublishArtifact::build performs no network I/O. It serializes the config, wraps the three layer payloads with their media types and org.chio.layer.role annotations, and stamps the manifest with the Chio artifact type plus the org.chio.guard.wit_world annotation and an optional org.chio.guard.signer_subject. Pushing is a separate call.
Before any of that, the CLI runs a preflight: it parses guard-manifest.yaml, rejects any wit_world other than chio:guard/guard@0.2.0, requires wasm_sha256 to be 64 lowercase hex characters, and refuses to publish when the digest recomputed from the module on disk disagrees with the declared one. An absent wit_world is not a rejection: the field is optional and defaults to the pinned world. That digest comparison is the only integrity check publish performs.
No Sigstore signature is produced here. Publish does require a signer key, from --signer-public-key or signer_public_key in the manifest, and normalizes bare hex into ed25519:<base64>, but that key is recorded and never checked against anything. --signer-subject is a manifest annotation and nothing more. The Ed25519 sidecar comes from chio guard sign, which writes a .wasm.sig envelope beside the module; it is not one of the three layers and never enters the artifact. A Sigstore bundle is attached out of band as an OCI referrer by whatever signs your releases.
Pull refuses before it writes
pull_guard_to_cache runs in a fixed order, and every step is a refusal point.
- Fetch the raw manifest and compare the registry-reported digest against the pinned one. A mismatch is
ManifestDigestMismatch. - Pull the three layers and compare the digest again, this time the one the image pull reported. The same check runs twice against two independently reported values, though the second run is conditional: if the client returns no digest for the pulled image, that check is skipped rather than failed.
- Resolve Sigstore bundle bytes. A caller-supplied bundle wins; the only other source is OCI 1.1 referrer discovery, filtered by the Sigstore bundle artifact type and sorted by digest so the pick is deterministic. The referrer manifest must declare a subject equal to the pinned digest, and the blob is checked for media type, size, and digest before it is returned. An index that lists no Sigstore referrer yields no bundle. A registry that answers
/referrerswith 404 is a hard failure: discovery runs on every pull that did not supply--sigstore-bundle, so a registry without the OCI 1.1 referrers API fails the whole pull withReferrersStatus, Sigstore policy or not. - Verify, if a policy was supplied. A verifier without an expected identity, or the reverse, is
InvalidClientConfig. Both, with no bundle found anywhere, isSigstoreBundleNotFound, and a test pins that the verifier is never invoked on that path. - Admit to cache.
validate_cache_admissionrecomputes the manifest digest over the manifest bytes, requires the OCI image manifest media type and the Chio artifact type, requires exactly three layers, and checks media type, digest, and byte size for the config and each payload.
Layers are located by media type, not by position, and a media type appearing twice is rejected rather than resolved to the first match (descriptor_for_layer). The role annotations written at publish time are never read back. Treat them as human-facing provenance, not a load-bearing field.
Pulling without a Sigstore policy succeeds and reports sigstore_verified: false. If a bundle was discovered it is still cached, unverified. The cache entry is therefore not evidence of verification, which is why the load gate re-verifies rather than trusting the entry’s existence. Cache writes are plain fs::write calls under the process umask with no temp-file rename, so a crash mid-write leaves a partial entry that the next load rejects on digest rather than repairing.
Rekor inclusion is required, and is currently never true
Verification is delegated. GuardSigstoreVerifier is a thin wrapper that forwards bytes to the shared AttestVerifier trait in chio-attest-verify and maps every failure into a distinct typed error, from VerifySignatureMismatch and VerifyWrongOidcIssuer through to a catch-all VerifyFailedClosed. Three verification kinds exist, and the admission rule differs by kind.
pub fn admits_guard_load(&self) -> bool {
match self.kind {
GuardVerificationKind::Ed25519Only => true,
GuardVerificationKind::SigstoreOnly | GuardVerificationKind::DualVerified => {
self.rekor_inclusion_verified == Some(true)
}
}
}Now read where that boolean comes from.
pub(super) fn bundle_rekor_inclusion_verified(_bundle: &Bundle) -> bool {
false
}Sigstore-gated admission denies today, by construction
rekor_inclusion_verified is hard-coded false on every Sigstore path, because sigstore-rs validates transparency-entry consistency without checking Merkle inclusion or the Signed Entry Timestamp, and Chio declines to claim a proof it did not check. The consequence is direct: chio guard pull --sigstore-identity-regex ... --sigstore-oidc-issuer ... fails with VerifyMissingRekorProof, and load_guard_with_policy in SigstoreOnly or DualVerified mode denies. This is a deliberate fail-closed position, not a bug, and offline_sigstore_load_denies_unverified_rekor_inclusion pins it.The deny arrives by two routes with two reasons, which matters when you read a log. An admission API (verify_bundle, verify_cached_layout_report) raises VerifyMissingRekorProof inside the closure, so the gate sees an error and records signature-verification-failed with no identity attached. The report-only APIs return successfully with rekor_inclusion_verified: Some(false), so the gate re-checks admits_guard_load itself and records rekor-inclusion-unverified along with the identity and subject digest the bundle carried. Both deny. Report-only exists so an operator can read that metadata without the API pretending to be an admission decision, and routing it into the gate does not launder it.
Ed25519-only is a different loader, not a fallback here
admits_guard_load returns true for Ed25519Only, and the cache layout has no slot for a .wasm.sig file, so nothing in this cache can satisfy it. Ed25519 sidecar verification lives in chio-wasm-guards::manifest and reads a .wasm plus .wasm.sig pair beside a local guard-manifest.yaml, never this cache. Ed25519 is the local-file loader; a registry-pulled artifact is admitted by the Sigstore path or not at all.Dual mode reconciles the two signing modes rather than accepting either. verify_dual_mode invokes both closures before inspecting either result, then requires Rekor inclusion, then the artifact digests to agree, then the normalized identities. Digest and identity disagreements are distinct VerifyFailedClosed messages, and the test asserts both verifiers ran before the denial.
Every decision carries one structured GuardLoadEvent named chio.guard.verify, attached to the returned value or hung off the error, with result, verification kind, source (network or offline-cache), the pinned digest, a stable reason on a deny, and identity plus subject digest when verification got far enough to have them. The crate builds the event and returns it; it never logs it, so emitting it is the caller’s job. The gate does no network I/O: online callers fetch into cache first, so an OnlineCacheIncomplete deny means the fetch did not finish, not that the registry was unreachable. Before verification runs, the gate re-reads the five artifact files and revalidates them against the pinned manifest, so an entry tampered with after admission is denied as cache-integrity-failed without the verifier being called. The cached Sigstore bundle is not in that set: it has no descriptor in the OCI manifest, so the gate only checks that the file exists, and only in the two Sigstore modes.
The replacement is built before the pointer moves
A guard registered with the engine holds its module behind an ArcSwap<LoadedModule>. Every evaluation takes one load_full() snapshot at the top and uses it for the whole call. The reload path never touches that snapshot. It compiles the replacement first, and only then stores a new pointer.
let module_sha256 = hex::encode(Sha256::digest(new_module_bytes));
self.ensure_digest_not_blocklisted(&module_sha256)?;
let backend = self
.backend_factory
.build_backend_for_guard(guard_id, new_module_bytes)
.map_err(|source| HotReloadError::ReplacementLoad {
guard_id: guard_id.to_string(),
source,
})?;
let epoch_id = guard
.replace_loaded_module(backend, Some(module_sha256))
.ok_or_else(|| HotReloadError::EpochCounterExhausted {
guard_id: guard_id.to_string(),
})?;Three properties fall out of that ordering. A blocklisted digest is refused before a compiler runs. A module that fails to compile leaves the live epoch untouched. And there is no window in which the guard has no module: the swap is a single store of an already-built value under a reload mutex. The blocklist check is not unconditional, though: Engine::new installs the default blocklist and without_blocklist() removes it outright, which several reload tests do.
crates/guards/chio-wasm-guards/src/hot_reload.rs:683-724at fe56570hot_reload_under_100rps_drops_no_requests drives that on a live tokio runtime: 100 requests at roughly 100 per second across four worker threads, with a reload landing partway through. All 100 complete, and sorted by dispatch index every request before the boundary used the old epoch and every one after it used the new one. The backend under test is a byte-keyed test double rather than a compiled wasm module, so the test proves the swap discipline, not wasmtime behavior under load.
Epochs are monotonic and never reused: the counter is a u64 reserved through fetch_update, and exhausting it returns EpochCounterExhausted rather than wrapping. Evidence from the last completed evaluation survives a swap, so a receipt collected across a reload stays attributed to the module that produced the verdict.
Four ways in, with different guarantees
| Entry point | Blocklist | Canary | Backends built | Rollback available |
|---|---|---|---|---|
reload | Yes | No | 1 | No. The previous module is dropped. |
reload_with_canary | Yes | Yes, 32 frozen fixtures | 2 | No |
reload_with_watchdog | Yes | No | 1 | Yes. Returns a ReloadWatchdog holding the previous module. |
reload_debounced | Yes | No | 1 | No |
Canary verification is not a dry run against the module that will serve. reload_with_canary builds one backend, replays every fixture in CANARY_FIXTURE_COUNT through it, discards it, and builds a second from the same bytes to publish, so the published module has never evaluated anything. The corpus is itself content-addressed: a MANIFEST.sha256 must name exactly the set of .json files present, each fixture digest must match its manifest line, names must be single non-traversing components, and the count must be exactly 32. Comparison is over serialized verdict bytes, so a changed deny reason fails the canary just as a flipped decision does.
Debouncing is per guard. The first request claims the slot and sleeps; requests arriving during the window overwrite the pending bytes and return None. When the window closes the executor publishes the latest bytes, not the ones it was called with: registry_poll_burst_does_not_double_swap fires 100 requests into one window and asserts a single applied reload at sequence 1 carrying the digest of the last submission. A Drop guard releases the in-flight flag when the executor future is cancelled, but it is best-effort: it takes try_lock and returns silently if the lock is held, so a cancellation racing another submission can still leave in_flight set.
Triggers and reloads are separate. The engine can watch a path and can poll a registry digest, but both only send a ReloadTrigger down an mpsc channel; wiring a trigger to a reload is the caller’s job. The file watcher is a non-recursive PollWatcher on a 100ms interval with content comparison enabled, not an inotify subscription, and it ignores read-only access events. It sends with try_send and discards the result, so a full channel drops the trigger without a log line. The registry poll task uses the awaiting send and exits with TriggerReceiverClosed when nobody is listening.
The watchdog rolls back to a module it already holds
reload_with_watchdog keeps the previous Arc<LoadedModule> alive. The default policy trips after five error-class verdicts inside a 60-second sliding window, and any recorded success clears the streak. On trip it calls restore_loaded_module with the module it already holds, so rollback involves no compilation and cannot itself fail. The rolled-back counter increments immediately after the restore, deliberately before the incident write, so an I/O failure while writing evidence cannot hide a real rollback from alerting. If that write then fails, record_error returns IncidentWrite while the rollback stands. Both behaviors have their own tests. A watchdog fires once: after it has rolled back, record_error returns Ok(None) forever, and re-arming means taking a new watchdog from the next reload.
The watchdog is a passive recorder. Nothing calls record_error automatically: the evaluation path in WasmGuard::evaluate denies on a trap or a fuel exhaustion and emits metrics, but does not notify a watchdog. Whoever holds the ReloadWatchdog has to feed it, and a guard marked advisory allows on those same errors, so a bad advisory module produces no denials for anyone to forward.
What the incident record does and does not contain
A rollback writes one directory named <utc-iso8601>-<guard_id>-<reload_seq> holding incident.json (guard id, reload sequence, rolled-back epoch, and a reason naming the error count and the window) plus last_5_eval_traces.ndjson. Each trace is an EvalTrace of exactly three fields: request_id, verdict_class (for example trap or fuel_exhausted), and a detail string. The shape has no room for call arguments, and the trace vector is trimmed to its last five entries on every push. A recorded success clears the traces along with the error streak, so an incident can contain fewer than five lines if a success intervened.
Redaction is a construction-site rule, not a type-level one
detail field is a plain String whose doc comment says request arguments must not be stored in it. Nothing enforces that: a caller that formats a request into EvalTrace::new will have it written verbatim. The test that asserts no secret appears in a written incident uses a fixture that never had one. Treat the three-field shape as the guarantee and the field contents as the caller’s responsibility. Incident directories are also created with the process umask rather than an explicit private mode, unlike the 0600 discipline the SQLite stores apply.One file, two digest domains
The blocklist is a JSON file holding a sorted set of sha256:<64 lower hex> strings, and it outranks every other check: on reload it is tested before the backend factory runs, and on pull before the registry client is even constructed. A hit is E_GUARD_DIGEST_BLOCKLISTED.
The two paths block different things. chio guard pull tests the OCI manifest digest from the reference; Engine::reload tests the SHA-256 of the wasm module bytes. normalize_digest accepts both sha256:<hex> and bare hex, so both land in the same set with no namespace between them. Blocking a withdrawn release therefore takes two entries, and neither implies the other. engine_reload_denies_blocklisted_digest pins the reload round trip: block, watch the reload fail while the guard stays at EpochId::INITIAL, unblock, watch the same bytes publish as epoch 1.
There is no add verb, and the fallback root is the temp directory
chio guard blocklist ships one subcommand, remove. Nothing outside the tests calls add_digest, so populating the list today means writing blocklist.json yourself. Writes are a plain read-modify-write with fs::write: no lock, no temp-file rename, no fsync, so two concurrent editors can lose an entry. And when neither XDG_STATE_HOME nor HOME is set, default_blocklist swallows the StateRootUnavailable error and falls back to <temp_dir>/chio-guard-state/chio/guards/blocklist.json, which is empty. A process running without a home directory silently reads an empty blocklist rather than refusing to start. The CLI does not share that fallback: it calls from_environment directly and exits on the error.The module is hot, the configuration is not
No guard configuration reloads in place. No SIGHUP handler exists anywhere in crates/, and the signal wiring that does exist is for shutdown: crates/protocol/chio-http-serve/src/signal.rs installs Ctrl-C and SignalKind::terminate() and drains on either. One live-reconfiguration signal exists in another subsystem: crates/trust/chio-tee installs a SIGUSR1 handler that reads ${CHIO_TEE_RUNTIME_DIR}/mode-request and swaps the TEE runner mode in place. Guards have no equivalent.
The chio.yaml schema for WASM guards is WasmGuardEntry in crates/platform/chio-config/src/schema.rs, under the wasm_guards key, and it has five fields: name, path, fuel_limit (default 10,000,000), priority (default 1000), and advisory. There is no memory field: the guest memory cap is MAX_MEMORY_BYTES, a 16 MiB constant in chio-wasm-guards/src/host.rs, overridable only in code through WasmtimeBackend::with_limits. The similarly named WasmGuardConfig in chio-wasm-guards/src/config.rs is not the config type: it has no reference outside its own unit tests. Every one of these is read once at startup, so changing any of them is a restart. See Node Lifecycle for what a restart costs and Health & Readiness for how a restarting node reports itself.
What a reload replaces is narrow: the module bytes behind an already-registered guard id. Name, advisory flag, and pipeline position belong to the WasmGuard handed over at registration, and limits come from whatever the ReloadBackendFactory closure captured when the engine was built. An unregistered guard cannot be reloaded into existence: GuardNotFound returns before anything else runs. Adding a guard, removing one, or retuning its limits is a restart.
The freshness probe is a stat, not a verification
Nothing on this page is exercised by chio doctor. Its cosign probe stats a bundle file and compares the mtime against a 90-day limit; it opens nothing and calls no verifier, so a clean result is a statement about a timestamp and not about a signature. The probe’s resolution order, its severity mapping, and the module doc it contradicts are in Node Preflight.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | A pull is refused before any byte reaches the cache when the manifest digest, the artifact type, the layer count, or any descriptor media type, digest, or size disagrees with the pinned manifest. | pull.rs, cache.rs::validate_cache_admission; rejects_descriptor_mismatch_before_cache_write, rejects_manifest_with_wrong_top_level_artifact_type |
| Shipped | A Sigstore bundle discovered through OCI referrers must name the pinned artifact as its subject and match its blob descriptor, or the pull fails. | sigstore_bundle_descriptor_from_manifest; rejects_sigstore_referrer_manifest_for_wrong_subject |
| Proved by test | An evaluation in flight when a reload lands completes on the epoch it snapshotted. At 100 requests per second across four threads, no request is dropped and no request straddles two epochs. | hot_reload_under_100rps_drops_no_requests, engine_reload_publishes_replacement_module_epoch_atomically |
| Proved by test | A replacement that fails to compile, or whose digest is blocklisted, leaves the live epoch and its verdicts unchanged. | engine_reload_failure_leaves_current_epoch_unchanged, engine_reload_denies_blocklisted_digest |
| Proved by test | A rollback increments chio_guard_reload_total{guard_id,outcome="rolled_back"} even when the incident write fails. | rollback_counts_even_when_incident_write_fails |
| Limit | Sigstore-mode admission always denies. rekor_inclusion_verified is hard-coded false, and admits_guard_load requires true for SigstoreOnly and DualVerified. The deny reason is signature-verification-failed through an admission API and rekor-inclusion-unverified through a report-only one. | bundle_verify.rs::bundle_rekor_inclusion_verified; offline_sigstore_load_denies_unverified_rekor_inclusion, offline_policy_denies_report_only_sigstore_without_rekor_inclusion |
| Limit | A pull fails outright against a registry with no OCI 1.1 referrers API unless the caller supplies a bundle, because referrer discovery runs on every pull and treats 404 as an error. | oci.rs RegistryNotFoundBehavior::Error; test rejects_registry_without_referrers_api |
| Limit | A cached artifact is not evidence of verification. A pull without a Sigstore policy caches a discovered bundle unverified, which is why the load gate re-verifies rather than trusting cache presence. | pull_without_sigstore_policy_does_not_claim_verification |
| Limit | The blocklist keys manifest digests and module digests into one unnamespaced set, and blocking one does not block the other. | guard/publish.rs (manifest digest) versus hot_reload.rs (module digest); normalize_digest |
| Not wired | The hot-reload engine has no production caller. hot_reload::Engine, CanaryCorpus, ReloadWatchdog, and IncidentWriter are constructed only by the crate’s own test binaries. | No non-test reference in crates/; the CLI imports only abi, blocklist, manifest, error, and runtime::wasmtime_backend |
| Not wired | The load gate has no production caller either. load_guard_with_policy is invoked only by tests, no code builds an Ed25519Only report outside tests, and nothing reads a cached artifact into a running kernel. | chio guard pull is the only production caller of the registry crate, and it stops at pull_guard_to_cache |
| Not wired | The wasm_guards block in chio.yaml is parsed and never consumed. The running pipeline is built from GuardPolicyConfig and contains no WasmGuard. | load_wasm_guards has no caller outside chio-wasm-guards; docs/guards/05-V1-DECISION.md still lists the wiring as open |
| Not wired | The watchdog observes nothing on its own. The evaluation path denies on a trap or fuel exhaustion without notifying any watchdog, so rollback requires a caller feeding record_error. | WasmGuard::evaluate; no non-test record_error caller |
| Not wired | The org.chio.layer.role annotations are written at publish and never read at pull. Layer resolution is by media type. | descriptor_for_layer; the only non-publish reference is publish_layer_order.rs |
| Unsupported | Hot guard-configuration reload. No SIGHUP handler exists anywhere in crates/. Adding, removing, or retuning a guard is a process restart. | Zero sighup or hangup matches in any .rs file; the only live-reconfiguration signal in the tree is chio-tee’s SIGUSR1 mode toggle, which does not touch guards |
| Unsupported | Cluster-wide guard distribution. Cache, blocklist, and incidents are per-node files, and no delta stream replicates them. | peer_pullers in cluster/deltas.rs covers budgets, tool receipts, child receipts, and lineage, plus revocations on their own round. No guard lane exists. |
| Unsupported | Verified end-to-end publish, pull, and load against a real registry in CI. The zot round trip exists but is #[ignore]d behind a Docker daemon and stubs the verifier. | zot_publish_pull_verify_and_offline_paths |
Next Steps
- Custom WASM Guards · the guest ABI, the fuel meter, and the manifest this page moves around
- Node State on Disk · the SQLite half, with a much stricter open discipline
- Node Lifecycle · what a restart costs, given that config changes need one
- Fail-Closed Semantics · the rule every refusal above is an instance of
- Node Egress Contract · the contract every referrer, blob, and token read on this page is built against
- Node Preflight · the six probes
chio doctorruns, one of which only stats a bundle - Replication & Convergence · what crosses node boundaries, and why guard artifacts do not