Chio/Docs
LOGIN · JOIN

Retention & Archive

Keep a long-running node's receipt database bounded while every receipt it signed stays verifiable from the live store or its archive.

Concept chio@5f63e6e

On this page

Retention moves the oldest checkpoint-covered stretch of the receipt log into a separate archive file and records a watermark where the live log now begins. The live database stays bounded on a node that runs for years, and a receipt below the watermark verifies from the archive.

The kernel rotates on a timer. Each rotation archives one contiguous prefix of the receipt log, entries 1 through W, and from the first rotation on the archive is part of the node's evidence. Receipt Aggregation covers receipt streams across several nodes.

Retention watermarkChoose a stage.
live logarchivecutoffc1c2c3c4c5c6archivable: 1 to 12W = 12live logarchivecutoffc1c2c3c4c5c61 to 12W

W stops at the last batch that is old enough

A checkpoint batch qualifies only when every entry at or below its end has aged past the cutoff. Entries 1 to 13 have, so W lands on entry 12, the end of batch 4. Batch 5 straddles the cutoff and stays live whole. Retention & Archive.

One receipt log of eighteen entries in six checkpoint batches. Batch size, timestamps, and the open reconciliation are illustrative, and the default batch is 100 entries; lineage, authorization-consumption, and settlement-attempt holds pin W the way the reconciliation does.

The watermark advances to the last checkpoint batch that has fully aged

Archival is one monotone entry_seq watermark, W: the largest checkpoint batch_end_seq whose entire covered prefix qualifies. Checkpoints tile the log contiguously, so the archived range is always [1, W] and no checkpoint straddles it.

The age rule reads the whole prefix, so one non-monotonic timestamp cannot carry an unaged receipt into the archive. Four more holds keep a prefix live while live evidence still points into it:

  1. Authorization consumption. An authorization receipt sits inside the prefix and the consumer that spent it sits above.
  2. Receipt lineage. A lineage parent sits inside the prefix and its child sits above.
  3. Settlement and metered reconciliation. A receipt in the prefix has a reconciliation still open or retry_scheduled.
  4. Settlement attempts. A settle_attempts row joins a receipt in the prefix, on stores that carry that table.

Each hold reads the whole covered prefix, which trades liveness for safety. One receipt with an open reconciliation disqualifies every checkpoint whose batch_end_seq reaches it, and pins W at the boundary below for as long as the row stays nonterminal, or at zero when it sits in the first batch. A flat retention_watermark_entry_seq under a growing database is the symptom; once the row reaches a terminal state, the next rotation can advance.

Retention frees only what a checkpoint covers. A large checkpoint_batch_size at low traffic keeps entries live past retention_days while a batch fills, and if checkpoint signing fails the uncovered tail keeps growing. A tenant is not a prefix of the claim log, so retention is store-wide and a tenant_id is refused.

The kernel rotates on its own timer, through the single writer

RetentionMaintenanceHandle::spawn takes a dedicated Arc<dyn ReceiptStore> clone and one OS thread. The thread sleeps check_interval_secs (3600 by default) in 200 ms slices, calls ReceiptStore::rotate_receipts inside catch_unwind, records the outcome, and sleeps again. It sleeps before its first rotation, so a freshly started kernel never rotates at boot, and dropping the handle joins after at most one slice plus any rotation in progress.

The rotation itself runs on the writer actor. dispatch_rotate sends ReceiptCommitCommand::Rotate to the same commit actor that owns appends, so rotations serialize with appends through one queue. The send is a non-blocking try_send: a full commit channel skips that interval, and the next interval retries. Each rotation resolves its cutoff in order:

  1. Explicit cutoff. A cutoff set through archive_receipts_before wins over both triggers.
  2. Age. The time trigger fires when the oldest claim-log entry falls before now - retention_days * 86400.
  3. Size. The size trigger fires when live_db_size_bytes, which is (page_count - freelist_count) * page_size, exceeds max_size_bytes. Its cutoff is one second past the median claim-log timestamp.

