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.
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.
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.
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:
- Authorization consumption. An authorization receipt sits inside the prefix and the consumer that spent it sits above.
- Receipt lineage. A lineage parent sits inside the prefix and its child sits above.
- Settlement and metered reconciliation. A receipt in the prefix has a reconciliation still
openorretry_scheduled. - Settlement attempts. A
settle_attemptsrow 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:
- Explicit cutoff. A cutoff set through
archive_receipts_beforewins over both triggers. - Age. The time trigger fires when the oldest claim-log entry falls before
now - retention_days * 86400. - Size. The size trigger fires when
live_db_size_bytes, which is(page_count - freelist_count) * page_size, exceedsmax_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:
- W is a checkpoint boundary. A
kernel_checkpointsrow hasbatch_end_seq = W. - The prefix is gone. No live claim-log row survives at or below W.
- 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
Try retention_days, RetentionArchiveIncomplete, or tombstone. Each entry has its own link.
Retention configuration
RetentionConfig field | Default | Set through |
|---|---|---|
retention_days | 90 | chio.yaml receipts. |
max_size_bytes | 10_737_418_240 | KernelConfig |
archive_path | "receipts- | KernelConfig |
tenant_id | None | Refused when set |
check_ | 3600 | KernelConfig |
explicit_ | None | archive_ |
retention_dayssets the time cutoff,now - retention_days * 86400.max_size_bytesis compared with the freelist-adjusted live size, not the on-disk size.archive_pathis created on the first rotation, then pinned for the life of the store.check_interval_secsis clamped to at least 1 second.explicit_cutoff_unix_secsis internal:archive_receipts_beforesets 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
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 belowThe 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
RESTRICTforeign 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 onDETACH, 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 useINSERT 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.
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
| Condition | Result | Checked |
|---|---|---|
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 | Retention | dispatch_rotate, then the writer |
Copied row differs from the live row | Retention | 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_ |
New mark does not exceed the current one | 0 archived; Retention 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_rotateand theRotatearm;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.