Chio/Docs

PlatformProcess Model

Node

Node Overview

One OS process hosting one kernel: what it owns alone, what it hands a cluster, and why standalone is the default.


The other side of the core boundary

Core and Shell documents what a kernel evaluation refuses to do. This page is the process standing on the other side of that refusal. The refusal is written down in one place, as a module doc comment on the file that performs the evaluation:

crates/kernel/chio-kernel-core/src/evaluate.rsrust
//! What it does NOT do (fenced into `chio-kernel` proper):
//!
//! - Revocation membership lookup (stateful `RevocationStore`).
//! - Budget mutation (stateful `BudgetStore`).

Six more items follow, and Core and Shell reads the whole list out. Every line on it needs a handle the function does not have: a store, a socket, a clock, a lock file. The node holds those handles.

The kernel decides whether one governed action is permitted; the node decides whether this process is still fit to decide at all, and answers that by reading its own disk. That is the filing test for this rung. Two excluded items leave the ladder rather than land here: budget mutation is priced and metered work belonging to Economy, and governed-transaction evaluation can require a co-signature under a key the operator does not hold, which climbs to Swarm.

rendering
One process holds the listener, the kernel, and the stores. The serving-owner fence exists only on the session-database configuration, and a cluster reaches the node only through its five obligations.
sourcecrates/platform/chio-control-plane/src/trust_control/service_runtime/init.rs:40-202at fe56570

What a node is

Five objects carry the whole rung, each with one canonical name in the code.

ObjectWhat it isWhere it lives
NodeOne OS process hosting one kernel, plus the stores, listener, and locks the kernel's excluded work needs.A chio trust serve or chio mcp serve-http process.
Serving ownerThe single writer identity for one session database, held by advisory lock on a private lock file.SqliteAuthorityStore::open_serving
Store mutation fence{ store_uuid, lease_id, owner_epoch }, stamped into every mutation this process makes against that store.crates/core/chio-core-types/src/store_fence.rs
Serving leaseA row spanning start_head_index to end_head_index, opened on boot and closed by the next boot.chio_serving_leases
Cluster runtime stateAn Option, where None is ordinary rather than a fault.build_cluster_state

The fence is what makes a node singular

A second process attempting to serve the same session database fails before it can write anything. Node State on Disk works the fence through store by store; the shape of it is this. open_serving canonicalizes the database path, requires both the lock root and the database's parent directory to be owned by the effective user and not group or world writable, then takes an advisory lock on the per-store lock file; a lock held elsewhere maps WouldBlock to SqliteServingOwnerError::AlreadyServing. The test for this spawns a real second process rather than a second handle: concurrent_process_open_is_fenced_before_serving.

Exclusivity alone is not enough, because a lock can be released and reacquired. So the epoch is monotonic. Every successful open increments owner_epoch, closes the previous lease at the current authority head, and inserts a new lease row starting there. A superseded handle then fails closed on budget writes with BudgetStoreError::Fenced and on revocation access, including reads such as is_revoked, with RevocationStoreError::Fenced. The lease history lets the node answer, from its own disk and nothing else, which lease authored a given commit: verify_historical_revocation_commit joins chio_serving_leases against admission_authority_commits and requires the commit index to fall inside that lease's open range.

The fence covers one configuration, not every node

The serving owner exists only on the session-database path, wired from the global --session-db flag (joint_authority_db_path in TrustServiceConfig). A node configured instead with separate --budget-db and --revocation-db paths gets no serving owner and no lock at all: SqliteBudgetStore::open and SqliteRevocationStore::open construct with serving_owner: None, so two processes can hold the same file. Both refuse to open a database that already carries a serving-owner table, which keeps the two shapes from being mixed on one file, and config.validate() rejects configuring both at once. For chio run and chio mcp serve-http the fenced shape is the default: the policy default for durable_admission_mode is side_effecting, which requires a session database, and setting it to off needs both allow_unsafe_durable_admission_off and an ephemeral receipt log.

Boot, in order