With both triggers firing, the rotation takes whichever frees more; with neither, it returns zero archived before reading a checkpoint. Excluding freelist pages lets a size-driven rotation converge instead of re-firing on freed pages, and the extra second keeps a burst of receipts that share one timestamp from blocking their own batch. Before the cutoff, the rotation runs a full checkpoint-chain verification and a full claim-log validation, so it holds the writer for time proportional to live plus archived history, not to the batch.

Read more in Node State on Disk, which covers the single writer and its reader pool.

The archive is copied and compared before the live prefix is deleted

The writer vets the archive path, copies the prefix and its related rows into the archive, then compares each copied row with its live counterpart column for column across nine tables. Any shortfall raises RetentionArchiveIncomplete before a single delete.

One BEGIN IMMEDIATE transaction then deletes the live prefix, records W in the watermark ledger, and recreates every immutability trigger, re-running the identity check, the path pin, and the archive-backing check under the write lock. A crash between copy and delete leaves the live store intact and the rotation re-runnable. Afterward the writer runs PRAGMA incremental_vacuum and PRAGMA wal_checkpoint(TRUNCATE) to return the pages.

On a store whose auto_vacuum still reads NONE, the first rotation sets the pragma and runs one full VACUUM: a whole-file rewrite on the writer that needs free disk about the size of the database and blocks appends until it finishes. It runs once per store, before any cutoff is resolved, so plan that first rotation like a maintenance window.

Read the statements in Copy, verify, delete below.

A watermark skips verification only when the archive backs it

Chain verification exempts every checkpoint at or below W from the live Merkle rebuild, because its claim-log rows are gone by design. Signature parsing, column agreement, transparency-projection rows, and predecessor linkage still run. A forged W would silence verification for the range it names, so trusted_retention_watermark requires three facts and returns 0 when any one fails:

  1. W is a checkpoint boundary. A kernel_checkpoints row has batch_end_seq = W.
  2. The prefix is gone. No live claim-log row survives at or below W.
  3. The archive backs it. The ledger's archive, opened read-only, re-derives the signed Merkle root of every covered checkpoint from its archived rows.

The third fact ties the exemption to evidence, and its cost grows with every rotation. archive_path_backs_prefix runs when the writer seeds its head at open, behind every health report through receipt_checkpoint_status, and in the rotation pre-flight, so startup and health checks cost time proportional to total archived receipts. A steady-state append never opens the archive: the claim-log delta check compares against the raw ledger value first.

The archive is load-bearing from the first rotation. Its path is pinned in the ledger, a rotation pointed anywhere else is refused, and a rotation also refuses while the archive behind the committed watermark is missing, emptied, or no longer re-derives its roots. Losing it leaves the live store intact and withdraws the exemption, so full verification fails; back it up with the database. Inclusion proofs for archived receipts come from the archive file opened as a receipt store, because the live store's evidence export reads only live tables.

Read more in Backup & Restore.

A failed rotation deletes nothing, and health stays red until one succeeds

Every refusal lands before the delete, and the worker records it with record_retention_rotation_outcome. The message lands in ReceiptStoreHealthReport.retention_error and forces healthy to false while it is set, so a store whose retention policy is failing cannot read green. A later successful rotation clears it.

Read that error from the process that owns the database. chio receipt health samples the file through receipt_store_health_read_only, which reports retention_watermark_entry_seq but leaves retention_error at None, defaults the writer block, and nulls db_size_bytes, because those live in the writer's memory. Receipt commands open a local --receipt-db and refuse --control-url, so run them on the node that owns the file.

A store whose delete left orphaned projection rows recovers with chio receipt retention repair --archive <path>. A delete that took source rows and their projection rows together fails the rotation pre-flight and needs a restore. Health & Readiness describes how a degraded writer is judged.

Retention settings, records, and refusals

Retention configuration

RetentionConfig fieldDefaultSet through
retention_days
90
chio.yaml receipts.retention_days
max_size_bytes
10_737_418_240
KernelConfig
archive_path
"receipts-archive.sqlite3"
KernelConfig
tenant_id
None
Refused when set
check_interval_secs
3600
KernelConfig
explicit_cutoff_unix_secs
None
archive_receipts_before
  • retention_days sets the time cutoff, now - retention_days * 86400.
  • max_size_bytes is compared with the freelist-adjusted live size, not the on-disk size.
  • archive_path is created on the first rotation, then pinned for the life of the store.
  • check_interval_secs is clamped to at least 1 second.
  • explicit_cutoff_unix_secs is internal: archive_receipts_before sets it to bypass both thresholds, and it has no serialized form.

