use std::time::{SystemTime, UNIX_EPOCH};
use chio_kernel::{ReceiptStoreError, RetentionConfig};
use crate::SqliteReceiptStore;
fn unique_db_path(prefix: &str) -> std::path::PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let base = std::fs::canonicalize(std::env::temp_dir()).unwrap_or_else(|_| std::env::temp_dir());
base.join(format!(
"chio-{prefix}-{}-{nonce}.sqlite3",
std::process::id()
))
}
#[test]
fn watermark_ledger_reports_max_and_rejects_regression() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::{insert_receipt_retention_watermark, retention_watermark};
let path = unique_db_path("watermark-ledger");
let store = SqliteReceiptStore::open(&path)?;
let connection = store.reader_connection_for_test()?;
assert_eq!(retention_watermark(&connection)?, None);
insert_receipt_retention_watermark(&connection, 10, 100, "archive.sqlite3", None, 1)?;
insert_receipt_retention_watermark(&connection, 25, 200, "archive.sqlite3", None, 2)?;
assert_eq!(retention_watermark(&connection)?, Some(25));
let regression =
insert_receipt_retention_watermark(&connection, 20, 300, "archive.sqlite3", None, 3);
let message = regression
.err()
.ok_or("expected RetentionWatermarkRegression")?
.to_string();
assert!(
message.contains("retention watermark regression"),
"unexpected error: {message}"
);
assert_eq!(retention_watermark(&connection)?, Some(25));
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn backfill_refuses_regeneration_over_checkpointed_range() -> Result<(), Box<dyn std::error::Error>>
{
use crate::receipt_store::support::validate_or_backfill_claim_receipt_log_entries;
let path = unique_db_path("backfill-refuse");
{
let store = SqliteReceiptStore::open(&path)?;
let keypair = super::support::receipt_test_keypair();
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt =
super::support::sample_receipt_with_keypair(&format!("bf-{i}"), i + 1, &keypair);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
}
let store = SqliteReceiptStore::open_existing(&path)?;
store.writer_handle().run_write(|connection| {
connection.execute_batch(
"DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete; \
DELETE FROM claim_receipt_log_entries;",
)?;
Ok(())
})?;
let connection = store.reader_connection_for_test()?;
let error = validate_or_backfill_claim_receipt_log_entries(&connection, true);
let message = error
.err()
.ok_or("expected ArchivedRangeProjection, backfill regenerated instead")?
.to_string();
assert!(
message.contains("checkpointed or archived range"),
"unexpected error: {message}"
);
assert!(
!message.contains("retention repair"),
"must not point at the no-op retention repair for a missing projection: {message}"
);
assert!(
message.contains("restore") && message.contains("backup"),
"must direct operators to an applicable recovery path: {message}"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
fn store_with_archived_first_checkpoint(
path: &std::path::Path,
archive_path: &str,
keypair: &chio_core::crypto::Keypair,
) -> Result<SqliteReceiptStore, Box<dyn std::error::Error>> {
let store = SqliteReceiptStore::open(path)?;
store.enable_background_checkpoints(super::support::signer(keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("ce-aged-{i}"),
i + 1,
100,
keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
for i in 2..6u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("ce-fresh-{i}"),
i + 1,
500,
keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 2, "only the aged [1,2] batch archives");
Ok(store)
}
#[test]
fn retention_preserves_exact_cost_projection() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("cost-projection-retention");
let archive = unique_db_path("cost-projection-retention-archive");
let archive_path = archive.to_str().ok_or("archive path is not valid utf-8")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for (id, cost) in [("archived-cost-max", u64::MAX), ("archived-cost-zero", 0)] {
store.append_chio_receipt_returning_seq(&super::support::sample_financial_receipt(
id, cost,
)?)?;
}
store.flush_receipt_writes()?;
assert_eq!(store.archive_receipts_before(2, archive_path)?, 2);
let archived = rusqlite::Connection::open(&archive)?;
let projections = archived
.prepare("SELECT cost_currency, cost_charged_be FROM chio_tool_receipts ORDER BY seq ASC")?
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
projections,
vec![
("USD".to_string(), u64::MAX.to_be_bytes().to_vec()),
("USD".to_string(), 0_u64.to_be_bytes().to_vec()),
]
);
drop(archived);
drop(store);
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(archive);
Ok(())
}
#[test]
fn checkpoint_chain_watermark_exemption() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::verify_checkpoint_chain_integrity;
let path = unique_db_path("chain-exemption");
let archive = unique_db_path("chain-exemption-archive");
let archive_path = archive.to_str().ok_or("archive path is not valid utf-8")?;
let keypair = super::support::receipt_test_keypair();
let store = store_with_archived_first_checkpoint(&path, archive_path, &keypair)?;
let connection = store.reader_connection_for_test()?;
verify_checkpoint_chain_integrity(&connection)?;
store.writer_handle().run_write(|connection| {
connection.execute_batch(
"DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_update; \
UPDATE claim_receipt_log_entries SET raw_json = '{\"tampered\":true}' WHERE entry_seq = 3;",
)?;
Ok(())
})?;
let connection = store.reader_connection_for_test()?;
assert!(
verify_checkpoint_chain_integrity(&connection).is_err(),
"tamper above the watermark must still fail the chain"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn watermark_trust_requires_backing_archive() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::trusted_retention_watermark;
let path = unique_db_path("watermark-backing");
let archive = unique_db_path("watermark-backing-archive");
let archive_path = archive.to_str().ok_or("archive path is not valid utf-8")?;
let keypair = super::support::receipt_test_keypair();
let store = store_with_archived_first_checkpoint(&path, archive_path, &keypair)?;
let connection = store.reader_connection_for_test()?;
assert_eq!(trusted_retention_watermark(&connection)?, 2);
drop(connection);
std::fs::remove_file(&archive)?;
let connection = store.reader_connection_for_test()?;
assert_eq!(
trusted_retention_watermark(&connection)?,
0,
"a watermark with no backing archive must not be trusted"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn retention_then_append_and_reopen_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("retention-reopen");
let keypair = super::support::receipt_test_keypair();
let archive = unique_db_path("retention-reopen-archive");
let archive_path = archive.to_str().ok_or("archive path is not valid utf-8")?;
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("aged-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the two checkpointed aged batches archive");
let fresh =
super::support::sample_receipt_with_keypair_and_timestamp("fresh-0", 5, 500, &keypair);
store.append_chio_receipt_returning_seq(&fresh)?;
store.flush_receipt_writes()?;
assert!(
store.receipt_store_health()?.healthy,
"store healthy post-archival"
);
assert!(store.receipt_checkpoint_status(Some(1))?.healthy);
}
let reopened = SqliteReceiptStore::open(&path)?;
let more =
super::support::sample_receipt_with_keypair_and_timestamp("fresh-1", 6, 600, &keypair);
reopened.append_chio_receipt_returning_seq(&more)?;
reopened.flush_receipt_writes()?;
assert!(reopened.receipt_store_health()?.healthy);
let archive_store = SqliteReceiptStore::open(&archive)?;
assert_eq!(archive_store.tool_receipt_count()?, 4);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn reader_pool_never_rotates() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("reader-never-rotates");
let store = SqliteReceiptStore::open(&path)?;
let archived = store.rotate_if_needed(&RetentionConfig::default())?;
assert_eq!(archived, 0);
let reader = store.reader_connection_for_test()?;
reader.execute_batch("PRAGMA query_only = ON;")?;
let write_attempt = reader.execute("CREATE TABLE reader_probe (x INTEGER)", []);
assert!(
write_attempt.is_err(),
"reader-pool connections must be read-only (retention runs on the writer)"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn watermark_ledger_db_triggers_reject_tamper() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::insert_receipt_retention_watermark;
let path = unique_db_path("watermark-triggers");
let store = SqliteReceiptStore::open(&path)?;
let connection = store.reader_connection_for_test()?;
insert_receipt_retention_watermark(&connection, 10, 100, "archive.sqlite3", None, 1)?;
let updated = connection.execute(
"UPDATE receipt_retention_watermark SET archived_through_entry_seq = 999",
[],
);
assert!(
updated.is_err(),
"raw UPDATE of the watermark must be rejected"
);
let deleted = connection.execute("DELETE FROM receipt_retention_watermark", []);
assert!(
deleted.is_err(),
"raw DELETE of the watermark must be rejected"
);
let equal = connection.execute(
"INSERT INTO receipt_retention_watermark \
(archived_through_entry_seq, archived_through_timestamp, archive_path, archive_sha256, rotated_at) \
VALUES (10, 200, 'archive.sqlite3', NULL, 2)",
[],
);
assert!(
equal.is_err(),
"a non-increasing raw INSERT must be rejected"
);
let lower = connection.execute(
"INSERT INTO receipt_retention_watermark \
(archived_through_entry_seq, archived_through_timestamp, archive_path, archive_sha256, rotated_at) \
VALUES (5, 300, 'archive.sqlite3', NULL, 3)",
[],
);
assert!(lower.is_err(), "a regressing raw INSERT must be rejected");
let (count, max_seq): (i64, i64) = connection.query_row(
"SELECT COUNT(*), COALESCE(MAX(archived_through_entry_seq), 0) FROM receipt_retention_watermark",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(count, 1);
assert_eq!(max_seq, 10);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn tenant_scoped_rotation_rejected() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("tenant-rejected");
let store = SqliteReceiptStore::open(&path)?;
let config = RetentionConfig {
tenant_id: Some("tenant-a".to_string()),
..RetentionConfig::default()
};
let error = store.rotate_if_needed(&config);
let message = error
.err()
.ok_or("expected RetentionTenantScopeUnsupported")?
.to_string();
assert!(
message.contains("tenant-scoped retention"),
"unexpected: {message}"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn settlement_and_metered_rows_are_archived_not_cascaded() -> Result<(), Box<dyn std::error::Error>>
{
let path = unique_db_path("recon-archived");
let archive = unique_db_path("recon-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("recon-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
let receipt_id = super::support::first_tool_receipt_id(&store)?;
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"INSERT INTO settlement_reconciliations (receipt_id, reconciliation_state, note, updated_at) \
VALUES (?1, 'settled', NULL, 1)",
rusqlite::params![receipt_id],
)?;
connection.execute(
"INSERT INTO metered_billing_reconciliations \
(receipt_id, adapter_kind, evidence_id, observed_units, billed_cost_units, billed_cost_currency, evidence_sha256, recorded_at, reconciliation_state, note, updated_at) \
VALUES (?1, 'test', 'ev-1', 1, 1, 'usd', NULL, 1, 'reconciled', NULL, 1)",
rusqlite::params![receipt_id],
)?;
connection.execute(
"INSERT INTO chio_authorization_receipt_consumptions \
(authorization_receipt_id, consumer_receipt_id, request_id, session_id, tool_call_id, tenant_id, parameter_hash, consumed_at_unix_ms) \
VALUES (?1, 'consumer-recon-0', 'req-recon-0', 'sess-recon-0', 'tool-call-recon-0', NULL, 'hash-recon-0', 1000)",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 2);
let live = store.reader_connection_for_test()?;
let live_settlement: i64 = live.query_row(
"SELECT COUNT(*) FROM settlement_reconciliations",
[],
|row| row.get(0),
)?;
assert_eq!(live_settlement, 0, "settlement row absent from live");
let live_metered: i64 = live.query_row(
"SELECT COUNT(*) FROM metered_billing_reconciliations",
[],
|row| row.get(0),
)?;
assert_eq!(live_metered, 0, "metered row absent from live");
let live_consumptions: i64 = live.query_row(
"SELECT COUNT(*) FROM chio_authorization_receipt_consumptions",
[],
|row| row.get(0),
)?;
assert_eq!(live_consumptions, 0, "consumption row absent from live");
let archive_store = SqliteReceiptStore::open_existing(&archive)?;
let arch = archive_store.reader_connection_for_test()?;
let arch_settlement: i64 = arch.query_row(
"SELECT COUNT(*) FROM settlement_reconciliations",
[],
|row| row.get(0),
)?;
let arch_metered: i64 = arch.query_row(
"SELECT COUNT(*) FROM metered_billing_reconciliations",
[],
|row| row.get(0),
)?;
let arch_consumptions: i64 = arch.query_row(
"SELECT COUNT(*) FROM chio_authorization_receipt_consumptions",
[],
|row| row.get(0),
)?;
assert_eq!(arch_settlement, 1, "settlement row co-archived");
assert_eq!(arch_metered, 1, "metered row co-archived");
assert_eq!(arch_consumptions, 1, "consumption row co-archived");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn rotation_refreshes_stale_reconciliation_archive_rows() -> Result<(), Box<dyn std::error::Error>>
{
let path = unique_db_path("recon-refresh");
let archive = unique_db_path("recon-refresh-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("recon-refresh-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
let receipt_id = super::support::first_tool_receipt_id(&store)?;
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"INSERT INTO settlement_reconciliations (receipt_id, reconciliation_state, note, updated_at) \
VALUES (?1, 'settled-final', 'live-note', 200)",
rusqlite::params![receipt_id],
)?;
connection.execute(
"INSERT INTO metered_billing_reconciliations \
(receipt_id, adapter_kind, evidence_id, observed_units, billed_cost_units, billed_cost_currency, evidence_sha256, recorded_at, reconciliation_state, note, updated_at) \
VALUES (?1, 'test', 'ev-final', 42, 42, 'usd', NULL, 200, 'reconciled', 'live-note', 200)",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
{
let seed = rusqlite::Connection::open(&archive)?;
seed.execute_batch(
r#"
CREATE TABLE settlement_reconciliations (
receipt_id TEXT PRIMARY KEY, reconciliation_state TEXT NOT NULL,
note TEXT, updated_at INTEGER NOT NULL
);
CREATE TABLE metered_billing_reconciliations (
receipt_id TEXT PRIMARY KEY, adapter_kind TEXT NOT NULL,
evidence_id TEXT NOT NULL, observed_units INTEGER NOT NULL,
billed_cost_units INTEGER NOT NULL, billed_cost_currency TEXT NOT NULL,
evidence_sha256 TEXT, recorded_at INTEGER NOT NULL,
reconciliation_state TEXT NOT NULL, note TEXT, updated_at INTEGER NOT NULL
);
"#,
)?;
seed.execute(
"INSERT INTO settlement_reconciliations (receipt_id, reconciliation_state, note, updated_at) \
VALUES (?1, 'settled-pending', 'stale-note', 100)",
rusqlite::params![receipt_id],
)?;
seed.execute(
"INSERT INTO metered_billing_reconciliations \
(receipt_id, adapter_kind, evidence_id, observed_units, billed_cost_units, billed_cost_currency, evidence_sha256, recorded_at, reconciliation_state, note, updated_at) \
VALUES (?1, 'test', 'ev-stale', 1, 1, 'usd', NULL, 100, 'pending', 'stale-note', 100)",
rusqlite::params![receipt_id],
)?;
}
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 2,
"rotation must refresh the stale archived reconciliation rows and complete"
);
let archive_store = SqliteReceiptStore::open_existing(&archive)?;
let arch = archive_store.reader_connection_for_test()?;
let settled_state: String = arch.query_row(
"SELECT reconciliation_state FROM settlement_reconciliations WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
settled_state, "settled-final",
"the archived settlement row must refresh to the current live state"
);
let metered_units: i64 = arch.query_row(
"SELECT observed_units FROM metered_billing_reconciliations WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
metered_units, 42,
"the archived metered row must refresh to the current live units"
);
let live = store.reader_connection_for_test()?;
let live_settlement: i64 = live.query_row(
"SELECT COUNT(*) FROM settlement_reconciliations",
[],
|row| row.get(0),
)?;
assert_eq!(live_settlement, 0, "settlement row absent from live");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn size_rotation_converges_below_threshold() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("size-converges");
let keypair = super::support::receipt_test_keypair();
let archive = unique_db_path("size-archive");
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 4))?;
for i in 0..64u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("sz-{i}"),
i + 1,
100 + i,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
let before = store.live_db_size_bytes()?;
let config = RetentionConfig {
retention_days: u64::MAX, max_size_bytes: before.saturating_sub(1),
archive_path: archive.to_str().ok_or("archive path invalid")?.to_string(),
..RetentionConfig::default()
};
let archived = store.rotate_if_needed(&config)?;
assert!(archived > 0, "size trigger archived a checkpointed prefix");
let after = store.live_db_size_bytes()?;
assert!(
after < before,
"live size shrank after rotation ({after} < {before})"
);
let again = store.rotate_if_needed(&config)?;
assert!(again == 0 || store.live_db_size_bytes()? <= after);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn size_rotation_archives_when_median_timestamp_is_shared() -> Result<(), Box<dyn std::error::Error>>
{
let path = unique_db_path("size-shared-median");
let keypair = super::support::receipt_test_keypair();
let archive = unique_db_path("size-shared-median-archive");
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 4))?;
for i in 0..64u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("sm-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
let before = store.live_db_size_bytes()?;
let config = RetentionConfig {
retention_days: u64::MAX, max_size_bytes: before.saturating_sub(1),
archive_path: archive.to_str().ok_or("archive path invalid")?.to_string(),
..RetentionConfig::default()
};
let archived = store.rotate_if_needed(&config)?;
assert!(
archived > 0,
"size rotation must archive a checkpointed prefix even when the median timestamp is shared"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn child_only_evidence_ages_out_under_time_trigger() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("retention-child-only");
let archive = unique_db_path("retention-child-only-archive");
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let child = super::support::sample_child_receipt_with_keypair_and_timestamp(
&format!("aged-child-{i}"),
100,
&keypair,
);
store.append_child_receipt_record(&child)?;
}
store.flush_receipt_writes()?;
assert!(
store.load_checkpoint_by_seq(1)?.is_some(),
"the child-only prefix must be checkpointed before it can be archived"
);
let before = store.reader_connection_for_test()?;
let tool_rows: i64 =
before.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(tool_rows, 0, "the store holds child receipts only");
let child_rows: i64 =
before.query_row("SELECT COUNT(*) FROM chio_child_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(child_rows, 2, "two child receipts are live before rotation");
let config = RetentionConfig {
retention_days: 1,
max_size_bytes: u64::MAX,
archive_path: archive.to_str().ok_or("archive path invalid")?.to_string(),
..RetentionConfig::default()
};
store.rotate_if_needed(&config)?;
let after = store.reader_connection_for_test()?;
let live_child: i64 =
after.query_row("SELECT COUNT(*) FROM chio_child_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_child, 0,
"aged child-only evidence must age out under the time trigger"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn size_trigger_applies_when_time_cutoff_is_a_noop() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("retention-size-fallthrough");
let archive = unique_db_path("retention-size-fallthrough-archive");
let keypair = super::support::receipt_test_keypair();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
let timestamps = [
now.saturating_sub(500_000),
now.saturating_sub(2_000_000),
now.saturating_sub(100_000),
now,
];
for (i, ts) in timestamps.iter().enumerate() {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("fallthrough-{i}"),
(i + 1) as u64,
*ts,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(
store.load_checkpoint_by_seq(2)?.is_some(),
"both checkpoint batches must be persisted before rotation"
);
let config = RetentionConfig {
retention_days: 10,
max_size_bytes: 1,
archive_path: archive.to_str().ok_or("archive path invalid")?.to_string(),
..RetentionConfig::default()
};
let archived = store.rotate_if_needed(&config)?;
assert_eq!(
archived, 2,
"the size cutoff must free the first checkpoint batch even though the age cutoff is a no-op"
);
let after = store.reader_connection_for_test()?;
let live_tool: i64 = after.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 2,
"the aged first batch was archived and deleted; the fresh batch stayed live"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn non_incremental_rotation_validates_chain_before_deleting(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("retention-nonincremental-validate");
let archive = unique_db_path("retention-nonincremental-validate-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let receipt_id = {
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
let mut first_id = String::new();
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("nonincr-{i}"),
i + 1,
100,
&keypair,
);
if i == 0 {
first_id = receipt.id.clone();
}
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(
store.load_checkpoint_by_seq(1)?.is_some(),
"the prefix must be checkpointed so a rotation would otherwise archive it"
);
first_id
};
let store = SqliteReceiptStore::open_existing_with_options(
&path,
crate::SqliteStoreOptions {
pool: crate::SqlitePoolConfig::default(),
incremental_verification: false,
},
)?;
assert!(!store.incremental_verification_enabled());
super::support::tamper_claim_log_tool_receipt(&store, &receipt_id, |receipt| {
receipt.tool_name = "tampered".to_string();
});
let error = store
.archive_receipts_before(150, archive_path)
.err()
.ok_or(
"rotation on a corrupt non-incremental chain must fail closed, not archive-and-delete",
)?;
assert!(
matches!(error, ReceiptStoreError::Conflict(_)),
"expected a fail-closed Conflict from the pre-rotation verification, got {error:?}"
);
let live = store.reader_connection_for_test()?;
let live_tool: i64 = live.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 2,
"no evidence may be deleted when the chain is unverified"
);
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 2,
"no claim-log rows may be deleted when the chain is unverified"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn incremental_rotation_rejects_projection_drift() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("incremental-drift-rotation");
let archive = unique_db_path("incremental-drift-rotation-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
assert!(store.incremental_verification_enabled());
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("id-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
let error = store
.archive_receipts_before(150, archive_path)
.err()
.ok_or("rotation over a drifted projection must fail closed, not archive-and-delete")?;
assert!(
matches!(error, ReceiptStoreError::Conflict(_)),
"expected a fail-closed Conflict from the projection audit, got {error:?}"
);
let live = store.reader_connection_for_test()?;
let orphans: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq <= 2",
[],
|row| row.get(0),
)?;
assert_eq!(
orphans, 2,
"orphaned claim-log rows must survive the refusal"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn incremental_rotation_audits_chain_before_deleting() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("incremental-chain-audit-rotation");
let archive = unique_db_path("incremental-chain-audit-rotation-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
assert!(store.incremental_verification_enabled());
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("id-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
store.writer_handle().run_write(|connection| {
connection.execute_batch(
"DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq = 2; \
DELETE FROM main.claim_receipt_log_entries WHERE entry_seq = 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END; \
CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_delete \
BEFORE DELETE ON claim_receipt_log_entries \
BEGIN SELECT RAISE(ABORT, 'claim receipt log entries are immutable'); END;",
)?;
Ok(())
})?;
let error = store
.archive_receipts_before(150, archive_path)
.err()
.ok_or(
"rotation over an unaudited checkpoint chain must fail closed, not archive-and-delete",
)?;
assert!(
matches!(error, ReceiptStoreError::Conflict(_)),
"expected a fail-closed Conflict from the chain audit, got {error:?}"
);
let live = store.reader_connection_for_test()?;
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq <= 2",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 1,
"the surviving covered claim-log row must not be deleted when the chain is unaudited"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn first_rotation_creates_archive_on_open_existing_store() -> Result<(), Box<dyn std::error::Error>>
{
let path = unique_db_path("open-existing-first-rotation");
let archive = unique_db_path("open-existing-first-rotation-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("first-rotation-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(
store.load_checkpoint_by_seq(1)?.is_some(),
"the prefix must be checkpointed so the rotation has something to archive"
);
}
assert!(
!archive.exists(),
"the archive must be absent before the first rotation"
);
let store = SqliteReceiptStore::open_existing(&path)?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 4,
"the aged checkpointed prefix archives on the first rotation"
);
assert!(
archive.exists(),
"the first rotation created the sibling archive database"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn rotation_rejects_non_durable_or_aliasing_archive_path() -> Result<(), Box<dyn std::error::Error>>
{
let path = unique_db_path("nondurable-archive");
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("nd-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let memory_error = store
.archive_receipts_before(150, ":memory:")
.err()
.ok_or("rotation into an in-memory archive must fail closed")?;
assert!(
matches!(memory_error, ReceiptStoreError::Conflict(_)),
"expected a fail-closed Conflict over a non-durable archive, got {memory_error:?}"
);
let live_path = path.to_str().ok_or("db path invalid")?;
let alias_error = store
.archive_receipts_before(150, live_path)
.err()
.ok_or("rotation into a self-aliasing archive must fail closed")?;
assert!(
matches!(alias_error, ReceiptStoreError::Conflict(_)),
"expected a fail-closed Conflict over a self-aliasing archive, got {alias_error:?}"
);
let live = store.reader_connection_for_test()?;
let live_tool: i64 = live.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 4,
"no receipts may be deleted on a rejected archive target"
);
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 4,
"no claim-log rows may be deleted on a rejected archive target"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn rotate_does_not_leak_inflight() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("rotate-inflight");
let archive = unique_db_path("rotate-inflight-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("inflight-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert_eq!(
store.receipt_store_health()?.writer.inflight,
0,
"baseline inflight must be zero after flush"
);
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the aged checkpointed prefix archives");
assert_eq!(
store.receipt_store_health()?.writer.inflight,
0,
"a successful rotation must not leak an in-flight writer"
);
let again = store.archive_receipts_before(150, archive_path)?;
assert_eq!(again, 0, "re-archiving the same aged prefix is a no-op");
assert_eq!(
store.receipt_store_health()?.writer.inflight,
0,
"a no-op rotation must not leak an in-flight writer either"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn bogus_watermark_does_not_skip_verification() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::{
insert_receipt_retention_watermark, verify_checkpoint_chain_integrity,
};
let path = unique_db_path("bogus-watermark");
let store = SqliteReceiptStore::open(&path)?;
let keypair = super::support::receipt_test_keypair();
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let receipt =
super::support::sample_receipt_with_keypair(&format!("bw-{i}"), i + 1, &keypair);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
store.writer_handle().run_write(|connection| {
insert_receipt_retention_watermark(connection, 100, 100, "bogus-archive.sqlite3", None, 1)?;
Ok(())
})?;
store.writer_handle().run_write(|connection| {
connection.execute_batch(
"DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_update; \
UPDATE claim_receipt_log_entries SET raw_json = '{\"tampered\":true}' WHERE entry_seq = 1;",
)?;
Ok(())
})?;
let connection = store.reader_connection_for_test()?;
assert!(
verify_checkpoint_chain_integrity(&connection).is_err(),
"a bogus watermark must not disable Merkle verification for un-archived ranges"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn co_archival_rejects_conflicting_stale_archive() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("co-archival-conflict");
let archive = unique_db_path("co-archival-conflict-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("conflict-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
{
let seed = rusqlite::Connection::open(&archive)?;
seed.execute_batch(
r#"
CREATE TABLE claim_receipt_log_entries (
entry_seq INTEGER PRIMARY KEY,
receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL,
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL,
capability_id TEXT, session_id TEXT, parent_request_id TEXT,
request_id TEXT, subject_key TEXT, issuer_key TEXT,
tool_server TEXT, tool_name TEXT, raw_json TEXT NOT NULL
);
"#,
)?;
seed.execute(
"INSERT INTO claim_receipt_log_entries \
(entry_seq, receipt_id, receipt_kind, source_seq, timestamp, raw_json) \
VALUES (1, 'stale-conflict-id', 'tool_receipt', 1, 100, '{\"stale\":true}')",
[],
)?;
}
let result = store.archive_receipts_before(150, archive_path);
let message = result
.err()
.ok_or(
"expected RetentionArchiveIncomplete; rotation succeeded over a conflicting archive",
)?
.to_string();
assert!(
message.contains("co-archival incomplete"),
"unexpected error: {message}"
);
let live = store.reader_connection_for_test()?;
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 2,
"no live claim-log rows deleted when co-archival verify fails"
);
let live_tool: i64 = live.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 2,
"tool receipts intact when co-archival verify fails"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn co_archival_rejects_conflicting_capability_lineage() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("co-archival-cap-lineage");
let archive = unique_db_path("co-archival-cap-lineage-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("cl-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
store.writer_handle().run_write(|connection| {
connection.execute(
"INSERT INTO capability_lineage \
(capability_id, subject_key, issuer_key, issued_at, expires_at, grants_json, delegation_depth, parent_capability_id) \
VALUES ('cap-1', 'subject-live', 'issuer-live', 1, 100, '[]', 0, NULL)",
[],
)?;
Ok(())
})?;
{
let seed = rusqlite::Connection::open(&archive)?;
seed.execute_batch(
r#"
CREATE TABLE capability_lineage (
capability_id TEXT PRIMARY KEY, subject_key TEXT NOT NULL,
issuer_key TEXT NOT NULL, issued_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, grants_json TEXT NOT NULL,
delegation_depth INTEGER NOT NULL DEFAULT 0, parent_capability_id TEXT
);
"#,
)?;
seed.execute(
"INSERT INTO capability_lineage \
(capability_id, subject_key, issuer_key, issued_at, expires_at, grants_json, delegation_depth, parent_capability_id) \
VALUES ('cap-1', 'subject-stale', 'issuer-stale', 1, 100, '[]', 0, NULL)",
[],
)?;
}
let result = store.archive_receipts_before(150, archive_path);
let message = result
.err()
.ok_or("expected RetentionArchiveIncomplete over a conflicting capability lineage")?
.to_string();
assert!(
message.contains("co-archival incomplete for capability_lineage"),
"unexpected error: {message}"
);
let live = store.reader_connection_for_test()?;
let live_tool: i64 = live.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 2,
"tool receipts intact when capability-lineage verify fails"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn bricked_store_repair_restores_append() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("bricked-repair");
let archive = unique_db_path("bricked-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("br-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
assert!(
SqliteReceiptStore::open(&path).is_err(),
"store should be bricked pre-repair"
);
let store = SqliteReceiptStore::open_existing(&path)?;
let removed = store.retention_repair(archive_path)?;
assert!(removed > 0, "repair removed the extra claim-log rows");
drop(store);
let repaired = SqliteReceiptStore::open(&path)?;
let r =
super::support::sample_receipt_with_keypair_and_timestamp("after-repair", 9, 900, &keypair);
repaired.append_chio_receipt_returning_seq(&r)?;
repaired.flush_receipt_writes()?;
assert!(repaired.receipt_store_health()?.healthy);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_creates_missing_watermark_ledger_on_legacy_store(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("legacy-repair-watermark");
let archive = unique_db_path("legacy-repair-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lg-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END; \
DROP TABLE IF EXISTS receipt_retention_watermark;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let removed = store.retention_repair(archive_path)?;
assert!(removed > 0, "repair removed the extra claim-log rows");
drop(store);
let repaired = SqliteReceiptStore::open(&path)?;
assert!(repaired.receipt_store_health()?.healthy);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn co_archival_rejects_reused_seq_archive() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("co-archival-reused-seq");
let archive = unique_db_path("co-archival-reused-seq-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("reused-seq-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
let (receipt_id, raw_json): (String, String) = {
let live = store.reader_connection_for_test()?;
live.query_row(
"SELECT receipt_id, raw_json FROM chio_tool_receipts WHERE seq = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?
};
{
let seed = rusqlite::Connection::open(&archive)?;
seed.execute_batch(
"CREATE TABLE chio_tool_receipts (\
seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, timestamp INTEGER NOT NULL, \
capability_id TEXT NOT NULL, subject_key TEXT, issuer_key TEXT, grant_index INTEGER, \
tool_server TEXT NOT NULL, tool_name TEXT NOT NULL, decision_kind TEXT NOT NULL, \
policy_hash TEXT NOT NULL, content_hash TEXT NOT NULL, raw_json TEXT NOT NULL, tenant_id TEXT);",
)?;
seed.execute(
"INSERT INTO chio_tool_receipts \
(seq, receipt_id, timestamp, capability_id, tool_server, tool_name, decision_kind, policy_hash, content_hash, raw_json) \
VALUES (9001, ?1, 100, 'cap', 'srv', 'tool', 'allow', 'ph', 'ch', ?2)",
rusqlite::params![receipt_id, raw_json],
)?;
}
let result = store.archive_receipts_before(150, archive_path);
let message = result
.err()
.ok_or("expected RetentionArchiveIncomplete; rotation accepted a reused-seq archive")?
.to_string();
assert!(
message.contains("co-archival incomplete"),
"unexpected error: {message}"
);
assert!(
message.contains("chio_tool_receipts"),
"the seq mismatch must fail the tool-receipt identity check: {message}"
);
let live = store.reader_connection_for_test()?;
let live_tool: i64 = live.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 2,
"tool receipts intact when co-archival verify fails"
);
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 2,
"claim-log intact when co-archival verify fails"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn co_archival_rejects_divergent_attribution_columns() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("co-archival-attribution");
let archive = unique_db_path("co-archival-attribution-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("attr-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
{
let live_path = path.to_str().ok_or("db path invalid")?.replace('\'', "''");
let seed = rusqlite::Connection::open(&archive)?;
seed.execute_batch(&format!("ATTACH DATABASE '{live_path}' AS live;"))?;
seed.execute_batch(
"CREATE TABLE chio_tool_receipts (\
seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, timestamp INTEGER NOT NULL, \
capability_id TEXT NOT NULL, subject_key TEXT, issuer_key TEXT, grant_index INTEGER, \
tool_server TEXT NOT NULL, tool_name TEXT NOT NULL, decision_kind TEXT NOT NULL, \
policy_hash TEXT NOT NULL, content_hash TEXT NOT NULL, raw_json TEXT NOT NULL, tenant_id TEXT);",
)?;
seed.execute_batch(
"INSERT INTO chio_tool_receipts \
(seq, receipt_id, timestamp, capability_id, subject_key, issuer_key, grant_index, \
tool_server, tool_name, decision_kind, policy_hash, content_hash, raw_json, tenant_id) \
SELECT seq, receipt_id, timestamp, capability_id, subject_key, issuer_key, grant_index, \
tool_server, tool_name, decision_kind, policy_hash, content_hash, raw_json, tenant_id \
FROM live.chio_tool_receipts WHERE seq = 1;",
)?;
seed.execute(
"UPDATE chio_tool_receipts SET subject_key = 'tampered-attribution-' || COALESCE(subject_key, '') WHERE seq = 1",
[],
)?;
seed.execute_batch("DETACH DATABASE live;")?;
}
let result = store.archive_receipts_before(150, archive_path);
let message = result
.err()
.ok_or("expected RetentionArchiveIncomplete; rotation accepted a divergent-attribution archive")?
.to_string();
assert!(
message.contains("co-archival incomplete"),
"unexpected error: {message}"
);
assert!(
message.contains("chio_tool_receipts"),
"the attribution mismatch must fail the tool-receipt identity check: {message}"
);
let live = store.reader_connection_for_test()?;
let live_tool: i64 = live.query_row("SELECT COUNT(*) FROM chio_tool_receipts", [], |row| {
row.get(0)
})?;
assert_eq!(
live_tool, 2,
"tool receipts intact when co-archival verify fails"
);
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 2,
"claim-log intact when co-archival verify fails"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_rejects_divergent_archive_identity() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-divergent-archive");
let archive = unique_db_path("repair-divergent-archive-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("dv-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
(entry_seq, receipt_id, receipt_kind, source_seq, timestamp, capability_id, session_id, \
parent_request_id, request_id, subject_key, issuer_key, tool_server, tool_name, raw_json) \
SELECT entry_seq, receipt_id, receipt_kind, source_seq + 500, timestamp, capability_id, session_id, \
parent_request_id, request_id, subject_key, issuer_key, tool_server, tool_name, raw_json \
FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let result = store.retention_repair(archive_path);
let message = result
.err()
.ok_or("expected RetentionArchiveIncomplete; repair trusted a divergent archive")?
.to_string();
assert!(
message.contains("co-archival incomplete"),
"unexpected error: {message}"
);
let live = store.reader_connection_for_test()?;
let orphans: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq <= 2",
[],
|row| row.get(0),
)?;
assert_eq!(
orphans, 2,
"orphaned claim-log rows must survive a rejected repair"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_refuses_partial_checkpoint_batch() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-partial-batch");
let archive = unique_db_path("repair-partial-batch-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 4))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("pb-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
assert!(
store.load_checkpoint_by_seq(2)?.is_none(),
"one batch [1,4]"
);
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let result = store.retention_repair(archive_path);
let message = result
.err()
.ok_or("expected a partial-batch refusal; repair watermarked live rows")?
.to_string();
assert!(
message.contains("partially archived batch"),
"unexpected error: {message}"
);
let live = store.reader_connection_for_test()?;
let orphans: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq <= 2",
[],
|row| row.get(0),
)?;
assert_eq!(
orphans, 2,
"orphaned claim-log rows must survive the refusal"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_refuses_incomplete_prefix_archive() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-incomplete-prefix");
let archive = unique_db_path("repair-incomplete-prefix-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 4))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("ip-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq IN (3, 4); \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 4; \
DELETE FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END; \
CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_delete \
BEFORE DELETE ON claim_receipt_log_entries \
BEGIN SELECT RAISE(ABORT, 'claim_receipt_log_entries is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let result = store.retention_repair(archive_path);
let message = result
.err()
.ok_or("expected an incomplete-archive refusal; repair sealed a partial archive")?
.to_string();
assert!(
message.contains("co-archival incomplete"),
"unexpected error: {message}"
);
assert!(
message.contains("claim_receipt_log_entries"),
"the missing prefix rows must fail the claim-log completeness check: {message}"
);
let live = store.reader_connection_for_test()?;
let orphans: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq IN (3, 4)",
[],
|row| row.get(0),
)?;
assert_eq!(
orphans, 2,
"surviving orphan rows must remain after the refusal"
);
let watermark: Option<i64> = live.query_row(
"SELECT MAX(archived_through_entry_seq) FROM receipt_retention_watermark",
[],
|row| row.get::<_, Option<i64>>(0),
)?;
assert_eq!(
watermark, None,
"no watermark may be stamped over an incomplete archive"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_is_idempotent_when_watermark_already_covers_boundary(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-idempotent-watermark");
let archive = unique_db_path("repair-idempotent-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("iw-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
let canonical_archive_path = std::fs::canonicalize(&archive_path)?;
let canonical_archive_path = canonical_archive_path.to_str().ok_or_else(|| {
ReceiptStoreError::Conflict(
"canonical retention archive path is not valid UTF-8".to_string(),
)
})?;
crate::receipt_store::support::insert_receipt_retention_watermark(
connection,
2,
100,
canonical_archive_path,
None,
1,
)?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let removed = store.retention_repair(archive_path)?;
assert_eq!(removed, 2, "repair removes the orphaned claim-log rows");
let live = store.reader_connection_for_test()?;
let orphans: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq <= 2",
[],
|row| row.get(0),
)?;
assert_eq!(orphans, 0, "orphaned claim-log rows must be removed");
let watermark: Option<i64> = live.query_row(
"SELECT MAX(archived_through_entry_seq) FROM receipt_retention_watermark",
[],
|row| row.get::<_, Option<i64>>(0),
)?;
assert_eq!(watermark, Some(2), "the covering watermark is preserved");
drop(live);
drop(store);
let reopened = SqliteReceiptStore::open(&path)?;
assert!(reopened.receipt_store_health()?.healthy);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn fully_archived_store_reopens_writable() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("fully-archived-reopen");
let archive = unique_db_path("fully-archived-reopen-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("fr-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole history archives");
}
let reopened = SqliteReceiptStore::open(&path)?;
assert!(reopened.receipt_store_health()?.healthy);
let fresh =
super::support::sample_receipt_with_keypair_and_timestamp("fr-fresh", 9, 900, &keypair);
reopened.append_chio_receipt_returning_seq(&fresh)?;
reopened.flush_receipt_writes()?;
assert!(reopened.receipt_store_health()?.healthy);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn checkpoint_status_floors_committed_at_watermark_after_full_archive(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("status-floor-watermark");
let archive = unique_db_path("status-floor-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("sf-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole history archives");
let status = store.receipt_checkpoint_status(None)?;
assert_eq!(
status.retention_watermark_entry_seq,
Some(4),
"the watermark records the fully archived boundary"
);
assert_eq!(
status.latest_checkpointed_entry_seq, 4,
"the checkpoint chain still sits at the archived boundary"
);
assert_eq!(
status.latest_committed_entry_seq, 4,
"committed progress must fold in the archived prefix, not regress to 0"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn latest_committed_entry_seq_floors_at_watermark_after_full_archive(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("committed-floor-watermark");
let archive = unique_db_path("committed-floor-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lc-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole history archives");
assert_eq!(
store.latest_committed_entry_seq()?,
4,
"committed progress must fold in the archived prefix, not regress to 0"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn health_reports_checkpoint_error_without_probing_archived_rows(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("health-missing-archive");
let archive = unique_db_path("health-missing-archive-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("hm-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole history archives");
std::fs::remove_file(&archive)?;
let report = store.receipt_store_health()?;
assert!(!report.healthy, "a missing backing archive is unhealthy");
assert!(
report.checkpoint_error.is_some(),
"the checkpoint_error must be surfaced, not swallowed by a hard error from probing deleted rows"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn rotation_preserves_binding_for_live_consumer_of_aged_authorization(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("live-consumer-binding");
let archive = unique_db_path("live-consumer-binding-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lb-aged-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
for i in 4..6u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lb-fresh-{i}"),
i + 1,
500,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(3)?.is_some());
let receipt_id_at = |entry_seq: i64| -> Result<String, Box<dyn std::error::Error>> {
let connection = store.reader_connection_for_test()?;
Ok(connection.query_row(
"SELECT receipt_id FROM claim_receipt_log_entries WHERE entry_seq = ?1",
rusqlite::params![entry_seq],
|row| row.get::<_, String>(0),
)?)
};
let authorization_id = receipt_id_at(3)?;
let consumer_id = receipt_id_at(5)?;
store.writer_handle().run_write({
let authorization_id = authorization_id.clone();
let consumer_id = consumer_id.clone();
move |connection| {
connection.execute(
"INSERT INTO chio_authorization_receipt_consumptions \
(authorization_receipt_id, consumer_receipt_id, request_id, session_id, tool_call_id, tenant_id, parameter_hash, consumed_at_unix_ms) \
VALUES (?1, ?2, 'req-lb', 'sess-lb', 'call-lb', NULL, 'hash-lb', 1000)",
rusqlite::params![authorization_id, consumer_id],
)?;
Ok(())
}
})?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 2,
"the watermark must stop below the authorization whose consumer is still live"
);
let live = store.reader_connection_for_test()?;
let surviving: i64 = live.query_row(
"SELECT COUNT(*) FROM chio_authorization_receipt_consumptions",
[],
|row| row.get(0),
)?;
assert_eq!(
surviving, 1,
"the live consumer's authorization-consumption binding must survive the rotation"
);
let binding: (String, String) = live.query_row(
"SELECT authorization_receipt_id, consumer_receipt_id \
FROM chio_authorization_receipt_consumptions",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)?;
assert_eq!(
binding,
(authorization_id.clone(), consumer_id),
"the surviving binding must still bind the live consumer to its authorization"
);
let authorization_live: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE receipt_id = ?1",
rusqlite::params![authorization_id],
|row| row.get(0),
)?;
assert_eq!(
authorization_live, 1,
"the authorization backing the live binding must remain live"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn rotation_preserves_live_child_lineage_parent() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("lineage-parent-preserve");
let archive = unique_db_path("lineage-parent-preserve-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lp-aged-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
for i in 4..6u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lp-fresh-{i}"),
i + 1,
500,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(3)?.is_some());
let receipt_id_at = |entry_seq: i64| -> Result<String, Box<dyn std::error::Error>> {
let connection = store.reader_connection_for_test()?;
Ok(connection.query_row(
"SELECT receipt_id FROM claim_receipt_log_entries WHERE entry_seq = ?1",
rusqlite::params![entry_seq],
|row| row.get::<_, String>(0),
)?)
};
let parent_id = receipt_id_at(3)?;
let child_id = receipt_id_at(5)?;
store.writer_handle().run_write({
let parent_id = parent_id.clone();
let child_id = child_id.clone();
move |connection| {
connection.execute(
"INSERT INTO receipt_lineage_statements \
(receipt_id, statement_id, request_id, session_id, session_anchor_id, chain_id, \
parent_request_id, parent_receipt_id, evidence_class, evidence_sources_json, \
verified_session_anchor, verified_parent_request, verified_parent_receipt, \
replay_protected, recorded_at, source_kind, json_sha256, raw_json) \
VALUES (?1, 'stmt-lp', NULL, NULL, NULL, 'chain-lp', NULL, ?2, 'delegated', NULL, \
0, 0, 1, 0, 500, 'test', 'sha-lp', '{\"schema\":\"lineage\"}')",
rusqlite::params![child_id, parent_id],
)?;
Ok(())
}
})?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 2,
"the watermark must stop below the lineage parent whose child is still live"
);
let live = store.reader_connection_for_test()?;
let parent_live: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE receipt_id = ?1",
rusqlite::params![parent_id],
|row| row.get(0),
)?;
assert_eq!(
parent_live, 1,
"the lineage parent backing the live child must remain live"
);
let parent_receipt_live: i64 = live.query_row(
"SELECT COUNT(*) FROM chio_tool_receipts WHERE receipt_id = ?1",
rusqlite::params![parent_id],
|row| row.get(0),
)?;
assert_eq!(
parent_receipt_live, 1,
"the parent's source receipt must survive so lineage verification can resolve it"
);
let lineage_live: i64 = live.query_row(
"SELECT COUNT(*) FROM receipt_lineage_statements WHERE receipt_id = ?1 AND parent_receipt_id = ?2",
rusqlite::params![child_id, parent_id],
|row| row.get(0),
)?;
assert_eq!(
lineage_live, 1,
"the live child's lineage row must still bind it to the surviving parent"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn rotation_preserves_receipt_with_open_settlement_reconciliation(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("open-settlement-preserve");
let archive = unique_db_path("open-settlement-preserve-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("os-aged-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
for i in 4..6u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("os-fresh-{i}"),
i + 1,
500,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(3)?.is_some());
let receipt_id_at = |entry_seq: i64| -> Result<String, Box<dyn std::error::Error>> {
let connection = store.reader_connection_for_test()?;
Ok(connection.query_row(
"SELECT receipt_id FROM claim_receipt_log_entries WHERE entry_seq = ?1",
rusqlite::params![entry_seq],
|row| row.get::<_, String>(0),
)?)
};
let receipt_id = receipt_id_at(3)?;
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"INSERT INTO settlement_reconciliations (receipt_id, reconciliation_state, note, updated_at) \
VALUES (?1, 'open', NULL, 500)",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 2,
"the watermark must stop below a receipt whose settlement reconciliation is still open"
);
let live = store.reader_connection_for_test()?;
let settlement_live: i64 = live.query_row(
"SELECT COUNT(*) FROM settlement_reconciliations WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
settlement_live, 1,
"the open reconciliation row must survive so a later reconciliation can update it"
);
let receipt_live: i64 = live.query_row(
"SELECT COUNT(*) FROM chio_tool_receipts WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
receipt_live, 1,
"the receipt backing the open reconciliation must remain live for the upsert path"
);
drop(live);
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"UPDATE settlement_reconciliations SET reconciliation_state = 'reconciled', updated_at = 600 \
WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
let archived_again = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived_again, 2,
"once the reconciliation is terminal the later rotation archives the [3,4] pair"
);
let live = store.reader_connection_for_test()?;
let settlement_after: i64 = live.query_row(
"SELECT COUNT(*) FROM settlement_reconciliations WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
settlement_after, 0,
"the terminal reconciliation archives with its receipt on the later rotation"
);
drop(live);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn rotation_preserves_receipt_with_scheduled_metered_reconciliation(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("scheduled-metered-preserve");
let archive = unique_db_path("scheduled-metered-preserve-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("sm-aged-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
for i in 4..6u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("sm-fresh-{i}"),
i + 1,
500,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(3)?.is_some());
let receipt_id_at = |entry_seq: i64| -> Result<String, Box<dyn std::error::Error>> {
let connection = store.reader_connection_for_test()?;
Ok(connection.query_row(
"SELECT receipt_id FROM claim_receipt_log_entries WHERE entry_seq = ?1",
rusqlite::params![entry_seq],
|row| row.get::<_, String>(0),
)?)
};
let receipt_id = receipt_id_at(3)?;
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"INSERT INTO metered_billing_reconciliations \
(receipt_id, adapter_kind, evidence_id, observed_units, billed_cost_units, billed_cost_currency, evidence_sha256, recorded_at, reconciliation_state, note, updated_at) \
VALUES (?1, 'test', 'ev-sm', 1, 1, 'usd', NULL, 500, 'retry_scheduled', NULL, 500)",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 2,
"the watermark must stop below a receipt whose metered-billing reconciliation is scheduled"
);
let live = store.reader_connection_for_test()?;
let metered_live: i64 = live.query_row(
"SELECT COUNT(*) FROM metered_billing_reconciliations WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
metered_live, 1,
"the scheduled reconciliation row must survive for the pending retry"
);
drop(live);
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"UPDATE metered_billing_reconciliations SET reconciliation_state = 'ignored', updated_at = 600 \
WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
let archived_again = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived_again, 2,
"once the reconciliation is terminal the later rotation archives the [3,4] pair"
);
let live = store.reader_connection_for_test()?;
let metered_after: i64 = live.query_row(
"SELECT COUNT(*) FROM metered_billing_reconciliations WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
metered_after, 0,
"the terminal reconciliation archives with its receipt on the later rotation"
);
drop(live);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn flush_report_floors_committed_at_watermark_after_full_archive(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("flush-floor-watermark");
let archive = unique_db_path("flush-floor-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("ff-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole history archives");
let report = store.flush_receipt_writes()?;
assert_eq!(
report.latest_checkpointed_entry_seq, 4,
"the checkpoint chain still sits at the archived boundary"
);
assert_eq!(
report.latest_committed_entry_seq, 4,
"flush committed progress must fold in the archived prefix, not regress to 0"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn flush_reports_watermark_covered_checkpoint_from_stale_head(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("flush-stale-head-watermark");
let archive = unique_db_path("flush-stale-head-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store_a = SqliteReceiptStore::open(&path)?;
store_a.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("fs-a-{i}"),
i + 1,
100,
&keypair,
);
store_a.append_chio_receipt_returning_seq(&r)?;
}
store_a.flush_receipt_writes()?;
assert!(store_a.load_checkpoint_by_seq(1)?.is_some());
{
let store_b = SqliteReceiptStore::open_existing(&path)?;
store_b.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 2..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("fs-b-{i}"),
i + 1,
100,
&keypair,
);
store_b.append_chio_receipt_returning_seq(&r)?;
}
store_b.flush_receipt_writes()?;
assert!(store_b.load_checkpoint_by_seq(2)?.is_some());
let archived = store_b.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole checkpointed history archives");
}
let report = store_a.flush_receipt_writes()?;
assert_eq!(
report.latest_committed_entry_seq, 4,
"committed progress folds in the archived prefix"
);
assert_eq!(
report.latest_checkpointed_entry_seq, 4,
"a watermark-covered persisted checkpoint must still be reported as checkpointed"
);
assert_eq!(
report.uncheckpointed_start_seq, None,
"a fully-checkpointed, fully-archived store has no uncheckpointed range"
);
assert_eq!(report.uncheckpointed_end_seq, None);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn read_only_health_ok_on_pre_retention_schema() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("read-only-pre-retention");
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let r =
super::support::sample_receipt_with_keypair(&format!("pr-{i}"), i + 1, &keypair);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write(|connection| {
connection.execute_batch("DROP TABLE IF EXISTS receipt_retention_watermark;")?;
Ok(())
})?;
store.flush_receipt_writes()?;
}
let report = SqliteReceiptStore::receipt_store_health_read_only(&path)?;
assert!(
report.healthy,
"a pre-retention store must still report health to a read-only observer"
);
assert_eq!(report.retention_watermark_entry_seq, None);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn boundary_matching_watermark_over_live_prefix_does_not_skip_verification(
) -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::{
insert_receipt_retention_watermark, verify_checkpoint_chain_integrity,
};
let path = unique_db_path("boundary-live-watermark");
let store = SqliteReceiptStore::open(&path)?;
let keypair = super::support::receipt_test_keypair();
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let receipt =
super::support::sample_receipt_with_keypair(&format!("bm-{i}"), i + 1, &keypair);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
store.writer_handle().run_write(|connection| {
insert_receipt_retention_watermark(connection, 2, 100, "phantom-archive.sqlite3", None, 1)?;
connection.execute_batch(
"DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_update; \
UPDATE claim_receipt_log_entries SET raw_json = '{\"tampered\":true}' WHERE entry_seq = 1;",
)?;
Ok(())
})?;
let connection = store.reader_connection_for_test()?;
assert!(
verify_checkpoint_chain_integrity(&connection).is_err(),
"a boundary-matching watermark over a live prefix must not skip verification"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn catch_up_honors_archival_watermark_exemption() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("catch-up-watermark");
let archive = unique_db_path("catch-up-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path is not valid utf-8")?;
let keypair = super::support::receipt_test_keypair();
let store = store_with_archived_first_checkpoint(&path, archive_path, &keypair)?;
let connection = store.reader_connection_for_test()?;
let mut head = crate::receipt_store::VerifiedHead::default();
crate::receipt_store::catch_up_verified_head_to(&connection, &mut head, 2)?;
assert_eq!(
head.checkpoint_seq(),
2,
"the head must catch up across the archived boundary"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn read_only_health_floors_committed_at_watermark() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("read-only-fully-archived");
let archive = unique_db_path("read-only-fully-archived-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("fa-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 4, "the whole history archives");
store.flush_receipt_writes()?;
}
let report = SqliteReceiptStore::receipt_store_health_read_only(&path)?;
assert!(
report.healthy,
"a fully-archived store must read healthy from the read-only watchdog"
);
assert_eq!(report.retention_watermark_entry_seq, Some(4));
assert_eq!(
report.latest_committed_entry_seq, 4,
"committed progress must be floored at the archival watermark"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[cfg(unix)]
#[test]
fn rotation_records_absolute_archive_path() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_db_path("abs-archive-dir");
std::fs::create_dir_all(&dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("secure directory");
}
let real_archive = dir.join("archive.sqlite3");
let link = dir.join("dirlink");
std::os::unix::fs::symlink(&dir, &link)?;
let noncanonical = link.join("archive.sqlite3");
let noncanonical_str = noncanonical.to_str().ok_or("archive path not utf-8")?;
let path = unique_db_path("abs-archive-store");
let keypair = super::support::receipt_test_keypair();
let store = store_with_archived_first_checkpoint(&path, noncanonical_str, &keypair)?;
let connection = store.reader_connection_for_test()?;
let stored: String = connection.query_row(
"SELECT archive_path FROM receipt_retention_watermark \
ORDER BY archived_through_entry_seq DESC LIMIT 1",
[],
|row| row.get(0),
)?;
drop(connection);
let canonical = std::fs::canonicalize(&real_archive)?;
let canonical_str = canonical.to_str().ok_or("canonical path not utf-8")?;
assert_eq!(
stored, canonical_str,
"the ledger must record the canonical absolute archive path"
);
assert_ne!(
stored, noncanonical_str,
"the ledger must not record the non-canonical input path verbatim"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn rotation_rejects_archive_path_change_after_first() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("archive-path-change");
let archive_a = unique_db_path("archive-path-change-a");
let archive_b = unique_db_path("archive-path-change-b");
let archive_a_path = archive_a.to_str().ok_or("archive path invalid")?;
let archive_b_path = archive_b.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("a-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
for i in 2..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("b-{i}"),
i + 1,
200,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
let first = store.archive_receipts_before(150, archive_a_path)?;
assert_eq!(first, 2, "the aged [1,2] batch archives to A");
let result = store.archive_receipts_before(250, archive_b_path);
let message = result
.err()
.ok_or("expected a Conflict; rotation accepted a changed archive path")?
.to_string();
assert!(
message.contains("differs from the archive"),
"unexpected error: {message}"
);
let live = store.reader_connection_for_test()?;
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq > 2",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 2,
"no [3,4] rows deleted when the path change is rejected"
);
drop(live);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive_a);
let _ = std::fs::remove_file(&archive_b);
Ok(())
}
#[test]
fn watermark_trust_rejects_tampered_archive_contents() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::support::trusted_retention_watermark;
let path = unique_db_path("watermark-tampered");
let archive = unique_db_path("watermark-tampered-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = store_with_archived_first_checkpoint(&path, archive_path, &keypair)?;
let connection = store.reader_connection_for_test()?;
assert_eq!(trusted_retention_watermark(&connection)?, 2);
drop(connection);
{
let tampered = rusqlite::Connection::open(&archive)?;
let changed = tampered.execute(
"UPDATE claim_receipt_log_entries SET raw_json = '{\"tampered\":true}' \
WHERE entry_seq = 1",
[],
)?;
assert_eq!(changed, 1, "exactly one archived row tampered");
}
let connection = store.reader_connection_for_test()?;
assert_eq!(
trusted_retention_watermark(&connection)?,
0,
"a watermark whose archive no longer matches the signed roots must not be trusted"
);
drop(connection);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_tombstones_archived_ids_to_block_reuse() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-tombstone");
let archive = unique_db_path("repair-tombstone-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("dup-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let removed = store.retention_repair(archive_path)?;
assert_eq!(removed, 2, "repair removed the two orphaned claim-log rows");
drop(store);
let reopened = SqliteReceiptStore::open(&path)?;
let reused =
super::support::sample_receipt_with_keypair_and_timestamp("dup-0", 1, 100, &keypair);
let result = reopened.append_chio_receipt_returning_seq(&reused);
assert!(
result.is_err(),
"re-appending an archived receipt id must be rejected by the retention tombstone"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_tombstones_already_deleted_prefix_ids() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-tombstone-prefix");
let archive = unique_db_path("repair-tombstone-prefix-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("pre-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END; \
DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete; \
DELETE FROM main.claim_receipt_log_entries WHERE entry_seq = 1; \
CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_delete \
BEFORE DELETE ON claim_receipt_log_entries \
BEGIN SELECT RAISE(ABORT, 'claim receipt log entries are immutable'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let removed = store.retention_repair(archive_path)?;
assert_eq!(removed, 1, "repair removed the one surviving orphaned row");
drop(store);
let reopened = SqliteReceiptStore::open(&path)?;
let reused =
super::support::sample_receipt_with_keypair_and_timestamp("pre-0", 1, 100, &keypair);
let result = reopened.append_chio_receipt_returning_seq(&reused);
assert!(
result.is_err(),
"re-appending an already-deleted archived receipt id must be rejected by the tombstone"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_rejects_archive_that_does_not_back_the_watermark(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-wrong-backing");
let ledger_archive = unique_db_path("repair-wrong-backing-ledger");
let ledger_path = ledger_archive
.to_str()
.ok_or("ledger archive path invalid")?;
let supplied_archive = unique_db_path("repair-wrong-backing-supplied");
let supplied_path = supplied_archive
.to_str()
.ok_or("supplied archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("wb-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
store.writer_handle().run_write({
let ledger_path = ledger_path.to_string();
let supplied_path = supplied_path.to_string();
move |connection| {
let ledger_escaped = ledger_path.replace('\'', "''");
connection
.execute_batch(&format!("ATTACH DATABASE '{ledger_escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2;",
)?;
connection.execute_batch("DETACH DATABASE archive")?;
let supplied_escaped = supplied_path.replace('\'', "''");
connection
.execute_batch(&format!("ATTACH DATABASE '{supplied_escaped}' AS supplied"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS supplied.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO supplied.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq = 2; \
INSERT INTO supplied.claim_receipt_log_entries \
(entry_seq, receipt_id, receipt_kind, source_seq, timestamp, raw_json) \
VALUES (1, 'wrong-archived-id', 'tool_receipt', 1, 100, '{\"wrong\":true}');",
)?;
connection.execute_batch("DETACH DATABASE supplied")?;
connection.execute_batch(
"DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END; \
DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete; \
DELETE FROM main.claim_receipt_log_entries WHERE entry_seq = 1; \
CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_delete \
BEFORE DELETE ON claim_receipt_log_entries \
BEGIN SELECT RAISE(ABORT, 'claim receipt log entries are immutable'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
crate::receipt_store::support::insert_receipt_retention_watermark(
connection,
2,
100,
&ledger_path,
None,
1,
)?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let result = store.retention_repair(supplied_path);
let message = result
.err()
.ok_or("expected repair to reject an archive that does not back the watermark")?
.to_string();
assert!(
message.contains("differs from the archive"),
"unexpected error: {message}"
);
let live = store.reader_connection_for_test()?;
let orphan_present: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq = 2",
[],
|row| row.get(0),
)?;
assert_eq!(
orphan_present, 1,
"a rejected repair must leave the orphan for a correct re-run"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&ledger_archive);
let _ = std::fs::remove_file(&supplied_archive);
Ok(())
}
#[test]
fn rotation_archives_to_freshest_verified_checkpoint_despite_stale_head(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("rotation-verified-ceiling");
let archive = unique_db_path("rotation-verified-ceiling-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store_a = SqliteReceiptStore::open(&path)?;
store_a.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("ceil-a-{i}"),
i + 1,
100,
&keypair,
);
store_a.append_chio_receipt_returning_seq(&r)?;
}
store_a.flush_receipt_writes()?;
assert!(store_a.load_checkpoint_by_seq(1)?.is_some());
{
let store_b = SqliteReceiptStore::open_existing(&path)?;
store_b.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 2..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("ceil-b-{i}"),
i + 1,
100,
&keypair,
);
store_b.append_chio_receipt_returning_seq(&r)?;
}
store_b.flush_receipt_writes()?;
assert!(store_b.load_checkpoint_by_seq(2)?.is_some());
}
let archived = store_a.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 4,
"rotation must archive to the freshest verified checkpoint boundary"
);
let conn = store_a.reader_connection_for_test()?;
let watermark: Option<i64> = conn.query_row(
"SELECT MAX(archived_through_entry_seq) FROM receipt_retention_watermark",
[],
|r| r.get(0),
)?;
assert_eq!(
watermark,
Some(4),
"the watermark advances to the freshest verified boundary"
);
let survivors: i64 = conn.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq IN (3, 4)",
[],
|r| r.get(0),
)?;
assert_eq!(
survivors, 0,
"the aged, verified checkpoint's rows are pruned once the chain is audited"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn rotation_creates_missing_watermark_ledger_on_legacy_store(
) -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("legacy-rotation-watermark");
let archive = unique_db_path("legacy-rotation-watermark-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lgr-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
store.writer_handle().run_write(|connection| {
connection.execute_batch("DROP TABLE IF EXISTS receipt_retention_watermark;")?;
Ok(())
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(
archived, 4,
"the aged checkpointed prefix archives once the ledger is created"
);
let conn = store.reader_connection_for_test()?;
let watermark: Option<i64> = conn.query_row(
"SELECT MAX(archived_through_entry_seq) FROM receipt_retention_watermark",
[],
|r| r.get(0),
)?;
assert_eq!(
watermark,
Some(4),
"the rotation created the ledger and recorded the boundary"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn retention_tombstones_are_immutable() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("tombstone-immutable");
let store = SqliteReceiptStore::open(&path)?;
let conn = store.reader_connection_for_test()?;
conn.execute(
"INSERT INTO receipt_retention_tombstones \
(receipt_id, receipt_kind, archived_through_entry_seq, tombstoned_at) \
VALUES ('archived-1', 'tool_receipt', 5, 100)",
[],
)?;
let updated = conn.execute(
"UPDATE receipt_retention_tombstones SET archived_through_entry_seq = 999",
[],
);
assert!(
updated.is_err(),
"raw UPDATE of a tombstone must be rejected"
);
let deleted = conn.execute("DELETE FROM receipt_retention_tombstones", []);
assert!(
deleted.is_err(),
"raw DELETE of a tombstone must be rejected"
);
let (count, seq): (i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(MAX(archived_through_entry_seq), 0) FROM receipt_retention_tombstones",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
assert_eq!(count, 1);
assert_eq!(seq, 5);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn rotation_refuses_when_prior_archive_missing() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("rotation-missing-prior-archive");
let archive = unique_db_path("rotation-missing-prior-archive-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = store_with_archived_first_checkpoint(&path, archive_path, &keypair)?;
std::fs::remove_file(&archive)?;
let result = store.archive_receipts_before(600, archive_path);
let message = result
.err()
.ok_or("expected a fail-closed refusal; rotation stranded the prior prefix")?
.to_string();
assert!(
message.contains("no longer backs the committed watermark")
|| message.contains("gap in checkpoint signer binding"),
"unexpected error: {message}"
);
let conn = store.reader_connection_for_test()?;
let watermark: Option<i64> = conn.query_row(
"SELECT MAX(archived_through_entry_seq) FROM receipt_retention_watermark",
[],
|r| r.get(0),
)?;
assert_eq!(watermark, Some(2), "the watermark must not advance");
let survivors: i64 = conn.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq IN (3, 4)",
[],
|r| r.get(0),
)?;
assert_eq!(survivors, 2, "the suffix rows must survive the refusal");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_rejects_corrupted_archive_prefix() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-corrupt-prefix");
let archive = unique_db_path("repair-corrupt-prefix-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 4))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("cp-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 4; \
UPDATE archive.claim_receipt_log_entries SET raw_json = '{\"tampered\":true}' WHERE entry_seq = 1; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 4; \
DELETE FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END; \
CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_delete \
BEFORE DELETE ON claim_receipt_log_entries \
BEGIN SELECT RAISE(ABORT, 'claim_receipt_log_entries is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let result = store.retention_repair(archive_path);
let message = result
.err()
.ok_or("expected a fail-closed refusal; repair sealed a corrupted archive")?
.to_string();
assert!(
message.contains("co-archival incomplete"),
"unexpected error: {message}"
);
let conn = store.reader_connection_for_test()?;
let orphans: i64 = conn.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq IN (3, 4)",
[],
|r| r.get(0),
)?;
assert_eq!(
orphans, 2,
"surviving orphans must remain after the refusal"
);
let watermark: Option<i64> = conn.query_row(
"SELECT MAX(archived_through_entry_seq) FROM receipt_retention_watermark",
[],
|r| r.get::<_, Option<i64>>(0),
)?;
assert_eq!(watermark, None, "no watermark may seal a corrupted archive");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn repair_refuses_when_ledger_names_missing_archive() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("repair-ledger-missing-archive");
let archive = unique_db_path("repair-ledger-missing-archive-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
{
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lm-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(1)?.is_some());
store.writer_handle().run_write({
let archive_path = archive_path.to_string();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries \
(entry_seq INTEGER PRIMARY KEY, receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL, \
source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL, capability_id TEXT, session_id TEXT, \
parent_request_id TEXT, request_id TEXT, subject_key TEXT, issuer_key TEXT, tool_server TEXT, \
tool_name TEXT, raw_json TEXT NOT NULL); \
INSERT OR IGNORE INTO archive.claim_receipt_log_entries \
SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= 2; \
DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete; \
DELETE FROM main.chio_tool_receipts WHERE seq <= 2; \
CREATE TRIGGER IF NOT EXISTS chio_tool_receipts_reject_delete \
BEFORE DELETE ON chio_tool_receipts \
BEGIN SELECT RAISE(ABORT, 'chio_tool_receipts is append-only'); END;",
)?;
super::support::restore_transparency_projection_guards(connection)?;
connection.execute_batch("DETACH DATABASE archive")?;
let missing = format!("{archive_path}.missing");
crate::receipt_store::support::insert_receipt_retention_watermark(
connection, 2, 100, &missing, None, 1,
)?;
Ok(())
}
})?;
}
let store = SqliteReceiptStore::open_existing(&path)?;
let result = store.retention_repair(archive_path);
let message = result
.err()
.ok_or("expected a fail-closed refusal; repair deleted orphans behind a broken ledger")?
.to_string();
assert!(
message.contains("differs from the archive"),
"unexpected error: {message}"
);
let conn = store.reader_connection_for_test()?;
let orphans: i64 = conn.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq <= 2",
[],
|r| r.get(0),
)?;
assert_eq!(
orphans, 2,
"orphaned rows must survive a refusal over a broken ledger"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[cfg(test)]
mod state_machine {
use std::collections::BTreeSet;
use super::*;
use proptest::prelude::*;
#[derive(Clone, Debug)]
enum Op {
AppendTool(u8),
AppendChild(u8),
Rotate,
}
fn op_strategy() -> impl Strategy<Value = Op> {
prop_oneof![
(0u8..8).prop_map(Op::AppendTool),
(0u8..8).prop_map(Op::AppendChild),
Just(Op::Rotate),
]
}
fn receipt_id_set(store: &SqliteReceiptStore) -> Result<BTreeSet<String>, ReceiptStoreError> {
let connection = store.reader_connection_for_test()?;
let mut ids = BTreeSet::new();
let mut tool_statement = connection.prepare("SELECT receipt_id FROM chio_tool_receipts")?;
let tool_rows = tool_statement.query_map([], |row| row.get::<_, String>(0))?;
for id in tool_rows {
ids.insert(id?);
}
let mut child_statement =
connection.prepare("SELECT receipt_id FROM chio_child_receipts")?;
let child_rows = child_statement.query_map([], |row| row.get::<_, String>(0))?;
for id in child_rows {
ids.insert(id?);
}
Ok(ids)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(24))]
#[test]
#[ignore = "wedges CI runners; see issue #1045"]
fn prop_retention_preserves_append_invariant(ops in prop::collection::vec(op_strategy(), 1..40)) {
let path = unique_db_path("prop-retention");
let archive = unique_db_path("prop-archive");
let keypair = super::super::support::receipt_test_keypair();
let archive_path = archive.to_str().ok_or_else(|| TestCaseError::fail("archive path"))?;
let mut seq = 0u64;
let mut appended_ids: BTreeSet<String> = BTreeSet::new();
{
let store = SqliteReceiptStore::open(&path).map_err(map_err)?;
store
.enable_background_checkpoints(super::super::support::signer(&keypair, 2))
.map_err(map_err)?;
for (i, op) in ops.iter().enumerate() {
let ts = 100 + ((i as u64 * 7) % 13);
match op {
Op::AppendTool(n) => {
seq += 1;
let r = super::super::support::sample_receipt_with_keypair_and_timestamp(
&format!("pt-{seq}-{n}"), seq, ts, &keypair);
appended_ids.insert(r.id.clone());
store.append_chio_receipt_returning_seq(&r).map_err(map_err)?;
}
Op::AppendChild(n) => {
seq += 1;
let r = super::super::support::sample_child_receipt_with_keypair_seq_and_timestamp(
&format!("pc-{seq}-{n}"), seq, ts, &keypair);
appended_ids.insert(r.id.clone());
store.append_child_receipt_record(&r).map_err(map_err)?;
}
Op::Rotate => {
store.flush_receipt_writes().map_err(map_err)?;
store.archive_receipts_before(3_000, archive_path).map_err(map_err)?;
store.flush_receipt_writes().map_err(map_err)?;
prop_assert!(store.receipt_store_health().map_err(map_err)?.healthy);
}
}
seq += 1;
let probe = super::super::support::sample_receipt_with_keypair_and_timestamp(
&format!("probe-{seq}"), seq, 2_000, &keypair);
appended_ids.insert(probe.id.clone());
store.append_chio_receipt_returning_seq(&probe).map_err(map_err)?;
}
store.flush_receipt_writes().map_err(map_err)?;
prop_assert!(store.receipt_store_health().map_err(map_err)?.healthy);
}
let reopened = SqliteReceiptStore::open(&path).map_err(map_err)?;
reopened.flush_receipt_writes().map_err(map_err)?;
prop_assert!(reopened.receipt_store_health().map_err(map_err)?.healthy);
let live_ids = receipt_id_set(&reopened).map_err(map_err)?;
let archive_store = SqliteReceiptStore::open(&archive).map_err(map_err)?;
let archived_ids = receipt_id_set(&archive_store).map_err(map_err)?;
let overlap: Vec<&String> = live_ids.intersection(&archived_ids).collect();
prop_assert!(
overlap.is_empty(),
"receipt ids double-counted in both live and archive: {overlap:?}"
);
let union: BTreeSet<String> = live_ids.union(&archived_ids).cloned().collect();
prop_assert_eq!(
union,
appended_ids,
"archived and live receipt-id sets must partition the full appended history"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
}
}
fn map_err(error: ReceiptStoreError) -> TestCaseError {
TestCaseError::fail(error.to_string())
}
}
#[test]
fn delete_fails_closed_when_a_dependent_row_escapes_the_copy(
) -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::evidence_retention::{
copy_archived_prefix, create_archive_schema, delete_archived_prefix_in_tx,
};
let path = unique_db_path("toctou-delete");
let archive = unique_db_path("toctou-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("toctou-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
let receipt_id = super::support::first_tool_receipt_id(&store)?;
let fail_closed = store.writer_handle().run_write({
let archive_path = archive_path.to_string();
let receipt_id = receipt_id.clone();
move |connection| {
let escaped = archive_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
create_archive_schema(connection)?;
copy_archived_prefix(connection, 2)?;
connection.execute(
"INSERT INTO settlement_reconciliations (receipt_id, reconciliation_state, note, updated_at) \
VALUES (?1, 'settled', NULL, 1)",
rusqlite::params![receipt_id],
)?;
let result = delete_archived_prefix_in_tx(connection, 2, 150, &archive_path);
connection.execute_batch("DETACH DATABASE archive")?;
Ok(result.is_err())
}
})?;
assert!(
fail_closed,
"the delete must fail closed when a dependent row is not in the archive"
);
let live = store.reader_connection_for_test()?;
let live_receipts: i64 = live.query_row(
"SELECT COUNT(*) FROM chio_tool_receipts WHERE seq <= 2",
[],
|row| row.get(0),
)?;
assert_eq!(
live_receipts, 2,
"the archived prefix must survive the refusal"
);
let live_settlement: i64 = live.query_row(
"SELECT COUNT(*) FROM settlement_reconciliations",
[],
|row| row.get(0),
)?;
assert_eq!(
live_settlement, 1,
"the un-archived reconciliation must survive the refusal"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}
#[test]
fn delete_rechecks_archive_path_under_the_write_lock() -> Result<(), Box<dyn std::error::Error>> {
use crate::receipt_store::evidence_retention::{
copy_archived_prefix, create_archive_schema, delete_archived_prefix_in_tx,
};
let path = unique_db_path("toctou-path-split");
let archive_a = unique_db_path("toctou-path-split-a");
let archive_b = unique_db_path("toctou-path-split-b");
let archive_a_path = archive_a.to_str().ok_or("archive path invalid")?;
let archive_b_path = archive_b.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("a-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
for i in 2..4u64 {
let r = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("b-{i}"),
i + 1,
200,
&keypair,
);
store.append_chio_receipt_returning_seq(&r)?;
}
store.flush_receipt_writes()?;
assert!(store.load_checkpoint_by_seq(2)?.is_some());
let first = store.archive_receipts_before(150, archive_a_path)?;
assert_eq!(first, 2, "the aged [1,2] batch archives to A");
let refusal = store.writer_handle().run_write({
let archive_b_path = archive_b_path.to_string();
move |connection| {
let escaped = archive_b_path.replace('\'', "''");
connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
create_archive_schema(connection)?;
copy_archived_prefix(connection, 4)?;
let result = delete_archived_prefix_in_tx(connection, 4, 250, &archive_b_path);
connection.execute_batch("DETACH DATABASE archive")?;
Ok(result.err().map(|error| error.to_string()))
}
})?;
let message = refusal
.ok_or("the delete must fail closed when a concurrent rotation split the archive path")?;
assert!(
message.contains("differs from the archive"),
"expected the archive-path pin to fire under the write lock, got: {message}"
);
let live = store.reader_connection_for_test()?;
let live_log: i64 = live.query_row(
"SELECT COUNT(*) FROM claim_receipt_log_entries WHERE entry_seq > 2",
[],
|row| row.get(0),
)?;
assert_eq!(
live_log, 2,
"no [3,4] rows may be deleted when the locked path re-check rejects the split"
);
drop(live);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive_a);
let _ = std::fs::remove_file(&archive_b);
Ok(())
}
#[test]
fn governed_receipt_lineage_is_co_archived() -> Result<(), Box<dyn std::error::Error>> {
let path = unique_db_path("lineage-archived");
let archive = unique_db_path("lineage-archive");
let archive_path = archive.to_str().ok_or("archive path invalid")?;
let keypair = super::support::receipt_test_keypair();
let store = SqliteReceiptStore::open(&path)?;
store.enable_background_checkpoints(super::support::signer(&keypair, 2))?;
for i in 0..2u64 {
let receipt = super::support::sample_receipt_with_keypair_and_timestamp(
&format!("lineage-{i}"),
i + 1,
100,
&keypair,
);
store.append_chio_receipt_returning_seq(&receipt)?;
}
store.flush_receipt_writes()?;
let receipt_id = super::support::first_tool_receipt_id(&store)?;
store.writer_handle().run_write({
let receipt_id = receipt_id.clone();
move |connection| {
connection.execute(
"INSERT INTO receipt_lineage_statements \
(receipt_id, statement_id, request_id, session_id, session_anchor_id, chain_id, \
parent_request_id, parent_receipt_id, evidence_class, evidence_sources_json, \
verified_session_anchor, verified_parent_request, verified_parent_receipt, \
replay_protected, recorded_at, source_kind, json_sha256, raw_json) \
VALUES (?1, 'stmt-lineage-0', NULL, NULL, NULL, 'chain-lineage-0', NULL, \
'parent-receipt-lineage-0', 'delegated', NULL, 0, 0, 1, 0, 100, 'test', \
'sha-lineage-0', '{\"schema\":\"lineage\"}')",
rusqlite::params![receipt_id],
)?;
Ok(())
}
})?;
let archived = store.archive_receipts_before(150, archive_path)?;
assert_eq!(archived, 2);
let live = store.reader_connection_for_test()?;
let live_lineage: i64 = live.query_row(
"SELECT COUNT(*) FROM receipt_lineage_statements WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| row.get(0),
)?;
assert_eq!(
live_lineage, 1,
"the live lineage row must survive rotation"
);
let archive_store = SqliteReceiptStore::open_existing(&archive)?;
let arch = archive_store.reader_connection_for_test()?;
let (arch_lineage, arch_chain, arch_parent): (i64, Option<String>, Option<String>) = arch
.query_row(
"SELECT COUNT(*), MAX(chain_id), MAX(parent_receipt_id) \
FROM receipt_lineage_statements WHERE receipt_id = ?1",
rusqlite::params![receipt_id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(
arch_lineage, 1,
"the governed receipt's lineage statement must be co-archived"
);
assert_eq!(arch_chain.as_deref(), Some("chain-lineage-0"));
assert_eq!(arch_parent.as_deref(), Some("parent-receipt-lineage-0"));
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&archive);
Ok(())
}