PlatformLifecycle & Health
Node
Node Upgrade
Nine ordered steps from a qualified candidate to a smoke-checked process, and the stamp that makes going backwards fail loudly.
What this page owns
An upgrade is a stop, a swap, and a start
There is no in-place upgrade in Chio. No process reloads a binary, no command migrates state ahead of a restart, and nothing coordinates a version change across a cluster. The upgrade procedure in docs/release/OPERATIONS_RUNBOOK.md is nine numbered steps that stop the processes, replace the file on disk, and start them again in a fixed order. Every schema change rides the first writable open of each store after the swap.
Read the ceiling before planning a window. The runbook’s Bounded Operational Profile states trust-control as local or leader-local single-writer truth with deterministic leader selection and eventual repair, explicitly not consensus-backed HA. Nothing in that profile supports a rolling upgrade in which two binary versions serve the same state concurrently, and no test drives one. Plan a window in which the node is down.
docs/release/OPERATIONS_RUNBOOK.md:328-356at fe56570| Step | What it does | Reversible? |
|---|---|---|
| 1. Qualify | ./scripts/qualify-release.sh on the candidate commit. | Yes. Nothing on the host has changed. |
| 2. Build or obtain | The exact candidate binary set, not a rebuild of the same tag. | Yes. |
| 3. Back up | Every SQLite store and every file-backed registry and policy. | This step is what makes 6 and 7 reversible. |
| 4. Stop write traffic | Drain external callers before the process sees SIGTERM. | Yes. |
| 5. Stop the processes | SIGTERM, then wait out the bounded drain. | Yes. |
| 6. Swap the binary | Replace /usr/local/bin/chio with the qualified candidate. | Yes, by itself. |
| 7. Start trust-control, then edges | The first writable open of each store runs its migrations and re-stamps it. | No. This is the ratchet. See rollback. |
| 8. Smoke | Two trust-control routes, two edge routes, plus cluster status. | Read-only. |
| 9. SDK checks | Only when SDK packages ship with the same release. | Read-only. |
Why a downgrade is safe to attempt
The reason step 7 is worth attempting in reverse is that a Chio store written by a newer binary does not open at all under an older one. It is refused, by name, before a single write touches the file. The gate is check_schema_version, and it runs on every store’s open path.
#[error(
"database schema version {found} is newer than this binary supports ({supported}); refusing to open"
)]
FutureSchema { found: i32, supported: i32 },The module header states the intent directly: a foreign, misdirected, or future database is refused before any write, so a rollback to an older binary or a mistargeted path is caught at open rather than after data has been commingled or a newer schema misread. Two properties make that a usable operator guarantee rather than a hope.
- A refusal never mutates the file.
future_schema_is_refused_without_mutationasserts the recorded revision is unchanged after a refused open. A table-driven case sweeps every binary revision 0 through 3 against every on-disk revision 0 through 5 and asserts the same thing at every point where the disk is ahead, and asserts that an accepted open never downgrades the stamp either. - The check runs before the pragmas. The receipt store’s open path calls the gate ahead of
configure_sqlite_connectionwith a comment saying why: a foreign or future database must be refused before any write touches its header, so a mistargeted path is never mutated into WAL mode.
The revision is per store, not per file. The database-wide PRAGMA user_version cannot represent independent revisions for stores the sidecar co-locates in one file, so each store records its own under a stable key in chio_store_schema_versions. These are the ones an operator backs up and restores.
| Store | Flag | Key | Supported revision | Anchor tables |
|---|---|---|---|---|
| Receipt | --receipt-db | receipt | 3 | chio_tool_receipts, http_receipts, tool_receipts |
| Revocation | --revocation-db | revocation | 2 | revoked_capabilities |
| Budget | --budget-db | budget | 10 | capability_grant_budgets |
| Capability authority | --authority-db | authority | 1 | authority_state |
| Approval | Co-located on the sidecar file | approval | 2 | chio_hitl_pending |
| Edge session tombstones | --session-db on chio mcp serve-http | None | Unstamped | None |
The revisions move independently, which is the whole point of the keyed table. co_located_stores_track_independent_schema_versions bumps a receipt revision inside a file an approval store also occupies, asserts PRAGMA user_version stayed 0, and reopens both stores at their own revisions. A release that touches nothing a given store persists leaves that store’s revision alone, and a downgrade across such a release works.
The stamp is not a downgrade path, it is a downgrade alarm
stamp_schema_version(&migration, RECEIPT_STORE_SCHEMA_KEY, RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION) in the same transaction as its last column migration; the revocation and budget stores stamp the same way, immediately after initialize_revocation_schema and the budget schema batch. So the moment a newer chio trust serve has opened a store once, that store is at the newer revision and the previous binary refuses it. There is no unstamp command, no --force-schema flag, and nothing in the tree writes a lower revision than the one it supports. The stamp turns a silent misread into a startup refusal. Getting back to the old binary still means restoring the backup, which is exactly what the runbook’s rollback procedure says.Not everything on disk is stamped
--session-db on chio mcp serve-http is the exception that matters. Its opener is open_session_state_db: a bare Connection::open followed by two CREATE TABLE IF NOT EXISTS statements. No application_id is written, no revision is recorded, and nothing in chio-mcp-remote calls check_schema_version at all. Its actual downgrade behavior is a row-level drop: load_active_session_records parses each row into RemoteSessionResumeRecord and logs dropping malformed persisted MCP session row for anything it cannot read, keeping the rest. Sessions written by a newer edge are lost quietly on a downgrade rather than refused loudly. The same applies to the file-backed registries, the policy files, and the kernel seed file: none of them carries a version stamp.Steps 1 and 2: qualify the candidate
Run the production qualification lane from the repo root, on the candidate commit, before anything touches a host.
./scripts/qualify-release.shThat script opens with ./scripts/ci-workspace.sh, the fast regression gate, then runs the provider replay conformance tests and calls ./scripts/qualify-trust-control.sh, qualify-portable-browser.sh, qualify-mobile-kernel.sh, and the four release checks. It requires node and python3 on PATH and exits 1 without either. Everything it produces lands under target/release-qualification/ with a SHA256SUMS and an artifact-manifest.json.
For the ship-facing bounded gate specifically, the runbook names two commands:
cargo xtask qualify bounded-chio
./scripts/qualify-trust-control.shqualify-trust-control.sh is seven named checks, each one a single test invoked with --nocapture and tee’d to its own log: node-identity, quorum-heal, stale-leader-fencing, denied-event-replication, replay-and-failover, duplicate-event-rejection, and stale-lease-rejection. The logs are the evidence, not the exit code. The first five are chio-cli cluster drills, and each one probes a loopback bind first and returns green without running when the environment denies it; the last two are chio-store-sqlite tests with no such probe. Read the drills for what each of the five exercises.
Deploy-time smoke checks are a separate, shorter list, and they are the ones worth running on the machine that builds the package:
./scripts/check-release-inputs.sh
./scripts/check-dashboard-release.sh
./scripts/check-chio-ts-release.sh
./scripts/check-chio-py-release.sh
./scripts/check-chio-go-release.shcheck-release-inputs.sh walks git ls-files and fails if any generated or cached path is tracked: __pycache__, the Python build and egg-info trees, the TypeScript dist and node_modules, the dashboard’s dist and node_modules, and the generated conformance results and reports. It then pins release evidence keys and a readiness-package SHA-256 against releases.toml. It says nothing about the binary.
Local qualification is not promotion evidence
CI and Release Qualification workflow results are still required before an external tag or publication, and the handoff list names the five conformance reports plus target/release-qualification/logs/trust-cluster-repeat-run.log. Step 2 says to build or obtain the exact candidate binary set, and the second half of that is the safer half: rebuilding the same tag on a different host produces a binary nothing qualified.Step 3: back up every store
Stop write traffic or open the maintenance window before this step, not at step 4. Step 3 delegates to the runbook’s backup procedure, and that procedure opens with the precondition: stop write traffic or place the service in a maintenance window before taking authoritative backups. Every durable store opens in WAL mode, so a plain cp of the .sqlite3 file is not a backup. Use SQLite’s own backup, which is what the runbook does:
sqlite3 /var/lib/chio/receipts.sqlite3 ".backup '/var/backups/chio/receipts.sqlite3'"
sqlite3 /var/lib/chio/revocations.sqlite3 ".backup '/var/backups/chio/revocations.sqlite3'"
sqlite3 /var/lib/chio/authority.sqlite3 ".backup '/var/backups/chio/authority.sqlite3'"
sqlite3 /var/lib/chio/budgets.sqlite3 ".backup '/var/backups/chio/budgets.sqlite3'"
sqlite3 /var/lib/chio/verifier-challenges.sqlite3 ".backup '/var/backups/chio/verifier-challenges.sqlite3'"
sqlite3 /var/lib/chio/edge-sessions.sqlite3 ".backup '/var/backups/chio/edge-sessions.sqlite3'"Then the file-backed inputs, which no SQLite command covers and no schema stamp protects:
cp /etc/chio/enterprise-providers.json /var/backups/chio/
cp /etc/chio/verifier-policies.json /var/backups/chio/
cp /etc/chio/certifications.json /var/backups/chio/
cp /etc/chio/*.yaml /var/backups/chio/Record the binary version and git commit used for the snapshot. That pairing is the only thing that tells a later operator which binary the backed-up stamps correspond to, and the rollback procedure needs both halves. chio --version answers the first half from the binary itself: Cli carries #[command(version, about)], so clap renders the crate version.
A receipt store is worth checking before it is copied, not after. Run the deep audit with the process stopped, or on a copy:
chio receipt health --receipt-db /var/lib/chio/receipts.sqlite3
chio receipt checkpoint status --receipt-db /var/lib/chio/receipts.sqlite3
chio receipt audit --receipt-db /var/lib/chio/receipts.sqlite3What a clean store looks like on the second and third of those, against a database holding two committed entries and no checkpoint yet. Read status: healthy and the pending range; a store you can copy is one that reports its own uncheckpointed tail:
$ chio receipt checkpoint status --receipt-db ./chio.db
status: healthy
committed_entry_seq: 2
checkpoint_seq: none
checkpointed_entry_seq: 0
next_range: 1..=2
retention_watermark_entry_seq: none
$ chio receipt audit --receipt-db ./chio.db
status: healthy
committed_entry_seq: 2
checkpoint_seq: none
checkpointed_entry_seq: 0
next_range: 1..=1
retention_watermark_entry_seq: noneThe three do genuinely different amounts of work. health samples a SQLITE_OPEN_READ_ONLY connection through receipt_store_health_read_only, which never creates the file and never opens a writer pool. checkpoint status and audit go through open_existing, which does run the schema gate. All three exit non-zero when the report is not healthy, and all three refuse --control-url with requires local --receipt-db; remote receipt operator operations are not supported in this release. Run them on the node that owns the database.
chio receipt audit --repair is an offline operation
--repair opens a local throwaway store and revalidates and reseeds the on-disk state on that connection. The CLI runs in a separate process from a live kernel and cannot reach that kernel’s in-memory writer head, so it does not clear a live poisoned writer. The command says so itself on stderr, via offline_repair_notice. If you need it, run it in the window between step 5 and step 7, with every process stopped, and let the restart seed a clean verified head from the validated on-disk state.Steps 4 and 5: stop traffic, then stop the process
Stopping write traffic and stopping the process are two steps because the drain is bounded. Every service installs a SIGTERM handler and drains in-flight requests before it exits, capped by DEFAULT_DRAIN_TIMEOUT, 25 seconds. Anything still running at 25 seconds is abandoned.
/// Wall-clock ceiling on the drain: how long to wait for in-flight requests to
/// finish after the listener stops accepting. Operators must size the unit
/// `TimeoutStopSec` at least this high plus a flush margin.
pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(25);
/// Per-request processing ceiling before the request is denied with 408.
///
/// This stays strictly below [`DEFAULT_DRAIN_TIMEOUT`] so a request admitted just
/// before a stop signal reaches its own 408 and completes cleanly within the
/// drain window ...
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);The 20 second request ceiling is not universal, and trust-control is the exception. serve_async builds its hygiene config with request_timeout: None on purpose: a clustered budget authorize parks in a rollback-aware quorum wait that is bounded on its own and can legitimately outrun any single request ceiling, and a blanket timeout firing after the local exposure write but before that wait returns would leave a charged write the client only saw fail. The consequence for an upgrade is that a trust-control request can occupy the full drain window rather than self-terminating at 20 seconds, which is another reason step 4 comes before step 5.
Under a supervisor, the shipped units are already sized for it. Both docs/release/systemd/chio-trust-control.service and chio-mcp-edge.service set KillSignal=SIGTERM and TimeoutStopSec=35s, the drain deadline plus a flush margin, so systemd escalates to SIGKILL only after the drain can finish. The deploy contract in the runbook is to set the platform stop grace at least as high: Cloud Run timeoutSeconds, ECS stopTimeout, or Kubernetes terminationGracePeriodSeconds at 35 seconds or more.
The edge unit adds KillMode=mixed, and the reason is specific to the wrapped command after --. The upstream tool server lives in the same control group; under systemd’s default control-group kill mode it would take SIGTERM at the same instant the edge began draining, tearing the upstream out from under in-flight MCP calls. mixed signals the main process first and escalates to the rest of the group only after TimeoutStopSec. If you supervise the edge with something other than systemd, reproduce that behavior or expect the last 25 seconds of calls to fail against a dead upstream.
On trust-control there is a second consumer of the same budget. The cluster sync loop watches the same shutdown signal, and after the HTTP drain returns the process joins that loop within whatever remains of the drain window rather than starting a fresh wait. cluster_join_budget is drain_timeout.saturating_sub(elapsed_since_signal), and cluster_join_never_extends_teardown_past_the_drain_window asserts the sum stays inside one 25 second window at every sample point. A stop grace sized for the drain is therefore sized for the whole teardown. Outbound peer sync abandoned at the deadline is best-effort catch-up that resumes on the next boot.
Step 6: swap the binary
Replace the binary while nothing is running. The shipped units both point ExecStart at /usr/local/bin/chio, and the MCP edge unit points at a separate /usr/local/bin/chio-mcp-upstream for the wrapped server. Both paths are outside ReadWritePaths, which is set to the state directory alone, so a running service cannot rewrite its own binary and the swap has to happen from outside the unit.
Nothing in the swap changes arguments. Step 4 of the restore procedure and step 7 of the upgrade procedure both say to restart with the same command line the process had before, and the reason is that several flags are load-bearing invariants rather than preferences. TrustServiceConfig::validate refuses a joint authority database alongside --budget-db or --revocation-db (a joint authority database replaces separate budget and revocation databases), refuses one alongside --peer-url, and runs validate_distinct_database_paths over all six configured paths. It opens no database, so every one of those failures is a fast argument error and none of them tells you anything about the state on disk.
--session-db means two different things
chio mcp serve-http it is the remote session tombstone database, the unstamped one. On chio trust serve the same flag is threaded into joint_authority_db_path, which goes through SqliteAuthorityStore::provision and open_serving: a provisioned, lock-anchored, schema-checked store that validates nine store keys at provision time. Copying a command line between the two subcommands during an upgrade is how a tombstone database ends up where a joint authority database was expected.Step 7: trust-control first, edges second
The order is not a preference. The onboarding runbook states it as a precondition (chio trust serve starts first; chio mcp serve-http starts after trust-control readiness) and the code makes it a hard dependency whenever the edge carries --control-url: RemoteSessionFactory::new constructs a DurableAdmissionRuntime::open_remote against that URL, requires --control-token, and requires --session-db for the identity path. That construction happens during startup, before the router is built, so an edge pointed at a trust-control that has not come back is a failed start, not a degraded one.
systemctl start chio-trust-control.service
# confirm /health and /v1/authority answer before continuing
systemctl start chio-mcp-edge.serviceWhat each process opens at startup differs, and it decides where a schema refusal shows up.
| Process | Opened during startup | Opened after startup |
|---|---|---|
chio trust serve | Joint authority store (when --session-db is set), budget store, revocation store | Receipt store and capability authority store, opened inside handlers |
chio mcp serve-http | Policy file, durable admission runtime, session tombstone database, and one kernel per persisted active session restored at boot, each of which opens the receipt, revocation, budget, and authority stores | Receipt, revocation, budget, and authority stores again, reopened for every new session by spawn_session |
The edge’s startup row is the one operators misread. Session restore is not a lazy path: serve_http_async loads every persisted active session and calls restore_session on each, and that builds a kernel and opens all four stores. A restore that fails does not fail the boot. It logs dropping persisted MCP session record that could not be restored and deletes the record, so a store the edge binary refuses costs you the persisted sessions rather than the process.
A future-schema budget or revocation database therefore kills chio trust serve outright, and the error text is worth knowing because it is what appears in the journal. The store error is wrapped once by the runtime: failed to open trust-control budget store: ... and failed to open trust-control revocation store: ..., each carrying the database schema version N is newer than this binary supports (M) text inside it.
A future-schema receipt database does not stop a start
serve_async binds the listener and builds the router without ever opening the receipt store; every SqliteReceiptStore::open in the control plane sits inside a request handler or the cluster sync loop, not in the startup path. So on trust-control a receipt store the binary refuses produces a process that starts, binds, answers /health with "ok": true, and fails the first request that needs a receipt. That is the case the smoke checks below have to catch, and /health alone does not catch it.Step 8: the smoke checks, and what each one proves
The runbook’s post-upgrade set is four curls. It swaps the /v1/authority call used at first deployment and after a restore for /v1/internal/cluster/status, which is the one substitution worth undoing: keep both.
curl -s http://127.0.0.1:8940/health | jq
curl -s -H "Authorization: Bearer $CHIO_SERVICE_TOKEN" \
http://127.0.0.1:8940/v1/internal/cluster/status | jq
curl -s -H "Authorization: Bearer $CHIO_ADMIN_TOKEN" \
http://127.0.0.1:8931/admin/health | jq
curl -s -H "Authorization: Bearer $CHIO_ADMIN_TOKEN" \
http://127.0.0.1:8931/admin/sessions | jqThe third curl is reproduced from the runbook, and a bare service-token bearer does not satisfy it. handle_internal_cluster_status calls validate_cluster_peer_auth before anything else, which requires x-chio-cluster-node-id, x-chio-cluster-auth-issued-at, and x-chio-cluster-auth-signature, a caller already listed in --peer-url, and a signature that matches the one derived from the service token. Anything else is 401 missing or invalid cluster peer authentication or 403 cluster peer is not in the configured allowlist. Past that gate, a node with no replication configured answers 404 cluster replication is not configured. On a single node, run the other three checks and the receipt commands below.
$ curl -si -H "Authorization: Bearer $CHIO_TRUST_SERVICE_TOKEN" \
http://127.0.0.1:8940/v1/internal/cluster/status
HTTP/1.1 401 Unauthorized
content-type: application/json
www-authenticate: chio.cluster.peer.v1
content-security-policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:
content-length: 58
{"error":"missing or invalid cluster peer authentication"}The substitution to undo, in full. Both of these answer to the service token, and the second is the one that surfaces a refused authority store:
$ curl -s http://127.0.0.1:8940/health | jq '{ok, clustered, stores}'
{
"ok": true,
"clustered": false,
"stores": {
"budgetsConfigured": true,
"receiptsConfigured": true,
"revocationsConfigured": true,
"verifierChallengesConfigured": false
}
}
$ curl -s -H "Authorization: Bearer $CHIO_TRUST_SERVICE_TOKEN" \
http://127.0.0.1:8940/v1/authority | jq
{
"configured": true,
"backend": "sqlite",
"publicKey": "8d7ddbdce2368deb5143de554fb574fea652fe3e5610acdb2b1be110083fe485",
"generation": 1,
"rotatedAt": 1785603453,
"appliesToFutureSessionsOnly": true,
"trustedPublicKeys": [
"8d7ddbdce2368deb5143de554fb574fea652fe3e5610acdb2b1be110083fe485"
]
}Read each one for what it actually opens, because the coverage is uneven.
| Check | What it opens | How a refused store shows |
|---|---|---|
GET /health | The capability authority database, through load_authority_status. Nothing else. | authority.available: false with a null publicKey and generation. ok stays true. |
GET /v1/authority | The same open, unswallowed. | 500 carrying the store error string. This is the sharper of the two. |
GET /v1/internal/cluster/status | The receipt store through a writable SqliteReceiptStore::open, plus the budget and revocation stores, all inside cluster_replication_heads, then in-memory peer state. | 500 carrying the store error. It is the only runbook curl that opens the receipt store, and the open it uses is the writable one that migrates and re-stamps. |
GET /admin/health | The edge’s authority database, or the control plane when --control-url is set. | 500 from load_authority_status, returned before the body is composed. |
GET /admin/sessions | The in-memory session ledger, after a reap pass. | Nothing. It counts sessions. |
The stores block on both health routes is the trap. On trust-control it is four booleans, receiptsConfigured, revocationsConfigured, budgetsConfigured, and verifierChallengesConfigured, computed as is_some() over the configured paths. On the edge it is six booleans, and two of them, revocationsConfigured and budgetsConfigured, go green on --control-url alone, with no local database behind them. Not one of them opens a file. A green stores block means the flags were passed, which the process could have told you before it started.
The one field on either route that identifies the binary is server.serverVersion on /admin/health. It defaults to env!("CARGO_PKG_VERSION") and is overridden only by an explicit --server-version. If your unit passes that flag, the field reports your string and proves nothing about the swap; drop the flag if you want the route to confirm which binary is serving.
Add the receipt checks. They are the only ones in the set that open the store the upgrade most likely migrated, and they run the schema gate on the way in:
chio receipt health --receipt-db /var/lib/chio/receipts.sqlite3 --json
chio receipt checkpoint status --receipt-db /var/lib/chio/receipts.sqlite3 --json
chio receipt checkpoint verify --receipt-db /var/lib/chio/receipts.sqlite3 --json$ chio receipt health --receipt-db ./chio.db --json
{
"report": {
"checkpointError": null,
"dbSizeBytes": null,
"healthy": true,
"latestCheckpointSeq": null,
"latestCheckpointedEntrySeq": 0,
"latestCommittedEntrySeq": 2,
"retentionError": null,
"retentionWatermarkEntrySeq": null,
"uncheckpointedEndSeq": 2,
"uncheckpointedStartSeq": 1,
"writer": {
"acceptedTotal": 0,
"committedTotal": 0,
"failedTotal": 0,
"firstAcceptUnixMs": null,
"inflight": 0,
"lastCommitUnixMs": null,
"lastError": null,
"queueDepth": 0,
"saturatedTotal": 0,
"timedOutInflight": 0,
"timedOutTotal": 0
},
"writerLevel": "healthy",
"writerLiveness": "unknown",
"writerRestartTotal": 0
},
"schema": "chio.cli.receipt.health.v1"
}Each emits a two-field envelope, schema and report, under a stable name: chio.cli.receipt.health.v1, chio.cli.receipt.checkpoint_status.v1, and chio.cli.receipt.checkpoint_verify.v1. checkpoint verify is a documented compatibility alias for chio receipt audit that never passes --repair and keeps emitting the legacy envelope name so existing automation does not break. Both exit non-zero when the chain or projection integrity fails, with the report’s checkpoint_error as the message.
chio receipt health cannot detect a downgrade
receipt_store_health_read_only opens a single SQLITE_OPEN_READ_ONLY connection and queries it directly. It calls check_schema_version nowhere, so a receipt database several revisions ahead of the binary reports a clean, healthy sample. That is deliberate for its original caller, a watchdog observing a database another process owns. It makes chio receipt health the wrong command for confirming a version match. checkpoint status, checkpoint verify, and audit all route through open_existing, which does run the gate, and which additionally refuses a store the binary is ahead of: receipt database schema version N requires writable migration to version M; reopen it with SqliteReceiptStore::open. Seeing that message after an upgrade means the service has not opened the store writably yet.Rollback is binary and state together
The runbook says it in one line: rollback is a full binary-and-state rollback to the last known good backup. Six steps, and the third is the one operators skip.
- Stop the candidate processes.
- Restore the previous binaries.
- Restore the backed-up SQLite and registry files if the candidate performed writes that must be discarded.
- Restart the previous version with the original arguments.
- Re-run the same health and admin smoke checks used in the upgrade.
- Record the failed candidate commit and attach the qualification logs and any cluster or admin diagnostics to the incident report.
Step 3 is conditional in the runbook and unconditional in practice whenever a store’s revision moved, because the candidate performed a write the moment it opened that store: the re-stamp. Try the binary-only rollback first if you like. It either works, meaning no store the old binary reads had its revision bumped, or it refuses at open with a message naming both numbers, and you have lost nothing but the restart. That is the whole value of the stamp during a rollback: the cheap attempt is safe, and its failure is unambiguous.
Restoring state has its own ordering. chio receipt audit --repair belongs here, between the restore and the restart, with every process stopped, because it cannot reach a live writer head. In a cluster, restoring one node’s stores is not the end of it: the restored node rejoins through the same full-snapshot path every repair uses, and the force-resync decision governs when that happens. A snapshot body over the 64 MiB peer response cap fails to decode on the receiving node, which is why the snapshot path range-encodes abandoned sequences and the delta path pages its bodies rather than emitting unbounded lists. If a resync does hit the cap, the node-local restore path is the way back.
Classify the failure before you roll back
What this procedure does not do
- It does not upgrade a cluster without downtime. One stream negotiates a contract between peers, the revocation stream, and a node at the current cursor version degrades to the tuple projection against an older peer rather than failing. Nothing else does: there is no read-only mode and no drain-to-follower step, and cluster convergence resumes on reachability alone once a node is back.
- It does not migrate anything ahead of time. There is no
chio migrate. Migrations run inside the first writable open of each store, in-process, on the startup path. The only way to rehearse one is to open a copy with the candidate binary. - It does not verify the binary. Nothing in steps 6 through 8 checks a signature, a digest, or a provenance attestation on the file that was installed. Guard Supply Chain covers what is verified, and it is guard bundles rather than the
chiobinary. - It does not protect unstamped state. The session tombstone database, the JSON registries, the policy YAML, and the kernel seed file all carry the version of whatever last wrote them and say nothing about it.
- It does not roll forward. A store that a newer binary has migrated cannot be handed back, and nothing writes a lower revision. Forward is a restart; backward is a restore.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | Every Chio operator store validates PRAGMA application_id, an anchor table, and a per-store revision before any write on the open path. | check_schema_version in crates/platform/chio-store-sqlite/src/schema_version.rs |
| Proved by test | A database whose revision exceeds what the binary supports is refused as FutureSchema with its recorded revision unchanged, before any migration or pragma write runs. | future_schema_is_refused_without_mutation; schema_version_monotonicity_across_binary_and_disk_versions sweeps binary 0 through 3 against disk 0 through 5 |
| Proved by test | Co-located stores in one file track independent revisions, so a bump to one never makes an unchanged sibling refuse the shared file. | co_located_stores_track_independent_schema_versions, asserting PRAGMA user_version stays 0 |
| Proved by test | A path mistargeted at a sibling store fails closed rather than commingling tables, which is what makes restoring backups into the wrong filenames survivable. | receipt_store_refuses_a_standalone_approval_database, receipt_store_refuses_a_stamped_budget_database, approval_store_refuses_a_standalone_revocation_database |
| Shipped | The drain is bounded at 25 seconds, the per-request timeout at 20, and the shipped units hold SIGKILL until 35. | DEFAULT_DRAIN_TIMEOUT and DEFAULT_REQUEST_TIMEOUT in chio-http-serve/src/hygiene.rs; TimeoutStopSec=35s in both units under docs/release/systemd/ |
| Proved by test | Trust-control’s post-drain cluster-loop join shares the one drain budget instead of adding a second wait, so a stop grace sized for the drain covers the whole teardown. | cluster_join_never_extends_teardown_past_the_drain_window in trust_control/service_runtime/init.rs |
| Shipped | A refused store stops chio trust serve only for the stores it opens during startup: joint authority, budget, and revocation. | serve_async in trust_control/service_runtime/init.rs; the receipt store has no startup open in that path |
| Limit | Migration is a one-way ratchet. Every store re-stamps to the binary’s supported revision at the end of its writable open, so one successful start makes the previous binary refuse that store. | stamp_schema_version calls at the tail of the receipt, revocation, and budget open paths; no writer of a lower revision exists in the tree |
| Limit | GET /health reports "ok": true as a literal and its stores block is four is_some() checks over configured paths. Neither says a store opened. | handle_health and trust_store_health_snapshot in trust_control/health.rs |
| Limit | chio receipt health cannot detect a version mismatch. Its read-only sampler never calls the schema gate. | receipt_store_health_read_only in chio-store-sqlite/src/receipt_store.rs |
| Limit | chio receipt audit --repair is offline. It cannot reseed a running kernel’s in-memory writer head, so it belongs in the stopped window, not after the restart. | offline_repair_notice in chio-cli/src/cli/trust/receipt/health.rs |
| Not wired | The edge session tombstone database has no stamp at all. A downgrade drops rows it cannot parse, one warning each, and keeps the rest. | open_session_state_db and load_active_session_records in chio-mcp-remote/src/remote_mcp/session_store.rs; no check_schema_version call anywhere in that crate |
| Unsupported | Remote receipt operator commands. Passing --control-url to chio receipt health, flush, audit, or the checkpoint commands is refused; they must run on the node owning the database. | local_receipt_db_path, which also requires the path to be an existing file |
| Unsupported | A rolling upgrade across a cluster. The bounded profile claims leader-local single-writer truth with eventual repair, not consensus-backed HA, and no drill runs two binary versions against one data set. | docs/release/OPERATIONS_RUNBOOK.md, Bounded Operational Profile; the seven checks in scripts/qualify-trust-control.sh all run one build |
| Not claimed | That a green qualification lane means the trust-control drills executed. The five cluster drills each probe a loopback bind first and return without running when the environment denies it, so read the per-check logs. | target/release-qualification/trust-control/logs/, written by run_check in scripts/qualify-trust-control.sh |
Next Steps
- Node Lifecycle · the drain this runbook waits on: the signal path, the deadline, and the refusals that stop a boot
- Node State on Disk · the stamp in full: anchor tables, adoption of unstamped files, and why WAL makes a file copy the wrong backup
- Backup & Restore · backup tiers, recovery objectives, and the checkpoint chain a restored receipt log rejoins
- Cluster Recovery · what a restarted node does to rejoin, and the resync that repairs it
- Health & Readiness · the full contract behind the two routes step 8 curls