ReceiptsConfig in crates/platform/chio-config/src/schema.rs is deny_unknown_fields and holds three keys: store, checkpoint_interval, and retention_days. The archive path, size ceiling, tenant scope, and check interval come from RetentionConfig::default(); set them on the returned KernelConfig when you embed the kernel. to_kernel_config always emits retention_config: Some(..), so a file-configured kernel runs retention hourly, and its archive lands at the relative default path, resolved against the process working directory on the first rotation.

Attaching a store is where a policy that could never be honored is refused. try_set_receipt_store_handle refuses three configurations instead of spawning a worker that would fail every interval: a store whose supports_retention is false; a tenant_id against a store whose supports_tenant_scoped_retention is false, which is every store, SqliteReceiptStore included; and retention alongside checkpoint_batch_size == 0, since the watermark advances only to a checkpoint boundary and could never leave zero.

Checkpoint signer

Retention frees only what a checkpoint has committed, so its throughput is the checkpoint signer's. The attach that spawns the maintenance thread installs a BackgroundCheckpointSigner { keypair, max_batch } as one more command on the writer actor, with max_batch from checkpoint_batch_size (DEFAULT_CHECKPOINT_BATCH_SIZE = 100; 0 disables checkpointing). A store that advertises kernel-signed checkpoints but fails to install a signer has its attach refused, so it can never append forever without producing one.

The build runs after commit_receipt_batch has sent every append durability response, so it adds nothing to append latency. It runs on the same single writer connection and before the co-drained flush waiters are released, which makes flush_receipt_writes a checkpoint barrier as well as a durability barrier. Each checkpoint is O(batch), and the loop repeats while the head owes max_batch entries. A build failure, a caught panic included, records last_error and leaves the head untouched; the append that triggered it is already durable and stays committed.

Archive file, ledger, and tombstones

The archive is a separate SQLite database stamped with the Chio application_id and the receipt-store schema revision. create_archive_schema creates thirteen evidence tables plus the schema-version table: nine receive copied rows, and four checkpoint-projection tables stay empty so that opening the archive as a receipt store finds every table. Archive tables carry no REFERENCES clauses; the archive is a write-once evidence copy.

receipt_retention_watermark records each rotation's archived_through_entry_seq, cutoff, and archive path. Three triggers make it append-only and strictly increasing at the database level, because a later verifier trusts this value to skip work. Archive identity comes from re-deriving signed roots from the archived rows.

receipt_retention_tombstones keeps one small row per archived receipt id. Deleting a live receipt also deletes its UNIQUE(receipt_id) sentinel, so without a tombstone an archived id would look new to the append path's ON CONFLICT DO NOTHING. Two BEFORE INSERT triggers on the receipt tables abort any insert of a tombstoned id, and two more make the tombstones immutable.

Watermark query

crates/platform/chio-store-sqlite/src/receipt_store/evidence_retention.rssql
SELECT COALESCE(MAX(kc.batch_end_seq), 0)
FROM kernel_checkpoints kc
WHERE NOT EXISTS (
    SELECT 1 FROM claim_receipt_log_entries e
    WHERE e.entry_seq <= kc.batch_end_seq
      AND e.timestamp >= ?1
)
AND NOT EXISTS (
    SELECT 1 FROM chio_authorization_receipt_consumptions ac
    JOIN claim_receipt_log_entries ae ON ae.receipt_id = ac.authorization_receipt_id
    JOIN claim_receipt_log_entries ce ON ce.receipt_id = ac.consumer_receipt_id
    WHERE ae.entry_seq <= kc.batch_end_seq
      AND ce.entry_seq > kc.batch_end_seq
)
-- ... three further NOT EXISTS clauses follow, plus a sixth on settle_attempts
-- ... when that table is present; see the table below