serve_async in crates/platform/chio-control-plane/src/trust_control/service_runtime/init.rs is the reference sequence. Each step returns an error that aborts the boot.

  1. Validate the config. config.validate() rejects a service token that is empty, whitespace-padded, or carries control characters; a zero cluster sync interval; a zero certification metadata TTL; a tenant read token equal to the service token; a session database configured alongside --budget-db or --revocation-db; a session database configured alongside peers; and any two configured databases that resolve to the same file.
  2. Claim the disk, if a session database was given. The lock root is a .locks sibling of that database, created at mode 0700. provision establishes or re-verifies the store identity, then open_serving takes the lock and advances the epoch. With no session database this step does not run and the process has no serving lease.
  3. Bind the listener. The TCP bind happens before the remaining stores open, because build_cluster_state needs the resolved local_addr to default the advertise URL.
  4. Open the separate stores. Budget and revocation stores open only when their own paths are configured, which is the branch a session database excludes. A node with no budget database still serves; it has no budget authority to offer.
  5. Decide whether it is clustered. build_cluster_state(&config, local_addr) returns Option, and the sync loop is spawned only on Some.
  6. Install, wrap, and serve. ShutdownController::install fans one signal handler out over a watch channel, apply_server_hygiene wraps the router, MaxConnListener caps accepted connections, and run_until_drained bounds the post-signal drain.

One more step, on the protocol-edge shapes only

A bare trust-control process never validates a manifest. A protocol edge adds that step to the sequence above, and does not exist until every manifest it was handed passes chio_manifest::validate_manifest. The check runs inside the constructor, so a bad manifest returns an error instead of a serving edge. ChioMcpEdge::new routes through build_exposed_tool_bindings, which validates each manifest and then rejects a tool name that appears in two of them with ManifestError::DuplicateToolName. Validation is structural: schema identifier, identity fields, unique tool names, JSON-object schemas, pricing completeness, duplicate-free permissions. It authenticates nothing. The fullest of those edge shapes, with Streamable HTTP, bearer and sender-constrained admission, and one kernel per session, is Remote MCP Edge.

Cross-manifest collisions are rejected on MCP only

ChioAcpEdge::new and ChioA2aEdge::new run the same validate_manifest loop over every manifest, so a malformed manifest fails all three edges. A tool name claimed by two manifests does not. ACP marks the colliding capability BridgeFidelity::Unsupported, drops it from the bindings, and continues; A2A counts the duplicate, flags the skill as ambiguous, and continues. Only the MCP edge refuses to construct.

Structural validation is not signature verification

Signature verification lives in chio_manifest::verify_manifest, which checks a SignedManifest against a known public key and requires the embedded public_key field to match the signer. It has no caller anywhere in the Chio repo outside chio-manifest's own tests. The FFI entry point chio_verify_manifest_json does not reach it either: it calls chio_binding_helpers::verify_signed_manifest_json, a separate implementation that returns a ManifestVerification report of four booleans rather than failing closed. If you need signed-manifest admission, verify before constructing the edge and check the booleans yourself.

Stopping is part of the contract

chio-http-serve exists so every Chio serve site converges on one implementation of three obligations: notice the stop signal, stop accepting and drain inside a bounded window, and refuse abusive load with an explicit denial rather than unbounded growth. Node Lifecycle is the whole of that path. Its defaults are a 25 second drain, a 20 second per-request timeout set below the drain so a late-admitted request reaches its own 408 rather than being severed, 1024 concurrent requests with load shedding to 503, and 2048 accepted connections. An oversized body is a 413, and the body cap defaults to None so a site with a large route-local upload limit is not clobbered by a global one.

Trust-control changes two of those fields and says why. It sets the 1 MiB body cap the default leaves unset, and disables the per-request timeout, because a clustered budget authorize parks in the quorum wait; a blanket timeout firing after the local exposure write but before that wait returned would drop the handler before its rollback branch, leaving a charged, leader-visible write the client saw fail. The post-drain join of the cluster sync loop then gets only what remains of the same drain window, never a fresh wait on top of it.

