Skip to main content

chio_store_sqlite/receipt_store/
evidence_retention.rs

1use super::support::{
2    ensure_checkpoint_transparency_guards, ensure_transparency_projection_guards,
3    insert_receipt_retention_watermark, load_claim_tree_canonical_bytes_range,
4    load_persisted_checkpoint_row, parse_persisted_checkpoint_row, store_kernel_checkpoint_atomic,
5};
6use super::*;
7
8pub(crate) fn receipt_query_sql(
9    query: &ReceiptQuery,
10    tenant_fragment: &str,
11) -> Result<(String, String), ReceiptStoreError> {
12    let currency = query
13        .validated_cost_currency()
14        .map_err(ReceiptStoreError::ReadBoundary)?;
15    let cost_fragment = match (
16        query.min_cost.is_some(),
17        query.max_cost.is_some(),
18        currency.is_some(),
19    ) {
20        (false, false, false) => "AND ?13 IS NULL",
21        (false, false, true) => "AND r.cost_currency = ?13",
22        (true, false, true) => "AND r.cost_currency = ?13 AND r.cost_charged_be >= ?7",
23        (false, true, true) => "AND r.cost_currency = ?13 AND r.cost_charged_be <= ?8",
24        (true, true, true) => {
25            "AND r.cost_currency = ?13 AND r.cost_charged_be >= ?7 AND r.cost_charged_be <= ?8"
26        }
27        _ => {
28            return Err(ReceiptStoreError::ReadBoundary(
29                "receipt query cost bounds require a currency".to_string(),
30            ))
31        }
32    };
33    let from_where = format!(
34        r#"
35        FROM chio_tool_receipts r
36        LEFT JOIN capability_lineage cl ON r.capability_id = cl.capability_id
37        WHERE (?1 IS NULL OR r.capability_id = ?1)
38          AND (?2 IS NULL OR r.tool_server = ?2)
39          AND (?3 IS NULL OR r.tool_name = ?3)
40          AND (?4 IS NULL OR r.decision_kind = ?4)
41          AND (?5 IS NULL OR r.timestamp >= ?5)
42          AND (?6 IS NULL OR r.timestamp <= ?6)
43          {cost_fragment}
44          AND (?9 IS NULL OR COALESCE(r.subject_key, cl.subject_key) = ?9)
45          AND {tenant_fragment}
46    "#
47    );
48    let data_sql = format!(
49        r#"
50        SELECT r.seq, r.raw_json
51        {from_where}
52          AND (?10 IS NULL OR r.seq > ?10)
53        ORDER BY r.seq ASC
54        LIMIT ?11
55    "#
56    );
57    let count_sql = format!("SELECT COUNT(*) {from_where}");
58    Ok((data_sql, count_sql))
59}
60
61impl SqliteReceiptStore {
62    pub fn append_chio_receipt_returning_seq(
63        &self,
64        receipt: &ChioReceipt,
65    ) -> Result<u64, ReceiptStoreError> {
66        let raw_json = serde_json::to_string(receipt)?;
67        self.append_verified_chio_receipt_record(receipt, &raw_json, false)
68    }
69
70    /// Store a signed KernelCheckpoint in the kernel_checkpoints table.
71    pub fn store_checkpoint(&self, checkpoint: &KernelCheckpoint) -> Result<(), ReceiptStoreError> {
72        let checkpoint = checkpoint.clone();
73        self.writer_handle()
74            .run_write(move |connection| store_kernel_checkpoint_atomic(connection, &checkpoint))
75    }
76
77    /// Load a KernelCheckpoint by its checkpoint_seq.
78    pub fn load_checkpoint_by_seq(
79        &self,
80        checkpoint_seq: u64,
81    ) -> Result<Option<KernelCheckpoint>, ReceiptStoreError> {
82        let connection = self.connection()?;
83        ensure_checkpoint_transparency_guards(&connection)?;
84        load_persisted_checkpoint_row(&connection, checkpoint_seq)?
85            .map(parse_persisted_checkpoint_row)
86            .transpose()
87    }
88
89    /// Return canonical JSON bytes for receipts with seq in [start_seq, end_seq], ordered by seq.
90    ///
91    /// Uses RFC 8785 canonical JSON for deterministic Merkle leaf hashing.
92    pub fn receipts_canonical_bytes_range(
93        &self,
94        start_seq: u64,
95        end_seq: u64,
96    ) -> Result<Vec<(u64, Vec<u8>)>, ReceiptStoreError> {
97        let connection = self.connection()?;
98        load_claim_tree_canonical_bytes_range(&connection, start_seq, end_seq)
99    }
100
101    /// Return the current on-disk size of the database in bytes.
102    ///
103    /// Uses `PRAGMA page_count` and `PRAGMA page_size` to compute the size
104    /// without requiring a filesystem stat, which is consistent in WAL mode.
105    pub fn db_size_bytes(&self) -> Result<u64, ReceiptStoreError> {
106        let page_count: i64 = self
107            .connection()?
108            .query_row("PRAGMA page_count", [], |row| row.get(0))?;
109        let page_size: i64 = self
110            .connection()?
111            .query_row("PRAGMA page_size", [], |row| row.get(0))?;
112        Ok((page_count.max(0) as u64) * (page_size.max(0) as u64))
113    }
114
115    /// Live logical size in bytes: `(page_count - freelist_count) * page_size`.
116    /// Unlike `db_size_bytes` (on-disk, freelist included), this drops after an
117    /// archival delete plus incremental_vacuum, so a size-driven rotation
118    /// trigger converges instead of re-firing on freed-but-not-reclaimed pages.
119    pub fn live_db_size_bytes(&self) -> Result<u64, ReceiptStoreError> {
120        let connection = self.connection()?;
121        live_db_size_bytes_on_connection(&connection)
122    }
123
124    /// Return the Unix timestamp (seconds) of the oldest receipt in the live
125    /// database, or `None` if there are no receipts.
126    pub fn oldest_receipt_timestamp(&self) -> Result<Option<u64>, ReceiptStoreError> {
127        let ts = self.connection()?.query_row(
128            "SELECT MIN(timestamp) FROM chio_tool_receipts",
129            [],
130            |row| row.get::<_, Option<i64>>(0),
131        )?;
132        Ok(ts.map(|t| t.max(0) as u64))
133    }
134
135    /// Return the oldest live receipt timestamp for a tenant.
136    pub fn oldest_receipt_timestamp_for_tenant(
137        &self,
138        tenant_id: &str,
139    ) -> Result<Option<u64>, ReceiptStoreError> {
140        let ts = self.connection()?.query_row(
141            "SELECT MIN(timestamp) FROM chio_tool_receipts WHERE tenant_id = ?1",
142            params![tenant_id],
143            |row| row.get::<_, Option<i64>>(0),
144        )?;
145        Ok(ts.map(|t| t.max(0) as u64))
146    }
147
148    /// Archive all receipts whose entire checkpointed prefix has aged past
149    /// `cutoff_unix_secs`, co-archiving the claim-log projection and
150    /// reconciliation evidence, then deleting the archived range from the live
151    /// store, all on the single writer connection. Returns the number of
152    /// tool-receipt rows archived.
153    pub fn archive_receipts_before(
154        &self,
155        cutoff_unix_secs: u64,
156        archive_path: &str,
157    ) -> Result<u64, ReceiptStoreError> {
158        let config = RetentionConfig {
159            retention_days: 0,
160            max_size_bytes: u64::MAX,
161            archive_path: archive_path.to_string(),
162            tenant_id: None,
163            explicit_cutoff_unix_secs: Some(cutoff_unix_secs),
164            ..RetentionConfig::default()
165        };
166        // archive_receipts_before is the explicit-cutoff entry point, so bypass
167        // rotate_if_needed's day/size threshold math and dispatch the cutoff
168        // directly to the writer actor.
169        self.dispatch_rotate(Box::new(config), Some(cutoff_unix_secs))
170    }
171
172    /// Check the time and size thresholds and, if either is exceeded, run a
173    /// checkpoint-aligned rotation on the writer connection.
174    ///
175    /// - Time threshold: receipts older than `config.retention_days` days age
176    ///   out of the checkpointed prefix.
177    /// - Size threshold: if the live database size exceeds `max_size_bytes`,
178    ///   the median-timestamp cutoff archives roughly the checkpointed half.
179    ///
180    /// Returns the number of tool-receipt rows archived (0 when no whole
181    /// checkpointed batch has fully aged, i.e. a no-op rotation).
182    pub fn rotate_if_needed(&self, config: &RetentionConfig) -> Result<u64, ReceiptStoreError> {
183        self.dispatch_rotate(Box::new(config.clone()), None)
184    }
185
186    fn dispatch_rotate(
187        &self,
188        config: Box<RetentionConfig>,
189        explicit_cutoff: Option<u64>,
190    ) -> Result<u64, ReceiptStoreError> {
191        if config.tenant_id.is_some() {
192            // Tenant-scoped archival is not expressible as a prefix watermark,
193            // so reject here before any partial work runs.
194            return Err(ReceiptStoreError::RetentionTenantScopeUnsupported);
195        }
196        let config = match explicit_cutoff {
197            Some(cutoff) => {
198                let mut config = config;
199                config.retention_days = 0;
200                config.explicit_cutoff_unix_secs = Some(cutoff);
201                config
202            }
203            None => config,
204        };
205        let (response, result) = std::sync::mpsc::sync_channel(1);
206        // A rotation is an in-flight writer just like an append or a Write job:
207        // increment BEFORE handing the command to the actor so a concurrent
208        // `receipt_store_health` cannot observe a dequeued-but-uncounted
209        // rotation, mirroring `ReceiptCommitActor::append` and
210        // `WriterHandle::run_write_kind`. The Rotate actor arm decrements
211        // unconditionally on dequeue; any send or recv failure here undoes the
212        // speculative increment so a rejected rotation never leaks inflight.
213        let health = &self.receipt_commit_actor.health;
214        health
215            .inflight
216            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
217        if let Err(error) = self
218            .receipt_commit_actor
219            .sender
220            .try_send(ReceiptCommitCommand::Rotate { config, response })
221        {
222            atomic_saturating_sub(&health.inflight, 1);
223            return Err(match error {
224                std::sync::mpsc::TrySendError::Full(_) => receipt_actor_saturated_error(),
225                std::sync::mpsc::TrySendError::Disconnected(_) => receipt_actor_unavailable_error(),
226            });
227        }
228        match result.recv() {
229            Ok(outcome) => outcome,
230            Err(_) => {
231                atomic_saturating_sub(&health.inflight, 1);
232                Err(receipt_actor_unavailable_error())
233            }
234        }
235    }
236
237    /// Internal implementation for `query_receipts` (called from `receipt_query` module).
238    ///
239    /// Requires access to the private `connection` field, so it lives here in `receipt_store`.
240    pub(crate) fn query_receipts_impl(
241        &self,
242        query: &ReceiptQuery,
243    ) -> Result<ReceiptQueryResult, ReceiptStoreError> {
244        // Validate the `outcome` filter against the known decision_kind values.
245        // Silently accepting unknown values would return zero results and could
246        // mask caller bugs; fail explicitly instead.
247        const VALID_OUTCOMES: &[&str] = &["allow", "deny", "cancelled", "incomplete"];
248        if let Some(outcome) = query.outcome.as_deref() {
249            if !VALID_OUTCOMES.contains(&outcome) {
250                return Err(ReceiptStoreError::InvalidOutcome(format!(
251                    "unknown outcome filter {:?}; valid values are: allow, deny, cancelled, incomplete",
252                    outcome
253                )));
254            }
255        }
256
257        let limit = query.limit.clamp(1, MAX_QUERY_LIMIT);
258
259        // Receipt read isolation: admin contexts can read all rows, tenant
260        // contexts see exact tenant rows by default, and local compatibility
261        // mode may include NULL-tenant (pre-multitenant) rows.
262        let read_scope = query
263            .effective_read_scope()
264            .map_err(ReceiptStoreError::ReadBoundary)?;
265        let tenant_fragment = match (
266            read_scope.tenant.as_deref(),
267            read_scope.include_null_tenant && !self.strict_tenant_isolation_enabled(),
268        ) {
269            (None, _) => "(?12 IS NULL)",
270            (Some(_), true) => "(r.tenant_id = ?12 OR r.tenant_id IS NULL)",
271            (Some(_), false) => "(r.tenant_id = ?12)",
272        };
273
274        let (data_sql, count_sql) = receipt_query_sql(query, tenant_fragment)?;
275
276        let cap_id = query.capability_id.as_deref();
277        let tool_srv = query.tool_server.as_deref();
278        let tool_nm = query.tool_name.as_deref();
279        let outcome = query.outcome.as_deref();
280        let since = query.since.map(|v| v as i64);
281        let until = query.until.map(|v| v as i64);
282        let min_cost = query.min_cost.map(|value| value.to_be_bytes().to_vec());
283        let max_cost = query.max_cost.map(|value| value.to_be_bytes().to_vec());
284        let agent_sub = query.agent_subject.as_deref();
285        let tenant = read_scope.tenant.as_deref();
286        let cost_currency = query.cost_currency.as_deref();
287        let mut connection = self.connection()?;
288        let transaction =
289            connection.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
290        // Convert cursor to signed i64 for SQLite. SQLite AUTOINCREMENT seq
291        // values are bounded by i64::MAX; a cursor above that can never be
292        // exceeded. Convert with a checked cast: on overflow return an empty
293        // receipts page (the cursor excludes everything) while still reporting
294        // the correct total_count for the uncursored filter set.
295        let cursor_i64: Option<i64> = match query.cursor {
296            None => None,
297            Some(c) => match i64::try_from(c) {
298                Ok(v) => Some(v),
299                Err(_) => {
300                    // cursor > i64::MAX: no AUTOINCREMENT seq can exceed it.
301                    // Run only the count query (no cursor applied) and return empty.
302                    // ?10 and ?11 (cursor/limit) are not used in the count query
303                    // but must still bind placeholders if we reuse `params!`;
304                    // the count SQL uses only ?1..=?9 and ?12, so we need to
305                    // bind ?10 and ?11 as NULL / 0 to keep indexes stable.
306                    let total_count: u64 = transaction
307                        .query_row(
308                            &count_sql,
309                            params![
310                                cap_id,
311                                tool_srv,
312                                tool_nm,
313                                outcome,
314                                since,
315                                until,
316                                min_cost,
317                                max_cost,
318                                agent_sub,
319                                // ?10, ?11 unused in count_sql but bound so ?12
320                                // resolves to the tenant filter.
321                                None::<i64>,
322                                0i64,
323                                tenant,
324                                cost_currency,
325                            ],
326                            |row| row.get::<_, i64>(0),
327                        )
328                        .map(|n| n.max(0) as u64)?;
329                    transaction.commit()?;
330                    return Ok(ReceiptQueryResult {
331                        receipts: Vec::new(),
332                        total_count,
333                        next_cursor: None,
334                    });
335                }
336            },
337        };
338
339        let receipts = {
340            let mut statement = transaction.prepare(&data_sql)?;
341            let rows = statement.query_map(
342                params![
343                    cap_id,
344                    tool_srv,
345                    tool_nm,
346                    outcome,
347                    since,
348                    until,
349                    min_cost,
350                    max_cost,
351                    agent_sub,
352                    cursor_i64,
353                    limit as i64,
354                    tenant,
355                    cost_currency,
356                ],
357                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
358            )?;
359
360            let mut receipts = Vec::new();
361            for row in rows {
362                let (seq, raw_json) = row?;
363                let seq = seq.max(0) as u64;
364                let receipt =
365                    decode_verified_chio_receipt(&raw_json, "persisted tool receipt", Some(seq))?;
366                receipts.push(StoredToolReceipt { seq, receipt });
367            }
368            receipts
369        };
370
371        let total_count: u64 = transaction
372            .query_row(
373                &count_sql,
374                params![
375                    cap_id,
376                    tool_srv,
377                    tool_nm,
378                    outcome,
379                    since,
380                    until,
381                    min_cost,
382                    max_cost,
383                    agent_sub,
384                    // ?10, ?11 unused in count_sql; bound to keep ?12 stable.
385                    None::<i64>,
386                    0i64,
387                    tenant,
388                    cost_currency,
389                ],
390                |row| row.get::<_, i64>(0),
391            )
392            .map(|n| n.max(0) as u64)?;
393
394        // next_cursor is Some(last_seq) when the page is full (more results may exist).
395        let next_cursor = if receipts.len() == limit {
396            receipts.last().map(|r| r.seq)
397        } else {
398            None
399        };
400        transaction.commit()?;
401
402        Ok(ReceiptQueryResult {
403            receipts,
404            total_count,
405            next_cursor,
406        })
407    }
408}
409
410/// Largest checkpoint `batch_end_seq` whose entire covered prefix (in the
411/// entry_seq domain) has aged past `cutoff`, never splits a live
412/// authorization-consumption pair, never strands a live receipt from its
413/// lineage parent, and never archives a receipt whose reconciliation is still
414/// nonterminal. 0 when no whole checkpointed batch qualifies (a no-op
415/// rotation).
416///
417/// An authorization receipt and the consumer receipt that consumed it are bound
418/// by a `chio_authorization_receipt_consumptions` row keyed on the authorization
419/// (a RESTRICT foreign key). Archiving-and-deleting the authorization while its
420/// consumer is still live would either strand that row (dropping the live
421/// consumer's replay/audit binding) or fail closed on the RESTRICT delete. The
422/// watermark therefore never advances past an authorization whose bound consumer
423/// still sits above the boundary: the whole pair archives together on a later
424/// rotation once the consumer has also aged out.
425///
426/// Governed receipts carry the same hazard through receipt lineage. A surviving
427/// child receipt records its parent in `receipt_lineage_statements.parent_receipt_id`,
428/// and `receipt_lineage_verification` / `ReceiptStore::load_chio_receipt` resolve
429/// that parent only in the LIVE receipt tables. Deleting a parent whose live
430/// child still sits above the boundary would leave the child unable to resolve
431/// its verified parent, breaking governed call-chain validation and explain.
432/// The watermark therefore also never advances past a lineage parent whose child
433/// is still live; the parent is preserved until the child ages out and the whole
434/// lineage archives together on a later rotation.
435///
436/// Reconciliation rows carry the hazard in the receipt itself. A receipt's
437/// settlement or metered-billing reconciliation lives in
438/// `settlement_reconciliations` / `metered_billing_reconciliations`, keyed on the
439/// receipt, and both upsert paths require that receipt to still exist in
440/// `chio_tool_receipts` before they can record progress. While a reconciliation
441/// is nonterminal (`open` or `retry_scheduled`) it is an actionable item that
442/// live operator reports display and a later reconciliation attempt must be able
443/// to update. Archiving-and-deleting the receipt drops the only live row those
444/// paths can find, so the actionable item disappears and the next reconciliation
445/// returns NotFound. The watermark therefore never advances past a receipt with a
446/// nonterminal reconciliation row; that receipt and its reconciliation archive
447/// together on a later rotation once the reconciliation reaches a terminal state
448/// (`reconciled` or `ignored`).
449///
450/// Settlement attempts are likewise live work. Every row in `settle_attempts`
451/// is pending or retryable, so its receipt remains in the live store until the
452/// attempt is resolved and removed. Legacy stores may not have that projection;
453/// they retain the pre-settlement retention behavior.
454fn compute_archival_watermark(
455    connection: &rusqlite::Connection,
456    cutoff_unix_secs: u64,
457) -> Result<u64, ReceiptStoreError> {
458    let cutoff = sqlite_i64(cutoff_unix_secs, "retention cutoff")?;
459    let settlement_attempts_installed: bool = connection.query_row(
460        "SELECT EXISTS(SELECT 1 FROM main.sqlite_master \
461         WHERE type = 'table' AND name = 'settle_attempts')",
462        [],
463        |row| row.get(0),
464    )?;
465    let active_settlement_guard = if settlement_attempts_installed {
466        r#"
467        AND NOT EXISTS (
468            SELECT 1 FROM settle_attempts sa
469            JOIN claim_receipt_log_entries e ON e.receipt_id = sa.receipt_id
470            WHERE e.entry_seq <= kc.batch_end_seq
471        )
472        "#
473    } else {
474        ""
475    };
476    let query = format!(
477        r#"
478        SELECT COALESCE(MAX(kc.batch_end_seq), 0)
479        FROM kernel_checkpoints kc
480        WHERE NOT EXISTS (
481            SELECT 1 FROM claim_receipt_log_entries e
482            WHERE e.entry_seq <= kc.batch_end_seq
483              AND e.timestamp >= ?1
484        )
485        AND NOT EXISTS (
486            SELECT 1 FROM chio_authorization_receipt_consumptions ac
487            JOIN claim_receipt_log_entries ae ON ae.receipt_id = ac.authorization_receipt_id
488            JOIN claim_receipt_log_entries ce ON ce.receipt_id = ac.consumer_receipt_id
489            WHERE ae.entry_seq <= kc.batch_end_seq
490              AND ce.entry_seq > kc.batch_end_seq
491        )
492        AND NOT EXISTS (
493            SELECT 1 FROM receipt_lineage_statements ls
494            JOIN claim_receipt_log_entries pe ON pe.receipt_id = ls.parent_receipt_id
495            JOIN claim_receipt_log_entries ce ON ce.receipt_id = ls.receipt_id
496            WHERE pe.entry_seq <= kc.batch_end_seq
497              AND ce.entry_seq > kc.batch_end_seq
498        )
499        AND NOT EXISTS (
500            SELECT 1 FROM settlement_reconciliations sr
501            JOIN claim_receipt_log_entries e ON e.receipt_id = sr.receipt_id
502            WHERE e.entry_seq <= kc.batch_end_seq
503              AND sr.reconciliation_state IN ('open', 'retry_scheduled')
504        )
505        AND NOT EXISTS (
506            SELECT 1 FROM metered_billing_reconciliations mbr
507            JOIN claim_receipt_log_entries e ON e.receipt_id = mbr.receipt_id
508            WHERE e.entry_seq <= kc.batch_end_seq
509              AND mbr.reconciliation_state IN ('open', 'retry_scheduled')
510        )
511        {active_settlement_guard}
512        "#
513    );
514    let watermark: i64 = connection.query_row(&query, params![cutoff], |row| row.get(0))?;
515    sqlite_u64(watermark, "retention watermark")
516}
517
518/// Resolve the effective cutoff for a rotation config (explicit cutoff wins;
519/// else the day/size thresholds from `rotate_if_needed`'s contract).
520fn resolve_rotation_cutoff(
521    connection: &rusqlite::Connection,
522    config: &RetentionConfig,
523) -> Result<Option<u64>, ReceiptStoreError> {
524    if let Some(cutoff) = config.explicit_cutoff_unix_secs {
525        return Ok(Some(cutoff));
526    }
527    let now = SystemTime::now()
528        .duration_since(UNIX_EPOCH)
529        .map(|d| d.as_secs())
530        .unwrap_or(0);
531    let time_cutoff = now.saturating_sub(config.retention_days.saturating_mul(86_400));
532    // Trigger thresholds are measured over the claim receipt log, which projects
533    // BOTH tool and child receipts. Reading only chio_tool_receipts would leave a
534    // store whose aged (or oldest-checkpointed) evidence is child-only stuck below
535    // every trigger, even though the rotation/delete path co-archives child rows
536    // once a cutoff is chosen.
537    let oldest: Option<i64> = connection.query_row(
538        "SELECT MIN(timestamp) FROM claim_receipt_log_entries",
539        [],
540        |row| row.get::<_, Option<i64>>(0),
541    )?;
542    // Both triggers are evaluated and the rotation uses whichever cutoff frees
543    // more (the max). The time cutoff alone can archive nothing, for example when
544    // a still-fresh receipt sits early in the checkpointed prefix, because
545    // `compute_archival_watermark` requires a checkpoint's ENTIRE covered prefix
546    // to be older than the cutoff. Returning that cutoff before the size check
547    // would let an oversized store stay oversized indefinitely even while the
548    // size trigger is exceeded, since the maintenance worker would keep applying
549    // a no-op time cutoff. Taking the max lets the size-relief cutoff apply
550    // whenever the store is over its limit, independent of the time trigger.
551    let mut cutoff: Option<u64> = None;
552    if let Some(oldest_ts) = oldest {
553        if (oldest_ts.max(0) as u64) < time_cutoff {
554            cutoff = Some(time_cutoff);
555        }
556    }
557    // Size trigger: measured against live_db_size_bytes (freelist-adjusted),
558    // not the raw on-disk db_size_bytes, so the trigger converges.
559    let size = live_db_size_bytes_on_connection(connection)?;
560    if size > config.max_size_bytes {
561        let median: Option<i64> = connection
562            .query_row(
563                "SELECT timestamp FROM claim_receipt_log_entries ORDER BY timestamp \
564                 LIMIT 1 OFFSET (SELECT COUNT(*) FROM claim_receipt_log_entries) / 2",
565                [],
566                |row| row.get(0),
567            )
568            .optional()?;
569        if let Some(median_ts) = median {
570            // `compute_archival_watermark` archives a checkpoint only when its
571            // entire prefix is strictly older than the cutoff (a row with
572            // `timestamp >= cutoff` blocks its batch). When many receipts share
573            // the median timestamp (second-resolution or bursty traffic), a
574            // cutoff equal to that timestamp blocks every batch containing one,
575            // so size rotation would archive nothing and never converge below
576            // `max_size_bytes`. Advance the cutoff one second past the median so
577            // receipts at the median become eligible and a checkpointed prefix
578            // can actually age out.
579            let size_cutoff = (median_ts.max(0) as u64).saturating_add(1);
580            cutoff = Some(cutoff.map_or(size_cutoff, |current| current.max(size_cutoff)));
581        }
582    }
583    Ok(cutoff)
584}
585
586/// Live logical size backing `SqliteReceiptStore::live_db_size_bytes`:
587/// `(page_count - freelist_count) * page_size`. Freelist pages are excluded
588/// so an archival delete plus `incremental_vacuum` strictly reduces this
589/// value and the size rotation trigger in `resolve_rotation_cutoff` converges
590/// instead of re-firing on freed-but-not-reclaimed pages.
591fn live_db_size_bytes_on_connection(
592    connection: &rusqlite::Connection,
593) -> Result<u64, ReceiptStoreError> {
594    let page_count: i64 = connection.query_row("PRAGMA page_count", [], |row| row.get(0))?;
595    let freelist_count: i64 =
596        connection.query_row("PRAGMA freelist_count", [], |row| row.get(0))?;
597    let page_size: i64 = connection.query_row("PRAGMA page_size", [], |row| row.get(0))?;
598    let live_pages = (page_count - freelist_count).max(0);
599    Ok((live_pages as u64) * (page_size.max(0) as u64))
600}
601
602/// Materialize the sibling archive database file before a rotation `ATTACH`es
603/// it.
604///
605/// The live store's writer connection is opened READ_WRITE without
606/// `SQLITE_OPEN_CREATE` so that opening a store whose main database is missing
607/// fails closed instead of silently creating an empty one. `ATTACH DATABASE`
608/// inherits the main connection's open flags, so the first rotation against such
609/// a store would be unable to create a not-yet-existing archive and would fail.
610/// Opening the archive path once with `SQLITE_OPEN_CREATE` creates an empty
611/// database file (a zero-length file is a valid empty SQLite database) that the
612/// subsequent no-CREATE `ATTACH` can open, without loosening the main store's
613/// flags. An already-present archive is opened and closed unchanged, so this is
614/// a no-op on every rotation after the first and never rewrites a prior archive;
615/// the co-archival identity verification still fails closed on a foreign or
616/// divergent archive.
617fn ensure_archive_file_exists(archive_path: &str) -> Result<(), ReceiptStoreError> {
618    let flags = rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
619        | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
620        | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX;
621    let connection = rusqlite::Connection::open_with_flags(archive_path, flags)?;
622    drop(connection);
623    Ok(())
624}
625
626/// Resolve `archive_path` to an absolute, symlink-free location so the watermark
627/// ledger records a path that is stable across process working directories and
628/// restarts.
629///
630/// The recorded archive path is read back by the watermark-trust check
631/// (`archived_prefix_is_backed`), which opens it to confirm the deleted prefix
632/// still survives in the archive. A relative path stored verbatim (including the
633/// default `receipts-archive.sqlite3`) resolves against whatever working
634/// directory the reader happens to run in, so a restart or a CLI health check
635/// launched from a different directory would find no archive, withdraw the
636/// exemption, and then fail full verification because the live prefix was
637/// intentionally deleted. Canonicalizing before the insert ties the recorded
638/// location to the real file. Fail-closed: the archive is expected to exist by
639/// this point (it was just materialized and co-archived into), so a path that
640/// cannot be resolved aborts the rotation rather than sealing an unstable
641/// relative path behind a trusted watermark.
642fn absolute_archive_path(archive_path: &str) -> Result<String, ReceiptStoreError> {
643    let canonical = std::fs::canonicalize(std::path::Path::new(archive_path)).map_err(|error| {
644        ReceiptStoreError::Conflict(format!(
645            "retention archive path {archive_path:?} could not be resolved to an absolute path: \
646             {error}"
647        ))
648    })?;
649    canonical.to_str().map(str::to_owned).ok_or_else(|| {
650        ReceiptStoreError::Conflict(format!(
651            "retention archive path {archive_path:?} is not valid UTF-8 after canonicalization"
652        ))
653    })
654}
655
656/// Reject an archive target that could destroy the only copy of the evidence a
657/// rotation is about to delete.
658///
659/// `ATTACH DATABASE` accepts non-durable and aliasing targets that silently
660/// break the co-archive-then-delete contract:
661///
662/// - An in-memory or temporary target (`:memory:`, an empty path, or a
663///   `mode=memory` URI) is destroyed on `DETACH`, so co-archiving into it and
664///   then deleting the live prefix leaves no copy of the archived rows behind a
665///   recorded watermark.
666/// - A target that aliases the live database (the same path, or a hard link to
667///   it) makes SQLite attach the live file itself as `archive`, so the
668///   co-archival identity checks compare the table to itself and pass trivially,
669///   then the delete removes the only copy while still recording a watermark.
670///
671/// Fail closed on both before the ATTACH so a rotation never records a watermark
672/// it cannot back with durable, independent evidence.
673fn ensure_durable_distinct_archive_path(
674    connection: &rusqlite::Connection,
675    archive_path: &str,
676) -> Result<(), ReceiptStoreError> {
677    let trimmed = archive_path.trim();
678    let lowered = trimmed.to_ascii_lowercase();
679    if trimmed.is_empty() || lowered == ":memory:" || lowered.contains("mode=memory") {
680        return Err(ReceiptStoreError::Conflict(format!(
681            "retention archive path {archive_path:?} is not a durable database file; refusing to \
682             co-archive evidence into a target destroyed on detach"
683        )));
684    }
685    // The live main database's backing file. Empty for an in-memory/temporary
686    // main store, which cannot alias a file archive, so the alias check is
687    // skipped in that case (the durable-target check above still applies).
688    let main_file: String = connection
689        .query_row(
690            "SELECT file FROM pragma_database_list WHERE name = 'main'",
691            [],
692            |row| row.get::<_, Option<String>>(0),
693        )
694        .optional()?
695        .flatten()
696        .unwrap_or_default();
697    if !main_file.is_empty() && archive_path_aliases_live(&main_file, trimmed) {
698        return Err(ReceiptStoreError::Conflict(format!(
699            "retention archive path {archive_path:?} aliases the live database; refusing to archive \
700             a store into itself"
701        )));
702    }
703    Ok(())
704}
705
706/// Reject a rotation whose archive target differs from the one an earlier
707/// rotation already committed to.
708///
709/// Retention archives a contiguous `[1, W]` prefix and then deletes those live
710/// rows. Once deleted they can never be re-copied, so a second rotation pointed
711/// at a NEW target writes only the newer suffix `(W1, W2]` there: the co-archival
712/// check still passes (it only inspects the still-live rows, all now above `W1`)
713/// and the ledger advances to `W2` naming the new target, but that target holds
714/// only `(W1, W2]` while `[1, W1]` remains stranded in the original archive. The
715/// watermark-trust reader then asks the new target for the whole `[1, W2]`
716/// prefix, finds it short, and withdraws the exemption, leaving a store whose
717/// evidence is real but split across two files that neither alone can satisfy.
718/// Pinning the archive path (compared canonical-to-canonical, see
719/// `absolute_archive_path`) keeps the archived prefix whole. Fail-closed: a
720/// mismatch aborts before any copy or delete.
721fn ensure_archive_path_matches_ledger(
722    connection: &rusqlite::Connection,
723    archive_path: &str,
724) -> Result<(), ReceiptStoreError> {
725    if let Some(recorded) = super::support::latest_watermark_archive_path(connection)? {
726        if recorded != archive_path {
727            return Err(ReceiptStoreError::Conflict(format!(
728                "retention archive path {archive_path:?} differs from the archive {recorded:?} an \
729                 earlier rotation committed to; the archived prefix would be split across two \
730                 files. Keep the same archive path or start a fresh store"
731            )));
732        }
733    }
734    Ok(())
735}
736
737/// Confirm the archive the ledger already committed to still re-derives the
738/// signed checkpoint roots for the committed prefix `[1, current]`.
739///
740/// A subsequent rotation only ever appends the newer suffix to that same
741/// archive; the earlier `[1, current]` prefix was deleted from the live store
742/// and survives nowhere else. If that archive was deleted, replaced, or emptied
743/// since, extending it would advance the watermark while the earlier prefix is
744/// backed by no archive at all, splitting one logical archive across a live
745/// store that no longer holds the prefix and a file that never did. Fail closed
746/// so the deleted prefix can never be stranded. A never-archived store (no
747/// watermark, or a zero watermark) is vacuously backed.
748fn ensure_committed_prefix_still_backed(
749    connection: &rusqlite::Connection,
750) -> Result<(), ReceiptStoreError> {
751    let Some(current) = super::support::retention_watermark(connection)? else {
752        return Ok(());
753    };
754    if current == 0 {
755        return Ok(());
756    }
757    let backed = match super::support::latest_watermark_archive_path(connection)? {
758        Some(ledger_path) => {
759            super::support::archive_path_backs_prefix(connection, &ledger_path, current)?
760        }
761        None => false,
762    };
763    if !backed {
764        return Err(ReceiptStoreError::Conflict(format!(
765            "retention archive no longer backs the committed watermark {current}; the prior \
766             archive is missing, unreadable, or does not re-derive the signed checkpoint roots. \
767             Refusing to rotate and strand the deleted prefix; restore the archive before \
768             rotating again"
769        )));
770    }
771    Ok(())
772}
773
774/// True when `archive_path` names the same on-disk file as the live database:
775/// an identical path string, the same file after resolving symlinks and
776/// `.`/`..`, or (on Unix) the same device+inode as a hard link.
777fn archive_path_aliases_live(main_file: &str, archive_path: &str) -> bool {
778    if main_file == archive_path {
779        return true;
780    }
781    let resolved = |path: &str| std::fs::canonicalize(std::path::Path::new(path)).ok();
782    if let (Some(main), Some(archive)) = (resolved(main_file), resolved(archive_path)) {
783        if main == archive {
784            return true;
785        }
786    }
787    #[cfg(unix)]
788    {
789        use std::os::unix::fs::MetadataExt;
790        if let (Ok(main), Ok(archive)) = (
791            std::fs::metadata(main_file),
792            std::fs::metadata(archive_path),
793        ) {
794            if main.dev() == archive.dev() && main.ino() == archive.ino() {
795                return true;
796            }
797        }
798    }
799    false
800}
801
802/// Entry point run on the single writer connection by the `Rotate` command.
803///
804/// `verified_checkpoint_ceiling` caps the archival watermark at the newest
805/// checkpoint boundary the writer actor has verified. In incremental mode the
806/// caller skips the O(N) chain rebuild before rotating and trusts the
807/// per-append verified head instead, but that head can be stale relative to
808/// `kernel_checkpoints` when a second store instance or an operator import has
809/// appended checkpoint rows since the head was seeded. Computing the watermark
810/// from every persisted checkpoint would then archive and delete up to an
811/// unaudited boundary. Capping at the verified boundary keeps pruning behind the
812/// chain the actor has actually validated; the unaudited suffix is archived only
813/// after a later append catches the head up and verifies it. `None` imposes no
814/// cap (the caller already ran full checkpoint verification).
815pub(super) fn rotate_on_writer_connection(
816    connection: &mut rusqlite::Connection,
817    config: &RetentionConfig,
818    verified_checkpoint_ceiling: Option<u64>,
819) -> Result<u64, ReceiptStoreError> {
820    if config.tenant_id.is_some() {
821        return Err(ReceiptStoreError::RetentionTenantScopeUnsupported);
822    }
823    // One-time migration: enable incremental auto-vacuum on a legacy store
824    // that predates this pragma so the first rotation on the drained writer
825    // starts reclaiming freed pages. A no-op once migrated.
826    super::support::migrate_auto_vacuum_incremental_if_needed(connection)?;
827    // The delete transaction writes archived ids into the tombstone table; make
828    // sure it and its reuse-reject triggers exist even on a store opened before
829    // this migration.
830    super::support::ensure_receipt_retention_tombstones(connection)?;
831    // A store opened through `open_existing` skips the writable `open()`
832    // migration that creates the watermark ledger, so a legacy database can
833    // reach rotation without it. The delete transaction records the archival
834    // high-water mark here; create the ledger first so that insert cannot fail
835    // on a missing table after the archive copy has already run and roll the
836    // whole rotation back into an endless retry.
837    super::support::ensure_receipt_retention_watermark_table(connection)?;
838    let Some(cutoff) = resolve_rotation_cutoff(connection, config)? else {
839        return Ok(0);
840    };
841    archive_range(
842        connection,
843        cutoff,
844        &config.archive_path,
845        verified_checkpoint_ceiling,
846    )
847}
848
849/// Co-archive-and-delete the checkpoint-aligned prefix [1, W]. All deletes plus
850/// the trigger drop/recreate plus the watermark insert run in ONE BEGIN
851/// IMMEDIATE transaction on the writer connection; the
852/// copy is idempotent and completes first, so a crash between copy and delete
853/// leaves the live store intact and the rotation re-runnable.
854fn archive_range(
855    connection: &mut rusqlite::Connection,
856    cutoff_unix_secs: u64,
857    archive_path: &str,
858    verified_checkpoint_ceiling: Option<u64>,
859) -> Result<u64, ReceiptStoreError> {
860    // Never archive past the checkpoint boundary the caller has verified. The
861    // ceiling is itself a checkpoint `batch_end_seq`, and `compute_archival_watermark`
862    // returns one too, so the minimum still lands on a real boundary.
863    let watermark = compute_archival_watermark(connection, cutoff_unix_secs)?
864        .min(verified_checkpoint_ceiling.unwrap_or(u64::MAX));
865    if watermark == 0 {
866        return Ok(0); // fail-safe: nothing checkpointed has fully aged.
867    }
868    // Idempotency / monotonicity: a rotation only ever advances the prefix.
869    // When the recomputed watermark does not exceed what is already archived
870    // (a re-run at the same or an earlier cutoff), there is nothing new to
871    // archive. Returning early keeps the DB-level monotonic watermark trigger
872    // (strictly-increasing) from rejecting a redundant re-insert and makes a
873    // repeated rotation a clean no-op rather than an error.
874    if let Some(current) = super::support::retention_watermark(connection)? {
875        if watermark <= current {
876            return Ok(0);
877        }
878    }
879    let w = sqlite_i64(watermark, "archival watermark")?;
880
881    ensure_durable_distinct_archive_path(connection, archive_path)?;
882    ensure_archive_file_exists(archive_path)?;
883    // Dial and record the archive by its absolute, symlink-free path so a later
884    // reader (a restart, a CLI health check) resolves the same file regardless
885    // of its working directory.
886    let archive_path = absolute_archive_path(archive_path)?;
887    // Pre-lock fast fail: reject a path split or a moved/emptied backing archive
888    // before doing any copy work. Both reads are re-run under the write lock in
889    // `delete_archived_prefix_in_tx`, which is the authoritative check: a
890    // concurrent rotation can still commit between here and that locked delete.
891    ensure_archive_path_matches_ledger(connection, &archive_path)?;
892    ensure_committed_prefix_still_backed(connection)?;
893    let escaped_path = archive_path.replace('\'', "''");
894    connection.execute_batch(&format!("ATTACH DATABASE '{escaped_path}' AS archive"))?;
895
896    let result = (|| -> Result<u64, ReceiptStoreError> {
897        create_archive_schema(connection)?;
898        let archived = copy_archived_prefix(connection, w)?;
899        verify_co_archival_complete(connection, w)?; // RetentionArchiveIncomplete on any shortfall
900        delete_archived_prefix_in_tx(connection, w, cutoff_unix_secs, &archive_path)?;
901        Ok(archived)
902    })();
903
904    let detach = connection.execute_batch("DETACH DATABASE archive");
905    let archived = match (result, detach) {
906        (Ok(archived), Ok(())) => archived,
907        (Err(error), _) => return Err(error),
908        (Ok(_), Err(error)) => return Err(error.into()),
909    };
910
911    // Reclaim freelist pages produced by the delete and shrink the WAL.
912    connection.execute_batch("PRAGMA incremental_vacuum")?;
913    connection.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
914    Ok(archived)
915}
916
917/// Create the archive schema. The archive gains the receipt tables, the
918/// checkpoint rows, capability lineage, the claim-log projection (with
919/// `entry_seq` preserved: `INTEGER PRIMARY KEY`, no `AUTOINCREMENT`, so copied
920/// values insert verbatim), settlement/metered reconciliations,
921/// authorization consumptions, and the governed-receipt lineage statements
922/// (keyed by `receipt_id`) that carry each archived receipt's call-chain
923/// provenance. The archive `chio_tool_receipts` mirrors the
924/// live column layout exactly, including the `tenant_id` column added to the
925/// live table by the attribution migration (`ensure_tool_receipt_attribution_columns`),
926/// so a global rotation over a mixed-tenant store preserves tenant attribution
927/// in the archive. Every copy names its columns explicitly (except the tables
928/// copied with `SELECT *`, whose column order is asserted to match the live
929/// DDL) so a future column added to one side cannot silently produce a
930/// positional or column-count mismatch at runtime.
931///
932/// The checkpoint-projection tables (`checkpoint_tree_heads`,
933/// `checkpoint_predecessor_witnesses`, `checkpoint_publication_metadata`,
934/// `checkpoint_publication_trust_anchor_bindings`) exist here schema-only
935/// (unpopulated): `SqliteReceiptStore::open()` on the archive rebuilds them
936/// from the co-archived `kernel_checkpoints` rows (the archive is a minimal
937/// evidence bundle, see the retention test suite), but
938/// `require_existing_receipt_schema` requires all seven core tables to be
939/// present for `open_existing()`, and `ensure_transparency_projection_guards`
940/// creates its reject-update/reject-delete triggers on all of them
941/// unconditionally. Without these table shells, `open_existing()` against a
942/// freshly rotated archive fails closed with a missing-table error before a
943/// caller can even read the co-archived reconciliation rows. Unlike the live
944/// schema, the archive versions carry no `REFERENCES` clauses: the archive is
945/// a write-once evidence copy, not a live database enforcing FK-cascade
946/// invariants.
947pub(super) fn create_archive_schema(
948    connection: &mut rusqlite::Connection,
949) -> Result<(), ReceiptStoreError> {
950    let transaction =
951        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
952    let application_id: i32 =
953        transaction.query_row("PRAGMA archive.application_id", [], |row| row.get(0))?;
954    if application_id != 0 && application_id != crate::CHIO_SQLITE_APPLICATION_ID {
955        return Err(ReceiptStoreError::Conflict(format!(
956            "retention archive application_id {application_id:#x} is not a Chio store"
957        )));
958    }
959    let version_table_exists: bool = transaction.query_row(
960        "SELECT EXISTS(SELECT 1 FROM archive.sqlite_master WHERE type = 'table' AND name = 'chio_store_schema_versions')",
961        [],
962        |row| row.get(0),
963    )?;
964    let archive_schema_version = if version_table_exists {
965        transaction
966            .query_row(
967                "SELECT version FROM archive.chio_store_schema_versions WHERE store_key = ?1",
968                [RECEIPT_STORE_SCHEMA_KEY],
969                |row| row.get::<_, i32>(0),
970            )
971            .optional()?
972            .unwrap_or(0)
973    } else {
974        0
975    };
976    if archive_schema_version > RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION {
977        return Err(ReceiptStoreError::Conflict(format!(
978            "retention archive schema version {archive_schema_version} is newer than this binary supports ({RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION})"
979        )));
980    }
981
982    transaction.execute_batch(
983        r#"
984        CREATE TABLE IF NOT EXISTS archive.chio_tool_receipts (
985            seq INTEGER PRIMARY KEY,
986            receipt_id TEXT NOT NULL UNIQUE, timestamp INTEGER NOT NULL,
987            capability_id TEXT NOT NULL, subject_key TEXT, issuer_key TEXT,
988            grant_index INTEGER, tool_server TEXT NOT NULL, tool_name TEXT NOT NULL,
989            decision_kind TEXT NOT NULL, policy_hash TEXT NOT NULL,
990            content_hash TEXT NOT NULL, raw_json TEXT NOT NULL, tenant_id TEXT,
991            cost_currency TEXT CHECK (
992                cost_currency IS NULL OR (
993                    typeof(cost_currency) = 'text' AND
994                    length(cost_currency) = 3 AND
995                    cost_currency NOT GLOB '*[^A-Z]*'
996                )
997            ),
998            cost_charged_be BLOB CHECK (
999                (cost_currency IS NULL AND cost_charged_be IS NULL) OR
1000                (
1001                    cost_currency IS NOT NULL AND
1002                    typeof(cost_charged_be) = 'blob' AND
1003                    length(cost_charged_be) = 8
1004                )
1005            )
1006        );
1007        CREATE TABLE IF NOT EXISTS archive.chio_child_receipts (
1008            seq INTEGER PRIMARY KEY,
1009            receipt_id TEXT NOT NULL UNIQUE, timestamp INTEGER NOT NULL,
1010            session_id TEXT NOT NULL, parent_request_id TEXT NOT NULL,
1011            request_id TEXT NOT NULL, operation_kind TEXT NOT NULL,
1012            terminal_state TEXT NOT NULL, policy_hash TEXT NOT NULL,
1013            outcome_hash TEXT NOT NULL, raw_json TEXT NOT NULL
1014        );
1015        CREATE TABLE IF NOT EXISTS archive.kernel_checkpoints (
1016            id INTEGER PRIMARY KEY, checkpoint_seq INTEGER NOT NULL UNIQUE,
1017            batch_start_seq INTEGER NOT NULL, batch_end_seq INTEGER NOT NULL,
1018            tree_size INTEGER NOT NULL, merkle_root TEXT NOT NULL,
1019            issued_at INTEGER NOT NULL, statement_json TEXT NOT NULL,
1020            signature TEXT NOT NULL, kernel_key TEXT NOT NULL
1021        );
1022        CREATE TABLE IF NOT EXISTS archive.capability_lineage (
1023            capability_id TEXT PRIMARY KEY, subject_key TEXT NOT NULL,
1024            issuer_key TEXT NOT NULL, issued_at INTEGER NOT NULL,
1025            expires_at INTEGER NOT NULL, grants_json TEXT NOT NULL,
1026            delegation_depth INTEGER NOT NULL DEFAULT 0, parent_capability_id TEXT,
1027            federated_parent_capability_id TEXT,
1028            provenance TEXT NOT NULL DEFAULT 'legacy_projection'
1029                CHECK (provenance IN ('signed_token', 'synthetic_anchor', 'legacy_projection')),
1030            signed_capability_json TEXT
1031        );
1032        CREATE TABLE IF NOT EXISTS archive.claim_receipt_log_entries (
1033            entry_seq INTEGER PRIMARY KEY,
1034            receipt_id TEXT NOT NULL UNIQUE, receipt_kind TEXT NOT NULL,
1035            source_seq INTEGER NOT NULL, timestamp INTEGER NOT NULL,
1036            capability_id TEXT, session_id TEXT, parent_request_id TEXT,
1037            request_id TEXT, subject_key TEXT, issuer_key TEXT,
1038            tool_server TEXT, tool_name TEXT, raw_json TEXT NOT NULL
1039        );
1040        CREATE TABLE IF NOT EXISTS archive.settlement_reconciliations (
1041            receipt_id TEXT PRIMARY KEY, reconciliation_state TEXT NOT NULL,
1042            note TEXT, updated_at INTEGER NOT NULL
1043        );
1044        CREATE TABLE IF NOT EXISTS archive.metered_billing_reconciliations (
1045            receipt_id TEXT PRIMARY KEY, adapter_kind TEXT NOT NULL,
1046            evidence_id TEXT NOT NULL, observed_units INTEGER NOT NULL,
1047            billed_cost_units INTEGER NOT NULL, billed_cost_currency TEXT NOT NULL,
1048            evidence_sha256 TEXT, recorded_at INTEGER NOT NULL,
1049            reconciliation_state TEXT NOT NULL, note TEXT, updated_at INTEGER NOT NULL
1050        );
1051        CREATE TABLE IF NOT EXISTS archive.chio_authorization_receipt_consumptions (
1052            authorization_receipt_id TEXT PRIMARY KEY, consumer_receipt_id TEXT NOT NULL,
1053            request_id TEXT NOT NULL, session_id TEXT NOT NULL, tool_call_id TEXT NOT NULL,
1054            tenant_id TEXT, parameter_hash TEXT NOT NULL, consumed_at_unix_ms INTEGER NOT NULL
1055        );
1056        CREATE TABLE IF NOT EXISTS archive.receipt_lineage_statements (
1057            receipt_id TEXT PRIMARY KEY, statement_id TEXT, request_id TEXT,
1058            session_id TEXT, session_anchor_id TEXT, chain_id TEXT,
1059            parent_request_id TEXT, parent_receipt_id TEXT, evidence_class TEXT,
1060            evidence_sources_json TEXT,
1061            verified_session_anchor INTEGER NOT NULL DEFAULT 0,
1062            verified_parent_request INTEGER NOT NULL DEFAULT 0,
1063            verified_parent_receipt INTEGER NOT NULL DEFAULT 0,
1064            replay_protected INTEGER NOT NULL DEFAULT 0,
1065            recorded_at INTEGER NOT NULL, source_kind TEXT NOT NULL,
1066            json_sha256 TEXT NOT NULL, raw_json TEXT NOT NULL
1067        );
1068        CREATE TABLE IF NOT EXISTS archive.checkpoint_tree_heads (
1069            checkpoint_seq INTEGER PRIMARY KEY, batch_start_seq INTEGER NOT NULL,
1070            batch_end_seq INTEGER NOT NULL, tree_size INTEGER NOT NULL,
1071            merkle_root TEXT NOT NULL, issued_at INTEGER NOT NULL, kernel_key TEXT NOT NULL,
1072            previous_checkpoint_sha256 TEXT, statement_json TEXT NOT NULL, signature TEXT NOT NULL
1073        );
1074        CREATE TABLE IF NOT EXISTS archive.checkpoint_predecessor_witnesses (
1075            predecessor_checkpoint_seq INTEGER NOT NULL, witness_checkpoint_seq INTEGER PRIMARY KEY,
1076            previous_checkpoint_sha256 TEXT NOT NULL, witnessed_at INTEGER NOT NULL,
1077            witness_statement_json TEXT NOT NULL
1078        );
1079        CREATE TABLE IF NOT EXISTS archive.checkpoint_publication_metadata (
1080            checkpoint_seq INTEGER PRIMARY KEY, publication_schema TEXT NOT NULL,
1081            merkle_root TEXT NOT NULL, published_at INTEGER NOT NULL, kernel_key TEXT NOT NULL,
1082            log_tree_size INTEGER NOT NULL, entry_start_seq INTEGER NOT NULL,
1083            entry_end_seq INTEGER NOT NULL, previous_checkpoint_sha256 TEXT
1084        );
1085        CREATE TABLE IF NOT EXISTS archive.checkpoint_publication_trust_anchor_bindings (
1086            checkpoint_seq INTEGER PRIMARY KEY, binding_json TEXT NOT NULL
1087        );
1088        "#,
1089    )?;
1090    if archive_schema_version < RECEIPT_COST_PROJECTION_SCHEMA_VERSION {
1091        migrate_archive_receipt_cost_projection(&transaction)?;
1092    }
1093    verify_archive_receipt_cost_projection(&transaction)?;
1094    for (column, definition) in [
1095        ("signed_capability_json", "signed_capability_json TEXT"),
1096        (
1097            "federated_parent_capability_id",
1098            "federated_parent_capability_id TEXT",
1099        ),
1100        (
1101            "provenance",
1102            "provenance TEXT NOT NULL DEFAULT 'legacy_projection' CHECK (provenance IN \
1103             ('signed_token', 'synthetic_anchor', 'legacy_projection'))",
1104        ),
1105    ] {
1106        let has_column = {
1107            let mut statement =
1108                transaction.prepare("PRAGMA archive.table_info(capability_lineage)")?;
1109            let mut rows = statement.query([])?;
1110            let mut found = false;
1111            while let Some(row) = rows.next()? {
1112                if row.get::<_, String>(1)? == column {
1113                    found = true;
1114                    break;
1115                }
1116            }
1117            found
1118        };
1119        if !has_column {
1120            transaction.execute(
1121                &format!("ALTER TABLE archive.capability_lineage ADD COLUMN {definition}"),
1122                [],
1123            )?;
1124        }
1125    }
1126    transaction.execute(
1127        "UPDATE archive.capability_lineage SET provenance = 'signed_token' \
1128         WHERE provenance = 'legacy_projection' \
1129           AND signed_capability_json IS NOT NULL",
1130        [],
1131    )?;
1132    transaction.execute_batch(&format!(
1133        "PRAGMA archive.application_id = {}; \
1134         CREATE TABLE IF NOT EXISTS archive.chio_store_schema_versions (\
1135             store_key TEXT PRIMARY KEY, version INTEGER NOT NULL\
1136         );",
1137        crate::CHIO_SQLITE_APPLICATION_ID
1138    ))?;
1139    transaction.execute(
1140        "INSERT INTO archive.chio_store_schema_versions (store_key, version) VALUES (?1, ?2) \
1141         ON CONFLICT(store_key) DO UPDATE SET version = excluded.version",
1142        params![
1143            RECEIPT_STORE_SCHEMA_KEY,
1144            RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION
1145        ],
1146    )?;
1147    transaction.commit()?;
1148    Ok(())
1149}
1150
1151/// Idempotent copy of the [1, W] prefix into the archive. Returns the number of
1152/// newly archived tool-receipt rows.
1153///
1154/// Append-only evidence (the receipt tables, the claim-log projection, the
1155/// signed checkpoints, the write-once authorization consumptions) copies with
1156/// `INSERT OR IGNORE`: those rows never change once written, so a pre-existing
1157/// archive row that diverges from the live row signals a reused or corrupt
1158/// archive and must be caught fail-closed by `verify_co_archival_complete`, not
1159/// silently overwritten.
1160///
1161/// The settlement and metered reconciliation rows, by contrast, are mutated in
1162/// place by ongoing reconciliation upserts, including for a receipt already in
1163/// the archival prefix. A rotation copies the reconciliation row, a later
1164/// reconciliation update changes it, and the write-locked co-archival re-verify
1165/// then aborts because the archive holds the pre-update bytes. An `INSERT OR
1166/// IGNORE` retry would keep the stale archive row on every rotation and leave
1167/// the prefix permanently unrotatable. These two tables therefore refresh their
1168/// archived copy from the current live row (`INSERT OR REPLACE`) so a retry
1169/// converges on the latest committed reconciliation state instead of stalling.
1170pub(super) fn copy_archived_prefix(
1171    connection: &rusqlite::Connection,
1172    w: i64,
1173) -> Result<u64, ReceiptStoreError> {
1174    let before: i64 = connection.query_row(
1175        "SELECT COUNT(*) FROM archive.chio_tool_receipts",
1176        [],
1177        |row| row.get(0),
1178    )?;
1179    connection.execute_batch(&format!(
1180        r#"
1181        INSERT OR IGNORE INTO archive.claim_receipt_log_entries
1182            SELECT * FROM main.claim_receipt_log_entries WHERE entry_seq <= {w};
1183        INSERT OR IGNORE INTO archive.chio_tool_receipts
1184            (seq, receipt_id, timestamp, capability_id, subject_key, issuer_key,
1185             grant_index, tool_server, tool_name, decision_kind, policy_hash,
1186             content_hash, raw_json, tenant_id, cost_currency, cost_charged_be)
1187            SELECT seq, receipt_id, timestamp, capability_id, subject_key, issuer_key,
1188                   grant_index, tool_server, tool_name, decision_kind, policy_hash,
1189                   content_hash, raw_json, tenant_id, cost_currency, cost_charged_be
1190            FROM main.chio_tool_receipts WHERE seq IN (
1191                SELECT source_seq FROM main.claim_receipt_log_entries
1192                WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1193        INSERT OR IGNORE INTO archive.chio_child_receipts
1194            (seq, receipt_id, timestamp, session_id, parent_request_id, request_id,
1195             operation_kind, terminal_state, policy_hash, outcome_hash, raw_json)
1196            SELECT seq, receipt_id, timestamp, session_id, parent_request_id, request_id,
1197                   operation_kind, terminal_state, policy_hash, outcome_hash, raw_json
1198            FROM main.chio_child_receipts WHERE seq IN (
1199                SELECT source_seq FROM main.claim_receipt_log_entries
1200                WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt');
1201        INSERT OR IGNORE INTO archive.kernel_checkpoints
1202            SELECT * FROM main.kernel_checkpoints WHERE batch_end_seq <= {w};
1203        INSERT OR IGNORE INTO archive.capability_lineage
1204            (capability_id, subject_key, issuer_key, issued_at, expires_at,
1205             grants_json, delegation_depth, parent_capability_id,
1206             federated_parent_capability_id, provenance, signed_capability_json)
1207            SELECT DISTINCT cl.capability_id, cl.subject_key, cl.issuer_key,
1208                   cl.issued_at, cl.expires_at, cl.grants_json,
1209                   cl.delegation_depth, cl.parent_capability_id,
1210                   cl.federated_parent_capability_id, cl.provenance,
1211                   cl.signed_capability_json
1212            FROM main.capability_lineage cl
1213            INNER JOIN main.chio_tool_receipts r ON r.capability_id = cl.capability_id
1214            WHERE r.seq IN (
1215                SELECT source_seq FROM main.claim_receipt_log_entries
1216                WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1217        -- Refresh (not IGNORE) the mutable reconciliation rows: an earlier copy
1218        -- may hold pre-update bytes for a receipt whose reconciliation state has
1219        -- since advanced, and only replacing it lets a retried rotation pass the
1220        -- write-locked co-archival re-verify instead of stalling on stale bytes.
1221        INSERT OR REPLACE INTO archive.settlement_reconciliations
1222            SELECT * FROM main.settlement_reconciliations WHERE receipt_id IN (
1223                SELECT receipt_id FROM main.claim_receipt_log_entries
1224                WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1225        INSERT OR REPLACE INTO archive.metered_billing_reconciliations
1226            SELECT * FROM main.metered_billing_reconciliations WHERE receipt_id IN (
1227                SELECT receipt_id FROM main.claim_receipt_log_entries
1228                WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1229        INSERT OR IGNORE INTO archive.chio_authorization_receipt_consumptions
1230            SELECT * FROM main.chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN (
1231                SELECT receipt_id FROM main.claim_receipt_log_entries
1232                WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1233        INSERT OR IGNORE INTO archive.receipt_lineage_statements
1234            (receipt_id, statement_id, request_id, session_id, session_anchor_id,
1235             chain_id, parent_request_id, parent_receipt_id, evidence_class,
1236             evidence_sources_json, verified_session_anchor, verified_parent_request,
1237             verified_parent_receipt, replay_protected, recorded_at, source_kind,
1238             json_sha256, raw_json)
1239            SELECT receipt_id, statement_id, request_id, session_id, session_anchor_id,
1240                   chain_id, parent_request_id, parent_receipt_id, evidence_class,
1241                   evidence_sources_json, verified_session_anchor, verified_parent_request,
1242                   verified_parent_receipt, replay_protected, recorded_at, source_kind,
1243                   json_sha256, raw_json
1244            FROM main.receipt_lineage_statements WHERE receipt_id IN (
1245                SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w});
1246        "#
1247    ))?;
1248    let after: i64 = connection.query_row(
1249        "SELECT COUNT(*) FROM archive.chio_tool_receipts",
1250        [],
1251        |row| row.get(0),
1252    )?;
1253    sqlite_u64((after - before).max(0), "archived tool receipt delta")
1254}
1255
1256/// Every row the delete will remove must already be in the archive, and it must
1257/// be IDENTICAL to the live row, not merely present. Presence alone is not
1258/// enough: the copy uses `INSERT OR IGNORE`, so a pre-existing archive row with
1259/// the same primary key but different bytes (a conflicting or partially-written
1260/// prior archive, or a reused archive file) is silently kept and the live row
1261/// is dropped by the IGNORE. A count-only check would pass while the archive
1262/// held stale bytes, and the delete would then leave the store with no faithful
1263/// archived copy. Every archived table the delete touches is therefore verified
1264/// by identity: the receipt tables (`chio_tool_receipts`, `chio_child_receipts`)
1265/// keyed on `seq` (the primary key the claim-log projection's `source_seq`
1266/// points at, so a same-`receipt_id` archive row copied under a different `seq`
1267/// cannot pass) with a NULL-safe (`IS`) compare of every remaining column, and
1268/// the claim-log projection, checkpoint rows, and
1269/// settlement/metered/consumption reconciliations by a NULL-safe (`IS`)
1270/// full-column compare of the live prefix rows against their archive
1271/// counterparts (keyed on each table's primary key). A partial compare that
1272/// skipped the indexed/attribution columns (`subject_key`, `issuer_key`,
1273/// `grant_index`, `tenant_id`, or the child session/request columns) would let a
1274/// reused archive row misattribute retained evidence after the live row is gone. Capability lineage is
1275/// verified the same way even though the delete leaves the live lineage rows in
1276/// place: the archived receipts ARE deleted, so the archive becomes the only
1277/// standalone-authoritative copy of their capability subject/issuer/grants, and
1278/// a divergent archived lineage row would misattribute them. The compare is
1279/// bounded to the archived prefix (`O(archived rows)`, a primary-key lookup per
1280/// row, never `O(full history)`). Any shortfall aborts before any delete
1281/// (fail-closed, `RetentionArchiveIncomplete`).
1282fn verify_co_archival_complete(
1283    connection: &rusqlite::Connection,
1284    w: i64,
1285) -> Result<(), ReceiptStoreError> {
1286    let checks: [(&'static str, String, String); 9] = [
1287        (
1288            // Present AND every column identical: `archive_sql` counts only live
1289            // prefix rows whose archive row matches on the `seq` primary key and
1290            // NULL-safely (`IS`) on every remaining column, so a missing OR
1291            // divergent archive row (including a differing indexed/attribution
1292            // column such as `subject_key`, `issuer_key`, `grant_index`, or
1293            // `tenant_id`, which archive reads filter on) makes archived < live
1294            // and aborts before the delete. A `receipt_id`/`raw_json`-only check
1295            // would keep a reused archive row that misattributes the receipt.
1296            "chio_tool_receipts",
1297            format!(
1298                "SELECT COUNT(*) FROM main.chio_tool_receipts WHERE seq IN \
1299                 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1300            ),
1301            format!(
1302                "SELECT COUNT(*) FROM main.chio_tool_receipts m WHERE m.seq IN \
1303                 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1304                 AND EXISTS (SELECT 1 FROM archive.chio_tool_receipts a WHERE a.seq = m.seq \
1305                 AND a.receipt_id IS m.receipt_id AND a.timestamp IS m.timestamp \
1306                 AND a.capability_id IS m.capability_id AND a.subject_key IS m.subject_key \
1307                 AND a.issuer_key IS m.issuer_key AND a.grant_index IS m.grant_index \
1308                 AND a.tool_server IS m.tool_server AND a.tool_name IS m.tool_name \
1309                 AND a.decision_kind IS m.decision_kind AND a.policy_hash IS m.policy_hash \
1310                 AND a.content_hash IS m.content_hash AND a.raw_json IS m.raw_json \
1311                 AND a.tenant_id IS m.tenant_id \
1312                 AND a.cost_currency IS m.cost_currency \
1313                 AND a.cost_charged_be IS m.cost_charged_be)"
1314            ),
1315        ),
1316        (
1317            // Same full-row identity for child receipts: a reused archive row
1318            // matching only `seq`/`receipt_id`/`raw_json` but diverging on
1319            // `session_id`, `parent_request_id`, `request_id`, or the outcome
1320            // columns would misattribute retained child evidence once the live
1321            // row is deleted.
1322            "chio_child_receipts",
1323            format!(
1324                "SELECT COUNT(*) FROM main.chio_child_receipts WHERE seq IN \
1325                 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt')"
1326            ),
1327            format!(
1328                "SELECT COUNT(*) FROM main.chio_child_receipts m WHERE m.seq IN \
1329                 (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt') \
1330                 AND EXISTS (SELECT 1 FROM archive.chio_child_receipts a WHERE a.seq = m.seq \
1331                 AND a.receipt_id IS m.receipt_id AND a.timestamp IS m.timestamp \
1332                 AND a.session_id IS m.session_id AND a.parent_request_id IS m.parent_request_id \
1333                 AND a.request_id IS m.request_id AND a.operation_kind IS m.operation_kind \
1334                 AND a.terminal_state IS m.terminal_state AND a.policy_hash IS m.policy_hash \
1335                 AND a.outcome_hash IS m.outcome_hash AND a.raw_json IS m.raw_json)"
1336            ),
1337        ),
1338        (
1339            // Identity on the full row (entry_seq is the PK, copied verbatim):
1340            // a divergent archived projection row makes archived < live.
1341            "claim_receipt_log_entries",
1342            format!("SELECT COUNT(*) FROM main.claim_receipt_log_entries WHERE entry_seq <= {w}"),
1343            format!(
1344                "SELECT COUNT(*) FROM main.claim_receipt_log_entries m WHERE m.entry_seq <= {w} \
1345                 AND EXISTS (SELECT 1 FROM archive.claim_receipt_log_entries a WHERE a.entry_seq = m.entry_seq \
1346                 AND a.receipt_id IS m.receipt_id AND a.receipt_kind IS m.receipt_kind \
1347                 AND a.source_seq IS m.source_seq AND a.timestamp IS m.timestamp \
1348                 AND a.capability_id IS m.capability_id AND a.session_id IS m.session_id \
1349                 AND a.parent_request_id IS m.parent_request_id AND a.request_id IS m.request_id \
1350                 AND a.subject_key IS m.subject_key AND a.issuer_key IS m.issuer_key \
1351                 AND a.tool_server IS m.tool_server AND a.tool_name IS m.tool_name \
1352                 AND a.raw_json IS m.raw_json)"
1353            ),
1354        ),
1355        (
1356            // Identity keyed on checkpoint_seq (UNIQUE): every content column,
1357            // including the signed statement and signature, must match.
1358            "kernel_checkpoints",
1359            format!("SELECT COUNT(*) FROM main.kernel_checkpoints WHERE batch_end_seq <= {w}"),
1360            format!(
1361                "SELECT COUNT(*) FROM main.kernel_checkpoints m WHERE m.batch_end_seq <= {w} \
1362                 AND EXISTS (SELECT 1 FROM archive.kernel_checkpoints a WHERE a.checkpoint_seq = m.checkpoint_seq \
1363                 AND a.id IS m.id AND a.batch_start_seq IS m.batch_start_seq \
1364                 AND a.batch_end_seq IS m.batch_end_seq AND a.tree_size IS m.tree_size \
1365                 AND a.merkle_root IS m.merkle_root AND a.issued_at IS m.issued_at \
1366                 AND a.statement_json IS m.statement_json AND a.signature IS m.signature \
1367                 AND a.kernel_key IS m.kernel_key)"
1368            ),
1369        ),
1370        (
1371            // Identity keyed on receipt_id (PK): state, note, and timestamp
1372            // must match the live reconciliation row being archived.
1373            "settlement_reconciliations",
1374            format!(
1375                "SELECT COUNT(*) FROM main.settlement_reconciliations WHERE receipt_id IN \
1376                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1377            ),
1378            format!(
1379                "SELECT COUNT(*) FROM main.settlement_reconciliations m WHERE m.receipt_id IN \
1380                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1381                 AND EXISTS (SELECT 1 FROM archive.settlement_reconciliations a WHERE a.receipt_id = m.receipt_id \
1382                 AND a.reconciliation_state IS m.reconciliation_state AND a.note IS m.note \
1383                 AND a.updated_at IS m.updated_at)"
1384            ),
1385        ),
1386        (
1387            // Identity keyed on receipt_id (PK): all metered evidence columns
1388            // (units, cost, currency, evidence hash, state, note) must match.
1389            "metered_billing_reconciliations",
1390            format!(
1391                "SELECT COUNT(*) FROM main.metered_billing_reconciliations WHERE receipt_id IN \
1392                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1393            ),
1394            format!(
1395                "SELECT COUNT(*) FROM main.metered_billing_reconciliations m WHERE m.receipt_id IN \
1396                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1397                 AND EXISTS (SELECT 1 FROM archive.metered_billing_reconciliations a WHERE a.receipt_id = m.receipt_id \
1398                 AND a.adapter_kind IS m.adapter_kind AND a.evidence_id IS m.evidence_id \
1399                 AND a.observed_units IS m.observed_units AND a.billed_cost_units IS m.billed_cost_units \
1400                 AND a.billed_cost_currency IS m.billed_cost_currency AND a.evidence_sha256 IS m.evidence_sha256 \
1401                 AND a.recorded_at IS m.recorded_at AND a.reconciliation_state IS m.reconciliation_state \
1402                 AND a.note IS m.note AND a.updated_at IS m.updated_at)"
1403            ),
1404        ),
1405        (
1406            // Identity keyed on authorization_receipt_id (PK): the consuming
1407            // receipt, request/session/tool-call identifiers, tenant, parameter
1408            // hash, and consumption timestamp must all match.
1409            "chio_authorization_receipt_consumptions",
1410            format!(
1411                "SELECT COUNT(*) FROM main.chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN \
1412                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')"
1413            ),
1414            format!(
1415                "SELECT COUNT(*) FROM main.chio_authorization_receipt_consumptions m WHERE m.authorization_receipt_id IN \
1416                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt') \
1417                 AND EXISTS (SELECT 1 FROM archive.chio_authorization_receipt_consumptions a \
1418                 WHERE a.authorization_receipt_id = m.authorization_receipt_id \
1419                 AND a.consumer_receipt_id IS m.consumer_receipt_id AND a.request_id IS m.request_id \
1420                 AND a.session_id IS m.session_id AND a.tool_call_id IS m.tool_call_id \
1421                 AND a.tenant_id IS m.tenant_id AND a.parameter_hash IS m.parameter_hash \
1422                 AND a.consumed_at_unix_ms IS m.consumed_at_unix_ms)"
1423            ),
1424        ),
1425        (
1426            // Identity keyed on capability_id (PK). The delete removes the live
1427            // receipts but NOT the live lineage rows, so the archive becomes the
1428            // ONLY copy of the archived receipts' capability lineage (subject,
1429            // issuer, grants). The idempotent `INSERT OR IGNORE` copy keeps a
1430            // pre-existing archive row on a capability_id conflict, so a reused
1431            // archive holding the same capability under divergent lineage bytes
1432            // would silently misattribute the archived receipts' subject/issuer/
1433            // grants and agent-subject filtering. Verify every lineage row
1434            // referenced by an archived tool receipt matches the live row before
1435            // the delete.
1436            "capability_lineage",
1437            format!(
1438                "SELECT COUNT(*) FROM main.capability_lineage m WHERE m.capability_id IN \
1439                 (SELECT r.capability_id FROM main.chio_tool_receipts r WHERE r.seq IN \
1440                  (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt'))"
1441            ),
1442            format!(
1443                "SELECT COUNT(*) FROM main.capability_lineage m WHERE m.capability_id IN \
1444                 (SELECT r.capability_id FROM main.chio_tool_receipts r WHERE r.seq IN \
1445                  (SELECT source_seq FROM main.claim_receipt_log_entries WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt')) \
1446                 AND EXISTS (SELECT 1 FROM archive.capability_lineage a WHERE a.capability_id = m.capability_id \
1447                 AND a.subject_key IS m.subject_key AND a.issuer_key IS m.issuer_key \
1448                 AND a.issued_at IS m.issued_at AND a.expires_at IS m.expires_at \
1449                 AND a.grants_json IS m.grants_json AND a.delegation_depth IS m.delegation_depth \
1450                 AND a.parent_capability_id IS m.parent_capability_id \
1451                 AND a.federated_parent_capability_id IS m.federated_parent_capability_id \
1452                 AND a.provenance IS m.provenance \
1453                 AND a.signed_capability_json IS m.signed_capability_json)"
1454            ),
1455        ),
1456        (
1457            // Identity keyed on receipt_id (PK). Governed receipts persist a
1458            // lineage statement carrying their call-chain provenance and the
1459            // stored verification flags. The delete removes the receipts but not
1460            // the live lineage rows, so the archive becomes the ONLY standalone
1461            // copy of an archived receipt's lineage evidence, read back by
1462            // `receipt_lineage_verification` / lineage-link queries. The
1463            // idempotent `INSERT OR IGNORE` copy keeps a pre-existing archive row
1464            // on a receipt_id conflict, so a reused archive holding the same
1465            // receipt under divergent lineage bytes would misattribute its
1466            // provenance. Verify every lineage row for an archived receipt matches
1467            // the live row before the delete.
1468            "receipt_lineage_statements",
1469            format!(
1470                "SELECT COUNT(*) FROM main.receipt_lineage_statements WHERE receipt_id IN \
1471                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w})"
1472            ),
1473            format!(
1474                "SELECT COUNT(*) FROM main.receipt_lineage_statements m WHERE m.receipt_id IN \
1475                 (SELECT receipt_id FROM main.claim_receipt_log_entries WHERE entry_seq <= {w}) \
1476                 AND EXISTS (SELECT 1 FROM archive.receipt_lineage_statements a WHERE a.receipt_id = m.receipt_id \
1477                 AND a.statement_id IS m.statement_id AND a.request_id IS m.request_id \
1478                 AND a.session_id IS m.session_id AND a.session_anchor_id IS m.session_anchor_id \
1479                 AND a.chain_id IS m.chain_id AND a.parent_request_id IS m.parent_request_id \
1480                 AND a.parent_receipt_id IS m.parent_receipt_id AND a.evidence_class IS m.evidence_class \
1481                 AND a.evidence_sources_json IS m.evidence_sources_json \
1482                 AND a.verified_session_anchor IS m.verified_session_anchor \
1483                 AND a.verified_parent_request IS m.verified_parent_request \
1484                 AND a.verified_parent_receipt IS m.verified_parent_receipt \
1485                 AND a.replay_protected IS m.replay_protected AND a.recorded_at IS m.recorded_at \
1486                 AND a.source_kind IS m.source_kind AND a.json_sha256 IS m.json_sha256 \
1487                 AND a.raw_json IS m.raw_json)"
1488            ),
1489        ),
1490    ];
1491    for (table, live_sql, archive_sql) in checks {
1492        let live: i64 = connection.query_row(&live_sql, [], |row| row.get(0))?;
1493        let archived: i64 = connection.query_row(&archive_sql, [], |row| row.get(0))?;
1494        if archived < live {
1495            return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1496                table,
1497                live: sqlite_u64(live, "live co-archival count")?,
1498                archived: sqlite_u64(archived, "archive co-archival count")?,
1499            });
1500        }
1501    }
1502    Ok(())
1503}
1504
1505/// Delete the [1, W] prefix from the live store in ONE BEGIN IMMEDIATE
1506/// transaction (FK-safe order: the reconciliation/consumption children first,
1507/// then the receipts, then the claim-log last so its rows drive the receipt
1508/// deletes), record the watermark, and restore the immutability guards. A
1509/// rollback restores rows AND triggers together, so a failed delete cannot
1510/// leave the store with its append-only guards dropped.
1511///
1512/// The claim-log and source-receipt tables must lose EXACTLY the same
1513/// receipt_id set together and atomically: deleting source rows while leaving
1514/// the claim-log projection intact would make the next projection validation
1515/// see set drift (the expected set shrank, the projection did not) and brick
1516/// the store on the following rotation.
1517pub(super) fn delete_archived_prefix_in_tx(
1518    connection: &mut rusqlite::Connection,
1519    w: i64,
1520    cutoff_unix_secs: u64,
1521    archive_path: &str,
1522) -> Result<(), ReceiptStoreError> {
1523    let now = SystemTime::now()
1524        .duration_since(UNIX_EPOCH)
1525        .map(|d| d.as_secs())
1526        .unwrap_or(0);
1527    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1528    // The archive copy and its completeness check ran before this transaction
1529    // took the write lock, so a second store handle or process could have
1530    // committed a new dependent row (a reconciliation or authorization-
1531    // consumption upsert) into the archived prefix in that window. Such a row
1532    // is not in the archive, and the deletes below would remove it un-archived.
1533    // Re-check co-archival completeness now that BEGIN IMMEDIATE holds the write
1534    // lock and no further writer can interleave, and fail closed on any
1535    // shortfall so a delete never outruns the archive. The rollback on error
1536    // leaves the prefix intact and re-runnable; a later rotation re-copies the
1537    // new row and completes.
1538    verify_co_archival_complete(&tx, w)?;
1539    // The archive-path pin and the backing check also ran before this
1540    // transaction took the write lock, so a concurrent rotation could have
1541    // committed the shared prefix to a DIFFERENT archive, or moved/emptied the
1542    // pinned archive, in that window: one rotation commits `[1, W1]` to archive
1543    // A after the outer check, and this rotation would then copy only the
1544    // surviving suffix to archive B and record a higher watermark naming B,
1545    // leaving the ledger pointing at a file that lacks the earlier prefix.
1546    // Re-read the ledger under BEGIN IMMEDIATE (the same TOCTOU class as the
1547    // co-archival re-check above) and re-enforce both, so a rotation never
1548    // advances a watermark that splits the archived prefix or extends an archive
1549    // that no longer backs the committed prefix. The rollback on error leaves the
1550    // prefix intact and re-runnable.
1551    ensure_archive_path_matches_ledger(&tx, archive_path)?;
1552    ensure_committed_prefix_still_backed(&tx)?;
1553    tx.execute_batch(&format!(
1554        r#"
1555        DROP TRIGGER IF EXISTS chio_tool_receipts_reject_delete;
1556        DROP TRIGGER IF EXISTS chio_child_receipts_reject_delete;
1557        DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete;
1558
1559        -- Tombstone every archived receipt id BEFORE its claim-log row is
1560        -- deleted so the append path still rejects a reused id once its live
1561        -- UNIQUE(receipt_id) sentinel is gone. Idempotent for a re-run.
1562        INSERT OR IGNORE INTO receipt_retention_tombstones
1563            (receipt_id, receipt_kind, archived_through_entry_seq, tombstoned_at)
1564            SELECT receipt_id, receipt_kind, {w}, {now}
1565            FROM claim_receipt_log_entries WHERE entry_seq <= {w};
1566
1567        -- `compute_archival_watermark` never advances W past a receipt whose
1568        -- settlement or metered-billing reconciliation is still nonterminal, so
1569        -- every reconciliation row removed here has already reached a terminal
1570        -- state and its co-archived copy preserves the closed record. No live,
1571        -- actionable reconciliation loses the receipt its upsert path resolves.
1572        DELETE FROM settlement_reconciliations WHERE receipt_id IN (
1573            SELECT receipt_id FROM claim_receipt_log_entries
1574            WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1575        DELETE FROM metered_billing_reconciliations WHERE receipt_id IN (
1576            SELECT receipt_id FROM claim_receipt_log_entries
1577            WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1578        -- Safe to key this delete on the archived authorization alone:
1579        -- `compute_archival_watermark` never advances W past an authorization
1580        -- whose bound consumer is still live, so every consumption removed here
1581        -- has both its authorization and (when the consumer is a live receipt)
1582        -- its consumer inside the archived prefix. No live consumer loses its
1583        -- binding, and the co-archived copy preserves the archived pair.
1584        DELETE FROM chio_authorization_receipt_consumptions WHERE authorization_receipt_id IN (
1585            SELECT receipt_id FROM claim_receipt_log_entries
1586            WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1587        DELETE FROM chio_tool_receipts WHERE seq IN (
1588            SELECT source_seq FROM claim_receipt_log_entries
1589            WHERE entry_seq <= {w} AND receipt_kind = 'tool_receipt');
1590        DELETE FROM chio_child_receipts WHERE seq IN (
1591            SELECT source_seq FROM claim_receipt_log_entries
1592            WHERE entry_seq <= {w} AND receipt_kind = 'child_receipt');
1593        DELETE FROM claim_receipt_log_entries WHERE entry_seq <= {w};
1594        "#
1595    ))?;
1596    insert_receipt_retention_watermark(
1597        &tx,
1598        sqlite_u64(w, "watermark entry_seq")?,
1599        cutoff_unix_secs,
1600        archive_path,
1601        None,
1602        now,
1603    )?;
1604    ensure_transparency_projection_guards(&tx)?; // recreate all reject-delete/update guards
1605    tx.commit()?;
1606    Ok(())
1607}
1608
1609/// Recover a store whose claim-log projection rows survived a source-row
1610/// delete: the source rows were deleted but the projection rows remained,
1611/// producing set drift that fails the projection guard on open. Fail-closed:
1612/// only the `extra` claim-log rows -- present in the projection but absent from
1613/// BOTH source tables -- are candidates, and each
1614/// candidate must (a) already be present in the named archive and (b) fall at
1615/// or below the smallest checkpoint `batch_end_seq` that covers it, so the
1616/// uncheckpointed suffix is never touched. Entry point for
1617/// `SqliteReceiptStore::retention_repair`, run on the single writer
1618/// connection.
1619pub(super) fn retention_repair_on_writer(
1620    connection: &mut rusqlite::Connection,
1621    archive_path: &str,
1622) -> Result<u64, ReceiptStoreError> {
1623    // 1. extra = claim-log receipt_ids absent from BOTH source tables.
1624    let extras: Vec<(i64, String)> = {
1625        let mut stmt = connection.prepare(
1626            "SELECT e.entry_seq, e.receipt_id FROM claim_receipt_log_entries e \
1627             WHERE NOT EXISTS (SELECT 1 FROM chio_tool_receipts t WHERE t.receipt_id = e.receipt_id) \
1628               AND NOT EXISTS (SELECT 1 FROM chio_child_receipts c WHERE c.receipt_id = e.receipt_id) \
1629             ORDER BY e.entry_seq",
1630        )?;
1631        let rows = stmt.query_map([], |row| {
1632            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1633        })?;
1634        let mut out = Vec::new();
1635        for row in rows {
1636            out.push(row?);
1637        }
1638        out
1639    };
1640    if extras.is_empty() {
1641        return Ok(0);
1642    }
1643    let max_extra_entry_seq = extras.iter().map(|(seq, _)| *seq).max().unwrap_or(0);
1644
1645    // 2. Assert every extra id is present in the archive, and its range is
1646    //    checkpoint-covered. Refuse otherwise (never delete a non-archived row,
1647    //    never touch the uncheckpointed suffix).
1648    ensure_durable_distinct_archive_path(connection, archive_path)?;
1649    ensure_archive_file_exists(archive_path)?;
1650    // Record the archive by its absolute, symlink-free path (see
1651    // `absolute_archive_path`): the repair watermark is trusted by the same
1652    // working-directory-independent reader as a rotation watermark.
1653    let archive_path = absolute_archive_path(archive_path)?;
1654    let archive_path = archive_path.as_str();
1655    let escaped = archive_path.replace('\'', "''");
1656    connection.execute_batch(&format!("ATTACH DATABASE '{escaped}' AS archive"))?;
1657    let assert_result = (|| -> Result<u64, ReceiptStoreError> {
1658        for (entry_seq, _) in &extras {
1659            // Identity, not mere presence: the orphaned live claim-log row is the
1660            // last faithful evidence for its receipt, and deleting it is only safe
1661            // if the archive holds a BYTE-IDENTICAL copy. A reused or wrong archive
1662            // that merely reuses the `receipt_id` under a divergent `entry_seq`,
1663            // `source_seq`, `receipt_kind`, or `raw_json` would pass a count-only
1664            // probe yet leave no faithful archived copy behind the delete, so
1665            // compare the whole row (keyed on the verbatim-copied `entry_seq`).
1666            let faithful: i64 = connection.query_row(
1667                "SELECT COUNT(*) FROM main.claim_receipt_log_entries m \
1668                 WHERE m.entry_seq = ?1 \
1669                   AND EXISTS (SELECT 1 FROM archive.claim_receipt_log_entries a \
1670                     WHERE a.entry_seq = m.entry_seq \
1671                       AND a.receipt_id IS m.receipt_id AND a.receipt_kind IS m.receipt_kind \
1672                       AND a.source_seq IS m.source_seq AND a.timestamp IS m.timestamp \
1673                       AND a.capability_id IS m.capability_id AND a.session_id IS m.session_id \
1674                       AND a.parent_request_id IS m.parent_request_id AND a.request_id IS m.request_id \
1675                       AND a.subject_key IS m.subject_key AND a.issuer_key IS m.issuer_key \
1676                       AND a.tool_server IS m.tool_server AND a.tool_name IS m.tool_name \
1677                       AND a.raw_json IS m.raw_json)",
1678                params![entry_seq],
1679                |row| row.get(0),
1680            )?;
1681            if faithful == 0 {
1682                return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1683                    table: "claim_receipt_log_entries",
1684                    live: 1,
1685                    archived: 0,
1686                });
1687            }
1688        }
1689        // Checkpoint-aligned rounding: smallest batch_end_seq >= max(extra).
1690        let rounded: Option<i64> = connection.query_row(
1691            "SELECT MIN(batch_end_seq) FROM kernel_checkpoints WHERE batch_end_seq >= ?1",
1692            params![max_extra_entry_seq],
1693            |row| row.get(0),
1694        )?;
1695        let rounded = rounded.ok_or_else(|| {
1696            ReceiptStoreError::Conflict(
1697                "retention repair: extra claim-log rows are not covered by any checkpoint; \
1698                 refusing to touch the uncheckpointed suffix"
1699                    .to_string(),
1700            )
1701        })?;
1702        // The rounded watermark is a checkpoint boundary at or above the largest
1703        // orphan. If the orphans cover only PART of that batch, rows between the
1704        // largest orphan and the boundary may still have LIVE source receipts;
1705        // stamping the watermark there would mark them archived and permanently
1706        // skip their Merkle rebuild. Refuse a partial batch: every claim-log row
1707        // up to the boundary must itself be an orphan (absent from both source
1708        // tables), so after the delete the whole covered prefix is genuinely
1709        // gone and the watermark never covers a live row.
1710        let live_in_prefix: i64 = connection.query_row(
1711            "SELECT COUNT(*) FROM claim_receipt_log_entries e \
1712             WHERE e.entry_seq <= ?1 \
1713               AND (EXISTS (SELECT 1 FROM chio_tool_receipts t WHERE t.receipt_id = e.receipt_id) \
1714                    OR EXISTS (SELECT 1 FROM chio_child_receipts c WHERE c.receipt_id = e.receipt_id))",
1715            params![rounded],
1716            |row| row.get(0),
1717        )?;
1718        if live_in_prefix > 0 {
1719            return Err(ReceiptStoreError::Conflict(
1720                "retention repair: the checkpoint batch covering the orphaned rows still has \
1721                 live source receipts; refusing to watermark a partially archived batch"
1722                    .to_string(),
1723            ));
1724        }
1725        // The watermark this repair is about to stamp trusts the ENTIRE
1726        // [1, rounded] prefix as archived and permanently skips its Merkle
1727        // rebuild (`trusted_retention_watermark`). The per-extra identity check
1728        // above only covers claim-log rows that SURVIVED in the live projection;
1729        // a botched rotation may also have deleted some projection rows in that
1730        // prefix outright, leaving no live row to compare. Require the archive to
1731        // hold a row for every entry_seq in the checkpoint-aligned prefix (the
1732        // projection's entry_seq is a gapless sequence from 1, so a faithful
1733        // archive of [1, rounded] has exactly `rounded` rows) so a partial or
1734        // missing archive can never be sealed behind a trusted watermark.
1735        let archived_prefix: i64 = connection.query_row(
1736            "SELECT COUNT(*) FROM archive.claim_receipt_log_entries WHERE entry_seq <= ?1",
1737            params![rounded],
1738            |row| row.get(0),
1739        )?;
1740        if archived_prefix < rounded {
1741            return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1742                table: "claim_receipt_log_entries",
1743                live: sqlite_u64(rounded, "repair rounded prefix width")?,
1744                archived: sqlite_u64(archived_prefix.max(0), "repair archived prefix count")?,
1745            });
1746        }
1747        // Row presence is not integrity. For prefix rows already deleted from the
1748        // live projection there is no live row to compare against, so corrupted
1749        // `raw_json` or attribution in the archived copy passes the count check
1750        // yet would fail the next archive-backed chain verification and leave the
1751        // store bricked. Re-derive the signed checkpoint roots from the archive
1752        // that backs the trusted watermark, and stamp the post-repair tombstones
1753        // from that SAME archive.
1754        //
1755        // The per-extra identity check above and the tombstone insert below both
1756        // read from the SUPPLIED archive attached as `archive`. When an existing
1757        // watermark already covers this boundary the prefix is backed by the
1758        // LEDGER's archive, which the supplied path need not equal: the prefix
1759        // entries already deleted from the live projection have no surviving extra
1760        // to compare, so a supplied archive that is faithful for the surviving
1761        // extras but divergent for those deleted entries would pass every
1762        // live-vs-archive check yet stamp tombstones for the WRONG receipt ids,
1763        // leaving the truly archived ids reusable. Require the supplied archive to
1764        // be the ledger archive so the root re-derivation and the tombstone
1765        // stamping run against the one archive that actually backs the watermark;
1766        // fail closed on a mismatch.
1767        let rounded_u64 = sqlite_u64(rounded, "repair rounded watermark")?;
1768        let watermark_covers = matches!(
1769            super::support::retention_watermark(connection)?,
1770            Some(current) if current >= rounded_u64
1771        );
1772        if watermark_covers {
1773            if let Some(ledger_archive) = super::support::latest_watermark_archive_path(connection)?
1774            {
1775                if ledger_archive != archive_path {
1776                    return Err(ReceiptStoreError::Conflict(format!(
1777                        "retention repair archive {archive_path:?} differs from the archive \
1778                         {ledger_archive:?} that backs the existing watermark; tombstones must be \
1779                         stamped from the archive that backs the prefix. Supply the ledger archive \
1780                         or start a fresh store"
1781                    )));
1782                }
1783            }
1784        }
1785        if !super::support::archive_path_backs_prefix(connection, archive_path, rounded_u64)? {
1786            return Err(ReceiptStoreError::RetentionArchiveIncomplete {
1787                table: "claim_receipt_log_entries",
1788                live: rounded_u64,
1789                archived: 0,
1790            });
1791        }
1792        Ok(rounded_u64)
1793    })();
1794    let rounded_watermark = match assert_result {
1795        Ok(w) => w,
1796        Err(error) => {
1797            // Detach the archive even when the pre-flight assertions reject the
1798            // repair, so a refusal never strands an attached database on the
1799            // writer connection.
1800            let _ = connection.execute_batch("DETACH DATABASE archive");
1801            return Err(error);
1802        }
1803    };
1804
1805    // 3. One BEGIN IMMEDIATE tx: drop guard, tombstone the archived prefix,
1806    //    delete extras, insert watermark, recreate guard, commit. The archive
1807    //    stays attached through the transaction so the tombstones can be stamped
1808    //    from the archived rows, and is detached only once the repair commits
1809    //    (DETACH cannot run inside an open transaction).
1810    let now = SystemTime::now()
1811        .duration_since(UNIX_EPOCH)
1812        .map(|d| d.as_secs())
1813        .unwrap_or(0);
1814    let removed = extras.len() as u64;
1815    let repair_result = (|| -> Result<(), ReceiptStoreError> {
1816        let rounded_i64 = sqlite_i64(rounded_watermark, "repair rounded watermark")?;
1817        let now_i64 = sqlite_i64(now, "repair tombstone timestamp")?;
1818        let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1819        tx.execute_batch("DROP TRIGGER IF EXISTS claim_receipt_log_entries_reject_delete;")?;
1820        // Repair runs on an `open_existing` connection, which skips the writable
1821        // open() migration that creates the tombstone table and its reuse-reject
1822        // triggers. Create them before deleting so a legacy store repaired here
1823        // still blocks archived-id reuse.
1824        super::support::ensure_receipt_retention_tombstones(&tx)?;
1825        // Tombstone EVERY archived id in the repaired prefix, not just the
1826        // claim-log rows that survived as extras. A botched rotation may have
1827        // already deleted some archived rows from the live projection, so those
1828        // ids have neither a live UNIQUE(receipt_id) sentinel nor an extra to
1829        // iterate; without a tombstone the same archived receipt_id could be
1830        // appended again as a brand-new live receipt, recreating the archived/live
1831        // identity ambiguity the tombstone exists to prevent. The archive is
1832        // already vetted to hold a faithful row for every entry_seq in the covered
1833        // prefix (the prefix-completeness and root re-derivation checks above), so
1834        // stamp the tombstones from the archived prefix, which also covers the
1835        // surviving extras.
1836        tx.execute(
1837            "INSERT OR IGNORE INTO receipt_retention_tombstones \
1838                 (receipt_id, receipt_kind, archived_through_entry_seq, tombstoned_at) \
1839             SELECT receipt_id, receipt_kind, ?1, ?2 \
1840             FROM archive.claim_receipt_log_entries WHERE entry_seq <= ?3",
1841            params![rounded_i64, now_i64, rounded_i64],
1842        )?;
1843        // Remove the surviving orphaned claim-log rows (the extras). Their source
1844        // receipts are already gone and the ids are now tombstoned above.
1845        for (entry_seq, _) in &extras {
1846            tx.execute(
1847                "DELETE FROM claim_receipt_log_entries WHERE entry_seq = ?1",
1848                params![entry_seq],
1849            )?;
1850        }
1851        // A store created before the retention migration has no watermark ledger,
1852        // and repair runs on an `open_existing` connection, which skips the
1853        // writable open() migration that would create it. Create the ledger before
1854        // recording the repair watermark; otherwise the insert fails on a missing
1855        // table and rolls the whole repair back, leaving the bricked store
1856        // unrepaired.
1857        super::support::ensure_receipt_retention_watermark_table(&tx)?;
1858        // A prior botched rotation may have already recorded a watermark at or
1859        // above this repair boundary while leaving the orphaned claim-log rows
1860        // behind. The ledger's monotonic-insert trigger rejects a non-increasing
1861        // mark, so an unconditional re-insert at the covered boundary would abort
1862        // and roll the whole repair back, leaving the store permanently
1863        // unrepairable. The watermark already covers the boundary, so skip the
1864        // redundant insert and let the deletion of the orphans stand.
1865        let watermark_covers = matches!(
1866            super::support::retention_watermark(&tx)?,
1867            Some(current) if current >= rounded_watermark
1868        );
1869        if !watermark_covers {
1870            insert_receipt_retention_watermark(
1871                &tx,
1872                rounded_watermark,
1873                now,
1874                archive_path,
1875                None,
1876                now,
1877            )?;
1878        }
1879        ensure_transparency_projection_guards(&tx)?;
1880        tx.commit()?;
1881        Ok(())
1882    })();
1883    let detach = connection.execute_batch("DETACH DATABASE archive");
1884    match (repair_result, detach) {
1885        (Ok(()), Ok(())) => {}
1886        (Err(error), _) => return Err(error),
1887        (Ok(()), Err(error)) => return Err(error.into()),
1888    }
1889
1890    connection.execute_batch("PRAGMA incremental_vacuum")?;
1891    connection.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
1892    Ok(removed)
1893}