The first clause is the age rule; the others share one shape, never archiving a row that live evidence still points at. Each hold has its reason:

  • Age. The unit of archival is a whole checkpoint batch.
  • Authorization consumption. The binding row is a RESTRICT foreign key on the authorization, so archiving the pair separately would strand the live consumer's replay binding or fail the delete.
  • Receipt lineage. Parent resolution for governed call-chain validation reads the live receipt tables, so a live child would lose its verified parent.
  • Settlement and metered reconciliation. Both upsert paths need the live receipt row; archiving it would turn an actionable item into a NotFound.
  • Settlement attempts. Every attempt row is pending or retryable work. Stores without the table skip the clause.

In incremental mode the result is capped at the newest checkpoint boundary the pre-flight audit verified, so a checkpoint row appended by a second handle since the head was seeded cannot widen the prune. A W of zero, or one that does not exceed the recorded high-water mark, returns zero archived without touching anything.

Copy, verify, delete

Before the rotation runs, the actor refuses if the writer's verified head is poisoned, then runs a full verify_checkpoint_chain_integrity (a Merkle rebuild per checkpoint above W, plus the archive re-hash below it) and a full claim-log set-equality validation that loads and sorts every source and projection row, in both verification modes. Only the delete and incremental_vacuum scale with the batch. An incremental head attests new appends only, so it would miss a checkpoint-covered source row and its projection row both deleted out of band after open; rotating over that drift would archive the survivors, delete the rest, and stamp a watermark the archive cannot back.

  • Vet the path. The archive path is checked before the ATTACH. A target that vanishes on DETACH, or one that aliases the live database by path, symlink, or inode, is refused, since the identity checks would compare a table with itself while the delete removed the only copy. The path is then canonicalized, so the ledger records an absolute location that resolves the same way from any working directory.
  • Copy idempotently. Append-only evidence uses INSERT OR IGNORE. The two reconciliation tables use INSERT OR REPLACE, because later reconciliation mutates their rows in place, and a stale archived copy would fail the identity re-check on every later rotation and stall the prefix.

verify_co_archival_complete checks identity, not presence. For each of nine tables it counts the live prefix rows whose archive counterpart matches on the table's key column and, NULL-safely, on every other column. Any shortfall raises RetentionArchiveIncomplete { table, live, archived } before a single delete; a count-only check would pass a reused archive holding the right ids under divergent bytes.

crates/platform/chio-store-sqlite/src/receipt_store/evidence_retention.rssql
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete;
DROP TRIGGER IF EXISTS chio_child_receipts_reject_delete;
DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete;

INSERT OR IGNORE INTO receipt_retention_tombstones
    (receipt_id, receipt_kind, archived_through_entry_seq, tombstoned_at)
    SELECT receipt_id, receipt_kind, {w}, {now}
    FROM claim_receipt_log_entries WHERE entry_seq <= {w};

