Skip to main content

chio_store_sqlite/receipt_store/bootstrap/
open.rs

1use super::*;
2
3/// Tables that identify a database this receipt store may open, all of them the
4/// store's own: `chio_tool_receipts` is the current anchor (also the table the
5/// store shipped before schema stamping existed) and `http_receipts` /
6/// `tool_receipts` are legacy names it may still encounter on disk. A populated
7/// database carrying none of these is refused rather than adopted, so a path
8/// mistargeted at another store's file (an approval, revocation, budget, or
9/// authority database) never has receipt tables written into it.
10///
11/// `chio api protect` co-locates the receipt and approval stores in one SQLite
12/// file and opens the receipt store first, so this store owns the shared file's
13/// provenance anchor. The approval store, opened second, adopts the
14/// receipt-anchored file through its own co-located open; the receipt store never
15/// needs to recognize a neighbor's table to bootstrap the shared file, so a
16/// standalone approval database is refused here.
17const RECEIPT_STORE_LEGACY_ANCHOR_TABLES: &[&str] =
18    &["chio_tool_receipts", "http_receipts", "tool_receipts"];
19
20/// The subset of legacy anchors whose names are generic enough that an unrelated
21/// SQLite database could carry them by coincidence. Adopting an unstamped
22/// database on one of these names alone would let a mistargeted path capture a
23/// foreign file, so each must also carry a receipt payload column before it is
24/// accepted. The Chio-specific `chio_tool_receipts` anchor needs no such check.
25const RECEIPT_STORE_GENERIC_LEGACY_ANCHOR_TABLES: &[&str] = &["http_receipts", "tool_receipts"];
26
27fn configure_sqlite_connection(
28    connection: &mut Connection,
29    max_page_count: Option<u32>,
30) -> Result<(), ReceiptStoreError> {
31    connection.execute_batch(
32        r#"
33        PRAGMA journal_mode = WAL;
34        PRAGMA synchronous = FULL;
35        PRAGMA busy_timeout = 5000;
36        PRAGMA foreign_keys = ON;
37        PRAGMA auto_vacuum = INCREMENTAL;
38        "#,
39    )?;
40    assert_sqlite_durability_pragmas(connection)?;
41    // Operational growth bound (see `SqlitePoolConfig::max_page_count`): cap the
42    // logical page count of the MAIN database file. A write that would push the
43    // main file past the cap fails closed with SQLITE_FULL. This bounds the main
44    // file only, not the `-wal` sidecar, so it is not a whole-volume exhaustion
45    // guard: under checkpoint starvation the WAL can still grow unbounded. `None`
46    // leaves SQLite's built-in page ceiling untouched.
47    if let Some(max_page_count) = max_page_count {
48        connection.pragma_update(None, "max_page_count", max_page_count)?;
49        // Verify the cap took effect. SQLite silently ignores a max_page_count of 0
50        // (leaving the default multi-gigabyte ceiling) and raises the effective
51        // limit to the current database size when the requested cap sits below it,
52        // so a caller could otherwise believe a fail-closed growth ceiling is
53        // installed while the file is effectively uncapped or already over the
54        // ceiling. Read the effective limit back and deny when it is not exactly
55        // the requested cap.
56        let effective: i64 = connection.query_row("PRAGMA max_page_count", [], |row| row.get(0))?;
57        if effective != i64::from(max_page_count) {
58            return Err(ReceiptStoreError::Conflict(format!(
59                "sqlite max_page_count requested {max_page_count} but effective limit is {effective}"
60            )));
61        }
62    }
63    Ok(())
64}
65
66fn assert_sqlite_durability_pragmas(connection: &Connection) -> Result<(), ReceiptStoreError> {
67    let journal_mode: String = connection.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
68    if !journal_mode.eq_ignore_ascii_case("wal") {
69        return Err(ReceiptStoreError::Conflict(format!(
70            "sqlite receipt store journal_mode must be WAL, got {journal_mode}"
71        )));
72    }
73
74    let synchronous: i64 = connection.query_row("PRAGMA synchronous", [], |row| row.get(0))?;
75    if synchronous != 2 {
76        return Err(ReceiptStoreError::Conflict(format!(
77            "sqlite receipt store synchronous must be FULL, got {synchronous}"
78        )));
79    }
80
81    let busy_timeout: i64 = connection.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?;
82    if busy_timeout < 5000 {
83        return Err(ReceiptStoreError::Conflict(format!(
84            "sqlite receipt store busy_timeout must be at least 5000ms, got {busy_timeout}"
85        )));
86    }
87
88    let foreign_keys: i64 = connection.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
89    if foreign_keys != 1 {
90        return Err(ReceiptStoreError::Conflict(format!(
91            "sqlite receipt store foreign_keys must be ON, got {foreign_keys}"
92        )));
93    }
94
95    Ok(())
96}
97
98fn settlement_store_binding_if_ready(
99    connection: &Connection,
100) -> Result<Option<chio_settle::SettlementStoreBinding>, ReceiptStoreError> {
101    if !settlement_projection_schema_is_installed(connection)? {
102        return Ok(None);
103    }
104    let mut digest = [0u8; 32];
105    OsRng.try_fill_bytes(&mut digest).map_err(|error| {
106        ReceiptStoreError::Io(std::io::Error::other(format!(
107            "failed to generate settlement store binding: {error}"
108        )))
109    })?;
110    Ok(Some(chio_settle::SettlementStoreBinding::from_digest(
111        digest,
112    )))
113}
114
115fn settlement_projection_schema_is_installed(
116    connection: &Connection,
117) -> Result<bool, ReceiptStoreError> {
118    let reference = Connection::open_in_memory()?;
119    reference.execute_batch(crate::dead_letters::SETTLE_DEAD_LETTERS_MIGRATION)?;
120    reference.execute_batch(crate::settle_attempts::SETTLE_ATTEMPTS_MIGRATION)?;
121    Ok(settlement_schema_manifest(connection)? == settlement_schema_manifest(&reference)?)
122}
123
124#[derive(Debug, PartialEq, Eq)]
125struct SettlementSchemaObject {
126    object_type: String,
127    name: String,
128    table_name: String,
129    sql: String,
130}
131
132fn settlement_schema_manifest(
133    connection: &Connection,
134) -> Result<Vec<SettlementSchemaObject>, ReceiptStoreError> {
135    let mut statement = connection.prepare(
136        "SELECT type, name, tbl_name, sql FROM sqlite_master \
137         WHERE name IN (\
138             'settle_attempts', \
139             'idx_settle_attempts_visible', \
140             'settle_dead_letters', \
141             'idx_settle_dead_letters_finalized_at', \
142             'trg_settle_attempts_reject_terminal_insert', \
143             'trg_settle_dead_letters_reject_attempt_insert'\
144         ) OR (\
145             type = 'trigger' AND \
146             tbl_name IN ('settle_attempts', 'settle_dead_letters')\
147         ) ORDER BY type ASC, name ASC",
148    )?;
149    let manifest = statement
150        .query_map([], |row| {
151            Ok(SettlementSchemaObject {
152                object_type: row.get(0)?,
153                name: row.get(1)?,
154                table_name: row.get(2)?,
155                sql: row.get(3)?,
156            })
157        })?
158        .collect::<Result<Vec<_>, _>>()
159        .map_err(ReceiptStoreError::from)?;
160    Ok(manifest)
161}
162
163impl SqliteReceiptStore {
164    pub fn open(path: impl AsRef<Path>) -> Result<Self, ReceiptStoreError> {
165        Self::open_with_pool_config(path, crate::SqlitePoolConfig::default())
166    }
167
168    /// Wait until the commit actor has seeded its durable head before a host
169    /// advertises readiness. The flush barrier is processed only after actor
170    /// initialization, and the serving check preserves a poisoned-head failure.
171    pub fn wait_for_writer_ready(&self, timeout: Duration) -> Result<(), ReceiptStoreError> {
172        self.receipt_commit_actor.flush_with_timeout(timeout)?;
173        if !self.writer_serving_closed() {
174            return Ok(());
175        }
176        let detail = self
177            .receipt_commit_actor
178            .writer_counters()
179            .last_error
180            .unwrap_or_else(|| "durable receipt head is unavailable".to_string());
181        Err(ReceiptStoreError::Conflict(format!(
182            "receipt commit writer failed startup readiness: {detail}"
183        )))
184    }
185
186    pub fn open_existing(path: impl AsRef<Path>) -> Result<Self, ReceiptStoreError> {
187        Self::open_existing_with_pool_config(path, crate::SqlitePoolConfig::default())
188    }
189
190    pub fn open_with_pool_config(
191        path: impl AsRef<Path>,
192        pool_config: crate::SqlitePoolConfig,
193    ) -> Result<Self, ReceiptStoreError> {
194        Self::open_with_options(
195            path,
196            crate::SqliteStoreOptions {
197                pool: pool_config,
198                ..crate::SqliteStoreOptions::default()
199            },
200        )
201    }
202
203    fn open_existing_with_pool_config(
204        path: impl AsRef<Path>,
205        pool_config: crate::SqlitePoolConfig,
206    ) -> Result<Self, ReceiptStoreError> {
207        Self::open_existing_with_options(
208            path,
209            crate::SqliteStoreOptions {
210                pool: pool_config,
211                ..crate::SqliteStoreOptions::default()
212            },
213        )
214    }
215
216    pub fn open_with_options(
217        path: impl AsRef<Path>,
218        options: crate::SqliteStoreOptions,
219    ) -> Result<Self, ReceiptStoreError> {
220        Self::open_with_pool_config_and_flags(path, options, true)
221    }
222
223    pub fn open_existing_with_options(
224        path: impl AsRef<Path>,
225        options: crate::SqliteStoreOptions,
226    ) -> Result<Self, ReceiptStoreError> {
227        Self::open_with_pool_config_and_flags(path, options, false)
228    }
229
230    fn open_with_pool_config_and_flags(
231        path: impl AsRef<Path>,
232        options: crate::SqliteStoreOptions,
233        create_if_missing: bool,
234    ) -> Result<Self, ReceiptStoreError> {
235        let path = path.as_ref();
236        let connection_flags = if create_if_missing {
237            None
238        } else {
239            Some(existing_database_open_flags())
240        };
241
242        if create_if_missing {
243            // Resolve `file:` URIs to their on-disk path before deriving the
244            // parent: a raw `parent()` on `file:/dir/db?mode=rwc` folds the
245            // scheme and query into a bogus directory and skips the real one.
246            if let Some(parent) = crate::sqlite_parent_dir_to_create(path) {
247                fs::create_dir_all(&parent)?;
248            }
249        }
250
251        let mut connection = match connection_flags {
252            Some(flags) => Connection::open_with_flags(path, flags).map_err(|error| {
253                if error.sqlite_error_code() == Some(rusqlite::ErrorCode::CannotOpen) {
254                    ReceiptStoreError::NotFound(format!(
255                        "receipt database {} does not exist",
256                        path.display()
257                    ))
258                } else {
259                    ReceiptStoreError::Sqlite(error)
260                }
261            })?,
262            None => Connection::open(path)?,
263        };
264        if !create_if_missing {
265            require_existing_receipt_schema(path, &connection)?;
266            // Validate provenance before configuring pragmas: a foreign or
267            // future database must be refused before any write touches its
268            // header, so a mistargeted path is never mutated into WAL mode.
269            let on_disk_schema_version = crate::check_schema_version(
270                &connection,
271                RECEIPT_STORE_SCHEMA_KEY,
272                RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION,
273                RECEIPT_STORE_LEGACY_ANCHOR_TABLES,
274            )
275            .map_err(|error| ReceiptStoreError::Conflict(error.to_string()))?;
276            if on_disk_schema_version < RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION {
277                return Err(ReceiptStoreError::Conflict(format!(
278                    "receipt database schema version {on_disk_schema_version} requires writable migration to version {RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION}; reopen it with SqliteReceiptStore::open"
279                )));
280            }
281            configure_sqlite_connection(&mut connection, options.pool.max_page_count)?;
282            super::support::ensure_transparency_projection_guards(&connection)?;
283            verify_receipt_cost_projection(&connection)?;
284            let settlement_store_binding = settlement_store_binding_if_ready(&connection)?;
285            drop(connection);
286
287            let reader_pool = build_receipt_pool(
288                path,
289                options.pool.reader_pool_max_size,
290                "reader",
291                connection_flags,
292                options.pool.max_page_count,
293            )?;
294            let writer_pool = build_receipt_pool(
295                path,
296                options.pool.writer_pool_max_size,
297                "writer",
298                connection_flags,
299                options.pool.max_page_count,
300            )?;
301
302            return Ok(Self {
303                receipt_commit_actor: ReceiptCommitActor::start(
304                    writer_pool,
305                    options.incremental_verification,
306                ),
307                pool: reader_pool,
308                settlement_store_binding,
309                strict_tenant_isolation: std::sync::atomic::AtomicBool::new(true),
310                incremental_verification: options.incremental_verification,
311            });
312        }
313
314        // Validate provenance before configuring pragmas: an existing file that
315        // is a foreign database must be refused before any write touches its
316        // header, so a mistargeted path is never mutated into WAL mode. The
317        // schema-version check adopts an unstamped database on a legacy anchor
318        // name alone, so first reject one whose generic anchor lacks the receipt
319        // shape, keeping a foreign file that merely reuses the name out.
320        reject_foreign_legacy_receipt_anchor(&connection)?;
321        let on_disk_schema_version = crate::check_schema_version(
322            &connection,
323            RECEIPT_STORE_SCHEMA_KEY,
324            RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION,
325            RECEIPT_STORE_LEGACY_ANCHOR_TABLES,
326        )
327        .map_err(|error| ReceiptStoreError::Conflict(error.to_string()))?;
328        configure_sqlite_connection(&mut connection, options.pool.max_page_count)?;
329        let schema_migration =
330            connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
331        schema_migration.execute_batch(
332            r#"
333            CREATE TABLE IF NOT EXISTS chio_tool_receipts (
334                seq INTEGER PRIMARY KEY AUTOINCREMENT,
335                receipt_id TEXT NOT NULL UNIQUE,
336                timestamp INTEGER NOT NULL,
337                capability_id TEXT NOT NULL,
338                subject_key TEXT,
339                issuer_key TEXT,
340                grant_index INTEGER,
341                tool_server TEXT NOT NULL,
342                tool_name TEXT NOT NULL,
343                decision_kind TEXT NOT NULL,
344                policy_hash TEXT NOT NULL,
345                content_hash TEXT NOT NULL,
346                raw_json TEXT NOT NULL,
347                cost_currency TEXT CHECK (
348                    cost_currency IS NULL OR (
349                        typeof(cost_currency) = 'text' AND
350                        length(cost_currency) = 3 AND
351                        cost_currency NOT GLOB '*[^A-Z]*'
352                    )
353                ),
354                cost_charged_be BLOB CHECK (
355                    (cost_currency IS NULL AND cost_charged_be IS NULL) OR
356                    (
357                        cost_currency IS NOT NULL AND
358                        typeof(cost_charged_be) = 'blob' AND
359                        length(cost_charged_be) = 8
360                    )
361                )
362            );
363
364            CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_timestamp
365                ON chio_tool_receipts(timestamp);
366            CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_capability
367                ON chio_tool_receipts(capability_id);
368            CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_subject
369                ON chio_tool_receipts(subject_key);
370            CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_grant
371                ON chio_tool_receipts(capability_id, grant_index);
372            CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_tool
373                ON chio_tool_receipts(tool_server, tool_name);
374            CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_decision
375                ON chio_tool_receipts(decision_kind);
376
377            CREATE TABLE IF NOT EXISTS chio_authorization_receipt_consumptions (
378                authorization_receipt_id TEXT PRIMARY KEY REFERENCES chio_tool_receipts(receipt_id) ON DELETE RESTRICT,
379                consumer_receipt_id TEXT NOT NULL UNIQUE,
380                request_id TEXT NOT NULL,
381                session_id TEXT NOT NULL,
382                tool_call_id TEXT NOT NULL,
383                -- tenant_id may be NULL for non-enterprise / single-tenant
384                -- deployments where the authorization receipt itself carries
385                -- `tenant_id: None`. The consumption record mirrors the
386                -- receipt's tenant scope verbatim.
387                tenant_id TEXT,
388                parameter_hash TEXT NOT NULL,
389                consumed_at_unix_ms INTEGER NOT NULL
390            );
391            CREATE INDEX IF NOT EXISTS idx_chio_authorization_consumptions_consumer
392                ON chio_authorization_receipt_consumptions(consumer_receipt_id);
393            CREATE INDEX IF NOT EXISTS idx_chio_authorization_consumptions_scope
394                ON chio_authorization_receipt_consumptions(session_id, tool_call_id, request_id);
395
396            CREATE TABLE IF NOT EXISTS settlement_reconciliations (
397                receipt_id TEXT PRIMARY KEY REFERENCES chio_tool_receipts(receipt_id) ON DELETE CASCADE,
398                reconciliation_state TEXT NOT NULL,
399                note TEXT,
400                updated_at INTEGER NOT NULL
401            );
402            CREATE INDEX IF NOT EXISTS idx_settlement_reconciliations_updated_at
403                ON settlement_reconciliations(updated_at);
404
405            CREATE TABLE IF NOT EXISTS metered_billing_reconciliations (
406                receipt_id TEXT PRIMARY KEY REFERENCES chio_tool_receipts(receipt_id) ON DELETE CASCADE,
407                adapter_kind TEXT NOT NULL,
408                evidence_id TEXT NOT NULL,
409                observed_units INTEGER NOT NULL,
410                billed_cost_units INTEGER NOT NULL,
411                billed_cost_currency TEXT NOT NULL,
412                evidence_sha256 TEXT,
413                recorded_at INTEGER NOT NULL,
414                reconciliation_state TEXT NOT NULL,
415                note TEXT,
416                updated_at INTEGER NOT NULL,
417                UNIQUE (adapter_kind, evidence_id)
418            );
419            CREATE INDEX IF NOT EXISTS idx_metered_billing_reconciliations_updated_at
420                ON metered_billing_reconciliations(updated_at);
421
422            CREATE TABLE IF NOT EXISTS underwriting_decisions (
423                decision_id TEXT PRIMARY KEY,
424                issued_at INTEGER NOT NULL,
425                capability_id TEXT,
426                subject_key TEXT,
427                tool_server TEXT,
428                tool_name TEXT,
429                outcome TEXT NOT NULL,
430                lifecycle_state TEXT NOT NULL,
431                review_state TEXT NOT NULL,
432                risk_class TEXT NOT NULL,
433                supersedes_decision_id TEXT REFERENCES underwriting_decisions(decision_id),
434                superseded_by_decision_id TEXT REFERENCES underwriting_decisions(decision_id),
435                premium_units INTEGER,
436                raw_json TEXT NOT NULL,
437                signer_key TEXT NOT NULL,
438                signature TEXT NOT NULL
439            );
440            CREATE INDEX IF NOT EXISTS idx_underwriting_decisions_issued_at
441                ON underwriting_decisions(issued_at);
442            CREATE INDEX IF NOT EXISTS idx_underwriting_decisions_capability
443                ON underwriting_decisions(capability_id);
444            CREATE INDEX IF NOT EXISTS idx_underwriting_decisions_subject
445                ON underwriting_decisions(subject_key);
446            CREATE INDEX IF NOT EXISTS idx_underwriting_decisions_tool
447                ON underwriting_decisions(tool_server, tool_name);
448            CREATE INDEX IF NOT EXISTS idx_underwriting_decisions_outcome
449                ON underwriting_decisions(outcome);
450            CREATE INDEX IF NOT EXISTS idx_underwriting_decisions_lifecycle
451                ON underwriting_decisions(lifecycle_state);
452
453            CREATE TABLE IF NOT EXISTS underwriting_appeals (
454                appeal_id TEXT PRIMARY KEY,
455                decision_id TEXT NOT NULL REFERENCES underwriting_decisions(decision_id) ON DELETE CASCADE,
456                requested_by TEXT NOT NULL,
457                reason TEXT NOT NULL,
458                status TEXT NOT NULL,
459                note TEXT,
460                created_at INTEGER NOT NULL,
461                updated_at INTEGER NOT NULL,
462                resolved_by TEXT,
463                replacement_decision_id TEXT REFERENCES underwriting_decisions(decision_id)
464            );
465            CREATE INDEX IF NOT EXISTS idx_underwriting_appeals_decision
466                ON underwriting_appeals(decision_id);
467            CREATE INDEX IF NOT EXISTS idx_underwriting_appeals_status
468                ON underwriting_appeals(status);
469            CREATE INDEX IF NOT EXISTS idx_underwriting_appeals_updated_at
470                ON underwriting_appeals(updated_at);
471
472            CREATE TABLE IF NOT EXISTS credit_facilities (
473                facility_id TEXT PRIMARY KEY,
474                issued_at INTEGER NOT NULL,
475                expires_at INTEGER NOT NULL,
476                capability_id TEXT,
477                subject_key TEXT,
478                tool_server TEXT,
479                tool_name TEXT,
480                disposition TEXT NOT NULL,
481                lifecycle_state TEXT NOT NULL,
482                supersedes_facility_id TEXT REFERENCES credit_facilities(facility_id),
483                superseded_by_facility_id TEXT REFERENCES credit_facilities(facility_id),
484                raw_json TEXT NOT NULL,
485                signer_key TEXT NOT NULL,
486                signature TEXT NOT NULL
487            );
488            CREATE INDEX IF NOT EXISTS idx_credit_facilities_issued_at
489                ON credit_facilities(issued_at);
490            CREATE INDEX IF NOT EXISTS idx_credit_facilities_expires_at
491                ON credit_facilities(expires_at);
492            CREATE INDEX IF NOT EXISTS idx_credit_facilities_capability
493                ON credit_facilities(capability_id);
494            CREATE INDEX IF NOT EXISTS idx_credit_facilities_subject
495                ON credit_facilities(subject_key);
496            CREATE INDEX IF NOT EXISTS idx_credit_facilities_tool
497                ON credit_facilities(tool_server, tool_name);
498            CREATE INDEX IF NOT EXISTS idx_credit_facilities_disposition
499                ON credit_facilities(disposition);
500            CREATE INDEX IF NOT EXISTS idx_credit_facilities_lifecycle
501                ON credit_facilities(lifecycle_state);
502
503            CREATE TABLE IF NOT EXISTS credit_bonds (
504                bond_id TEXT PRIMARY KEY,
505                issued_at INTEGER NOT NULL,
506                expires_at INTEGER NOT NULL,
507                facility_id TEXT,
508                capability_id TEXT,
509                subject_key TEXT,
510                tool_server TEXT,
511                tool_name TEXT,
512                disposition TEXT NOT NULL,
513                lifecycle_state TEXT NOT NULL,
514                supersedes_bond_id TEXT REFERENCES credit_bonds(bond_id),
515                superseded_by_bond_id TEXT REFERENCES credit_bonds(bond_id),
516                raw_json TEXT NOT NULL,
517                signer_key TEXT NOT NULL,
518                signature TEXT NOT NULL
519            );
520            CREATE INDEX IF NOT EXISTS idx_credit_bonds_issued_at
521                ON credit_bonds(issued_at);
522            CREATE INDEX IF NOT EXISTS idx_credit_bonds_expires_at
523                ON credit_bonds(expires_at);
524            CREATE INDEX IF NOT EXISTS idx_credit_bonds_facility
525                ON credit_bonds(facility_id);
526            CREATE INDEX IF NOT EXISTS idx_credit_bonds_capability
527                ON credit_bonds(capability_id);
528            CREATE INDEX IF NOT EXISTS idx_credit_bonds_subject
529                ON credit_bonds(subject_key);
530            CREATE INDEX IF NOT EXISTS idx_credit_bonds_tool
531                ON credit_bonds(tool_server, tool_name);
532            CREATE INDEX IF NOT EXISTS idx_credit_bonds_disposition
533                ON credit_bonds(disposition);
534            CREATE INDEX IF NOT EXISTS idx_credit_bonds_lifecycle
535                ON credit_bonds(lifecycle_state);
536
537            CREATE TABLE IF NOT EXISTS liability_providers (
538                provider_record_id TEXT PRIMARY KEY,
539                issued_at INTEGER NOT NULL,
540                provider_id TEXT NOT NULL,
541                lifecycle_state TEXT NOT NULL,
542                supersedes_provider_record_id TEXT REFERENCES liability_providers(provider_record_id),
543                superseded_by_provider_record_id TEXT REFERENCES liability_providers(provider_record_id),
544                raw_json TEXT NOT NULL,
545                signer_key TEXT NOT NULL,
546                signature TEXT NOT NULL
547            );
548            CREATE INDEX IF NOT EXISTS idx_liability_providers_issued_at
549                ON liability_providers(issued_at);
550            CREATE INDEX IF NOT EXISTS idx_liability_providers_provider_id
551                ON liability_providers(provider_id);
552            CREATE INDEX IF NOT EXISTS idx_liability_providers_lifecycle
553                ON liability_providers(lifecycle_state);
554
555            CREATE TABLE IF NOT EXISTS liability_quote_requests (
556                quote_request_id TEXT PRIMARY KEY,
557                issued_at INTEGER NOT NULL,
558                provider_id TEXT NOT NULL,
559                jurisdiction TEXT NOT NULL,
560                coverage_class TEXT NOT NULL,
561                currency TEXT NOT NULL,
562                subject_key TEXT NOT NULL,
563                raw_json TEXT NOT NULL,
564                signer_key TEXT NOT NULL,
565                signature TEXT NOT NULL
566            );
567            CREATE INDEX IF NOT EXISTS idx_liability_quote_requests_issued_at
568                ON liability_quote_requests(issued_at);
569            CREATE INDEX IF NOT EXISTS idx_liability_quote_requests_provider
570                ON liability_quote_requests(provider_id);
571            CREATE INDEX IF NOT EXISTS idx_liability_quote_requests_subject
572                ON liability_quote_requests(subject_key);
573
574            CREATE TABLE IF NOT EXISTS liability_quote_responses (
575                quote_response_id TEXT PRIMARY KEY,
576                issued_at INTEGER NOT NULL,
577                quote_request_id TEXT NOT NULL REFERENCES liability_quote_requests(quote_request_id),
578                provider_id TEXT NOT NULL,
579                disposition TEXT NOT NULL,
580                expires_at INTEGER,
581                supersedes_quote_response_id TEXT REFERENCES liability_quote_responses(quote_response_id),
582                superseded_by_quote_response_id TEXT REFERENCES liability_quote_responses(quote_response_id),
583                raw_json TEXT NOT NULL,
584                signer_key TEXT NOT NULL,
585                signature TEXT NOT NULL
586            );
587            CREATE INDEX IF NOT EXISTS idx_liability_quote_responses_issued_at
588                ON liability_quote_responses(issued_at);
589            CREATE INDEX IF NOT EXISTS idx_liability_quote_responses_request
590                ON liability_quote_responses(quote_request_id);
591            CREATE INDEX IF NOT EXISTS idx_liability_quote_responses_provider
592                ON liability_quote_responses(provider_id);
593
594            CREATE TABLE IF NOT EXISTS liability_pricing_authorities (
595                authority_id TEXT PRIMARY KEY,
596                issued_at INTEGER NOT NULL,
597                quote_request_id TEXT NOT NULL REFERENCES liability_quote_requests(quote_request_id),
598                provider_id TEXT NOT NULL,
599                facility_id TEXT NOT NULL,
600                underwriting_decision_id TEXT NOT NULL,
601                expires_at INTEGER NOT NULL,
602                raw_json TEXT NOT NULL,
603                signer_key TEXT NOT NULL,
604                signature TEXT NOT NULL
605            );
606            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_pricing_authorities_request
607                ON liability_pricing_authorities(quote_request_id);
608            CREATE INDEX IF NOT EXISTS idx_liability_pricing_authorities_provider
609                ON liability_pricing_authorities(provider_id);
610            CREATE INDEX IF NOT EXISTS idx_liability_pricing_authorities_facility
611                ON liability_pricing_authorities(facility_id);
612
613            CREATE TABLE IF NOT EXISTS liability_placements (
614                placement_id TEXT PRIMARY KEY,
615                issued_at INTEGER NOT NULL,
616                quote_request_id TEXT NOT NULL REFERENCES liability_quote_requests(quote_request_id),
617                quote_response_id TEXT NOT NULL REFERENCES liability_quote_responses(quote_response_id),
618                provider_id TEXT NOT NULL,
619                raw_json TEXT NOT NULL,
620                signer_key TEXT NOT NULL,
621                signature TEXT NOT NULL
622            );
623            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_placements_request
624                ON liability_placements(quote_request_id);
625            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_placements_response
626                ON liability_placements(quote_response_id);
627            CREATE INDEX IF NOT EXISTS idx_liability_placements_provider
628                ON liability_placements(provider_id);
629
630            CREATE TABLE IF NOT EXISTS liability_bound_coverages (
631                bound_coverage_id TEXT PRIMARY KEY,
632                issued_at INTEGER NOT NULL,
633                quote_request_id TEXT NOT NULL REFERENCES liability_quote_requests(quote_request_id),
634                quote_response_id TEXT NOT NULL REFERENCES liability_quote_responses(quote_response_id),
635                placement_id TEXT NOT NULL REFERENCES liability_placements(placement_id),
636                provider_id TEXT NOT NULL,
637                raw_json TEXT NOT NULL,
638                signer_key TEXT NOT NULL,
639                signature TEXT NOT NULL
640            );
641            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_bound_coverages_request
642                ON liability_bound_coverages(quote_request_id);
643            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_bound_coverages_response
644                ON liability_bound_coverages(quote_response_id);
645            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_bound_coverages_placement
646                ON liability_bound_coverages(placement_id);
647            CREATE INDEX IF NOT EXISTS idx_liability_bound_coverages_provider
648                ON liability_bound_coverages(provider_id);
649
650            CREATE TABLE IF NOT EXISTS liability_auto_bind_decisions (
651                decision_id TEXT PRIMARY KEY,
652                issued_at INTEGER NOT NULL,
653                quote_request_id TEXT NOT NULL REFERENCES liability_quote_requests(quote_request_id),
654                quote_response_id TEXT NOT NULL REFERENCES liability_quote_responses(quote_response_id),
655                authority_id TEXT NOT NULL REFERENCES liability_pricing_authorities(authority_id),
656                provider_id TEXT NOT NULL,
657                disposition TEXT NOT NULL,
658                raw_json TEXT NOT NULL,
659                signer_key TEXT NOT NULL,
660                signature TEXT NOT NULL
661            );
662            CREATE UNIQUE INDEX IF NOT EXISTS idx_liability_auto_bind_decisions_response
663                ON liability_auto_bind_decisions(quote_response_id);
664            CREATE INDEX IF NOT EXISTS idx_liability_auto_bind_decisions_request
665                ON liability_auto_bind_decisions(quote_request_id);
666            CREATE INDEX IF NOT EXISTS idx_liability_auto_bind_decisions_authority
667                ON liability_auto_bind_decisions(authority_id);
668
669            CREATE TABLE IF NOT EXISTS liability_claim_packages (
670                claim_id TEXT PRIMARY KEY,
671                issued_at INTEGER NOT NULL,
672                provider_id TEXT NOT NULL,
673                policy_number TEXT NOT NULL,
674                jurisdiction TEXT NOT NULL,
675                subject_key TEXT NOT NULL,
676                claim_event_at INTEGER NOT NULL,
677                raw_json TEXT NOT NULL,
678                signer_key TEXT NOT NULL,
679                signature TEXT NOT NULL
680            );
681            CREATE INDEX IF NOT EXISTS idx_liability_claim_packages_issued_at
682                ON liability_claim_packages(issued_at);
683            CREATE INDEX IF NOT EXISTS idx_liability_claim_packages_provider
684                ON liability_claim_packages(provider_id);
685            CREATE INDEX IF NOT EXISTS idx_liability_claim_packages_policy_number
686                ON liability_claim_packages(policy_number);
687            CREATE INDEX IF NOT EXISTS idx_liability_claim_packages_subject
688                ON liability_claim_packages(subject_key);
689
690            CREATE TABLE IF NOT EXISTS liability_claim_responses (
691                claim_response_id TEXT PRIMARY KEY,
692                issued_at INTEGER NOT NULL,
693                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
694                provider_id TEXT NOT NULL,
695                disposition TEXT NOT NULL,
696                raw_json TEXT NOT NULL,
697                signer_key TEXT NOT NULL,
698                signature TEXT NOT NULL
699            );
700            CREATE INDEX IF NOT EXISTS idx_liability_claim_responses_issued_at
701                ON liability_claim_responses(issued_at);
702            CREATE INDEX IF NOT EXISTS idx_liability_claim_responses_provider
703                ON liability_claim_responses(provider_id);
704
705            CREATE TABLE IF NOT EXISTS liability_claim_disputes (
706                dispute_id TEXT PRIMARY KEY,
707                issued_at INTEGER NOT NULL,
708                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
709                claim_response_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_responses(claim_response_id),
710                provider_id TEXT NOT NULL,
711                raw_json TEXT NOT NULL,
712                signer_key TEXT NOT NULL,
713                signature TEXT NOT NULL
714            );
715            CREATE INDEX IF NOT EXISTS idx_liability_claim_disputes_issued_at
716                ON liability_claim_disputes(issued_at);
717            CREATE INDEX IF NOT EXISTS idx_liability_claim_disputes_provider
718                ON liability_claim_disputes(provider_id);
719
720            CREATE TABLE IF NOT EXISTS liability_claim_adjudications (
721                adjudication_id TEXT PRIMARY KEY,
722                issued_at INTEGER NOT NULL,
723                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
724                dispute_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_disputes(dispute_id),
725                outcome TEXT NOT NULL,
726                raw_json TEXT NOT NULL,
727                signer_key TEXT NOT NULL,
728                signature TEXT NOT NULL
729            );
730            CREATE INDEX IF NOT EXISTS idx_liability_claim_adjudications_issued_at
731                ON liability_claim_adjudications(issued_at);
732
733            CREATE TABLE IF NOT EXISTS liability_claim_payout_instructions (
734                payout_instruction_id TEXT PRIMARY KEY,
735                issued_at INTEGER NOT NULL,
736                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
737                adjudication_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_adjudications(adjudication_id),
738                payout_amount_units INTEGER NOT NULL,
739                payout_amount_currency TEXT NOT NULL,
740                raw_json TEXT NOT NULL,
741                signer_key TEXT NOT NULL,
742                signature TEXT NOT NULL
743            );
744            CREATE INDEX IF NOT EXISTS idx_liability_claim_payout_instructions_issued_at
745                ON liability_claim_payout_instructions(issued_at);
746
747            CREATE TABLE IF NOT EXISTS liability_claim_payout_receipts (
748                payout_receipt_id TEXT PRIMARY KEY,
749                issued_at INTEGER NOT NULL,
750                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
751                payout_instruction_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_payout_instructions(payout_instruction_id),
752                reconciliation_state TEXT NOT NULL,
753                raw_json TEXT NOT NULL,
754                signer_key TEXT NOT NULL,
755                signature TEXT NOT NULL
756            );
757            CREATE INDEX IF NOT EXISTS idx_liability_claim_payout_receipts_issued_at
758                ON liability_claim_payout_receipts(issued_at);
759
760            CREATE TABLE IF NOT EXISTS liability_claim_settlement_instructions (
761                settlement_instruction_id TEXT PRIMARY KEY,
762                issued_at INTEGER NOT NULL,
763                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
764                payout_receipt_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_payout_receipts(payout_receipt_id),
765                settlement_kind TEXT NOT NULL,
766                payer_role TEXT NOT NULL,
767                payer_id TEXT NOT NULL,
768                payee_role TEXT NOT NULL,
769                payee_id TEXT NOT NULL,
770                settlement_amount_units INTEGER NOT NULL,
771                settlement_amount_currency TEXT NOT NULL,
772                raw_json TEXT NOT NULL,
773                signer_key TEXT NOT NULL,
774                signature TEXT NOT NULL
775            );
776            CREATE INDEX IF NOT EXISTS idx_liability_claim_settlement_instructions_issued_at
777                ON liability_claim_settlement_instructions(issued_at);
778
779            CREATE TABLE IF NOT EXISTS liability_claim_settlement_receipts (
780                settlement_receipt_id TEXT PRIMARY KEY,
781                issued_at INTEGER NOT NULL,
782                claim_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_packages(claim_id),
783                settlement_instruction_id TEXT NOT NULL UNIQUE REFERENCES liability_claim_settlement_instructions(settlement_instruction_id),
784                reconciliation_state TEXT NOT NULL,
785                raw_json TEXT NOT NULL,
786                signer_key TEXT NOT NULL,
787                signature TEXT NOT NULL
788            );
789            CREATE INDEX IF NOT EXISTS idx_liability_claim_settlement_receipts_issued_at
790                ON liability_claim_settlement_receipts(issued_at);
791
792            CREATE TABLE IF NOT EXISTS credit_loss_lifecycle (
793                event_id TEXT PRIMARY KEY,
794                issued_at INTEGER NOT NULL,
795                bond_id TEXT NOT NULL REFERENCES credit_bonds(bond_id),
796                facility_id TEXT,
797                capability_id TEXT,
798                subject_key TEXT,
799                tool_server TEXT,
800                tool_name TEXT,
801                event_kind TEXT NOT NULL,
802                projected_bond_lifecycle_state TEXT NOT NULL,
803                raw_json TEXT NOT NULL,
804                signer_key TEXT NOT NULL,
805                signature TEXT NOT NULL
806            );
807            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_issued_at
808                ON credit_loss_lifecycle(issued_at);
809            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_bond
810                ON credit_loss_lifecycle(bond_id);
811            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_facility
812                ON credit_loss_lifecycle(facility_id);
813            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_capability
814                ON credit_loss_lifecycle(capability_id);
815            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_subject
816                ON credit_loss_lifecycle(subject_key);
817            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_tool
818                ON credit_loss_lifecycle(tool_server, tool_name);
819            CREATE INDEX IF NOT EXISTS idx_credit_loss_lifecycle_kind
820                ON credit_loss_lifecycle(event_kind);
821
822            CREATE TABLE IF NOT EXISTS chio_child_receipts (
823                seq INTEGER PRIMARY KEY AUTOINCREMENT,
824                receipt_id TEXT NOT NULL UNIQUE,
825                timestamp INTEGER NOT NULL,
826                session_id TEXT NOT NULL,
827                parent_request_id TEXT NOT NULL,
828                request_id TEXT NOT NULL,
829                operation_kind TEXT NOT NULL,
830                terminal_state TEXT NOT NULL,
831                policy_hash TEXT NOT NULL,
832                outcome_hash TEXT NOT NULL,
833                raw_json TEXT NOT NULL
834            );
835
836            CREATE INDEX IF NOT EXISTS idx_chio_child_receipts_timestamp
837                ON chio_child_receipts(timestamp);
838            CREATE INDEX IF NOT EXISTS idx_chio_child_receipts_session
839                ON chio_child_receipts(session_id);
840            CREATE INDEX IF NOT EXISTS idx_chio_child_receipts_parent
841                ON chio_child_receipts(parent_request_id);
842            CREATE INDEX IF NOT EXISTS idx_chio_child_receipts_request
843                ON chio_child_receipts(request_id);
844
845            CREATE TABLE IF NOT EXISTS claim_receipt_log_entries (
846                entry_seq INTEGER PRIMARY KEY AUTOINCREMENT,
847                receipt_id TEXT NOT NULL UNIQUE,
848                receipt_kind TEXT NOT NULL,
849                source_seq INTEGER NOT NULL,
850                timestamp INTEGER NOT NULL,
851                capability_id TEXT,
852                session_id TEXT,
853                parent_request_id TEXT,
854                request_id TEXT,
855                subject_key TEXT,
856                issuer_key TEXT,
857                tool_server TEXT,
858                tool_name TEXT,
859                raw_json TEXT NOT NULL,
860                CHECK (entry_seq > 0),
861                CHECK (source_seq > 0),
862                CHECK (receipt_kind IN ('tool_receipt', 'child_receipt')),
863                CHECK (
864                    (receipt_kind = 'tool_receipt'
865                        AND capability_id IS NOT NULL
866                        AND session_id IS NULL
867                        AND parent_request_id IS NULL
868                        AND request_id IS NULL
869                        AND tool_server IS NOT NULL
870                        AND tool_name IS NOT NULL)
871                    OR
872                    (receipt_kind = 'child_receipt'
873                        AND capability_id IS NULL
874                        AND session_id IS NOT NULL
875                        AND parent_request_id IS NOT NULL
876                        AND request_id IS NOT NULL
877                        AND tool_server IS NULL
878                        AND tool_name IS NULL)
879                )
880            );
881            CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_receipt_log_kind_source
882                ON claim_receipt_log_entries(receipt_kind, source_seq);
883            CREATE INDEX IF NOT EXISTS idx_claim_receipt_log_timestamp
884                ON claim_receipt_log_entries(timestamp, entry_seq);
885            CREATE INDEX IF NOT EXISTS idx_claim_receipt_log_tool
886                ON claim_receipt_log_entries(tool_server, tool_name, timestamp)
887                WHERE receipt_kind = 'tool_receipt';
888            CREATE INDEX IF NOT EXISTS idx_claim_receipt_log_child_request
889                ON claim_receipt_log_entries(session_id, request_id, timestamp)
890                WHERE receipt_kind = 'child_receipt';
891
892            DROP TRIGGER IF EXISTS chio_tool_receipts_project_claim_log_entry;
893            CREATE TRIGGER chio_tool_receipts_project_claim_log_entry
894            AFTER INSERT ON chio_tool_receipts
895            BEGIN
896                INSERT INTO claim_receipt_log_entries (
897                    receipt_id,
898                    receipt_kind,
899                    source_seq,
900                    timestamp,
901                    capability_id,
902                    session_id,
903                    parent_request_id,
904                    request_id,
905                    subject_key,
906                    issuer_key,
907                    tool_server,
908                    tool_name,
909                    raw_json
910                ) VALUES (
911                    NEW.receipt_id,
912                    'tool_receipt',
913                    NEW.seq,
914                    NEW.timestamp,
915                    NEW.capability_id,
916                    NULL,
917                    NULL,
918                    NULL,
919                    NEW.subject_key,
920                    NEW.issuer_key,
921                    NEW.tool_server,
922                    NEW.tool_name,
923                    NEW.raw_json
924                );
925            END;
926
927            DROP TRIGGER IF EXISTS chio_child_receipts_project_claim_log_entry;
928            CREATE TRIGGER chio_child_receipts_project_claim_log_entry
929            AFTER INSERT ON chio_child_receipts
930            BEGIN
931                INSERT INTO claim_receipt_log_entries (
932                    receipt_id,
933                    receipt_kind,
934                    source_seq,
935                    timestamp,
936                    capability_id,
937                    session_id,
938                    parent_request_id,
939                    request_id,
940                    subject_key,
941                    issuer_key,
942                    tool_server,
943                    tool_name,
944                    raw_json
945                ) VALUES (
946                    NEW.receipt_id,
947                    'child_receipt',
948                    NEW.seq,
949                    NEW.timestamp,
950                    NULL,
951                    NEW.session_id,
952                    NEW.parent_request_id,
953                    NEW.request_id,
954                    NULL,
955                    NULL,
956                    NULL,
957                    NULL,
958                    NEW.raw_json
959                );
960            END;
961
962            CREATE TABLE IF NOT EXISTS session_anchors (
963                anchor_id TEXT PRIMARY KEY,
964                session_id TEXT NOT NULL,
965                auth_context_fingerprint TEXT NOT NULL,
966                issued_at INTEGER NOT NULL,
967                supersedes_anchor_id TEXT REFERENCES session_anchors(anchor_id),
968                is_current INTEGER NOT NULL DEFAULT 1,
969                source_kind TEXT NOT NULL,
970                json_sha256 TEXT NOT NULL,
971                raw_json TEXT NOT NULL
972            );
973            CREATE INDEX IF NOT EXISTS idx_session_anchors_session
974                ON session_anchors(session_id, issued_at DESC);
975            CREATE INDEX IF NOT EXISTS idx_session_anchors_supersedes
976                ON session_anchors(supersedes_anchor_id);
977            CREATE UNIQUE INDEX IF NOT EXISTS idx_session_anchors_current
978                ON session_anchors(session_id)
979                WHERE is_current = 1;
980
981            CREATE TABLE IF NOT EXISTS request_lineage (
982                session_id TEXT NOT NULL,
983                request_id TEXT NOT NULL,
984                parent_request_id TEXT,
985                session_anchor_id TEXT REFERENCES session_anchors(anchor_id),
986                recorded_at INTEGER NOT NULL,
987                request_fingerprint TEXT,
988                source_kind TEXT NOT NULL,
989                json_sha256 TEXT NOT NULL,
990                raw_json TEXT NOT NULL,
991                PRIMARY KEY (session_id, request_id)
992            );
993            CREATE INDEX IF NOT EXISTS idx_request_lineage_parent
994                ON request_lineage(session_id, parent_request_id);
995            CREATE INDEX IF NOT EXISTS idx_request_lineage_anchor
996                ON request_lineage(session_anchor_id);
997            CREATE UNIQUE INDEX IF NOT EXISTS idx_request_lineage_fingerprint
998                ON request_lineage(session_id, COALESCE(session_anchor_id, ''), request_fingerprint)
999                WHERE request_fingerprint IS NOT NULL;
1000
1001            CREATE TABLE IF NOT EXISTS receipt_lineage_statements (
1002                receipt_id TEXT PRIMARY KEY,
1003                statement_id TEXT,
1004                request_id TEXT,
1005                session_id TEXT,
1006                session_anchor_id TEXT REFERENCES session_anchors(anchor_id),
1007                chain_id TEXT,
1008                parent_request_id TEXT,
1009                parent_receipt_id TEXT,
1010                evidence_class TEXT,
1011                evidence_sources_json TEXT,
1012                verified_session_anchor INTEGER NOT NULL DEFAULT 0,
1013                verified_parent_request INTEGER NOT NULL DEFAULT 0,
1014                verified_parent_receipt INTEGER NOT NULL DEFAULT 0,
1015                replay_protected INTEGER NOT NULL DEFAULT 0,
1016                recorded_at INTEGER NOT NULL,
1017                source_kind TEXT NOT NULL,
1018                json_sha256 TEXT NOT NULL,
1019                raw_json TEXT NOT NULL
1020            );
1021            CREATE INDEX IF NOT EXISTS idx_receipt_lineage_request
1022                ON receipt_lineage_statements(session_id, request_id);
1023            CREATE UNIQUE INDEX IF NOT EXISTS idx_receipt_lineage_statement_id
1024                ON receipt_lineage_statements(statement_id)
1025                WHERE statement_id IS NOT NULL;
1026            CREATE INDEX IF NOT EXISTS idx_receipt_lineage_parent_request
1027                ON receipt_lineage_statements(session_id, parent_request_id);
1028            CREATE INDEX IF NOT EXISTS idx_receipt_lineage_parent_receipt
1029                ON receipt_lineage_statements(parent_receipt_id);
1030            CREATE INDEX IF NOT EXISTS idx_receipt_lineage_anchor
1031                ON receipt_lineage_statements(session_anchor_id);
1032            CREATE UNIQUE INDEX IF NOT EXISTS idx_receipt_lineage_request_anchor
1033                ON receipt_lineage_statements(session_id, COALESCE(session_anchor_id, ''), request_id)
1034                WHERE session_id IS NOT NULL
1035                  AND request_id IS NOT NULL;
1036
1037            CREATE TABLE IF NOT EXISTS kernel_checkpoints (
1038                id INTEGER PRIMARY KEY AUTOINCREMENT,
1039                checkpoint_seq INTEGER NOT NULL UNIQUE,
1040                batch_start_seq INTEGER NOT NULL,
1041                batch_end_seq INTEGER NOT NULL,
1042                tree_size INTEGER NOT NULL,
1043                merkle_root TEXT NOT NULL,
1044                issued_at INTEGER NOT NULL,
1045                statement_json TEXT NOT NULL,
1046                signature TEXT NOT NULL,
1047                kernel_key TEXT NOT NULL
1048            );
1049            CREATE INDEX IF NOT EXISTS idx_kernel_checkpoints_batch_end
1050                ON kernel_checkpoints(batch_end_seq);
1051
1052            CREATE TABLE IF NOT EXISTS checkpoint_tree_heads (
1053                checkpoint_seq INTEGER PRIMARY KEY
1054                    REFERENCES kernel_checkpoints(checkpoint_seq) ON DELETE CASCADE,
1055                batch_start_seq INTEGER NOT NULL,
1056                batch_end_seq INTEGER NOT NULL,
1057                tree_size INTEGER NOT NULL,
1058                merkle_root TEXT NOT NULL,
1059                issued_at INTEGER NOT NULL,
1060                kernel_key TEXT NOT NULL,
1061                previous_checkpoint_sha256 TEXT,
1062                statement_json TEXT NOT NULL,
1063                signature TEXT NOT NULL
1064            );
1065            CREATE INDEX IF NOT EXISTS idx_checkpoint_tree_heads_tree_size
1066                ON checkpoint_tree_heads(tree_size);
1067            CREATE INDEX IF NOT EXISTS idx_checkpoint_tree_heads_previous
1068                ON checkpoint_tree_heads(previous_checkpoint_sha256);
1069
1070            CREATE TABLE IF NOT EXISTS checkpoint_predecessor_witnesses (
1071                predecessor_checkpoint_seq INTEGER NOT NULL
1072                    REFERENCES checkpoint_tree_heads(checkpoint_seq) ON DELETE CASCADE,
1073                witness_checkpoint_seq INTEGER PRIMARY KEY
1074                    REFERENCES checkpoint_tree_heads(checkpoint_seq) ON DELETE CASCADE,
1075                previous_checkpoint_sha256 TEXT NOT NULL,
1076                witnessed_at INTEGER NOT NULL,
1077                witness_statement_json TEXT NOT NULL
1078            );
1079            CREATE INDEX IF NOT EXISTS idx_checkpoint_predecessor_witnesses_predecessor
1080                ON checkpoint_predecessor_witnesses(predecessor_checkpoint_seq);
1081            CREATE INDEX IF NOT EXISTS idx_checkpoint_predecessor_witnesses_previous
1082                ON checkpoint_predecessor_witnesses(previous_checkpoint_sha256);
1083
1084            CREATE TABLE IF NOT EXISTS checkpoint_publication_metadata (
1085                checkpoint_seq INTEGER PRIMARY KEY
1086                    REFERENCES kernel_checkpoints(checkpoint_seq) ON DELETE CASCADE,
1087                publication_schema TEXT NOT NULL,
1088                merkle_root TEXT NOT NULL,
1089                published_at INTEGER NOT NULL,
1090                kernel_key TEXT NOT NULL,
1091                log_tree_size INTEGER NOT NULL,
1092                entry_start_seq INTEGER NOT NULL,
1093                entry_end_seq INTEGER NOT NULL,
1094                previous_checkpoint_sha256 TEXT
1095            );
1096            CREATE INDEX IF NOT EXISTS idx_checkpoint_publication_metadata_published_at
1097                ON checkpoint_publication_metadata(published_at);
1098            CREATE INDEX IF NOT EXISTS idx_checkpoint_publication_metadata_log_tree_size
1099                ON checkpoint_publication_metadata(log_tree_size);
1100            CREATE INDEX IF NOT EXISTS idx_checkpoint_publication_metadata_previous
1101                ON checkpoint_publication_metadata(previous_checkpoint_sha256);
1102
1103            CREATE TABLE IF NOT EXISTS checkpoint_publication_trust_anchor_bindings (
1104                checkpoint_seq INTEGER PRIMARY KEY
1105                    REFERENCES kernel_checkpoints(checkpoint_seq) ON DELETE CASCADE,
1106                binding_json TEXT NOT NULL
1107            );
1108
1109            DROP TRIGGER IF EXISTS kernel_checkpoints_project_tree_head;
1110            CREATE TRIGGER kernel_checkpoints_project_tree_head
1111            AFTER INSERT ON kernel_checkpoints
1112            BEGIN
1113                INSERT INTO checkpoint_tree_heads (
1114                    checkpoint_seq,
1115                    batch_start_seq,
1116                    batch_end_seq,
1117                    tree_size,
1118                    merkle_root,
1119                    issued_at,
1120                    kernel_key,
1121                    previous_checkpoint_sha256,
1122                    statement_json,
1123                    signature
1124                ) VALUES (
1125                    NEW.checkpoint_seq,
1126                    NEW.batch_start_seq,
1127                    NEW.batch_end_seq,
1128                    NEW.tree_size,
1129                    NEW.merkle_root,
1130                    NEW.issued_at,
1131                    NEW.kernel_key,
1132                    CAST(json_extract(NEW.statement_json, '$.previous_checkpoint_sha256') AS TEXT),
1133                    NEW.statement_json,
1134                    NEW.signature
1135                );
1136
1137                INSERT INTO checkpoint_predecessor_witnesses (
1138                    predecessor_checkpoint_seq,
1139                    witness_checkpoint_seq,
1140                    previous_checkpoint_sha256,
1141                    witnessed_at,
1142                    witness_statement_json
1143                )
1144                SELECT
1145                    NEW.checkpoint_seq - 1,
1146                    NEW.checkpoint_seq,
1147                    CAST(json_extract(NEW.statement_json, '$.previous_checkpoint_sha256') AS TEXT),
1148                    NEW.issued_at,
1149                    NEW.statement_json
1150                WHERE json_extract(NEW.statement_json, '$.previous_checkpoint_sha256') IS NOT NULL;
1151
1152                INSERT INTO checkpoint_publication_metadata (
1153                    checkpoint_seq,
1154                    publication_schema,
1155                    merkle_root,
1156                    published_at,
1157                    kernel_key,
1158                    log_tree_size,
1159                    entry_start_seq,
1160                    entry_end_seq,
1161                    previous_checkpoint_sha256
1162                ) VALUES (
1163                    NEW.checkpoint_seq,
1164                    'chio.checkpoint_publication.v1',
1165                    NEW.merkle_root,
1166                    NEW.issued_at,
1167                    NEW.kernel_key,
1168                    NEW.batch_end_seq,
1169                    NEW.batch_start_seq,
1170                    NEW.batch_end_seq,
1171                    CAST(json_extract(NEW.statement_json, '$.previous_checkpoint_sha256') AS TEXT)
1172                );
1173            END;
1174
1175            CREATE TABLE IF NOT EXISTS capability_lineage (
1176                capability_id        TEXT PRIMARY KEY,
1177                subject_key          TEXT NOT NULL,
1178                issuer_key           TEXT NOT NULL,
1179                issued_at            INTEGER NOT NULL,
1180                expires_at           INTEGER NOT NULL,
1181                grants_json          TEXT NOT NULL,
1182                delegation_depth     INTEGER NOT NULL DEFAULT 0,
1183                parent_capability_id TEXT REFERENCES capability_lineage(capability_id),
1184                federated_parent_capability_id TEXT,
1185                provenance TEXT NOT NULL DEFAULT 'legacy_projection'
1186                    CHECK (provenance IN ('signed_token', 'synthetic_anchor', 'legacy_projection')),
1187                signed_capability_json TEXT
1188            );
1189            CREATE INDEX IF NOT EXISTS idx_capability_lineage_subject
1190                ON capability_lineage(subject_key);
1191            CREATE INDEX IF NOT EXISTS idx_capability_lineage_issuer
1192                ON capability_lineage(issuer_key);
1193            CREATE INDEX IF NOT EXISTS idx_capability_lineage_issued_at
1194                ON capability_lineage(issued_at);
1195            CREATE INDEX IF NOT EXISTS idx_capability_lineage_parent
1196                ON capability_lineage(parent_capability_id);
1197            CREATE TABLE IF NOT EXISTS federated_lineage_bridges (
1198                local_capability_id TEXT PRIMARY KEY REFERENCES capability_lineage(capability_id) ON DELETE CASCADE,
1199                parent_capability_id TEXT NOT NULL,
1200                share_id TEXT REFERENCES federated_evidence_shares(share_id)
1201            );
1202            CREATE INDEX IF NOT EXISTS idx_federated_lineage_bridges_parent
1203                ON federated_lineage_bridges(parent_capability_id);
1204
1205            CREATE TABLE IF NOT EXISTS federated_evidence_shares (
1206                share_id TEXT PRIMARY KEY,
1207                manifest_hash TEXT NOT NULL,
1208                imported_at INTEGER NOT NULL,
1209                exported_at INTEGER NOT NULL,
1210                issuer TEXT NOT NULL,
1211                partner TEXT NOT NULL,
1212                signer_public_key TEXT NOT NULL,
1213                require_proofs INTEGER NOT NULL DEFAULT 0,
1214                query_json TEXT NOT NULL
1215            );
1216            CREATE INDEX IF NOT EXISTS idx_federated_evidence_shares_imported_at
1217                ON federated_evidence_shares(imported_at);
1218
1219            CREATE TABLE IF NOT EXISTS federated_share_tool_receipts (
1220                share_id TEXT NOT NULL REFERENCES federated_evidence_shares(share_id) ON DELETE CASCADE,
1221                seq INTEGER NOT NULL,
1222                receipt_id TEXT NOT NULL,
1223                timestamp INTEGER NOT NULL,
1224                capability_id TEXT NOT NULL,
1225                subject_key TEXT,
1226                issuer_key TEXT,
1227                raw_json TEXT NOT NULL,
1228                PRIMARY KEY (share_id, seq),
1229                UNIQUE (share_id, receipt_id)
1230            );
1231            CREATE INDEX IF NOT EXISTS idx_federated_share_receipts_capability
1232                ON federated_share_tool_receipts(capability_id);
1233            CREATE INDEX IF NOT EXISTS idx_federated_share_receipts_subject
1234                ON federated_share_tool_receipts(subject_key);
1235
1236            CREATE TABLE IF NOT EXISTS federated_share_capability_lineage (
1237                share_id TEXT NOT NULL REFERENCES federated_evidence_shares(share_id) ON DELETE CASCADE,
1238                capability_id TEXT NOT NULL,
1239                subject_key TEXT NOT NULL,
1240                issuer_key TEXT NOT NULL,
1241                issued_at INTEGER NOT NULL,
1242                expires_at INTEGER NOT NULL,
1243                grants_json TEXT NOT NULL,
1244                delegation_depth INTEGER NOT NULL DEFAULT 0,
1245                parent_capability_id TEXT,
1246                federated_parent_capability_id TEXT,
1247                provenance TEXT NOT NULL DEFAULT 'legacy_projection'
1248                    CHECK (provenance IN ('signed_token', 'synthetic_anchor', 'legacy_projection')),
1249                signed_capability_json TEXT,
1250                PRIMARY KEY (share_id, capability_id)
1251            );
1252            CREATE INDEX IF NOT EXISTS idx_federated_share_lineage_capability
1253                ON federated_share_capability_lineage(capability_id);
1254            CREATE INDEX IF NOT EXISTS idx_federated_share_lineage_subject
1255                ON federated_share_capability_lineage(subject_key);
1256
1257            CREATE TABLE IF NOT EXISTS receipt_retention_watermark (
1258                archived_through_entry_seq INTEGER NOT NULL,
1259                archived_through_timestamp INTEGER NOT NULL,
1260                archive_path               TEXT NOT NULL,
1261                archive_sha256             TEXT,
1262                rotated_at                 INTEGER NOT NULL,
1263                CHECK (archived_through_entry_seq >= 0)
1264            );
1265
1266            "#,
1267        )?;
1268        schema_migration.commit()?;
1269        connection.execute_batch(crate::IOU_ENVELOPE_MIGRATION)?;
1270        connection.execute_batch(crate::dead_letters::SETTLE_DEAD_LETTERS_MIGRATION)?;
1271        connection.execute_batch(crate::settle_attempts::SETTLE_ATTEMPTS_MIGRATION)?;
1272        ensure_tool_receipt_attribution_columns(&connection)?;
1273        super::support::ensure_receipt_lineage_statement_columns(&connection)?;
1274        super::support::drop_transparency_projection_guards(&connection)?;
1275        let backfill_result = (|| -> Result<(), ReceiptStoreError> {
1276            super::support::ensure_receipt_retention_watermark_table(&connection)?;
1277            super::support::ensure_receipt_retention_tombstones(&connection)?;
1278            backfill_tool_receipt_attribution_columns(&connection)?;
1279            super::support::backfill_provenance_lineage_tables(&mut connection)?;
1280            super::support::backfill_claim_receipt_log_entries(&mut connection)?;
1281            super::support::backfill_checkpoint_transparency_projections(&mut connection)?;
1282            if on_disk_schema_version < RECEIPT_COST_PROJECTION_SCHEMA_VERSION {
1283                let migration = connection
1284                    .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1285                migrate_receipt_cost_projection(&migration)?;
1286                migration.commit()?;
1287            }
1288            Ok(())
1289        })();
1290        let guard_result = super::support::ensure_transparency_projection_guards(&connection);
1291        match (backfill_result, guard_result) {
1292            (Ok(()), Ok(())) => {}
1293            (Err(error), Ok(())) => return Err(error),
1294            (Ok(()), Err(error)) => return Err(error),
1295            (Err(backfill_error), Err(guard_error)) => {
1296                return Err(ReceiptStoreError::Canonical(format!(
1297                    "receipt projection backfill failed ({backfill_error}); restoring immutability guards also failed ({guard_error})"
1298                )));
1299            }
1300        }
1301        verify_receipt_cost_projection(&connection)?;
1302
1303        let migration =
1304            connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1305        ensure_capability_lineage_provenance_columns(&migration)?;
1306        crate::stamp_schema_version(
1307            &migration,
1308            RECEIPT_STORE_SCHEMA_KEY,
1309            RECEIPT_STORE_SUPPORTED_SCHEMA_VERSION,
1310        )
1311        .map_err(|error| ReceiptStoreError::Conflict(error.to_string()))?;
1312        migration.commit()?;
1313
1314        let settlement_store_binding = settlement_store_binding_if_ready(&connection)?;
1315
1316        drop(connection);
1317
1318        let reader_pool = build_receipt_pool(
1319            path,
1320            options.pool.reader_pool_max_size,
1321            "reader",
1322            connection_flags,
1323            options.pool.max_page_count,
1324        )?;
1325        let writer_pool = build_receipt_pool(
1326            path,
1327            options.pool.writer_pool_max_size,
1328            "writer",
1329            connection_flags,
1330            options.pool.max_page_count,
1331        )?;
1332
1333        Ok(Self {
1334            receipt_commit_actor: ReceiptCommitActor::start(
1335                writer_pool,
1336                options.incremental_verification,
1337            ),
1338            pool: reader_pool,
1339            settlement_store_binding,
1340            strict_tenant_isolation: std::sync::atomic::AtomicBool::new(true),
1341            incremental_verification: options.incremental_verification,
1342        })
1343    }
1344
1345    pub fn open_with_pool_sizes(
1346        path: impl AsRef<Path>,
1347        reader_pool_max_size: u32,
1348        writer_pool_max_size: u32,
1349    ) -> Result<Self, ReceiptStoreError> {
1350        Self::open_with_pool_config(
1351            path,
1352            crate::SqlitePoolConfig {
1353                reader_pool_max_size,
1354                writer_pool_max_size,
1355                max_page_count: None,
1356            },
1357        )
1358    }
1359}
1360
1361fn build_receipt_pool(
1362    path: &Path,
1363    max_size: u32,
1364    pool_name: &str,
1365    flags: Option<rusqlite::OpenFlags>,
1366    max_page_count: Option<u32>,
1367) -> Result<Pool<SqliteConnectionManager>, ReceiptStoreError> {
1368    if max_size == 0 {
1369        return Err(ReceiptStoreError::Pool(format!(
1370            "{pool_name} receipt sqlite pool max_size must be greater than zero"
1371        )));
1372    }
1373    let mut manager = SqliteConnectionManager::file(path);
1374    if let Some(flags) = flags {
1375        manager = manager.with_flags(flags);
1376    }
1377    let manager = manager.with_init(move |connection| {
1378        configure_sqlite_connection(connection, max_page_count).map_err(|error| match error {
1379            ReceiptStoreError::Sqlite(error) => error,
1380            other => rusqlite::Error::InvalidParameterName(other.to_string()),
1381        })
1382    });
1383    Pool::builder()
1384        .max_size(max_size)
1385        .build(manager)
1386        .map_err(|error| ReceiptStoreError::Pool(error.to_string()))
1387}
1388
1389fn existing_database_open_flags() -> rusqlite::OpenFlags {
1390    rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
1391}
1392
1393fn require_existing_receipt_schema(
1394    path: &Path,
1395    connection: &Connection,
1396) -> Result<(), ReceiptStoreError> {
1397    const REQUIRED_TABLES: &[&str] = &[
1398        "chio_tool_receipts",
1399        "chio_child_receipts",
1400        "claim_receipt_log_entries",
1401        "kernel_checkpoints",
1402        "checkpoint_tree_heads",
1403        "checkpoint_predecessor_witnesses",
1404        "checkpoint_publication_metadata",
1405    ];
1406
1407    for table in REQUIRED_TABLES {
1408        let exists: Option<String> = connection
1409            .query_row(
1410                "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1",
1411                [*table],
1412                |row| row.get(0),
1413            )
1414            .optional()?;
1415        if exists.is_none() {
1416            return Err(ReceiptStoreError::NotFound(format!(
1417                "receipt database {} is not an initialized Chio receipt store: missing table {table}",
1418                path.display()
1419            )));
1420        }
1421    }
1422
1423    Ok(())
1424}
1425
1426/// Refuse an unstamped database whose generic legacy anchor (`http_receipts` or
1427/// `tool_receipts`) does not carry a receipt payload column. These names are
1428/// generic enough that an unrelated SQLite database could hold one by
1429/// coincidence; the schema-version check would otherwise adopt and stamp such a
1430/// file as a receipt store. A database already carrying the Chio application_id
1431/// is provably a Chio store and is left to the schema-version check.
1432fn reject_foreign_legacy_receipt_anchor(connection: &Connection) -> Result<(), ReceiptStoreError> {
1433    let application_id: i32 =
1434        connection.query_row("PRAGMA application_id", [], |row| row.get(0))?;
1435    if application_id != 0 {
1436        return Ok(());
1437    }
1438    for anchor in RECEIPT_STORE_GENERIC_LEGACY_ANCHOR_TABLES {
1439        if table_exists(connection, anchor)?
1440            && !table_has_receipt_payload_column(connection, anchor)?
1441        {
1442            return Err(ReceiptStoreError::Conflict(format!(
1443                "database has a `{anchor}` table without a receipt payload column \
1444                 (raw_json or receipt_json); refusing to adopt a foreign database as a receipt store"
1445            )));
1446        }
1447    }
1448    Ok(())
1449}
1450
1451fn table_exists(connection: &Connection, table: &str) -> Result<bool, ReceiptStoreError> {
1452    let present: bool = connection.query_row(
1453        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
1454        [table],
1455        |row| row.get(0),
1456    )?;
1457    Ok(present)
1458}
1459
1460/// Whether `table` stores a receipt payload under a recognised JSON column:
1461/// `raw_json` on the current store, `receipt_json` on the pre-stamping one. The
1462/// table name is one of the store's own compile-time anchors, never caller
1463/// input, and `PRAGMA table_info` takes no bound parameter for it.
1464fn table_has_receipt_payload_column(
1465    connection: &Connection,
1466    table: &str,
1467) -> Result<bool, ReceiptStoreError> {
1468    let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
1469    let mut rows = statement.query([])?;
1470    while let Some(row) = rows.next()? {
1471        let column: String = row.get(1)?;
1472        if column.eq_ignore_ascii_case("raw_json") || column.eq_ignore_ascii_case("receipt_json") {
1473            return Ok(true);
1474        }
1475    }
1476    Ok(false)
1477}
1478
1479fn ensure_capability_lineage_provenance_columns(
1480    connection: &Connection,
1481) -> Result<(), ReceiptStoreError> {
1482    for table in ["capability_lineage", "federated_share_capability_lineage"] {
1483        if !table_has_column(connection, table, "signed_capability_json")? {
1484            connection.execute(
1485                &format!("ALTER TABLE {table} ADD COLUMN signed_capability_json TEXT"),
1486                [],
1487            )?;
1488        }
1489        if !table_has_column(connection, table, "federated_parent_capability_id")? {
1490            connection.execute(
1491                &format!("ALTER TABLE {table} ADD COLUMN federated_parent_capability_id TEXT"),
1492                [],
1493            )?;
1494        }
1495        if !table_has_column(connection, table, "provenance")? {
1496            connection.execute(
1497                &format!(
1498                    "ALTER TABLE {table} ADD COLUMN provenance TEXT NOT NULL DEFAULT \
1499                     'legacy_projection' CHECK (provenance IN ('signed_token', \
1500                     'synthetic_anchor', 'legacy_projection'))"
1501                ),
1502                [],
1503            )?;
1504        }
1505        connection.execute(
1506            &format!(
1507                "UPDATE {table} SET provenance = 'signed_token' \
1508                 WHERE provenance = 'legacy_projection' \
1509                   AND signed_capability_json IS NOT NULL"
1510            ),
1511            [],
1512        )?;
1513    }
1514
1515    let conflicting_bridge: bool = connection.query_row(
1516        r#"
1517        SELECT EXISTS(
1518            SELECT 1
1519            FROM capability_lineage c
1520            INNER JOIN federated_lineage_bridges b
1521                ON b.local_capability_id = c.capability_id
1522            WHERE c.federated_parent_capability_id IS NOT NULL
1523              AND c.federated_parent_capability_id != b.parent_capability_id
1524        )
1525        "#,
1526        [],
1527        |row| row.get(0),
1528    )?;
1529    if conflicting_bridge {
1530        return Err(ReceiptStoreError::Conflict(
1531            "capability lineage federation metadata conflicts with its persisted bridge"
1532                .to_string(),
1533        ));
1534    }
1535    let bridges_to_backfill = {
1536        let mut statement = connection.prepare(
1537            r#"
1538            SELECT b.local_capability_id, b.parent_capability_id
1539            FROM federated_lineage_bridges b
1540            INNER JOIN capability_lineage c
1541                ON c.capability_id = b.local_capability_id
1542            WHERE c.federated_parent_capability_id IS NULL
1543            ORDER BY c.rowid
1544            "#,
1545        )?;
1546        let bridges = statement
1547            .query_map([], |row| {
1548                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1549            })?
1550            .collect::<Result<Vec<_>, _>>()?;
1551        bridges
1552    };
1553    let mut next_rowid: i64 = connection.query_row(
1554        "SELECT COALESCE(MAX(rowid), 0) FROM capability_lineage",
1555        [],
1556        |row| row.get(0),
1557    )?;
1558    for (local_capability_id, parent_capability_id) in bridges_to_backfill {
1559        next_rowid = next_rowid.checked_add(1).ok_or_else(|| {
1560            ReceiptStoreError::Conflict(
1561                "capability lineage replication sequence is exhausted".to_string(),
1562            )
1563        })?;
1564        connection.execute(
1565            r#"
1566            UPDATE capability_lineage
1567            SET federated_parent_capability_id = ?2,
1568                rowid = ?3
1569            WHERE capability_id = ?1
1570              AND federated_parent_capability_id IS NULL
1571            "#,
1572            params![local_capability_id, parent_capability_id, next_rowid],
1573        )?;
1574    }
1575    connection.execute(
1576        "CREATE INDEX IF NOT EXISTS idx_capability_lineage_federated_parent \
1577         ON capability_lineage(federated_parent_capability_id)",
1578        [],
1579    )?;
1580    Ok(())
1581}
1582
1583fn table_has_column(
1584    connection: &Connection,
1585    table: &str,
1586    expected_column: &str,
1587) -> Result<bool, ReceiptStoreError> {
1588    let mut statement = connection.prepare(&format!("PRAGMA table_info({table})"))?;
1589    let mut rows = statement.query([])?;
1590    while let Some(row) = rows.next()? {
1591        let column: String = row.get(1)?;
1592        if column.eq_ignore_ascii_case(expected_column) {
1593            return Ok(true);
1594        }
1595    }
1596    Ok(false)
1597}