What a forced drain does not bound

A drain that hits its deadline is logged and reported as DrainOutcome::Forced, and the serve future is dropped to release the accept loop. It does not stop the straggler: axum runs each connection on a detached task that a bounded drain can neither join nor cancel, so a handler can still be finishing after the process reports a forced drain. Trust-control also passes a no-op flush hook, since its handlers write budget and revocation state synchronously, so ServeError::Flush is a guarantee the crate offers rather than one this process exercises.

The standalone node is the default

A single node is not a cluster missing its peers. It is the shape the code treats as ordinary, and on the one dimension where the two differ measurably, it is the stronger of the two.

crates/platform/chio-control-plane/src/trust_control/cluster/consensus.rsrust
pub(crate) fn build_cluster_state(
    config: &TrustServiceConfig,
    local_addr: SocketAddr,
) -> Result<Option<Arc<Mutex<ClusterRuntimeState>>>, CliError> {
    config.validate()?;
    // ... rejects peers configured alongside --authority-seed-file
    if config.peer_urls.is_empty() {
        return Ok(None);
    }

The return type is the whole argument. No peers means Ok(None), and so does a peer list that normalizes down to nothing once the node's own advertise URL is filtered out of it. In init.rs the sync task is spawned through state.cluster.is_some().then(...), so an unclustered node runs no replication loop, opens no peer connections, and has no background work to fail. The health response reports clustered as exactly that boolean; what else it reports, and what a tripped level costs, is Health & Readiness.

FieldDetail
StatusShipped.
ClaimAn unclustered node carries the stronger budget guarantee level, and two commit paths accept nothing weaker.
SubjectBudget and revocation commits on one SQLite session store.
Evidencebudget_authority_guarantee_level returns single_node_atomic when state.cluster is None, advisory_posthoc otherwise. AdmissionCombinedCaptureCommit::validate and verify_historical_revocation_commit reject anything but SingleNodeAtomic.
LimitOne node is one failure domain. Availability is a supervisor, a restart, and a backup, not a quorum. See Backup & Restore.

The same asymmetry runs through the write path. wait_for_budget_write_quorum_commit returns Ok(None) immediately when the node has no cluster progress handle: no quorum to wait for, so no quorum wait to time out and 503. The release runbook states the same ceiling, setting single-node atomic on one SQLite store against a clustered mode that admits a documented overrun bound and is not distributed-linearizable. ADR-0006 puts a number on that bound, max_cost_per_invocation * node_count, exercised by the concurrent_charge_overrun_bound test in budget_store.

In trust-control, the fence and the cluster are mutually exclusive

config.validate() rejects a session database configured alongside any peer with "a local joint authority database cannot run with the legacy cluster coordinator", and optional_budget_store answers 503 with the same refusal if the pair is ever reached at runtime. A clustered trust-control node therefore has no serving owner, no serving lease, and no cross-process write fence; a fenced one has no peers. Everything below describes the clustered configuration, which is a different process shape from the one the fence protects.

Five things a node hands up

Adding peers does not change what a node is. It adds exactly five obligations the node supplies and the cluster consumes. What the cluster does with them belongs to Trust Control Plane.

ObligationWhat the node suppliesRefused when
Advertise URL--advertise-url, defaulting to http:// plus the bound local address. Normalized once, at boot, into self_url.The scheme is not http or https, or the URL carries credentials, a query string, or a fragment. Without --allow-local-peer-urls, which defaults off, a loopback or private host is rejected too.
Peer setRepeated --peer-url values, normalized the same way, with the node's own URL filtered out.Peers are configured alongside --authority-seed-file: clustering requires the SQLite --authority-db, which is the capability-authority store, not the session database.
Replication cursorstool_seq, child_seq, lineage_seq, budget_seq, and a revocation cursor of (revoked_at, capability_id). An unconfigured store publishes a zero, or null for the revocation cursor, rather than an error.A peer page that fails to advance past the cursor, or is not contiguous from the expected next seq, is a wire-contract violation and demotes the peer to Unhealthy.
Fenced authority leaseClusterAuthorityLeaseView: a lease ID of {leader_url}#term-{epoch}, the election term, and an expiry. The stored TTL is three sync intervals clamped to 500 ms through 5 s, so the 500 ms default interval yields 1.5 s; consensus then truncates it to whole seconds with a 1 s floor when computing expiry and contact freshness.current_budget_event_authority answers 503 when the lease is unavailable, which includes having no leader for want of quorum, and again when it expired before the write could start.
Budget write quorum acksPer-origin contiguous ack heads, published as budget_ack_heads and consumed as witnesses for another node's write.The ack head for that exact origin has not reached the write's event_seq, or the peer is unreachable, partitioned, or pending a forced snapshot.

The lease is where the cluster's shared authority is pinned to a term. The election term is persisted in the capability-authority store, and on boot build_cluster_state reads it only when --authority-db is configured, and adopts it only when the recorded generation and rotation timestamp still match the live authority. A rotated authority makes that fence stale: the node logs the discard and starts from term zero rather than carrying a lease across a key change. A persisted leader URL is kept only if it still normalizes and still names either this node or a configured peer.


If you reach Chio through an SDK

Consuming Chio through the TypeScript, Python, or Go SDK makes you a client of someone else's node: every entry point takes that node's base URL, and the serving lock, the epoch, the drain window, and the advertise URL are the operator's obligation, not yours. One part travels with you. The invariant helpers for canonical JSON, hashing, signing, receipts, capabilities, and manifests are local computation and need no node, which is what lets a client verify evidence without trusting the process that handed it over.


Guarantees and limits

StatusClaimEvidence
ShippedOne process at a time serves a session database, and a superseded process cannot write. Scoped to that configuration: separate budget and revocation databases have no such lock.serving_owner.rs, with a cross-process test and an epoch-fencing test; SqliteBudgetStore::open sets serving_owner: None.
ShippedStop is bounded and logged: a forced drain is reported as DrainOutcome::Forced, and a failed post-drain flush is a non-zero exit at any site that supplies a real flush hook.DrainOutcome and ServeError::Flush; trust-control passes a no-op hook.
ShippedEvery protocol edge refuses to construct on a malformed manifest. Only the MCP edge refuses on a tool name claimed by two manifests.build_exposed_tool_bindings; chio-manifest/src/validation.rs; BridgeFidelity::Unsupported on the ACP edge.
BoundedClustered budget writes are advisory_posthoc, overrun-bounded by max_cost_per_invocation * node_count, and not distributed-linearizable.budget_authority_guarantee_level; docs/adr/ADR-0006-monetary-budget-semantics.md; docs/release/OPERATIONS_RUNBOOK.md.
Not wiredManifest signature verification anywhere. The primitive ships; nothing in the repo calls it outside its own tests.chio_manifest::verify_manifest; the FFI path uses a separate boolean-report helper.
UnsupportedA single trust-control process that is both cross-process fenced and clustered. The two configurations exclude each other.config.validate(); optional_budget_store.
Not claimedConsensus. With quorum, leadership is the lexicographically first URL among this node and its healthy, unpartitioned, freshly contacted peers; without quorum there is no leader and the role is candidate.compute_cluster_consensus_locked sorts candidates and takes the first.

Next Steps

  • Node State on Disk · the serving owner, the single-writer rule, and the refusals before every write
  • Node Lifecycle · the stop signal, the bounded drain, and the numbers a platform recipe must satisfy
  • Remote MCP Edge · the fullest single-process shape: Streamable HTTP, three bearer modes, one kernel per session
  • Health & Readiness · what one process answers about itself, and why a tripped level never self-heals
  • Budget Store · authorize, capture, release, reconcile as one process performs them
  • Revocation Store · the revoked set on this disk, and the gate that refuses to dispatch without one
  • Core and Shell · the Kernel half of this boundary
  • Trust Control Plane · the next rung, where the five obligations are consumed