DELETE FROM settlement_reconciliations WHERE receipt_id IN (
    SELECT receipt_id FROM claim_receipt_log_entries
    WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
DELETE FROM metered_billing_reconciliations WHERE receipt_id IN (
    SELECT receipt_id FROM claim_receipt_log_entries
    WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
DELETE FROM chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN (
    SELECT receipt_id FROM claim_receipt_log_entries
    WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
DELETE FROM chio_tool_receipts WHERE seq IN (
    SELECT source_seq FROM claim_receipt_log_entries
    WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
DELETE FROM chio_child_receipts WHERE seq IN (
    SELECT source_seq FROM claim_receipt_log_entries
    WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt');
DELETE FROM claim_receipt_log_entries WHERE entry_seq <= {w};

The deletion batch, the watermark insert, and the recreation of every immutability guard run inside one BEGIN IMMEDIATE transaction. SQLite executes CREATE and DROP TRIGGER transactionally, so a rollback restores the rows and the guards together. The pre-flight checks are fast fails; the transaction repeats them because a second handle can commit a dependent row or repoint the ledger in the window between.

The source tables and the projection lose exactly the same receipt-id set in that transaction, which is why the set-equality validator passes afterward with no exemption of its own. Cross-database atomicity is never required: the copy finishes first, the delete touches only main, and a crash between them leaves the live store intact. The first rotation on a store still reading auto_vacuum = NONE runs migrate_auto_vacuum_incremental_if_needed from rotate_on_writer_connection, because SQLite applies a changed auto_vacuum mode only to a new file or after a full VACUUM.

Rotation refusals

ConditionResultChecked
Writer's verified head is poisoned
Conflict, naming chio receipt audit --repair
First; nothing read
Chain or claim-log projection fails validation
That validation's error
Before the cutoff
tenant_id is set
RetentionTenantScopeUnsupported
dispatch_rotate, then the writer
Copied row differs from the live row
RetentionArchiveIncomplete
After copy; again under the lock
Target is :memory:, a mode=memory URI, empty, or the live file
Conflict
Before the ATTACH
Path differs from the pinned archive
Conflict naming both paths
Pre-flight; again under the lock
Archive no longer backs the committed W
Conflict: restore the archive first
Pre-flight; again under the lock
Foreign application_id or newer schema
Conflict
create_archive_schema
New mark does not exceed the current one
0 archived; RetentionWatermarkRegression on the helper path
Ledger trigger aborts raw inserts
Commit channel full, or actor gone
Pool("sqlite receipt commit queue saturated") or actor unavailable
try_send; next interval retries
Rotation panics
Typed writer-panic error
Actor and thread catch_unwind

A panic is caught at two levels: the actor's catch_unwind returns a typed writer-panic error and takes a fresh connection for the next command, and the maintenance thread catches anything that escapes and retries at the next interval.

Retention repair

chio receipt retention repair --archive <path> recovers a store damaged by a delete that left orphaned projection rows. It opens through open_existing, so diagnosing a damaged store never fails at open. It removes only claim-log rows absent from both source tables, only when the named archive holds a byte-identical copy of each, only up to the smallest checkpoint boundary covering them, and only when every row in that boundary's prefix is itself an orphan whose archived rows re-derive the signed roots.

If a watermark already covers that boundary, the --archive you pass must be the one the ledger pinned, because the tombstones are stamped from it. Anything else aborts untouched, and both output modes exit non-zero when the drift survives. Every receipt command, from health through retention repair, resolves a local --receipt-db.

Contracts and their tests

  • Retention runs on the single writer actor, never on a reader-pool connection. dispatch_rotate and the Rotate arm; reader_pool_never_rotates.
  • Archived and live receipt-id sets partition the appended history. Over random interleavings of tool appends, child appends, and rotations, the store stays appendable, reopenable, and healthy, with no overlap and no loss; prop_retention_preserves_append_invariant, a state-machine proptest.
  • A rotated store keeps working. The next append, a fresh open(), checkpoint status, and further checkpoint creation succeed; retention_then_append_and_reopen_succeeds, checkpoint_chain_watermark_exemption.
  • An unbacked or tampered watermark skips nothing. bogus_watermark_does_not_skip_verification, watermark_trust_requires_backing_archive, watermark_trust_rejects_tampered_archive_contents, boundary_matching_watermark_over_live_prefix_does_not_skip_verification.
  • A dependent row that escapes the copy survives. The delete fails closed instead of removing it unarchived; delete_fails_closed_when_a_dependent_row_escapes_the_copy, delete_rechecks_archive_path_under_the_write_lock.
  • Size-driven rotation converges. It shrinks the measured live size, and a repeat under the same configuration archives nothing or leaves the store no larger; the tests assert convergence rather than one pass under the threshold. size_rotation_converges_below_threshold, size_rotation_archives_when_median_timestamp_is_shared.
  • An archived id stays archived. It cannot be appended again, and its tombstone cannot be updated or deleted; archived_receipt_id_cannot_be_reappended, retention_tombstones_are_immutable.
  • Tenant-scoped rotation is refused and changes nothing. Every store reports supports_tenant_scoped_retention() == false; tenant_scoped_rotation_rejected, tenant_scoped_rotation_is_rejected_and_leaves_counts_unchanged.

Next: put the archive in the backup set