1use super::*;
2
3pub(crate) fn unix_timestamp_now_i64() -> i64 {
4 SystemTime::now()
5 .duration_since(UNIX_EPOCH)
6 .map(|duration| duration.as_secs() as i64)
7 .unwrap_or(0)
8}
9
10pub(crate) fn sqlite_i64(value: u64, field: &str) -> Result<i64, ReceiptStoreError> {
11 i64::try_from(value).map_err(|_| {
12 ReceiptStoreError::Conflict(format!(
13 "{field} value {value} exceeds SQLite INTEGER range"
14 ))
15 })
16}
17
18pub(crate) fn sqlite_u64(value: i64, field: &str) -> Result<u64, ReceiptStoreError> {
19 u64::try_from(value).map_err(|_| {
20 ReceiptStoreError::Conflict(format!(
21 "{field} value {value} is outside the supported u64 range"
22 ))
23 })
24}
25
26pub(crate) fn sqlite_bool(value: bool) -> i64 {
27 if value {
28 1
29 } else {
30 0
31 }
32}
33
34pub(crate) fn ensure_chio_receipt_verified(receipt: &ChioReceipt) -> Result<(), ReceiptStoreError> {
35 ensure_chio_receipt_verified_with_context(receipt, "tool receipt", None)
36}
37
38pub(crate) fn ensure_child_receipt_verified(
39 receipt: &ChildRequestReceipt,
40) -> Result<(), ReceiptStoreError> {
41 ensure_child_receipt_verified_with_context(receipt, "child receipt", None)
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum ActionParameterHashPolicy {
46 Strict,
47 AllowLegacySignedMismatch,
48}
49
50fn format_receipt_context(
51 receipt_kind: &str,
52 receipt_id: Option<&str>,
53 seq: Option<u64>,
54) -> String {
55 let mut context = receipt_kind.to_string();
56 if let Some(seq) = seq {
57 context.push_str(&format!(" seq {seq}"));
58 }
59 if let Some(receipt_id) = receipt_id {
60 context.push_str(&format!(" receipt {receipt_id}"));
61 }
62 context
63}
64
65pub(crate) fn ensure_chio_receipt_verified_with_context(
66 receipt: &ChioReceipt,
67 receipt_kind: &str,
68 seq: Option<u64>,
69) -> Result<(), ReceiptStoreError> {
70 ensure_chio_receipt_verified_with_context_and_action_hash_policy(
71 receipt,
72 receipt_kind,
73 seq,
74 ActionParameterHashPolicy::Strict,
75 )
76}
77
78fn ensure_chio_receipt_verified_with_context_and_action_hash_policy(
79 receipt: &ChioReceipt,
80 receipt_kind: &str,
81 seq: Option<u64>,
82 action_hash_policy: ActionParameterHashPolicy,
83) -> Result<(), ReceiptStoreError> {
84 let context = format_receipt_context(receipt_kind, Some(receipt.id.as_str()), seq);
85 let signature_valid = receipt.verify_signature().map_err(|error| {
86 ReceiptStoreError::Conflict(format!("{context} verification failed: {error}"))
87 })?;
88 if !signature_valid {
89 return Err(ReceiptStoreError::Conflict(format!(
90 "{context} has invalid signature",
91 )));
92 }
93
94 let parameter_hash_valid = receipt.action.verify_hash().map_err(|error| {
95 ReceiptStoreError::Conflict(format!("{context} verification failed: {error}"))
96 })?;
97 if !parameter_hash_valid {
98 if action_hash_policy == ActionParameterHashPolicy::AllowLegacySignedMismatch {
99 return Ok(());
102 }
103 return Err(ReceiptStoreError::Conflict(format!(
104 "{context} has mismatched action parameter hash",
105 )));
106 }
107
108 Ok(())
109}
110
111pub(crate) fn ensure_child_receipt_verified_with_context(
112 receipt: &ChildRequestReceipt,
113 receipt_kind: &str,
114 seq: Option<u64>,
115) -> Result<(), ReceiptStoreError> {
116 let context = format_receipt_context(receipt_kind, Some(receipt.id.as_str()), seq);
117 let signature_valid = receipt.verify_signature().map_err(|error| {
118 ReceiptStoreError::Conflict(format!("{context} verification failed: {error}"))
119 })?;
120 if !signature_valid {
121 return Err(ReceiptStoreError::Conflict(format!(
122 "{context} has invalid signature",
123 )));
124 }
125
126 Ok(())
127}
128
129pub(crate) fn decode_verified_chio_receipt(
130 raw_json: &str,
131 receipt_kind: &str,
132 seq: Option<u64>,
133) -> Result<ChioReceipt, ReceiptStoreError> {
134 let value: serde_json::Value = serde_json::from_str(raw_json).map_err(|error| {
135 ReceiptStoreError::Conflict(format!(
136 "{} failed to decode: {error}",
137 format_receipt_context(receipt_kind, None, seq)
138 ))
139 })?;
140 let receipt_id = value
141 .get("id")
142 .and_then(|field| field.as_str())
143 .map(str::to_string);
144 let receipt: ChioReceipt = serde_json::from_value(value).map_err(|error| {
145 ReceiptStoreError::Conflict(format!(
146 "{} failed to decode: {error}",
147 format_receipt_context(receipt_kind, receipt_id.as_deref(), seq)
148 ))
149 })?;
150 ensure_chio_receipt_verified_with_context_and_action_hash_policy(
151 &receipt,
152 receipt_kind,
153 seq,
154 ActionParameterHashPolicy::AllowLegacySignedMismatch,
155 )?;
156 Ok(receipt)
157}
158
159pub(crate) fn decode_verified_child_receipt(
160 raw_json: &str,
161 receipt_kind: &str,
162 seq: Option<u64>,
163) -> Result<ChildRequestReceipt, ReceiptStoreError> {
164 let value: serde_json::Value = serde_json::from_str(raw_json).map_err(|error| {
165 ReceiptStoreError::Conflict(format!(
166 "{} failed to decode: {error}",
167 format_receipt_context(receipt_kind, None, seq)
168 ))
169 })?;
170 let receipt_id = value
171 .get("id")
172 .and_then(|field| field.as_str())
173 .map(str::to_string);
174 let receipt: ChildRequestReceipt = serde_json::from_value(value).map_err(|error| {
175 ReceiptStoreError::Conflict(format!(
176 "{} failed to decode: {error}",
177 format_receipt_context(receipt_kind, receipt_id.as_deref(), seq)
178 ))
179 })?;
180 ensure_child_receipt_verified_with_context(&receipt, receipt_kind, seq)?;
181 Ok(receipt)
182}
183
184const CHECKPOINT_TRANSPARENCY_GUARDS_SQL: &str = r#"
185CREATE TRIGGER IF NOT EXISTS kernel_checkpoints_reject_update
186BEFORE UPDATE ON kernel_checkpoints
187BEGIN
188 SELECT RAISE(ABORT, 'kernel checkpoints are immutable');
189END;
190
191CREATE TRIGGER IF NOT EXISTS kernel_checkpoints_reject_delete
192BEFORE DELETE ON kernel_checkpoints
193BEGIN
194 SELECT RAISE(ABORT, 'kernel checkpoints are immutable');
195END;
196
197CREATE TRIGGER IF NOT EXISTS kernel_checkpoints_enforce_append_only
198BEFORE INSERT ON kernel_checkpoints
199BEGIN
200 SELECT CASE
201 WHEN NEW.checkpoint_seq < 1
202 THEN RAISE(ABORT, 'checkpoint_seq must be greater than zero')
203 WHEN NEW.batch_start_seq < 1
204 THEN RAISE(ABORT, 'batch_start_seq must be greater than zero')
205 WHEN NEW.batch_end_seq < NEW.batch_start_seq
206 THEN RAISE(ABORT, 'checkpoint batch_end_seq must be >= batch_start_seq')
207 WHEN NEW.tree_size < 1
208 THEN RAISE(ABORT, 'checkpoint tree_size must be greater than zero')
209 WHEN EXISTS (
210 SELECT 1
211 FROM kernel_checkpoints existing
212 WHERE existing.checkpoint_seq >= NEW.checkpoint_seq
213 )
214 THEN RAISE(
215 ABORT,
216 'kernel checkpoints must be appended in strictly increasing checkpoint_seq order'
217 )
218 WHEN NEW.checkpoint_seq > 1
219 AND NOT EXISTS (
220 SELECT 1
221 FROM kernel_checkpoints predecessor
222 WHERE predecessor.checkpoint_seq = NEW.checkpoint_seq - 1
223 AND predecessor.batch_end_seq + 1 = NEW.batch_start_seq
224 )
225 THEN RAISE(ABORT, 'kernel checkpoint predecessor continuity violation')
226 END;
227END;
228"#;
229
230#[derive(Debug, Clone)]
231pub(crate) struct PersistedCheckpointRow {
232 checkpoint_seq: u64,
233 batch_start_seq: u64,
234 batch_end_seq: u64,
235 tree_size: u64,
236 merkle_root_hex: String,
237 issued_at: u64,
238 statement_json: String,
239 signature_hex: String,
240 kernel_key_hex: String,
241}
242
243pub(crate) fn checkpoint_error_to_receipt_store(
244 error: chio_kernel::checkpoint::CheckpointError,
245) -> ReceiptStoreError {
246 ReceiptStoreError::Conflict(format!("checkpoint integrity failure: {error}"))
247}
248
249pub(crate) fn ensure_checkpoint_transparency_guards(
250 connection: &Connection,
251) -> Result<(), ReceiptStoreError> {
252 connection.execute_batch(CHECKPOINT_TRANSPARENCY_GUARDS_SQL)?;
253 Ok(())
254}
255
256pub(crate) fn load_persisted_checkpoint_row(
257 connection: &Connection,
258 checkpoint_seq: u64,
259) -> Result<Option<PersistedCheckpointRow>, ReceiptStoreError> {
260 connection
261 .query_row(
262 r#"
263 SELECT checkpoint_seq, batch_start_seq, batch_end_seq, tree_size,
264 merkle_root, issued_at, statement_json, signature, kernel_key
265 FROM kernel_checkpoints
266 WHERE checkpoint_seq = ?1
267 "#,
268 params![sqlite_i64(checkpoint_seq, "checkpoint_seq")?],
269 |row| {
270 Ok((
271 row.get::<_, i64>(0)?,
272 row.get::<_, i64>(1)?,
273 row.get::<_, i64>(2)?,
274 row.get::<_, i64>(3)?,
275 row.get::<_, String>(4)?,
276 row.get::<_, i64>(5)?,
277 row.get::<_, String>(6)?,
278 row.get::<_, String>(7)?,
279 row.get::<_, String>(8)?,
280 ))
281 },
282 )
283 .optional()?
284 .map(
285 |(
286 checkpoint_seq,
287 batch_start_seq,
288 batch_end_seq,
289 tree_size,
290 merkle_root_hex,
291 issued_at,
292 statement_json,
293 signature_hex,
294 kernel_key_hex,
295 )| {
296 Ok(PersistedCheckpointRow {
297 checkpoint_seq: sqlite_u64(checkpoint_seq, "checkpoint_seq")?,
298 batch_start_seq: sqlite_u64(batch_start_seq, "batch_start_seq")?,
299 batch_end_seq: sqlite_u64(batch_end_seq, "batch_end_seq")?,
300 tree_size: sqlite_u64(tree_size, "tree_size")?,
301 merkle_root_hex,
302 issued_at: sqlite_u64(issued_at, "issued_at")?,
303 statement_json,
304 signature_hex,
305 kernel_key_hex,
306 })
307 },
308 )
309 .transpose()
310}
311
312pub(crate) fn load_latest_persisted_checkpoint_row(
313 connection: &Connection,
314) -> Result<Option<PersistedCheckpointRow>, ReceiptStoreError> {
315 let latest_seq = connection
316 .query_row(
317 "SELECT checkpoint_seq FROM kernel_checkpoints ORDER BY checkpoint_seq DESC LIMIT 1",
318 [],
319 |row| row.get::<_, i64>(0),
320 )
321 .optional()?;
322 latest_seq
323 .map(|value| {
324 load_persisted_checkpoint_row(connection, sqlite_u64(value, "checkpoint_seq")?)
325 })
326 .transpose()
327 .map(|row| row.flatten())
328}
329
330pub(crate) fn load_all_persisted_checkpoint_rows(
331 connection: &Connection,
332) -> Result<Vec<PersistedCheckpointRow>, ReceiptStoreError> {
333 let mut statement = connection.prepare(
334 r#"
335 SELECT checkpoint_seq, batch_start_seq, batch_end_seq, tree_size,
336 merkle_root, issued_at, statement_json, signature, kernel_key
337 FROM kernel_checkpoints
338 ORDER BY checkpoint_seq ASC
339 "#,
340 )?;
341 let rows = statement.query_map([], |row| {
342 Ok((
343 row.get::<_, i64>(0)?,
344 row.get::<_, i64>(1)?,
345 row.get::<_, i64>(2)?,
346 row.get::<_, i64>(3)?,
347 row.get::<_, String>(4)?,
348 row.get::<_, i64>(5)?,
349 row.get::<_, String>(6)?,
350 row.get::<_, String>(7)?,
351 row.get::<_, String>(8)?,
352 ))
353 })?;
354
355 rows.map(|row| {
356 let (
357 checkpoint_seq,
358 batch_start_seq,
359 batch_end_seq,
360 tree_size,
361 merkle_root_hex,
362 issued_at,
363 statement_json,
364 signature_hex,
365 kernel_key_hex,
366 ) = row.map_err(ReceiptStoreError::from)?;
367 Ok(PersistedCheckpointRow {
368 checkpoint_seq: sqlite_u64(checkpoint_seq, "checkpoint_seq")?,
369 batch_start_seq: sqlite_u64(batch_start_seq, "batch_start_seq")?,
370 batch_end_seq: sqlite_u64(batch_end_seq, "batch_end_seq")?,
371 tree_size: sqlite_u64(tree_size, "tree_size")?,
372 merkle_root_hex,
373 issued_at: sqlite_u64(issued_at, "issued_at")?,
374 statement_json,
375 signature_hex,
376 kernel_key_hex,
377 })
378 })
379 .collect::<Result<Vec<_>, _>>()
380}
381
382pub(crate) fn parse_persisted_checkpoint_row(
383 row: PersistedCheckpointRow,
384) -> Result<KernelCheckpoint, ReceiptStoreError> {
385 let body: KernelCheckpointBody = serde_json::from_str(&row.statement_json)?;
386 let signature = Signature::from_hex(&row.signature_hex)
387 .map_err(|error| ReceiptStoreError::CryptoDecode(error.to_string()))?;
388 let checkpoint = KernelCheckpoint { body, signature };
389
390 if checkpoint.body.checkpoint_seq != row.checkpoint_seq {
391 return Err(ReceiptStoreError::Conflict(format!(
392 "checkpoint row seq {} does not match signed checkpoint_seq {}",
393 row.checkpoint_seq, checkpoint.body.checkpoint_seq
394 )));
395 }
396 if checkpoint.body.batch_start_seq != row.batch_start_seq {
397 return Err(ReceiptStoreError::Conflict(format!(
398 "checkpoint {} batch_start_seq column {} does not match signed body {}",
399 row.checkpoint_seq, row.batch_start_seq, checkpoint.body.batch_start_seq
400 )));
401 }
402 if checkpoint.body.batch_end_seq != row.batch_end_seq {
403 return Err(ReceiptStoreError::Conflict(format!(
404 "checkpoint {} batch_end_seq column {} does not match signed body {}",
405 row.checkpoint_seq, row.batch_end_seq, checkpoint.body.batch_end_seq
406 )));
407 }
408 if checkpoint.body.tree_size as u64 != row.tree_size {
409 return Err(ReceiptStoreError::Conflict(format!(
410 "checkpoint {} tree_size column {} does not match signed body {}",
411 row.checkpoint_seq, row.tree_size, checkpoint.body.tree_size
412 )));
413 }
414 if checkpoint.body.merkle_root.to_hex() != row.merkle_root_hex {
415 return Err(ReceiptStoreError::Conflict(format!(
416 "checkpoint {} merkle_root column {} does not match signed body {}",
417 row.checkpoint_seq,
418 row.merkle_root_hex,
419 checkpoint.body.merkle_root.to_hex()
420 )));
421 }
422 if checkpoint.body.issued_at != row.issued_at {
423 return Err(ReceiptStoreError::Conflict(format!(
424 "checkpoint {} issued_at column {} does not match signed body {}",
425 row.checkpoint_seq, row.issued_at, checkpoint.body.issued_at
426 )));
427 }
428 if checkpoint.signature.to_hex() != row.signature_hex {
429 return Err(ReceiptStoreError::Conflict(format!(
430 "checkpoint {} signature column does not match parsed signature",
431 row.checkpoint_seq
432 )));
433 }
434 if checkpoint.body.kernel_key.to_hex() != row.kernel_key_hex {
435 return Err(ReceiptStoreError::Conflict(format!(
436 "checkpoint {} kernel_key column {} does not match signed body {}",
437 row.checkpoint_seq,
438 row.kernel_key_hex,
439 checkpoint.body.kernel_key.to_hex()
440 )));
441 }
442
443 chio_kernel::checkpoint::validate_checkpoint(&checkpoint)
444 .map_err(checkpoint_error_to_receipt_store)?;
445
446 Ok(checkpoint)
447}
448
449pub(crate) fn verify_latest_checkpoint_integrity(
450 connection: &Connection,
451) -> Result<(), ReceiptStoreError> {
452 if load_latest_persisted_checkpoint_row(connection)?.is_none() {
453 return Ok(());
454 }
455 verify_checkpoint_chain_integrity(connection).map(|_| ())
456}
457
458pub(crate) fn verify_checkpoint_chain_integrity(
459 connection: &Connection,
460) -> Result<Option<KernelCheckpoint>, ReceiptStoreError> {
461 let rows = load_all_persisted_checkpoint_rows(connection)?;
462 let mut latest = None;
463
464 for row in rows {
465 let checkpoint = parse_persisted_checkpoint_row(row)?;
466 if let Some(predecessor) = latest.as_ref() {
467 chio_kernel::checkpoint::validate_checkpoint_predecessor(predecessor, &checkpoint)
468 .map_err(checkpoint_error_to_receipt_store)?;
469 }
470 latest = Some(checkpoint);
471 }
472
473 Ok(latest)
474}
475
476const TRANSPARENCY_PROJECTION_GUARDS_SQL: &str = r#"
477CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_update
478BEFORE UPDATE ON claim_receipt_log_entries
479BEGIN
480 SELECT RAISE(ABORT, 'claim receipt log entries are immutable');
481END;
482
483CREATE TRIGGER IF NOT EXISTS claim_receipt_log_entries_reject_delete
484BEFORE DELETE ON claim_receipt_log_entries
485BEGIN
486 SELECT RAISE(ABORT, 'claim receipt log entries are immutable');
487END;
488
489CREATE TRIGGER IF NOT EXISTS checkpoint_tree_heads_reject_update
490BEFORE UPDATE ON checkpoint_tree_heads
491BEGIN
492 SELECT RAISE(ABORT, 'checkpoint tree heads are immutable');
493END;
494
495CREATE TRIGGER IF NOT EXISTS checkpoint_tree_heads_reject_delete
496BEFORE DELETE ON checkpoint_tree_heads
497BEGIN
498 SELECT RAISE(ABORT, 'checkpoint tree heads are immutable');
499END;
500
501CREATE TRIGGER IF NOT EXISTS checkpoint_predecessor_witnesses_reject_update
502BEFORE UPDATE ON checkpoint_predecessor_witnesses
503BEGIN
504 SELECT RAISE(ABORT, 'checkpoint predecessor witnesses are immutable');
505END;
506
507CREATE TRIGGER IF NOT EXISTS checkpoint_predecessor_witnesses_reject_delete
508BEFORE DELETE ON checkpoint_predecessor_witnesses
509BEGIN
510 SELECT RAISE(ABORT, 'checkpoint predecessor witnesses are immutable');
511END;
512
513CREATE TRIGGER IF NOT EXISTS checkpoint_publication_metadata_reject_update
514BEFORE UPDATE ON checkpoint_publication_metadata
515BEGIN
516 SELECT RAISE(ABORT, 'checkpoint publication metadata is immutable');
517END;
518
519CREATE TRIGGER IF NOT EXISTS checkpoint_publication_metadata_reject_delete
520BEFORE DELETE ON checkpoint_publication_metadata
521BEGIN
522 SELECT RAISE(ABORT, 'checkpoint publication metadata is immutable');
523END;
524
525CREATE TRIGGER IF NOT EXISTS checkpoint_publication_trust_anchor_bindings_reject_update
526BEFORE UPDATE ON checkpoint_publication_trust_anchor_bindings
527BEGIN
528 SELECT RAISE(ABORT, 'checkpoint publication trust-anchor bindings are immutable');
529END;
530
531CREATE TRIGGER IF NOT EXISTS checkpoint_publication_trust_anchor_bindings_reject_delete
532BEFORE DELETE ON checkpoint_publication_trust_anchor_bindings
533BEGIN
534 SELECT RAISE(ABORT, 'checkpoint publication trust-anchor bindings are immutable');
535END;
536"#;
537
538#[derive(Debug, Clone, PartialEq, Eq)]
539struct ClaimReceiptLogProjectionRow {
540 receipt_id: String,
541 receipt_kind: String,
542 source_seq: u64,
543 timestamp: u64,
544 capability_id: Option<String>,
545 session_id: Option<String>,
546 parent_request_id: Option<String>,
547 request_id: Option<String>,
548 subject_key: Option<String>,
549 issuer_key: Option<String>,
550 tool_server: Option<String>,
551 tool_name: Option<String>,
552 raw_json: String,
553}
554
555impl ClaimReceiptLogProjectionRow {
556 fn kind_rank(&self) -> u8 {
557 match self.receipt_kind.as_str() {
558 "tool_receipt" => 0,
559 "child_receipt" => 1,
560 _ => 2,
561 }
562 }
563
564 fn matches_projection_or_legacy_enrichment(&self, expected: &Self) -> bool {
565 self.receipt_id == expected.receipt_id
566 && self.receipt_kind == expected.receipt_kind
567 && self.source_seq == expected.source_seq
568 && self.timestamp == expected.timestamp
569 && self.capability_id == expected.capability_id
570 && self.session_id == expected.session_id
571 && self.parent_request_id == expected.parent_request_id
572 && self.request_id == expected.request_id
573 && self.tool_server == expected.tool_server
574 && self.tool_name == expected.tool_name
575 && self.raw_json == expected.raw_json
576 && legacy_optional_enrichment_matches(&self.subject_key, &expected.subject_key)
577 && legacy_optional_enrichment_matches(&self.issuer_key, &expected.issuer_key)
578 }
579}
580
581fn legacy_optional_enrichment_matches(
582 existing: &Option<String>,
583 expected: &Option<String>,
584) -> bool {
585 existing == expected || (existing.is_none() && expected.is_some())
586}
587
588#[derive(Debug, Clone, PartialEq, Eq)]
589struct CheckpointTreeHeadProjectionRow {
590 checkpoint_seq: u64,
591 batch_start_seq: u64,
592 batch_end_seq: u64,
593 tree_size: u64,
594 merkle_root: String,
595 issued_at: u64,
596 kernel_key: String,
597 previous_checkpoint_sha256: Option<String>,
598 statement_json: String,
599 signature: String,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
603struct CheckpointPredecessorWitnessProjectionRow {
604 predecessor_checkpoint_seq: u64,
605 witness_checkpoint_seq: u64,
606 previous_checkpoint_sha256: String,
607 witnessed_at: u64,
608 witness_statement_json: String,
609}
610
611#[derive(Debug, Clone, PartialEq, Eq)]
612struct CheckpointPublicationMetadataProjectionRow {
613 checkpoint_seq: u64,
614 publication_schema: String,
615 merkle_root: String,
616 published_at: u64,
617 kernel_key: String,
618 log_tree_size: u64,
619 entry_start_seq: u64,
620 entry_end_seq: u64,
621 previous_checkpoint_sha256: Option<String>,
622}
623
624fn load_tool_claim_receipt_projection_rows(
625 connection: &Connection,
626) -> Result<Vec<ClaimReceiptLogProjectionRow>, ReceiptStoreError> {
627 let mut statement = connection.prepare(
628 r#"
629 SELECT receipt_id, seq, timestamp, capability_id, subject_key, issuer_key,
630 tool_server, tool_name, raw_json
631 FROM chio_tool_receipts
632 ORDER BY timestamp ASC, seq ASC
633 "#,
634 )?;
635 let rows = statement.query_map([], |row| {
636 Ok((
637 row.get::<_, String>(0)?,
638 row.get::<_, i64>(1)?,
639 row.get::<_, i64>(2)?,
640 row.get::<_, Option<String>>(3)?,
641 row.get::<_, Option<String>>(4)?,
642 row.get::<_, Option<String>>(5)?,
643 row.get::<_, Option<String>>(6)?,
644 row.get::<_, Option<String>>(7)?,
645 row.get::<_, String>(8)?,
646 ))
647 })?;
648 rows.map(|row| {
649 let (
650 receipt_id,
651 source_seq,
652 timestamp,
653 capability_id,
654 subject_key,
655 issuer_key,
656 tool_server,
657 tool_name,
658 raw_json,
659 ) = row.map_err(ReceiptStoreError::from)?;
660 Ok(ClaimReceiptLogProjectionRow {
661 receipt_id,
662 receipt_kind: "tool_receipt".to_string(),
663 source_seq: sqlite_u64(source_seq, "claim tool source_seq")?,
664 timestamp: sqlite_u64(timestamp, "claim tool timestamp")?,
665 capability_id,
666 session_id: None,
667 parent_request_id: None,
668 request_id: None,
669 subject_key,
670 issuer_key,
671 tool_server,
672 tool_name,
673 raw_json,
674 })
675 })
676 .collect::<Result<Vec<_>, _>>()
677}
678
679fn load_child_claim_receipt_projection_rows(
680 connection: &Connection,
681) -> Result<Vec<ClaimReceiptLogProjectionRow>, ReceiptStoreError> {
682 let mut statement = connection.prepare(
683 r#"
684 SELECT receipt_id, seq, timestamp, session_id, parent_request_id,
685 request_id, raw_json
686 FROM chio_child_receipts
687 ORDER BY timestamp ASC, seq ASC
688 "#,
689 )?;
690 let rows = statement.query_map([], |row| {
691 Ok((
692 row.get::<_, String>(0)?,
693 row.get::<_, i64>(1)?,
694 row.get::<_, i64>(2)?,
695 row.get::<_, Option<String>>(3)?,
696 row.get::<_, Option<String>>(4)?,
697 row.get::<_, Option<String>>(5)?,
698 row.get::<_, String>(6)?,
699 ))
700 })?;
701 rows.map(|row| {
702 let (
703 receipt_id,
704 source_seq,
705 timestamp,
706 session_id,
707 parent_request_id,
708 request_id,
709 raw_json,
710 ) = row?;
711 Ok(ClaimReceiptLogProjectionRow {
712 receipt_id,
713 receipt_kind: "child_receipt".to_string(),
714 source_seq: sqlite_u64(source_seq, "claim child source_seq")?,
715 timestamp: sqlite_u64(timestamp, "claim child timestamp")?,
716 capability_id: None,
717 session_id,
718 parent_request_id,
719 request_id,
720 subject_key: None,
721 issuer_key: None,
722 tool_server: None,
723 tool_name: None,
724 raw_json,
725 })
726 })
727 .collect()
728}
729
730fn load_claim_receipt_log_projection_row(
731 connection: &Connection,
732 receipt_id: &str,
733) -> Result<Option<ClaimReceiptLogProjectionRow>, ReceiptStoreError> {
734 connection
735 .query_row(
736 r#"
737 SELECT receipt_id, receipt_kind, source_seq, timestamp, capability_id,
738 session_id, parent_request_id, request_id, subject_key, issuer_key,
739 tool_server, tool_name, raw_json
740 FROM claim_receipt_log_entries
741 WHERE receipt_id = ?1
742 "#,
743 params![receipt_id],
744 |row| {
745 Ok((
746 row.get::<_, String>(0)?,
747 row.get::<_, String>(1)?,
748 row.get::<_, i64>(2)?,
749 row.get::<_, i64>(3)?,
750 row.get::<_, Option<String>>(4)?,
751 row.get::<_, Option<String>>(5)?,
752 row.get::<_, Option<String>>(6)?,
753 row.get::<_, Option<String>>(7)?,
754 row.get::<_, Option<String>>(8)?,
755 row.get::<_, Option<String>>(9)?,
756 row.get::<_, Option<String>>(10)?,
757 row.get::<_, Option<String>>(11)?,
758 row.get::<_, String>(12)?,
759 ))
760 },
761 )
762 .optional()
763 .map_err(ReceiptStoreError::from)?
764 .map(
765 |(
766 receipt_id,
767 receipt_kind,
768 source_seq,
769 timestamp,
770 capability_id,
771 session_id,
772 parent_request_id,
773 request_id,
774 subject_key,
775 issuer_key,
776 tool_server,
777 tool_name,
778 raw_json,
779 )| {
780 Ok(ClaimReceiptLogProjectionRow {
781 receipt_id,
782 receipt_kind,
783 source_seq: sqlite_u64(source_seq, "claim log source_seq")?,
784 timestamp: sqlite_u64(timestamp, "claim log timestamp")?,
785 capability_id,
786 session_id,
787 parent_request_id,
788 request_id,
789 subject_key,
790 issuer_key,
791 tool_server,
792 tool_name,
793 raw_json,
794 })
795 },
796 )
797 .transpose()
798}
799
800fn insert_claim_receipt_log_projection_row(
801 connection: &Connection,
802 row: &ClaimReceiptLogProjectionRow,
803) -> Result<(), ReceiptStoreError> {
804 connection.execute(
805 r#"
806 INSERT INTO claim_receipt_log_entries (
807 receipt_id, receipt_kind, source_seq, timestamp, capability_id,
808 session_id, parent_request_id, request_id, subject_key, issuer_key,
809 tool_server, tool_name, raw_json
810 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
811 "#,
812 params![
813 row.receipt_id.as_str(),
814 row.receipt_kind.as_str(),
815 sqlite_i64(row.source_seq, "claim log source_seq")?,
816 sqlite_i64(row.timestamp, "claim log timestamp")?,
817 row.capability_id.as_deref(),
818 row.session_id.as_deref(),
819 row.parent_request_id.as_deref(),
820 row.request_id.as_deref(),
821 row.subject_key.as_deref(),
822 row.issuer_key.as_deref(),
823 row.tool_server.as_deref(),
824 row.tool_name.as_deref(),
825 row.raw_json.as_str(),
826 ],
827 )?;
828 Ok(())
829}
830
831fn load_claim_receipt_log_receipt_ids(
832 connection: &Connection,
833) -> Result<BTreeSet<String>, ReceiptStoreError> {
834 let mut statement = connection
835 .prepare("SELECT receipt_id FROM claim_receipt_log_entries ORDER BY entry_seq ASC")?;
836 let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
837 rows.collect::<Result<BTreeSet<_>, _>>()
838 .map_err(ReceiptStoreError::from)
839}
840
841fn canonical_bytes_from_claim_log_row(
842 receipt_kind: &str,
843 raw_json: &str,
844 entry_seq: u64,
845) -> Result<Vec<u8>, ReceiptStoreError> {
846 match receipt_kind {
847 "tool_receipt" => {
848 let receipt =
849 decode_verified_chio_receipt(raw_json, "claim-log tool receipt", Some(entry_seq))?;
850 chio_core::canonical::canonical_json_bytes(&receipt)
851 .map_err(|error| ReceiptStoreError::Canonical(error.to_string()))
852 }
853 "child_receipt" => {
854 let receipt = decode_verified_child_receipt(
855 raw_json,
856 "claim-log child receipt",
857 Some(entry_seq),
858 )?;
859 chio_core::canonical::canonical_json_bytes(&receipt)
860 .map_err(|error| ReceiptStoreError::Canonical(error.to_string()))
861 }
862 other => Err(ReceiptStoreError::Conflict(format!(
863 "unsupported claim receipt kind `{other}` in claim tree"
864 ))),
865 }
866}
867
868pub(crate) fn load_claim_tree_canonical_bytes_range(
869 connection: &Connection,
870 start_entry_seq: u64,
871 end_entry_seq: u64,
872) -> Result<Vec<(u64, Vec<u8>)>, ReceiptStoreError> {
873 let mut statement = connection.prepare(
874 r#"
875 SELECT entry_seq, receipt_kind, raw_json
876 FROM claim_receipt_log_entries
877 WHERE entry_seq >= ?1 AND entry_seq <= ?2
878 ORDER BY entry_seq ASC
879 "#,
880 )?;
881 let rows = statement.query_map(
882 params![
883 sqlite_i64(start_entry_seq, "claim tree start_entry_seq")?,
884 sqlite_i64(end_entry_seq, "claim tree end_entry_seq")?,
885 ],
886 |row| {
887 Ok((
888 row.get::<_, i64>(0)?,
889 row.get::<_, String>(1)?,
890 row.get::<_, String>(2)?,
891 ))
892 },
893 )?;
894 let mut result = Vec::new();
895 for row in rows {
896 let (entry_seq, receipt_kind, raw_json) = row?;
897 let entry_seq = sqlite_u64(entry_seq, "claim tree entry_seq")?;
898 result.push((
899 entry_seq,
900 canonical_bytes_from_claim_log_row(&receipt_kind, &raw_json, entry_seq)?,
901 ));
902 }
903 Ok(result)
904}
905
906fn load_checkpoint_tree_head_projection_row(
907 connection: &Connection,
908 checkpoint_seq: u64,
909) -> Result<Option<CheckpointTreeHeadProjectionRow>, ReceiptStoreError> {
910 connection
911 .query_row(
912 r#"
913 SELECT checkpoint_seq, batch_start_seq, batch_end_seq, tree_size, merkle_root,
914 issued_at, kernel_key, previous_checkpoint_sha256, statement_json, signature
915 FROM checkpoint_tree_heads
916 WHERE checkpoint_seq = ?1
917 "#,
918 params![sqlite_i64(checkpoint_seq, "checkpoint_seq")?],
919 |row| {
920 Ok((
921 row.get::<_, i64>(0)?,
922 row.get::<_, i64>(1)?,
923 row.get::<_, i64>(2)?,
924 row.get::<_, i64>(3)?,
925 row.get::<_, String>(4)?,
926 row.get::<_, i64>(5)?,
927 row.get::<_, String>(6)?,
928 row.get::<_, Option<String>>(7)?,
929 row.get::<_, String>(8)?,
930 row.get::<_, String>(9)?,
931 ))
932 },
933 )
934 .optional()
935 .map_err(ReceiptStoreError::from)?
936 .map(
937 |(
938 checkpoint_seq,
939 batch_start_seq,
940 batch_end_seq,
941 tree_size,
942 merkle_root,
943 issued_at,
944 kernel_key,
945 previous_checkpoint_sha256,
946 statement_json,
947 signature,
948 )| {
949 Ok(CheckpointTreeHeadProjectionRow {
950 checkpoint_seq: sqlite_u64(checkpoint_seq, "tree head checkpoint_seq")?,
951 batch_start_seq: sqlite_u64(batch_start_seq, "tree head batch_start_seq")?,
952 batch_end_seq: sqlite_u64(batch_end_seq, "tree head batch_end_seq")?,
953 tree_size: sqlite_u64(tree_size, "tree head tree_size")?,
954 merkle_root,
955 issued_at: sqlite_u64(issued_at, "tree head issued_at")?,
956 kernel_key,
957 previous_checkpoint_sha256,
958 statement_json,
959 signature,
960 })
961 },
962 )
963 .transpose()
964}
965
966fn insert_checkpoint_tree_head_projection_row(
967 connection: &Connection,
968 row: &CheckpointTreeHeadProjectionRow,
969) -> Result<(), ReceiptStoreError> {
970 connection.execute(
971 r#"
972 INSERT INTO checkpoint_tree_heads (
973 checkpoint_seq, batch_start_seq, batch_end_seq, tree_size, merkle_root,
974 issued_at, kernel_key, previous_checkpoint_sha256, statement_json, signature
975 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
976 "#,
977 params![
978 sqlite_i64(row.checkpoint_seq, "checkpoint_seq")?,
979 sqlite_i64(row.batch_start_seq, "batch_start_seq")?,
980 sqlite_i64(row.batch_end_seq, "batch_end_seq")?,
981 sqlite_i64(row.tree_size, "tree_size")?,
982 row.merkle_root.as_str(),
983 sqlite_i64(row.issued_at, "issued_at")?,
984 row.kernel_key.as_str(),
985 row.previous_checkpoint_sha256.as_deref(),
986 row.statement_json.as_str(),
987 row.signature.as_str(),
988 ],
989 )?;
990 Ok(())
991}
992
993fn load_checkpoint_tree_head_projection_ids(
994 connection: &Connection,
995) -> Result<BTreeSet<u64>, ReceiptStoreError> {
996 let mut statement = connection
997 .prepare("SELECT checkpoint_seq FROM checkpoint_tree_heads ORDER BY checkpoint_seq ASC")?;
998 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
999 rows.map(|row| {
1000 let checkpoint_seq = row.map_err(ReceiptStoreError::from)?;
1001 sqlite_u64(checkpoint_seq, "checkpoint_seq")
1002 })
1003 .collect::<Result<BTreeSet<_>, _>>()
1004}
1005
1006fn load_checkpoint_predecessor_witness_projection_row(
1007 connection: &Connection,
1008 witness_checkpoint_seq: u64,
1009) -> Result<Option<CheckpointPredecessorWitnessProjectionRow>, ReceiptStoreError> {
1010 connection
1011 .query_row(
1012 r#"
1013 SELECT predecessor_checkpoint_seq, witness_checkpoint_seq,
1014 previous_checkpoint_sha256, witnessed_at, witness_statement_json
1015 FROM checkpoint_predecessor_witnesses
1016 WHERE witness_checkpoint_seq = ?1
1017 "#,
1018 params![sqlite_i64(
1019 witness_checkpoint_seq,
1020 "witness_checkpoint_seq"
1021 )?],
1022 |row| {
1023 Ok((
1024 row.get::<_, i64>(0)?,
1025 row.get::<_, i64>(1)?,
1026 row.get::<_, String>(2)?,
1027 row.get::<_, i64>(3)?,
1028 row.get::<_, String>(4)?,
1029 ))
1030 },
1031 )
1032 .optional()
1033 .map_err(ReceiptStoreError::from)?
1034 .map(
1035 |(
1036 predecessor_checkpoint_seq,
1037 witness_checkpoint_seq,
1038 previous_checkpoint_sha256,
1039 witnessed_at,
1040 witness_statement_json,
1041 )| {
1042 Ok(CheckpointPredecessorWitnessProjectionRow {
1043 predecessor_checkpoint_seq: sqlite_u64(
1044 predecessor_checkpoint_seq,
1045 "predecessor_checkpoint_seq",
1046 )?,
1047 witness_checkpoint_seq: sqlite_u64(
1048 witness_checkpoint_seq,
1049 "witness_checkpoint_seq",
1050 )?,
1051 previous_checkpoint_sha256,
1052 witnessed_at: sqlite_u64(witnessed_at, "witnessed_at")?,
1053 witness_statement_json,
1054 })
1055 },
1056 )
1057 .transpose()
1058}
1059
1060fn insert_checkpoint_predecessor_witness_projection_row(
1061 connection: &Connection,
1062 row: &CheckpointPredecessorWitnessProjectionRow,
1063) -> Result<(), ReceiptStoreError> {
1064 connection.execute(
1065 r#"
1066 INSERT INTO checkpoint_predecessor_witnesses (
1067 predecessor_checkpoint_seq, witness_checkpoint_seq,
1068 previous_checkpoint_sha256, witnessed_at, witness_statement_json
1069 ) VALUES (?1, ?2, ?3, ?4, ?5)
1070 "#,
1071 params![
1072 sqlite_i64(row.predecessor_checkpoint_seq, "predecessor_checkpoint_seq")?,
1073 sqlite_i64(row.witness_checkpoint_seq, "witness_checkpoint_seq")?,
1074 row.previous_checkpoint_sha256.as_str(),
1075 sqlite_i64(row.witnessed_at, "witnessed_at")?,
1076 row.witness_statement_json.as_str(),
1077 ],
1078 )?;
1079 Ok(())
1080}
1081
1082fn load_checkpoint_predecessor_witness_projection_ids(
1083 connection: &Connection,
1084) -> Result<BTreeSet<u64>, ReceiptStoreError> {
1085 let mut statement = connection.prepare(
1086 "SELECT witness_checkpoint_seq FROM checkpoint_predecessor_witnesses ORDER BY witness_checkpoint_seq ASC",
1087 )?;
1088 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
1089 rows.map(|row| {
1090 let witness_checkpoint_seq = row.map_err(ReceiptStoreError::from)?;
1091 sqlite_u64(witness_checkpoint_seq, "witness_checkpoint_seq")
1092 })
1093 .collect::<Result<BTreeSet<_>, _>>()
1094}
1095
1096fn load_checkpoint_publication_metadata_projection_row(
1097 connection: &Connection,
1098 checkpoint_seq: u64,
1099) -> Result<Option<CheckpointPublicationMetadataProjectionRow>, ReceiptStoreError> {
1100 connection
1101 .query_row(
1102 r#"
1103 SELECT checkpoint_seq, publication_schema, merkle_root, published_at,
1104 kernel_key, log_tree_size, entry_start_seq, entry_end_seq,
1105 previous_checkpoint_sha256
1106 FROM checkpoint_publication_metadata
1107 WHERE checkpoint_seq = ?1
1108 "#,
1109 params![sqlite_i64(checkpoint_seq, "checkpoint_seq")?],
1110 |row| {
1111 Ok((
1112 row.get::<_, i64>(0)?,
1113 row.get::<_, String>(1)?,
1114 row.get::<_, String>(2)?,
1115 row.get::<_, i64>(3)?,
1116 row.get::<_, String>(4)?,
1117 row.get::<_, i64>(5)?,
1118 row.get::<_, i64>(6)?,
1119 row.get::<_, i64>(7)?,
1120 row.get::<_, Option<String>>(8)?,
1121 ))
1122 },
1123 )
1124 .optional()
1125 .map_err(ReceiptStoreError::from)?
1126 .map(
1127 |(
1128 checkpoint_seq,
1129 publication_schema,
1130 merkle_root,
1131 published_at,
1132 kernel_key,
1133 log_tree_size,
1134 entry_start_seq,
1135 entry_end_seq,
1136 previous_checkpoint_sha256,
1137 )| {
1138 Ok(CheckpointPublicationMetadataProjectionRow {
1139 checkpoint_seq: sqlite_u64(
1140 checkpoint_seq,
1141 "checkpoint publication metadata checkpoint_seq",
1142 )?,
1143 publication_schema,
1144 merkle_root,
1145 published_at: sqlite_u64(
1146 published_at,
1147 "checkpoint publication metadata published_at",
1148 )?,
1149 kernel_key,
1150 log_tree_size: sqlite_u64(
1151 log_tree_size,
1152 "checkpoint publication metadata log_tree_size",
1153 )?,
1154 entry_start_seq: sqlite_u64(
1155 entry_start_seq,
1156 "checkpoint publication metadata entry_start_seq",
1157 )?,
1158 entry_end_seq: sqlite_u64(
1159 entry_end_seq,
1160 "checkpoint publication metadata entry_end_seq",
1161 )?,
1162 previous_checkpoint_sha256,
1163 })
1164 },
1165 )
1166 .transpose()
1167}
1168
1169fn insert_checkpoint_publication_metadata_projection_row(
1170 connection: &Connection,
1171 row: &CheckpointPublicationMetadataProjectionRow,
1172) -> Result<(), ReceiptStoreError> {
1173 connection.execute(
1174 r#"
1175 INSERT INTO checkpoint_publication_metadata (
1176 checkpoint_seq, publication_schema, merkle_root, published_at, kernel_key,
1177 log_tree_size, entry_start_seq, entry_end_seq, previous_checkpoint_sha256
1178 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1179 "#,
1180 params![
1181 sqlite_i64(row.checkpoint_seq, "checkpoint_seq")?,
1182 row.publication_schema.as_str(),
1183 row.merkle_root.as_str(),
1184 sqlite_i64(row.published_at, "published_at")?,
1185 row.kernel_key.as_str(),
1186 sqlite_i64(row.log_tree_size, "log_tree_size")?,
1187 sqlite_i64(row.entry_start_seq, "entry_start_seq")?,
1188 sqlite_i64(row.entry_end_seq, "entry_end_seq")?,
1189 row.previous_checkpoint_sha256.as_deref(),
1190 ],
1191 )?;
1192 Ok(())
1193}
1194
1195fn load_checkpoint_publication_metadata_projection_ids(
1196 connection: &Connection,
1197) -> Result<BTreeSet<u64>, ReceiptStoreError> {
1198 let mut statement = connection.prepare(
1199 "SELECT checkpoint_seq FROM checkpoint_publication_metadata ORDER BY checkpoint_seq ASC",
1200 )?;
1201 let rows = statement.query_map([], |row| row.get::<_, i64>(0))?;
1202 rows.map(|row| {
1203 let checkpoint_seq = row.map_err(ReceiptStoreError::from)?;
1204 sqlite_u64(
1205 checkpoint_seq,
1206 "checkpoint publication metadata checkpoint_seq",
1207 )
1208 })
1209 .collect::<Result<BTreeSet<_>, _>>()
1210}
1211
1212pub(crate) fn ensure_transparency_projection_guards(
1213 connection: &Connection,
1214) -> Result<(), ReceiptStoreError> {
1215 connection.execute_batch(TRANSPARENCY_PROJECTION_GUARDS_SQL)?;
1216 Ok(())
1217}
1218
1219pub(crate) fn backfill_claim_receipt_log_entries(
1220 connection: &mut Connection,
1221) -> Result<(), ReceiptStoreError> {
1222 let mut expected = load_tool_claim_receipt_projection_rows(connection)?;
1223 expected.extend(load_child_claim_receipt_projection_rows(connection)?);
1224 expected.sort_by(|left, right| {
1225 (
1226 left.timestamp,
1227 left.kind_rank(),
1228 left.source_seq,
1229 left.receipt_id.as_str(),
1230 )
1231 .cmp(&(
1232 right.timestamp,
1233 right.kind_rank(),
1234 right.source_seq,
1235 right.receipt_id.as_str(),
1236 ))
1237 });
1238
1239 let existing_count = connection.query_row(
1240 "SELECT COUNT(*) FROM claim_receipt_log_entries",
1241 [],
1242 |row| row.get::<_, i64>(0),
1243 )?;
1244 let existing_count = sqlite_u64(existing_count, "claim_receipt_log_entries count")?;
1245 let expected_receipt_ids = expected
1246 .iter()
1247 .map(|row| row.receipt_id.clone())
1248 .collect::<BTreeSet<_>>();
1249
1250 let tx = connection.transaction()?;
1251 if existing_count == 0 {
1252 for row in &expected {
1253 insert_claim_receipt_log_projection_row(&tx, row)?;
1254 }
1255 tx.commit()?;
1256 return Ok(());
1257 }
1258
1259 for row in &expected {
1260 let Some(existing) = load_claim_receipt_log_projection_row(&tx, &row.receipt_id)? else {
1261 return Err(ReceiptStoreError::Conflict(format!(
1262 "claim receipt log entry `{}` is missing for persisted {} source row",
1263 row.receipt_id, row.receipt_kind
1264 )));
1265 };
1266 if !existing.matches_projection_or_legacy_enrichment(row) {
1267 return Err(ReceiptStoreError::Conflict(format!(
1268 "claim receipt log entry `{}` diverges from persisted {} source row",
1269 row.receipt_id, row.receipt_kind
1270 )));
1271 }
1272 }
1273
1274 let existing_receipt_ids = load_claim_receipt_log_receipt_ids(&tx)?;
1275 if existing_receipt_ids != expected_receipt_ids {
1276 let missing = expected_receipt_ids
1277 .difference(&existing_receipt_ids)
1278 .next()
1279 .cloned();
1280 let extra = existing_receipt_ids
1281 .difference(&expected_receipt_ids)
1282 .next()
1283 .cloned();
1284 return Err(ReceiptStoreError::Conflict(format!(
1285 "claim receipt log entry set drift detected (missing: {}, extra: {})",
1286 missing.as_deref().unwrap_or("<none>"),
1287 extra.as_deref().unwrap_or("<none>")
1288 )));
1289 }
1290
1291 tx.commit()?;
1292 Ok(())
1293}
1294
1295pub(crate) fn backfill_checkpoint_transparency_projections(
1296 connection: &mut Connection,
1297) -> Result<(), ReceiptStoreError> {
1298 let rows = load_all_persisted_checkpoint_rows(connection)?;
1299 let mut parsed_checkpoints = Vec::with_capacity(rows.len());
1300 let mut expected_heads = Vec::with_capacity(rows.len());
1301 let mut expected_witnesses = Vec::new();
1302 let mut expected_publications = Vec::with_capacity(rows.len());
1303
1304 for row in rows {
1305 let checkpoint = parse_persisted_checkpoint_row(row.clone())?;
1306 if let Some(predecessor) = parsed_checkpoints.last() {
1307 chio_kernel::checkpoint::validate_checkpoint_predecessor(predecessor, &checkpoint)
1308 .map_err(checkpoint_error_to_receipt_store)?;
1309 }
1310 let publication = chio_kernel::checkpoint::build_checkpoint_publication(&checkpoint)
1311 .map_err(checkpoint_error_to_receipt_store)?;
1312
1313 expected_heads.push(CheckpointTreeHeadProjectionRow {
1314 checkpoint_seq: row.checkpoint_seq,
1315 batch_start_seq: row.batch_start_seq,
1316 batch_end_seq: row.batch_end_seq,
1317 tree_size: row.tree_size,
1318 merkle_root: row.merkle_root_hex,
1319 issued_at: row.issued_at,
1320 kernel_key: row.kernel_key_hex,
1321 previous_checkpoint_sha256: checkpoint.body.previous_checkpoint_sha256.clone(),
1322 statement_json: row.statement_json.clone(),
1323 signature: row.signature_hex,
1324 });
1325 expected_publications.push(CheckpointPublicationMetadataProjectionRow {
1326 checkpoint_seq: publication.checkpoint_seq,
1327 publication_schema: publication.schema,
1328 merkle_root: publication.merkle_root.to_hex(),
1329 published_at: publication.published_at,
1330 kernel_key: publication.kernel_key.to_hex(),
1331 log_tree_size: publication.log_tree_size,
1332 entry_start_seq: publication.entry_start_seq,
1333 entry_end_seq: publication.entry_end_seq,
1334 previous_checkpoint_sha256: publication.previous_checkpoint_sha256,
1335 });
1336
1337 if let Some(previous_checkpoint_sha256) = checkpoint.body.previous_checkpoint_sha256.clone()
1338 {
1339 if checkpoint.body.checkpoint_seq <= 1 {
1340 return Err(ReceiptStoreError::Conflict(format!(
1341 "checkpoint {} cannot witness a predecessor digest",
1342 checkpoint.body.checkpoint_seq
1343 )));
1344 }
1345 expected_witnesses.push(CheckpointPredecessorWitnessProjectionRow {
1346 predecessor_checkpoint_seq: checkpoint.body.checkpoint_seq - 1,
1347 witness_checkpoint_seq: checkpoint.body.checkpoint_seq,
1348 previous_checkpoint_sha256,
1349 witnessed_at: checkpoint.body.issued_at,
1350 witness_statement_json: row.statement_json,
1351 });
1352 }
1353
1354 parsed_checkpoints.push(checkpoint);
1355 }
1356
1357 let tx = connection.transaction()?;
1358 for row in &expected_heads {
1359 match load_checkpoint_tree_head_projection_row(&tx, row.checkpoint_seq)? {
1360 Some(existing) if existing == *row => {}
1361 Some(_) => {
1362 return Err(ReceiptStoreError::Conflict(format!(
1363 "checkpoint tree head projection for checkpoint {} diverges from persisted checkpoint row",
1364 row.checkpoint_seq
1365 )))
1366 }
1367 None => insert_checkpoint_tree_head_projection_row(&tx, row)?,
1368 }
1369 }
1370
1371 let expected_head_ids = expected_heads
1372 .iter()
1373 .map(|row| row.checkpoint_seq)
1374 .collect::<BTreeSet<_>>();
1375 let existing_head_ids = load_checkpoint_tree_head_projection_ids(&tx)?;
1376 if existing_head_ids != expected_head_ids {
1377 let missing = expected_head_ids
1378 .difference(&existing_head_ids)
1379 .next()
1380 .copied();
1381 let extra = existing_head_ids
1382 .difference(&expected_head_ids)
1383 .next()
1384 .copied();
1385 return Err(ReceiptStoreError::Conflict(format!(
1386 "checkpoint tree head projection drift detected (missing: {}, extra: {})",
1387 missing
1388 .map(|value| value.to_string())
1389 .unwrap_or_else(|| "<none>".to_string()),
1390 extra
1391 .map(|value| value.to_string())
1392 .unwrap_or_else(|| "<none>".to_string())
1393 )));
1394 }
1395
1396 for row in &expected_witnesses {
1397 match load_checkpoint_predecessor_witness_projection_row(&tx, row.witness_checkpoint_seq)? {
1398 Some(existing) if existing == *row => {}
1399 Some(_) => {
1400 return Err(ReceiptStoreError::Conflict(format!(
1401 "checkpoint predecessor witness projection for checkpoint {} diverges from persisted checkpoint chain",
1402 row.witness_checkpoint_seq
1403 )))
1404 }
1405 None => insert_checkpoint_predecessor_witness_projection_row(&tx, row)?,
1406 }
1407 }
1408
1409 let expected_witness_ids = expected_witnesses
1410 .iter()
1411 .map(|row| row.witness_checkpoint_seq)
1412 .collect::<BTreeSet<_>>();
1413 let existing_witness_ids = load_checkpoint_predecessor_witness_projection_ids(&tx)?;
1414 if existing_witness_ids != expected_witness_ids {
1415 let missing = expected_witness_ids
1416 .difference(&existing_witness_ids)
1417 .next()
1418 .copied();
1419 let extra = existing_witness_ids
1420 .difference(&expected_witness_ids)
1421 .next()
1422 .copied();
1423 return Err(ReceiptStoreError::Conflict(format!(
1424 "checkpoint predecessor witness projection drift detected (missing: {}, extra: {})",
1425 missing
1426 .map(|value| value.to_string())
1427 .unwrap_or_else(|| "<none>".to_string()),
1428 extra
1429 .map(|value| value.to_string())
1430 .unwrap_or_else(|| "<none>".to_string())
1431 )));
1432 }
1433
1434 for row in &expected_publications {
1435 match load_checkpoint_publication_metadata_projection_row(&tx, row.checkpoint_seq)? {
1436 Some(existing) if existing == *row => {}
1437 Some(_) => {
1438 return Err(ReceiptStoreError::Conflict(format!(
1439 "checkpoint publication metadata projection for checkpoint {} diverges from persisted checkpoint row",
1440 row.checkpoint_seq
1441 )))
1442 }
1443 None => insert_checkpoint_publication_metadata_projection_row(&tx, row)?,
1444 }
1445 }
1446
1447 let expected_publication_ids = expected_publications
1448 .iter()
1449 .map(|row| row.checkpoint_seq)
1450 .collect::<BTreeSet<_>>();
1451 let existing_publication_ids = load_checkpoint_publication_metadata_projection_ids(&tx)?;
1452 if existing_publication_ids != expected_publication_ids {
1453 let missing = expected_publication_ids
1454 .difference(&existing_publication_ids)
1455 .next()
1456 .copied();
1457 let extra = existing_publication_ids
1458 .difference(&expected_publication_ids)
1459 .next()
1460 .copied();
1461 return Err(ReceiptStoreError::Conflict(format!(
1462 "checkpoint publication metadata projection drift detected (missing: {}, extra: {})",
1463 missing
1464 .map(|value| value.to_string())
1465 .unwrap_or_else(|| "<none>".to_string()),
1466 extra
1467 .map(|value| value.to_string())
1468 .unwrap_or_else(|| "<none>".to_string())
1469 )));
1470 }
1471
1472 tx.commit()?;
1473 Ok(())
1474}
1475
1476pub(crate) fn settlement_reconciliation_state_text(
1477 state: SettlementReconciliationState,
1478) -> &'static str {
1479 match state {
1480 SettlementReconciliationState::Open => "open",
1481 SettlementReconciliationState::Reconciled => "reconciled",
1482 SettlementReconciliationState::Ignored => "ignored",
1483 SettlementReconciliationState::RetryScheduled => "retry_scheduled",
1484 }
1485}
1486
1487pub(crate) fn parse_settlement_reconciliation_state(
1488 value: &str,
1489) -> Result<SettlementReconciliationState, ReceiptStoreError> {
1490 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1491}
1492
1493pub(crate) fn metered_billing_reconciliation_state_text(
1494 state: MeteredBillingReconciliationState,
1495) -> &'static str {
1496 match state {
1497 MeteredBillingReconciliationState::Open => "open",
1498 MeteredBillingReconciliationState::Reconciled => "reconciled",
1499 MeteredBillingReconciliationState::Ignored => "ignored",
1500 MeteredBillingReconciliationState::RetryScheduled => "retry_scheduled",
1501 }
1502}
1503
1504pub(crate) fn parse_metered_billing_reconciliation_state(
1505 value: &str,
1506) -> Result<MeteredBillingReconciliationState, ReceiptStoreError> {
1507 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1508}
1509
1510pub(crate) fn underwriting_decision_outcome_label(
1511 outcome: UnderwritingDecisionOutcome,
1512) -> &'static str {
1513 match outcome {
1514 UnderwritingDecisionOutcome::Approve => "approve",
1515 UnderwritingDecisionOutcome::ReduceCeiling => "reduce_ceiling",
1516 UnderwritingDecisionOutcome::StepUp => "step_up",
1517 UnderwritingDecisionOutcome::Deny => "deny",
1518 }
1519}
1520
1521pub(crate) fn underwriting_lifecycle_state_label(
1522 state: UnderwritingDecisionLifecycleState,
1523) -> &'static str {
1524 match state {
1525 UnderwritingDecisionLifecycleState::Active => "active",
1526 UnderwritingDecisionLifecycleState::Superseded => "superseded",
1527 }
1528}
1529
1530pub(crate) fn underwriting_review_state_label(
1531 state: chio_kernel::UnderwritingReviewState,
1532) -> &'static str {
1533 match state {
1534 chio_kernel::UnderwritingReviewState::Approved => "approved",
1535 chio_kernel::UnderwritingReviewState::ManualReviewRequired => "manual_review_required",
1536 chio_kernel::UnderwritingReviewState::Denied => "denied",
1537 }
1538}
1539
1540pub(crate) fn underwriting_risk_class_label(
1541 class: chio_kernel::UnderwritingRiskClass,
1542) -> &'static str {
1543 match class {
1544 chio_kernel::UnderwritingRiskClass::Baseline => "baseline",
1545 chio_kernel::UnderwritingRiskClass::Guarded => "guarded",
1546 chio_kernel::UnderwritingRiskClass::Elevated => "elevated",
1547 chio_kernel::UnderwritingRiskClass::Critical => "critical",
1548 }
1549}
1550
1551pub(crate) fn underwriting_appeal_status_label(status: UnderwritingAppealStatus) -> &'static str {
1552 match status {
1553 UnderwritingAppealStatus::Open => "open",
1554 UnderwritingAppealStatus::Accepted => "accepted",
1555 UnderwritingAppealStatus::Rejected => "rejected",
1556 }
1557}
1558
1559pub(crate) fn credit_facility_disposition_label(
1560 disposition: CreditFacilityDisposition,
1561) -> &'static str {
1562 match disposition {
1563 CreditFacilityDisposition::Grant => "grant",
1564 CreditFacilityDisposition::ManualReview => "manual_review",
1565 CreditFacilityDisposition::Deny => "deny",
1566 }
1567}
1568
1569pub(crate) fn credit_facility_lifecycle_state_label(
1570 state: CreditFacilityLifecycleState,
1571) -> &'static str {
1572 match state {
1573 CreditFacilityLifecycleState::Active => "active",
1574 CreditFacilityLifecycleState::Superseded => "superseded",
1575 CreditFacilityLifecycleState::Denied => "denied",
1576 CreditFacilityLifecycleState::Expired => "expired",
1577 }
1578}
1579
1580pub(crate) fn credit_bond_disposition_label(disposition: CreditBondDisposition) -> &'static str {
1581 match disposition {
1582 CreditBondDisposition::Lock => "lock",
1583 CreditBondDisposition::Hold => "hold",
1584 CreditBondDisposition::Release => "release",
1585 CreditBondDisposition::Impair => "impair",
1586 }
1587}
1588
1589pub(crate) fn credit_bond_lifecycle_state_label(state: CreditBondLifecycleState) -> &'static str {
1590 match state {
1591 CreditBondLifecycleState::Active => "active",
1592 CreditBondLifecycleState::Superseded => "superseded",
1593 CreditBondLifecycleState::Released => "released",
1594 CreditBondLifecycleState::Impaired => "impaired",
1595 CreditBondLifecycleState::Expired => "expired",
1596 }
1597}
1598
1599pub(crate) fn liability_provider_lifecycle_state_label(
1600 state: LiabilityProviderLifecycleState,
1601) -> &'static str {
1602 match state {
1603 LiabilityProviderLifecycleState::Active => "active",
1604 LiabilityProviderLifecycleState::Suspended => "suspended",
1605 LiabilityProviderLifecycleState::Superseded => "superseded",
1606 LiabilityProviderLifecycleState::Retired => "retired",
1607 }
1608}
1609
1610pub(crate) fn credit_loss_lifecycle_event_kind_label(
1611 kind: CreditLossLifecycleEventKind,
1612) -> &'static str {
1613 match kind {
1614 CreditLossLifecycleEventKind::Delinquency => "delinquency",
1615 CreditLossLifecycleEventKind::Recovery => "recovery",
1616 CreditLossLifecycleEventKind::ReserveRelease => "reserve_release",
1617 CreditLossLifecycleEventKind::ReserveSlash => "reserve_slash",
1618 CreditLossLifecycleEventKind::WriteOff => "write_off",
1619 }
1620}
1621
1622pub(crate) fn parse_underwriting_lifecycle_state(
1623 value: &str,
1624) -> Result<UnderwritingDecisionLifecycleState, ReceiptStoreError> {
1625 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1626}
1627
1628pub(crate) fn parse_credit_facility_lifecycle_state(
1629 value: &str,
1630) -> Result<CreditFacilityLifecycleState, ReceiptStoreError> {
1631 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1632}
1633
1634pub(crate) fn parse_credit_bond_lifecycle_state(
1635 value: &str,
1636) -> Result<CreditBondLifecycleState, ReceiptStoreError> {
1637 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1638}
1639
1640pub(crate) fn parse_liability_provider_lifecycle_state(
1641 value: &str,
1642) -> Result<LiabilityProviderLifecycleState, ReceiptStoreError> {
1643 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1644}
1645
1646pub(crate) fn liability_quote_disposition_label(
1647 disposition: &LiabilityQuoteDisposition,
1648) -> &'static str {
1649 match disposition {
1650 LiabilityQuoteDisposition::Quoted => "quoted",
1651 LiabilityQuoteDisposition::Declined => "declined",
1652 }
1653}
1654
1655pub(crate) fn liability_auto_bind_disposition_label(
1656 disposition: &LiabilityAutoBindDisposition,
1657) -> &'static str {
1658 match disposition {
1659 LiabilityAutoBindDisposition::AutoBound => "auto_bound",
1660 LiabilityAutoBindDisposition::ManualReview => "manual_review",
1661 LiabilityAutoBindDisposition::Denied => "denied",
1662 }
1663}
1664
1665pub(crate) fn query_underwriting_appeal(
1666 tx: &rusqlite::Transaction<'_>,
1667 appeal_id: &str,
1668) -> Result<Option<UnderwritingAppealRecord>, ReceiptStoreError> {
1669 let row = tx
1670 .query_row(
1671 "SELECT decision_id, requested_by, reason, status, note, created_at, updated_at,
1672 resolved_by, replacement_decision_id
1673 FROM underwriting_appeals
1674 WHERE appeal_id = ?1",
1675 params![appeal_id],
1676 |row| {
1677 Ok((
1678 row.get::<_, String>(0)?,
1679 row.get::<_, String>(1)?,
1680 row.get::<_, String>(2)?,
1681 row.get::<_, String>(3)?,
1682 row.get::<_, Option<String>>(4)?,
1683 row.get::<_, i64>(5)?,
1684 row.get::<_, i64>(6)?,
1685 row.get::<_, Option<String>>(7)?,
1686 row.get::<_, Option<String>>(8)?,
1687 ))
1688 },
1689 )
1690 .optional()
1691 .map_err(ReceiptStoreError::from)?;
1692
1693 row.map(
1694 |(
1695 decision_id,
1696 requested_by,
1697 reason,
1698 status,
1699 note,
1700 created_at,
1701 updated_at,
1702 resolved_by,
1703 replacement_decision_id,
1704 )| {
1705 Ok(UnderwritingAppealRecord {
1706 schema: chio_kernel::UNDERWRITING_APPEAL_SCHEMA.to_string(),
1707 appeal_id: appeal_id.to_string(),
1708 decision_id,
1709 requested_by,
1710 reason,
1711 status: parse_underwriting_appeal_status(&status)?,
1712 note,
1713 created_at: created_at.max(0) as u64,
1714 updated_at: updated_at.max(0) as u64,
1715 resolved_by,
1716 replacement_decision_id,
1717 })
1718 },
1719 )
1720 .transpose()
1721}
1722
1723pub(crate) fn parse_underwriting_appeal_status(
1724 value: &str,
1725) -> Result<UnderwritingAppealStatus, ReceiptStoreError> {
1726 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
1727}
1728
1729pub(crate) fn load_underwriting_appeal_rows(
1730 connection: &Connection,
1731) -> Result<Vec<UnderwritingAppealRecord>, ReceiptStoreError> {
1732 let mut statement = connection.prepare(
1733 "SELECT appeal_id, decision_id, requested_by, reason, status, note, created_at,
1734 updated_at, resolved_by, replacement_decision_id
1735 FROM underwriting_appeals
1736 ORDER BY updated_at DESC, appeal_id DESC",
1737 )?;
1738 let rows = statement.query_map([], |row| {
1739 Ok((
1740 row.get::<_, String>(0)?,
1741 row.get::<_, String>(1)?,
1742 row.get::<_, String>(2)?,
1743 row.get::<_, String>(3)?,
1744 row.get::<_, String>(4)?,
1745 row.get::<_, Option<String>>(5)?,
1746 row.get::<_, i64>(6)?,
1747 row.get::<_, i64>(7)?,
1748 row.get::<_, Option<String>>(8)?,
1749 row.get::<_, Option<String>>(9)?,
1750 ))
1751 })?;
1752 rows.map(|row| {
1753 let (
1754 appeal_id,
1755 decision_id,
1756 requested_by,
1757 reason,
1758 status,
1759 note,
1760 created_at,
1761 updated_at,
1762 resolved_by,
1763 replacement_decision_id,
1764 ) = row.map_err(ReceiptStoreError::from)?;
1765 Ok(UnderwritingAppealRecord {
1766 schema: chio_kernel::UNDERWRITING_APPEAL_SCHEMA.to_string(),
1767 appeal_id,
1768 decision_id,
1769 requested_by,
1770 reason,
1771 status: parse_underwriting_appeal_status(&status)?,
1772 note,
1773 created_at: created_at.max(0) as u64,
1774 updated_at: updated_at.max(0) as u64,
1775 resolved_by,
1776 replacement_decision_id,
1777 })
1778 })
1779 .collect::<Result<Vec<_>, _>>()
1780}
1781
1782pub(crate) fn underwriting_decision_matches_query(
1783 decision: &SignedUnderwritingDecision,
1784 lifecycle_state: UnderwritingDecisionLifecycleState,
1785 latest_appeal_status: Option<UnderwritingAppealStatus>,
1786 query: &UnderwritingDecisionQuery,
1787) -> bool {
1788 let filters = &decision.body.evaluation.input.filters;
1789 let decision_id_matches = query
1790 .decision_id
1791 .as_deref()
1792 .is_none_or(|decision_id| decision.body.decision_id == decision_id);
1793 let capability_matches = query
1794 .capability_id
1795 .as_deref()
1796 .is_none_or(|capability_id| filters.capability_id.as_deref() == Some(capability_id));
1797 let subject_matches = query
1798 .agent_subject
1799 .as_deref()
1800 .is_none_or(|subject| filters.agent_subject.as_deref() == Some(subject));
1801 let tool_server_matches = query
1802 .tool_server
1803 .as_deref()
1804 .is_none_or(|tool_server| filters.tool_server.as_deref() == Some(tool_server));
1805 let tool_name_matches = query
1806 .tool_name
1807 .as_deref()
1808 .is_none_or(|tool_name| filters.tool_name.as_deref() == Some(tool_name));
1809 let outcome_matches = query
1810 .outcome
1811 .is_none_or(|outcome| decision.body.evaluation.outcome == outcome);
1812 let lifecycle_matches = query
1813 .lifecycle_state
1814 .is_none_or(|state| lifecycle_state == state);
1815 let appeal_matches = query
1816 .appeal_status
1817 .is_none_or(|status| latest_appeal_status == Some(status));
1818
1819 decision_id_matches
1820 && capability_matches
1821 && subject_matches
1822 && tool_server_matches
1823 && tool_name_matches
1824 && outcome_matches
1825 && lifecycle_matches
1826 && appeal_matches
1827}
1828
1829pub(crate) fn effective_credit_facility_lifecycle_state(
1830 facility: &SignedCreditFacility,
1831 persisted: CreditFacilityLifecycleState,
1832 now: u64,
1833) -> CreditFacilityLifecycleState {
1834 if persisted == CreditFacilityLifecycleState::Active && facility.body.expires_at <= now {
1835 CreditFacilityLifecycleState::Expired
1836 } else {
1837 persisted
1838 }
1839}
1840
1841pub(crate) fn effective_credit_bond_lifecycle_state(
1842 bond: &SignedCreditBond,
1843 persisted: CreditBondLifecycleState,
1844 now: u64,
1845) -> CreditBondLifecycleState {
1846 if persisted == CreditBondLifecycleState::Active && bond.body.expires_at <= now {
1847 CreditBondLifecycleState::Expired
1848 } else {
1849 persisted
1850 }
1851}
1852
1853pub(crate) fn credit_facility_matches_query(
1854 facility: &SignedCreditFacility,
1855 lifecycle_state: CreditFacilityLifecycleState,
1856 query: &CreditFacilityListQuery,
1857) -> bool {
1858 let filters = &facility.body.report.filters;
1859 let facility_id_matches = query
1860 .facility_id
1861 .as_deref()
1862 .is_none_or(|facility_id| facility.body.facility_id == facility_id);
1863 let capability_matches = query
1864 .capability_id
1865 .as_deref()
1866 .is_none_or(|capability_id| filters.capability_id.as_deref() == Some(capability_id));
1867 let subject_matches = query
1868 .agent_subject
1869 .as_deref()
1870 .is_none_or(|subject| filters.agent_subject.as_deref() == Some(subject));
1871 let tool_server_matches = query
1872 .tool_server
1873 .as_deref()
1874 .is_none_or(|tool_server| filters.tool_server.as_deref() == Some(tool_server));
1875 let tool_name_matches = query
1876 .tool_name
1877 .as_deref()
1878 .is_none_or(|tool_name| filters.tool_name.as_deref() == Some(tool_name));
1879 let disposition_matches = query
1880 .disposition
1881 .is_none_or(|disposition| facility.body.report.disposition == disposition);
1882 let lifecycle_matches = query
1883 .lifecycle_state
1884 .is_none_or(|state| lifecycle_state == state);
1885
1886 facility_id_matches
1887 && capability_matches
1888 && subject_matches
1889 && tool_server_matches
1890 && tool_name_matches
1891 && disposition_matches
1892 && lifecycle_matches
1893}
1894
1895pub(crate) fn credit_bond_matches_query(
1896 bond: &SignedCreditBond,
1897 lifecycle_state: CreditBondLifecycleState,
1898 query: &CreditBondListQuery,
1899) -> bool {
1900 let filters = &bond.body.report.filters;
1901 let bond_id_matches = query
1902 .bond_id
1903 .as_deref()
1904 .is_none_or(|bond_id| bond.body.bond_id == bond_id);
1905 let facility_id_matches = query.facility_id.as_deref().is_none_or(|facility_id| {
1906 bond.body.report.latest_facility_id.as_deref() == Some(facility_id)
1907 });
1908 let capability_matches = query
1909 .capability_id
1910 .as_deref()
1911 .is_none_or(|capability_id| filters.capability_id.as_deref() == Some(capability_id));
1912 let subject_matches = query
1913 .agent_subject
1914 .as_deref()
1915 .is_none_or(|subject| filters.agent_subject.as_deref() == Some(subject));
1916 let tool_server_matches = query
1917 .tool_server
1918 .as_deref()
1919 .is_none_or(|tool_server| filters.tool_server.as_deref() == Some(tool_server));
1920 let tool_name_matches = query
1921 .tool_name
1922 .as_deref()
1923 .is_none_or(|tool_name| filters.tool_name.as_deref() == Some(tool_name));
1924 let disposition_matches = query
1925 .disposition
1926 .is_none_or(|disposition| bond.body.report.disposition == disposition);
1927 let lifecycle_matches = query
1928 .lifecycle_state
1929 .is_none_or(|state| lifecycle_state == state);
1930
1931 bond_id_matches
1932 && facility_id_matches
1933 && capability_matches
1934 && subject_matches
1935 && tool_server_matches
1936 && tool_name_matches
1937 && disposition_matches
1938 && lifecycle_matches
1939}
1940
1941pub(crate) fn liability_provider_matches_query(
1942 provider: &SignedLiabilityProvider,
1943 lifecycle_state: LiabilityProviderLifecycleState,
1944 query: &LiabilityProviderListQuery,
1945) -> bool {
1946 let report = &provider.body.report;
1947 let provider_id_matches = query
1948 .provider_id
1949 .as_deref()
1950 .is_none_or(|provider_id| report.provider_id == provider_id);
1951 let lifecycle_matches = query
1952 .lifecycle_state
1953 .is_none_or(|state| lifecycle_state == state);
1954 let jurisdiction_matches = query.jurisdiction.as_deref().is_none_or(|jurisdiction| {
1955 report
1956 .policies
1957 .iter()
1958 .any(|policy| policy.jurisdiction.eq_ignore_ascii_case(jurisdiction))
1959 });
1960 let coverage_matches = query.coverage_class.is_none_or(|coverage_class| {
1961 report
1962 .policies
1963 .iter()
1964 .any(|policy| policy.coverage_classes.contains(&coverage_class))
1965 });
1966 let currency_matches = query.currency.as_deref().is_none_or(|currency| {
1967 report.policies.iter().any(|policy| {
1968 policy
1969 .supported_currencies
1970 .iter()
1971 .any(|candidate| candidate.eq_ignore_ascii_case(currency))
1972 })
1973 });
1974
1975 provider_id_matches
1976 && lifecycle_matches
1977 && jurisdiction_matches
1978 && coverage_matches
1979 && currency_matches
1980}
1981
1982pub(crate) fn liability_provider_policy_matches_resolution(
1983 policy: &chio_kernel::LiabilityJurisdictionPolicy,
1984 query: &LiabilityProviderResolutionQuery,
1985) -> bool {
1986 policy
1987 .jurisdiction
1988 .eq_ignore_ascii_case(&query.jurisdiction)
1989 && policy.coverage_classes.contains(&query.coverage_class)
1990 && policy
1991 .supported_currencies
1992 .iter()
1993 .any(|currency| currency.eq_ignore_ascii_case(&query.currency))
1994}
1995
1996pub(crate) fn liability_market_workflow_matches_query(
1997 quote_request: &SignedLiabilityQuoteRequest,
1998 query: &LiabilityMarketWorkflowQuery,
1999) -> bool {
2000 let request = "e_request.body;
2001 let quote_request_id_matches = query
2002 .quote_request_id
2003 .as_deref()
2004 .is_none_or(|quote_request_id| request.quote_request_id == quote_request_id);
2005 let provider_id_matches = query
2006 .provider_id
2007 .as_deref()
2008 .is_none_or(|provider_id| request.provider_policy.provider_id == provider_id);
2009 let subject_matches = query
2010 .agent_subject
2011 .as_deref()
2012 .is_none_or(|subject| request.risk_package.body.subject_key == subject);
2013 let jurisdiction_matches = query.jurisdiction.as_deref().is_none_or(|jurisdiction| {
2014 request
2015 .provider_policy
2016 .jurisdiction
2017 .eq_ignore_ascii_case(jurisdiction)
2018 });
2019 let coverage_matches = query
2020 .coverage_class
2021 .is_none_or(|coverage_class| request.provider_policy.coverage_class == coverage_class);
2022 let currency_matches = query.currency.as_deref().is_none_or(|currency| {
2023 request
2024 .requested_coverage_amount
2025 .currency
2026 .eq_ignore_ascii_case(currency)
2027 });
2028
2029 quote_request_id_matches
2030 && provider_id_matches
2031 && subject_matches
2032 && jurisdiction_matches
2033 && coverage_matches
2034 && currency_matches
2035}
2036
2037pub(crate) fn liability_claim_workflow_matches_query(
2038 claim: &SignedLiabilityClaimPackage,
2039 query: &LiabilityClaimWorkflowQuery,
2040) -> bool {
2041 let claim_body = &claim.body;
2042 let provider_policy = &claim_body
2043 .bound_coverage
2044 .body
2045 .placement
2046 .body
2047 .quote_response
2048 .body
2049 .quote_request
2050 .body
2051 .provider_policy;
2052 let claim_id_matches = query
2053 .claim_id
2054 .as_deref()
2055 .is_none_or(|claim_id| claim_body.claim_id == claim_id);
2056 let provider_id_matches = query
2057 .provider_id
2058 .as_deref()
2059 .is_none_or(|provider_id| provider_policy.provider_id == provider_id);
2060 let subject_matches = query.agent_subject.as_deref().is_none_or(|subject| {
2061 claim_body
2062 .bound_coverage
2063 .body
2064 .placement
2065 .body
2066 .quote_response
2067 .body
2068 .quote_request
2069 .body
2070 .risk_package
2071 .body
2072 .subject_key
2073 == subject
2074 });
2075 let jurisdiction_matches = query.jurisdiction.as_deref().is_none_or(|jurisdiction| {
2076 provider_policy
2077 .jurisdiction
2078 .eq_ignore_ascii_case(jurisdiction)
2079 });
2080 let policy_number_matches = query
2081 .policy_number
2082 .as_deref()
2083 .is_none_or(|policy_number| claim_body.bound_coverage.body.policy_number == policy_number);
2084
2085 claim_id_matches
2086 && provider_id_matches
2087 && subject_matches
2088 && jurisdiction_matches
2089 && policy_number_matches
2090}
2091
2092pub(crate) fn unix_now() -> u64 {
2093 match SystemTime::now().duration_since(UNIX_EPOCH) {
2094 Ok(duration) => duration.as_secs(),
2095 Err(_) => 0,
2096 }
2097}
2098
2099pub(crate) fn parse_settlement_status(value: &str) -> Result<SettlementStatus, ReceiptStoreError> {
2100 serde_json::from_str(&format!("\"{value}\"")).map_err(ReceiptStoreError::from)
2101}
2102
2103pub(crate) fn settlement_reconciliation_action_required(
2104 settlement_status: SettlementStatus,
2105 reconciliation_state: SettlementReconciliationState,
2106) -> bool {
2107 matches!(
2108 settlement_status,
2109 SettlementStatus::Pending | SettlementStatus::Failed
2110 ) && !matches!(
2111 reconciliation_state,
2112 SettlementReconciliationState::Reconciled | SettlementReconciliationState::Ignored
2113 )
2114}
2115
2116pub(crate) fn metered_billing_evidence_record_from_columns(
2117 adapter_kind: Option<String>,
2118 evidence_id: Option<String>,
2119 observed_units: Option<i64>,
2120 billed_cost_units: Option<i64>,
2121 billed_cost_currency: Option<String>,
2122 evidence_sha256: Option<String>,
2123 recorded_at: Option<i64>,
2124) -> Option<MeteredBillingEvidenceRecord> {
2125 let (
2126 Some(adapter_kind),
2127 Some(evidence_id),
2128 Some(observed_units),
2129 Some(billed_cost_units),
2130 Some(billed_cost_currency),
2131 Some(recorded_at),
2132 ) = (
2133 adapter_kind,
2134 evidence_id,
2135 observed_units,
2136 billed_cost_units,
2137 billed_cost_currency,
2138 recorded_at,
2139 )
2140 else {
2141 return None;
2142 };
2143
2144 Some(MeteredBillingEvidenceRecord {
2145 usage_evidence: chio_core::receipt::MeteredUsageEvidenceReceiptMetadata {
2146 evidence_kind: adapter_kind,
2147 evidence_id,
2148 observed_units: observed_units.max(0) as u64,
2149 evidence_sha256,
2150 },
2151 billed_cost: chio_core::capability::MonetaryAmount {
2152 units: billed_cost_units.max(0) as u64,
2153 currency: billed_cost_currency,
2154 },
2155 recorded_at: recorded_at.max(0) as u64,
2156 })
2157}
2158
2159pub(crate) struct MeteredBillingReconciliationAnalysis {
2160 pub(crate) evidence_missing: bool,
2161 pub(crate) exceeds_quoted_units: bool,
2162 pub(crate) exceeds_max_billed_units: bool,
2163 pub(crate) exceeds_quoted_cost: bool,
2164 pub(crate) financial_mismatch: bool,
2165 pub(crate) action_required: bool,
2166}
2167
2168pub(crate) fn analyze_metered_billing_reconciliation(
2169 metered: &chio_core::receipt::MeteredBillingReceiptMetadata,
2170 financial: Option<&FinancialReceiptMetadata>,
2171 evidence: Option<&MeteredBillingEvidenceRecord>,
2172 reconciliation_state: MeteredBillingReconciliationState,
2173) -> MeteredBillingReconciliationAnalysis {
2174 let evidence_missing = evidence.is_none();
2175 let exceeds_quoted_units = evidence
2176 .is_some_and(|record| record.usage_evidence.observed_units > metered.quote.quoted_units);
2177 let exceeds_max_billed_units = evidence.is_some_and(|record| {
2178 metered
2179 .max_billed_units
2180 .is_some_and(|max_units| record.usage_evidence.observed_units > max_units)
2181 });
2182 let exceeds_quoted_cost = evidence.is_some_and(|record| {
2183 record.billed_cost.currency != metered.quote.quoted_cost.currency
2184 || record.billed_cost.units > metered.quote.quoted_cost.units
2185 });
2186 let financial_mismatch = evidence.is_some_and(|record| {
2187 financial.is_some_and(|financial| {
2188 record.billed_cost.currency != financial.currency
2189 || record.billed_cost.units != financial.cost_charged
2190 })
2191 });
2192 let action_required = (evidence_missing
2193 || exceeds_quoted_units
2194 || exceeds_max_billed_units
2195 || exceeds_quoted_cost
2196 || financial_mismatch)
2197 && !matches!(
2198 reconciliation_state,
2199 MeteredBillingReconciliationState::Reconciled
2200 | MeteredBillingReconciliationState::Ignored
2201 );
2202
2203 MeteredBillingReconciliationAnalysis {
2204 evidence_missing,
2205 exceeds_quoted_units,
2206 exceeds_max_billed_units,
2207 exceeds_quoted_cost,
2208 financial_mismatch,
2209 action_required,
2210 }
2211}
2212
2213#[derive(Default)]
2214pub(crate) struct RootAggregate {
2215 pub(crate) receipt_count: u64,
2216 pub(crate) total_cost_charged: u64,
2217 pub(crate) total_attempted_cost: u64,
2218 pub(crate) max_delegation_depth: u64,
2219 pub(crate) leaf_subjects: BTreeSet<String>,
2220}
2221
2222#[derive(Default)]
2223pub(crate) struct LeafAggregate {
2224 pub(crate) receipt_count: u64,
2225 pub(crate) total_cost_charged: u64,
2226 pub(crate) total_attempted_cost: u64,
2227 pub(crate) max_delegation_depth: u64,
2228}
2229
2230#[derive(Default)]
2231pub(crate) struct ReceiptAttributionColumns {
2232 pub(crate) subject_key: Option<String>,
2233 pub(crate) issuer_key: Option<String>,
2234 pub(crate) grant_index: Option<u32>,
2235}
2236
2237pub(crate) fn extract_receipt_attribution(receipt: &ChioReceipt) -> ReceiptAttributionColumns {
2238 let Some(metadata) = receipt.metadata.as_ref() else {
2239 return ReceiptAttributionColumns::default();
2240 };
2241
2242 let attribution = metadata
2243 .get("attribution")
2244 .cloned()
2245 .and_then(|value| serde_json::from_value::<ReceiptAttributionMetadata>(value).ok());
2246 let grant_index = attribution
2247 .as_ref()
2248 .and_then(|value| value.grant_index)
2249 .or_else(|| {
2250 metadata
2251 .get("financial")
2252 .and_then(|value| value.get("grant_index"))
2253 .and_then(serde_json::Value::as_u64)
2254 .map(|value| value as u32)
2255 });
2256
2257 ReceiptAttributionColumns {
2258 subject_key: attribution.as_ref().map(|value| value.subject_key.clone()),
2259 issuer_key: attribution.as_ref().map(|value| value.issuer_key.clone()),
2260 grant_index,
2261 }
2262}
2263
2264pub(crate) fn extract_financial_metadata(
2265 receipt: &ChioReceipt,
2266) -> Option<FinancialReceiptMetadata> {
2267 receipt
2268 .metadata
2269 .as_ref()
2270 .and_then(|metadata| metadata.get("financial"))
2271 .cloned()
2272 .and_then(|value| serde_json::from_value::<FinancialReceiptMetadata>(value).ok())
2273}
2274
2275pub(crate) fn extract_governed_transaction_metadata(
2276 receipt: &ChioReceipt,
2277) -> Option<GovernedTransactionReceiptMetadata> {
2278 receipt
2279 .metadata
2280 .as_ref()
2281 .and_then(|metadata| metadata.get("governed_transaction"))
2282 .cloned()
2283 .and_then(|value| serde_json::from_value::<GovernedTransactionReceiptMetadata>(value).ok())
2284}
2285
2286pub(crate) fn extract_economic_authorization_metadata(
2287 receipt: &ChioReceipt,
2288) -> Option<chio_core::receipt::EconomicAuthorizationReceiptMetadata> {
2289 extract_governed_transaction_metadata(receipt)
2290 .and_then(|governed| governed.economic_authorization)
2291}
2292
2293pub(crate) fn authorization_details_from_governed_metadata(
2294 governed: &GovernedTransactionReceiptMetadata,
2295) -> Vec<GovernedAuthorizationDetail> {
2296 let mut details = vec![GovernedAuthorizationDetail {
2297 detail_type: CHIO_OAUTH_AUTHORIZATION_TOOL_DETAIL_TYPE.to_string(),
2298 locations: vec![governed.server_id.clone()],
2299 actions: vec![governed.tool_name.clone()],
2300 purpose: Some(governed.purpose.clone()),
2301 max_amount: governed.max_amount.clone(),
2302 commerce: None,
2303 metered_billing: None,
2304 }];
2305
2306 if let Some(commerce) = governed.commerce.as_ref() {
2307 details.push(GovernedAuthorizationDetail {
2308 detail_type: CHIO_OAUTH_AUTHORIZATION_COMMERCE_DETAIL_TYPE.to_string(),
2309 locations: Vec::new(),
2310 actions: Vec::new(),
2311 purpose: None,
2312 max_amount: governed.max_amount.clone(),
2313 commerce: Some(GovernedAuthorizationCommerceDetail {
2314 seller: commerce.seller.clone(),
2315 shared_payment_token_id: commerce.shared_payment_token_id.clone(),
2316 }),
2317 metered_billing: None,
2318 });
2319 }
2320
2321 if let Some(metered) = governed.metered_billing.as_ref() {
2322 details.push(GovernedAuthorizationDetail {
2323 detail_type: CHIO_OAUTH_AUTHORIZATION_METERED_BILLING_DETAIL_TYPE.to_string(),
2324 locations: Vec::new(),
2325 actions: Vec::new(),
2326 purpose: None,
2327 max_amount: None,
2328 commerce: None,
2329 metered_billing: Some(GovernedAuthorizationMeteredBillingDetail {
2330 settlement_mode: metered.settlement_mode,
2331 provider: metered.quote.provider.clone(),
2332 quote_id: metered.quote.quote_id.clone(),
2333 billing_unit: metered.quote.billing_unit.clone(),
2334 quoted_units: metered.quote.quoted_units,
2335 quoted_cost: metered.quote.quoted_cost.clone(),
2336 max_billed_units: metered.max_billed_units,
2337 }),
2338 });
2339 }
2340
2341 details
2342}
2343
2344pub(crate) fn authorization_transaction_context_from_governed_metadata(
2345 governed: &GovernedTransactionReceiptMetadata,
2346) -> GovernedAuthorizationTransactionContext {
2347 GovernedAuthorizationTransactionContext {
2348 intent_id: governed.intent_id.clone(),
2349 intent_hash: governed.intent_hash.clone(),
2350 approval_token_id: governed
2351 .approval
2352 .as_ref()
2353 .map(|value| value.token_id.clone()),
2354 approval_approved: governed.approval.as_ref().map(|value| value.approved),
2355 approver_key: governed
2356 .approval
2357 .as_ref()
2358 .map(|value| value.approver_key.clone()),
2359 runtime_assurance_tier: governed.runtime_assurance.as_ref().map(|value| value.tier),
2360 runtime_assurance_schema: governed
2361 .runtime_assurance
2362 .as_ref()
2363 .map(|value| value.schema.clone()),
2364 runtime_assurance_verifier_family: governed
2365 .runtime_assurance
2366 .as_ref()
2367 .and_then(|value| value.verifier_family),
2368 runtime_assurance_verifier: governed
2369 .runtime_assurance
2370 .as_ref()
2371 .map(|value| value.verifier.clone()),
2372 runtime_assurance_evidence_sha256: governed
2373 .runtime_assurance
2374 .as_ref()
2375 .map(|value| value.evidence_sha256.clone()),
2376 call_chain: governed.call_chain.clone(),
2377 identity_assertion: None,
2378 }
2379}
2380
2381fn delegated_call_chain_is_sender_bound(
2382 call_chain: Option<&chio_core::capability::GovernedCallChainProvenance>,
2383) -> bool {
2384 let Some(call_chain) = call_chain else {
2385 return false;
2386 };
2387 if call_chain.evidence_class == chio_core::capability::GovernedProvenanceEvidenceClass::Asserted
2388 {
2389 return false;
2390 }
2391
2392 let has_local_lineage_link = call_chain.evidence_sources.iter().any(|source| {
2393 matches!(
2394 source,
2395 chio_core::capability::GovernedCallChainEvidenceSource::SessionParentRequestLineage
2396 | chio_core::capability::GovernedCallChainEvidenceSource::LocalParentReceiptLinkage
2397 | chio_core::capability::GovernedCallChainEvidenceSource::UpstreamDelegatorProof
2398 )
2399 });
2400 let has_capability_subject_binding = call_chain.evidence_sources.iter().any(|source| {
2401 matches!(
2402 source,
2403 chio_core::capability::GovernedCallChainEvidenceSource::CapabilityDelegatorSubject
2404 | chio_core::capability::GovernedCallChainEvidenceSource::CapabilityOriginSubject
2405 )
2406 });
2407
2408 has_local_lineage_link
2409 || (call_chain.evidence_class
2410 == chio_core::capability::GovernedProvenanceEvidenceClass::Verified
2411 && has_capability_subject_binding)
2412}
2413
2414pub(crate) fn resolve_sender_constraint_subject_key(
2415 receipt_id: &str,
2416 receipt_subject_key: Option<&str>,
2417 lineage_subject_key: Option<&str>,
2418) -> Result<(String, String), ReceiptStoreError> {
2419 match (receipt_subject_key, lineage_subject_key) {
2420 (Some(receipt_key), Some(lineage_key)) => {
2421 ensure_non_empty_profile_value(receipt_id, "senderConstraint.subjectKey", receipt_key)?;
2422 ensure_non_empty_profile_value(receipt_id, "capabilitySnapshot.subjectKey", lineage_key)?;
2423 if receipt_key != lineage_key {
2424 return Err(invalid_chio_oauth_authorization_profile(
2425 receipt_id,
2426 format!(
2427 "senderConstraint.subjectKey `{receipt_key}` does not match capability snapshot subject `{lineage_key}`"
2428 ),
2429 ));
2430 }
2431 Ok((receipt_key.to_string(), "receipt_attribution".to_string()))
2432 }
2433 (Some(receipt_key), None) => {
2434 ensure_non_empty_profile_value(receipt_id, "senderConstraint.subjectKey", receipt_key)?;
2435 Ok((receipt_key.to_string(), "receipt_attribution".to_string()))
2436 }
2437 (None, Some(lineage_key)) => {
2438 ensure_non_empty_profile_value(receipt_id, "senderConstraint.subjectKey", lineage_key)?;
2439 Ok((lineage_key.to_string(), "capability_snapshot".to_string()))
2440 }
2441 (None, None) => Err(invalid_chio_oauth_authorization_profile(
2442 receipt_id,
2443 "sender-constrained profile requires a bound subjectKey from receipt attribution or capability snapshot",
2444 )),
2445 }
2446}
2447
2448pub(crate) fn resolve_sender_constraint_issuer_key(
2449 receipt_id: &str,
2450 receipt_issuer_key: Option<&str>,
2451 lineage_issuer_key: Option<&str>,
2452) -> Result<(String, String), ReceiptStoreError> {
2453 match (receipt_issuer_key, lineage_issuer_key) {
2454 (Some(receipt_key), Some(lineage_key)) => {
2455 ensure_non_empty_profile_value(receipt_id, "senderConstraint.issuerKey", receipt_key)?;
2456 ensure_non_empty_profile_value(receipt_id, "capabilitySnapshot.issuerKey", lineage_key)?;
2457 if receipt_key != lineage_key {
2458 return Err(invalid_chio_oauth_authorization_profile(
2459 receipt_id,
2460 format!(
2461 "senderConstraint.issuerKey `{receipt_key}` does not match capability snapshot issuer `{lineage_key}`"
2462 ),
2463 ));
2464 }
2465 Ok((receipt_key.to_string(), "receipt_attribution".to_string()))
2466 }
2467 (Some(receipt_key), None) => {
2468 ensure_non_empty_profile_value(receipt_id, "senderConstraint.issuerKey", receipt_key)?;
2469 Ok((receipt_key.to_string(), "receipt_attribution".to_string()))
2470 }
2471 (None, Some(lineage_key)) => {
2472 ensure_non_empty_profile_value(receipt_id, "senderConstraint.issuerKey", lineage_key)?;
2473 Ok((lineage_key.to_string(), "capability_snapshot".to_string()))
2474 }
2475 (None, None) => Err(invalid_chio_oauth_authorization_profile(
2476 receipt_id,
2477 "sender-constrained profile requires a bound issuerKey from receipt attribution or capability snapshot",
2478 )),
2479 }
2480}
2481
2482pub(crate) fn resolve_sender_constraint_grant(
2483 receipt_id: &str,
2484 tool_server: &str,
2485 tool_name: &str,
2486 grant_index: Option<u32>,
2487 grants_json: Option<&str>,
2488) -> Result<(u32, bool), ReceiptStoreError> {
2489 let grants_json = grants_json.ok_or_else(|| {
2490 invalid_chio_oauth_authorization_profile(
2491 receipt_id,
2492 "sender-constrained profile requires capability snapshot grants_json",
2493 )
2494 })?;
2495 let scope: ChioScope = serde_json::from_str(grants_json).map_err(|error| {
2496 invalid_chio_oauth_authorization_profile(
2497 receipt_id,
2498 format!("invalid capability snapshot grants_json: {error}"),
2499 )
2500 })?;
2501
2502 if let Some(index) = grant_index {
2503 let grant = scope.grants.get(index as usize).ok_or_else(|| {
2504 invalid_chio_oauth_authorization_profile(
2505 receipt_id,
2506 format!("matched grant_index `{index}` is outside the capability scope"),
2507 )
2508 })?;
2509 if grant.server_id != tool_server || grant.tool_name != tool_name {
2510 return Err(invalid_chio_oauth_authorization_profile(
2511 receipt_id,
2512 format!(
2513 "grant_index `{index}` resolves to {}/{} instead of {tool_server}/{tool_name}",
2514 grant.server_id, grant.tool_name
2515 ),
2516 ));
2517 }
2518 return Ok((index, grant.dpop_required == Some(true)));
2519 }
2520
2521 let mut matches = scope
2522 .grants
2523 .iter()
2524 .enumerate()
2525 .filter(|(_, grant)| grant.server_id == tool_server && grant.tool_name == tool_name);
2526 let Some((index, grant)) = matches.next() else {
2527 return Err(invalid_chio_oauth_authorization_profile(
2528 receipt_id,
2529 format!("capability snapshot does not contain a grant for {tool_server}/{tool_name}"),
2530 ));
2531 };
2532 if matches.next().is_some() {
2533 return Err(invalid_chio_oauth_authorization_profile(
2534 receipt_id,
2535 format!(
2536 "capability snapshot contains multiple grants for {tool_server}/{tool_name}; grant_index is required"
2537 ),
2538 ));
2539 }
2540 Ok((index as u32, grant.dpop_required == Some(true)))
2541}
2542
2543pub(crate) struct AuthorizationSenderConstraintArgs<'a> {
2544 pub(crate) tool_server: &'a str,
2545 pub(crate) tool_name: &'a str,
2546 pub(crate) receipt_subject_key: Option<&'a str>,
2547 pub(crate) receipt_issuer_key: Option<&'a str>,
2548 pub(crate) lineage_subject_key: Option<&'a str>,
2549 pub(crate) lineage_issuer_key: Option<&'a str>,
2550 pub(crate) grant_index: Option<u32>,
2551 pub(crate) grants_json: Option<&'a str>,
2552}
2553
2554pub(crate) fn derive_authorization_sender_constraint(
2555 receipt_id: &str,
2556 args: AuthorizationSenderConstraintArgs<'_>,
2557 transaction_context: &GovernedAuthorizationTransactionContext,
2558) -> Result<AuthorizationContextSenderConstraint, ReceiptStoreError> {
2559 let AuthorizationSenderConstraintArgs {
2560 tool_server,
2561 tool_name,
2562 receipt_subject_key,
2563 receipt_issuer_key,
2564 lineage_subject_key,
2565 lineage_issuer_key,
2566 grant_index,
2567 grants_json,
2568 } = args;
2569 let (subject_key, subject_key_source) = resolve_sender_constraint_subject_key(
2570 receipt_id,
2571 receipt_subject_key,
2572 lineage_subject_key,
2573 )?;
2574 let (issuer_key, issuer_key_source) =
2575 resolve_sender_constraint_issuer_key(receipt_id, receipt_issuer_key, lineage_issuer_key)?;
2576 let (matched_grant_index, proof_required) = resolve_sender_constraint_grant(
2577 receipt_id,
2578 tool_server,
2579 tool_name,
2580 grant_index,
2581 grants_json,
2582 )?;
2583
2584 Ok(AuthorizationContextSenderConstraint {
2585 subject_key,
2586 subject_key_source,
2587 issuer_key,
2588 issuer_key_source,
2589 matched_grant_index,
2590 proof_required,
2591 proof_type: proof_required.then(|| CHIO_OAUTH_SENDER_PROOF_CHIO_DPOP.to_string()),
2592 proof_schema: proof_required.then(|| DPOP_SCHEMA.to_string()),
2593 runtime_assurance_bound: transaction_context.runtime_assurance_tier.is_some(),
2594 delegated_call_chain_bound: delegated_call_chain_is_sender_bound(
2595 transaction_context.call_chain.as_ref(),
2596 ),
2597 })
2598}
2599
2600pub(crate) fn invalid_chio_oauth_authorization_profile(
2601 receipt_id: &str,
2602 detail: impl AsRef<str>,
2603) -> ReceiptStoreError {
2604 ReceiptStoreError::Canonical(format!(
2605 "receipt {receipt_id} violates Chio OAuth authorization profile: {}",
2606 detail.as_ref()
2607 ))
2608}
2609
2610pub(crate) fn ensure_non_empty_profile_value(
2611 receipt_id: &str,
2612 field: &str,
2613 value: &str,
2614) -> Result<(), ReceiptStoreError> {
2615 if value.trim().is_empty() {
2616 return Err(invalid_chio_oauth_authorization_profile(
2617 receipt_id,
2618 format!("{field} must not be empty"),
2619 ));
2620 }
2621 Ok(())
2622}
2623
2624pub(crate) fn validate_chio_oauth_authorization_detail(
2625 receipt_id: &str,
2626 detail: &GovernedAuthorizationDetail,
2627) -> Result<bool, ReceiptStoreError> {
2628 match detail.detail_type.as_str() {
2629 CHIO_OAUTH_AUTHORIZATION_TOOL_DETAIL_TYPE => {
2630 if detail.locations.is_empty() {
2631 return Err(invalid_chio_oauth_authorization_profile(
2632 receipt_id,
2633 "chio_governed_tool must include at least one location",
2634 ));
2635 }
2636 if detail.actions.is_empty() {
2637 return Err(invalid_chio_oauth_authorization_profile(
2638 receipt_id,
2639 "chio_governed_tool must include at least one action",
2640 ));
2641 }
2642 for location in &detail.locations {
2643 ensure_non_empty_profile_value(
2644 receipt_id,
2645 "authorizationDetails.locations[]",
2646 location,
2647 )?;
2648 }
2649 for action in &detail.actions {
2650 ensure_non_empty_profile_value(
2651 receipt_id,
2652 "authorizationDetails.actions[]",
2653 action,
2654 )?;
2655 }
2656 if detail.commerce.is_some() || detail.metered_billing.is_some() {
2657 return Err(invalid_chio_oauth_authorization_profile(
2658 receipt_id,
2659 "chio_governed_tool must not carry commerce or meteredBilling sidecars",
2660 ));
2661 }
2662 Ok(true)
2663 }
2664 CHIO_OAUTH_AUTHORIZATION_COMMERCE_DETAIL_TYPE => {
2665 let Some(commerce) = detail.commerce.as_ref() else {
2666 return Err(invalid_chio_oauth_authorization_profile(
2667 receipt_id,
2668 "chio_governed_commerce must include commerce detail",
2669 ));
2670 };
2671 ensure_non_empty_profile_value(
2672 receipt_id,
2673 "authorizationDetails.commerce.seller",
2674 &commerce.seller,
2675 )?;
2676 ensure_non_empty_profile_value(
2677 receipt_id,
2678 "authorizationDetails.commerce.sharedPaymentTokenId",
2679 &commerce.shared_payment_token_id,
2680 )?;
2681 if detail.metered_billing.is_some() {
2682 return Err(invalid_chio_oauth_authorization_profile(
2683 receipt_id,
2684 "chio_governed_commerce must not carry meteredBilling detail",
2685 ));
2686 }
2687 Ok(false)
2688 }
2689 CHIO_OAUTH_AUTHORIZATION_METERED_BILLING_DETAIL_TYPE => {
2690 let Some(metered) = detail.metered_billing.as_ref() else {
2691 return Err(invalid_chio_oauth_authorization_profile(
2692 receipt_id,
2693 "chio_governed_metered_billing must include meteredBilling detail",
2694 ));
2695 };
2696 ensure_non_empty_profile_value(
2697 receipt_id,
2698 "authorizationDetails.meteredBilling.provider",
2699 &metered.provider,
2700 )?;
2701 ensure_non_empty_profile_value(
2702 receipt_id,
2703 "authorizationDetails.meteredBilling.quoteId",
2704 &metered.quote_id,
2705 )?;
2706 ensure_non_empty_profile_value(
2707 receipt_id,
2708 "authorizationDetails.meteredBilling.billingUnit",
2709 &metered.billing_unit,
2710 )?;
2711 if detail.commerce.is_some() {
2712 return Err(invalid_chio_oauth_authorization_profile(
2713 receipt_id,
2714 "chio_governed_metered_billing must not carry commerce detail",
2715 ));
2716 }
2717 Ok(false)
2718 }
2719 unsupported => Err(invalid_chio_oauth_authorization_profile(
2720 receipt_id,
2721 format!("unsupported authorizationDetails.type `{unsupported}`"),
2722 )),
2723 }
2724}
2725
2726pub(crate) fn validate_chio_oauth_authorization_row(
2727 row: &AuthorizationContextRow,
2728) -> Result<(), ReceiptStoreError> {
2729 ensure_non_empty_profile_value(
2730 &row.receipt_id,
2731 "transactionContext.intentId",
2732 &row.transaction_context.intent_id,
2733 )?;
2734 ensure_non_empty_profile_value(
2735 &row.receipt_id,
2736 "transactionContext.intentHash",
2737 &row.transaction_context.intent_hash,
2738 )?;
2739
2740 let mut saw_tool_detail = false;
2741 for detail in &row.authorization_details {
2742 if validate_chio_oauth_authorization_detail(&row.receipt_id, detail)? {
2743 saw_tool_detail = true;
2744 }
2745 }
2746 if !saw_tool_detail {
2747 return Err(invalid_chio_oauth_authorization_profile(
2748 &row.receipt_id,
2749 "report must include one chio_governed_tool authorization detail",
2750 ));
2751 }
2752
2753 if let Some(token_id) = row.transaction_context.approval_token_id.as_deref() {
2754 ensure_non_empty_profile_value(
2755 &row.receipt_id,
2756 "transactionContext.approvalTokenId",
2757 token_id,
2758 )?;
2759 let approver_key = row
2760 .transaction_context
2761 .approver_key
2762 .as_deref()
2763 .ok_or_else(|| {
2764 invalid_chio_oauth_authorization_profile(
2765 &row.receipt_id,
2766 "approvalTokenId requires approverKey",
2767 )
2768 })?;
2769 ensure_non_empty_profile_value(
2770 &row.receipt_id,
2771 "transactionContext.approverKey",
2772 approver_key,
2773 )?;
2774 if row.transaction_context.approval_approved.is_none() {
2775 return Err(invalid_chio_oauth_authorization_profile(
2776 &row.receipt_id,
2777 "approvalTokenId requires approvalApproved",
2778 ));
2779 }
2780 }
2781
2782 if let Some(call_chain) = row.transaction_context.call_chain.as_ref() {
2783 ensure_non_empty_profile_value(
2784 &row.receipt_id,
2785 "transactionContext.callChain.chainId",
2786 &call_chain.chain_id,
2787 )?;
2788 ensure_non_empty_profile_value(
2789 &row.receipt_id,
2790 "transactionContext.callChain.parentRequestId",
2791 &call_chain.parent_request_id,
2792 )?;
2793 ensure_non_empty_profile_value(
2794 &row.receipt_id,
2795 "transactionContext.callChain.originSubject",
2796 &call_chain.origin_subject,
2797 )?;
2798 ensure_non_empty_profile_value(
2799 &row.receipt_id,
2800 "transactionContext.callChain.delegatorSubject",
2801 &call_chain.delegator_subject,
2802 )?;
2803 if let Some(parent_receipt_id) = call_chain.parent_receipt_id.as_deref() {
2804 ensure_non_empty_profile_value(
2805 &row.receipt_id,
2806 "transactionContext.callChain.parentReceiptId",
2807 parent_receipt_id,
2808 )?;
2809 }
2810 if row.sender_constraint.delegated_call_chain_bound
2811 && !delegated_call_chain_is_sender_bound(Some(call_chain))
2812 {
2813 return Err(invalid_chio_oauth_authorization_profile(
2814 &row.receipt_id,
2815 "senderConstraint.delegatedCallChainBound requires corroborated call-chain provenance",
2816 ));
2817 }
2818 }
2819
2820 if row.transaction_context.runtime_assurance_tier.is_some() {
2821 let runtime_assurance_schema = row
2822 .transaction_context
2823 .runtime_assurance_schema
2824 .as_deref()
2825 .ok_or_else(|| {
2826 invalid_chio_oauth_authorization_profile(
2827 &row.receipt_id,
2828 "runtimeAssuranceTier requires runtimeAssuranceSchema",
2829 )
2830 })?;
2831 ensure_non_empty_profile_value(
2832 &row.receipt_id,
2833 "transactionContext.runtimeAssuranceSchema",
2834 runtime_assurance_schema,
2835 )?;
2836 row.transaction_context
2837 .runtime_assurance_verifier_family
2838 .ok_or_else(|| {
2839 invalid_chio_oauth_authorization_profile(
2840 &row.receipt_id,
2841 "runtimeAssuranceTier requires runtimeAssuranceVerifierFamily",
2842 )
2843 })?;
2844 let runtime_assurance_verifier = row
2845 .transaction_context
2846 .runtime_assurance_verifier
2847 .as_deref()
2848 .ok_or_else(|| {
2849 invalid_chio_oauth_authorization_profile(
2850 &row.receipt_id,
2851 "runtimeAssuranceTier requires runtimeAssuranceVerifier",
2852 )
2853 })?;
2854 ensure_non_empty_profile_value(
2855 &row.receipt_id,
2856 "transactionContext.runtimeAssuranceVerifier",
2857 runtime_assurance_verifier,
2858 )?;
2859 let runtime_assurance_evidence_sha256 = row
2860 .transaction_context
2861 .runtime_assurance_evidence_sha256
2862 .as_deref()
2863 .ok_or_else(|| {
2864 invalid_chio_oauth_authorization_profile(
2865 &row.receipt_id,
2866 "runtimeAssuranceTier requires runtimeAssuranceEvidenceSha256",
2867 )
2868 })?;
2869 ensure_non_empty_profile_value(
2870 &row.receipt_id,
2871 "transactionContext.runtimeAssuranceEvidenceSha256",
2872 runtime_assurance_evidence_sha256,
2873 )?;
2874 }
2875
2876 ensure_non_empty_profile_value(
2877 &row.receipt_id,
2878 "senderConstraint.subjectKey",
2879 &row.sender_constraint.subject_key,
2880 )?;
2881 if row.subject_key.as_deref() != Some(row.sender_constraint.subject_key.as_str()) {
2882 return Err(invalid_chio_oauth_authorization_profile(
2883 &row.receipt_id,
2884 "row subjectKey must match senderConstraint.subjectKey",
2885 ));
2886 }
2887 ensure_non_empty_profile_value(
2888 &row.receipt_id,
2889 "senderConstraint.subjectKeySource",
2890 &row.sender_constraint.subject_key_source,
2891 )?;
2892 ensure_non_empty_profile_value(
2893 &row.receipt_id,
2894 "senderConstraint.issuerKey",
2895 &row.sender_constraint.issuer_key,
2896 )?;
2897 ensure_non_empty_profile_value(
2898 &row.receipt_id,
2899 "senderConstraint.issuerKeySource",
2900 &row.sender_constraint.issuer_key_source,
2901 )?;
2902 if row.sender_constraint.proof_required {
2903 let proof_type = row.sender_constraint.proof_type.as_deref().ok_or_else(|| {
2904 invalid_chio_oauth_authorization_profile(
2905 &row.receipt_id,
2906 "proofRequired requires senderConstraint.proofType",
2907 )
2908 })?;
2909 ensure_non_empty_profile_value(&row.receipt_id, "senderConstraint.proofType", proof_type)?;
2910 let proof_schema = row
2911 .sender_constraint
2912 .proof_schema
2913 .as_deref()
2914 .ok_or_else(|| {
2915 invalid_chio_oauth_authorization_profile(
2916 &row.receipt_id,
2917 "proofRequired requires senderConstraint.proofSchema",
2918 )
2919 })?;
2920 ensure_non_empty_profile_value(
2921 &row.receipt_id,
2922 "senderConstraint.proofSchema",
2923 proof_schema,
2924 )?;
2925 }
2926
2927 Ok(())
2928}
2929
2930pub(crate) fn chain_is_complete(
2931 capability_id: &str,
2932 chain: &[chio_kernel::CapabilitySnapshot],
2933) -> bool {
2934 if chain.is_empty() {
2935 return false;
2936 }
2937 let Some(leaf) = chain.last() else {
2938 return false;
2939 };
2940 if leaf.capability_id != capability_id {
2941 return false;
2942 }
2943 if chain
2944 .first()
2945 .and_then(|snapshot| snapshot.parent_capability_id.as_ref())
2946 .is_some()
2947 {
2948 return false;
2949 }
2950 if chain.windows(2).any(|window| {
2951 window[1].parent_capability_id.as_deref() != Some(window[0].capability_id.as_str())
2952 }) {
2953 return false;
2954 }
2955 if leaf.parent_capability_id.is_some() && chain.len() == 1 {
2956 return false;
2957 }
2958 if leaf.delegation_depth as usize != chain.len().saturating_sub(1) {
2959 return false;
2960 }
2961 true
2962}
2963
2964pub(crate) fn ratio_option(numerator: u64, denominator: u64) -> Option<f64> {
2965 if denominator == 0 {
2966 None
2967 } else {
2968 Some(numerator as f64 / denominator as f64)
2969 }
2970}
2971
2972pub(crate) fn compliance_export_scope_note(
2973 query: &OperatorReportQuery,
2974 export_query: &EvidenceExportQuery,
2975) -> Option<String> {
2976 let mut notes = Vec::new();
2977
2978 if !query.direct_evidence_export_supported() {
2979 notes.push(
2980 "tool filters narrow the operator report only; direct evidence export can scope by capability, agent, and time window".to_string(),
2981 );
2982 }
2983
2984 match export_query.child_receipt_scope() {
2985 EvidenceChildReceiptScope::TimeWindowContextOnly => notes.push(
2986 "child receipts are included only as time-window context for this export scope".to_string(),
2987 ),
2988 EvidenceChildReceiptScope::OmittedNoJoinPath => notes.push(
2989 "child receipts are omitted for this export scope because no capability/agent join exists yet".to_string(),
2990 ),
2991 EvidenceChildReceiptScope::FullQueryWindow => {}
2992 }
2993
2994 if notes.is_empty() {
2995 None
2996 } else {
2997 Some(notes.join(" "))
2998 }
2999}
3000
3001pub(crate) fn ensure_tool_receipt_attribution_columns(
3002 connection: &Connection,
3003) -> Result<(), ReceiptStoreError> {
3004 let mut statement = connection.prepare("PRAGMA table_info(chio_tool_receipts)")?;
3005 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
3006 let columns = columns.collect::<Result<Vec<_>, _>>()?;
3007
3008 if !columns.iter().any(|column| column == "subject_key") {
3009 connection.execute(
3010 "ALTER TABLE chio_tool_receipts ADD COLUMN subject_key TEXT",
3011 [],
3012 )?;
3013 }
3014 if !columns.iter().any(|column| column == "issuer_key") {
3015 connection.execute(
3016 "ALTER TABLE chio_tool_receipts ADD COLUMN issuer_key TEXT",
3017 [],
3018 )?;
3019 }
3020 if !columns.iter().any(|column| column == "grant_index") {
3021 connection.execute(
3022 "ALTER TABLE chio_tool_receipts ADD COLUMN grant_index INTEGER",
3023 [],
3024 )?;
3025 }
3026
3027 if !columns.iter().any(|column| column == "tenant_id") {
3041 connection.execute(
3042 "ALTER TABLE chio_tool_receipts ADD COLUMN tenant_id TEXT",
3043 [],
3044 )?;
3045 }
3046
3047 connection.execute(
3048 "CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_subject ON chio_tool_receipts(subject_key)",
3049 [],
3050 )?;
3051 connection.execute(
3052 "CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_grant ON chio_tool_receipts(capability_id, grant_index)",
3053 [],
3054 )?;
3055 connection.execute(
3056 "CREATE INDEX IF NOT EXISTS idx_chio_tool_receipts_tenant ON chio_tool_receipts(tenant_id)",
3057 [],
3058 )?;
3059 Ok(())
3060}
3061
3062pub(crate) fn ensure_receipt_lineage_statement_columns(
3063 connection: &Connection,
3064) -> Result<(), ReceiptStoreError> {
3065 let mut statement = connection.prepare("PRAGMA table_info(receipt_lineage_statements)")?;
3066 let columns = statement.query_map([], |row| row.get::<_, String>(1))?;
3067 let columns = columns.collect::<Result<Vec<_>, _>>()?;
3068
3069 if !columns.iter().any(|column| column == "statement_id") {
3070 connection.execute(
3071 "ALTER TABLE receipt_lineage_statements ADD COLUMN statement_id TEXT",
3072 [],
3073 )?;
3074 }
3075
3076 connection.execute(
3077 r#"
3078 CREATE UNIQUE INDEX IF NOT EXISTS idx_receipt_lineage_statement_id
3079 ON receipt_lineage_statements(statement_id)
3080 WHERE statement_id IS NOT NULL
3081 "#,
3082 [],
3083 )?;
3084 connection.execute(
3085 r#"
3086 UPDATE receipt_lineage_statements
3087 SET statement_id = json_extract(raw_json, '$.id')
3088 WHERE statement_id IS NULL
3089 AND json_extract(raw_json, '$.schema') = ?1
3090 "#,
3091 params![chio_core::receipt::CHIO_RECEIPT_LINEAGE_STATEMENT_SCHEMA],
3092 )?;
3093 Ok(())
3094}
3095
3096pub(crate) fn backfill_tool_receipt_attribution_columns(
3097 connection: &Connection,
3098) -> Result<(), ReceiptStoreError> {
3099 connection.execute_batch(
3100 r#"
3101 UPDATE chio_tool_receipts
3102 SET grant_index = CAST(COALESCE(
3103 json_extract(raw_json, '$.metadata.attribution.grant_index'),
3104 json_extract(raw_json, '$.metadata.financial.grant_index')
3105 ) AS INTEGER)
3106 WHERE grant_index IS NULL
3107 AND COALESCE(
3108 json_extract(raw_json, '$.metadata.attribution.grant_index'),
3109 json_extract(raw_json, '$.metadata.financial.grant_index')
3110 ) IS NOT NULL;
3111
3112 UPDATE chio_tool_receipts
3113 SET subject_key = COALESCE(
3114 subject_key,
3115 CAST(json_extract(raw_json, '$.metadata.attribution.subject_key') AS TEXT),
3116 (SELECT cl.subject_key FROM capability_lineage cl WHERE cl.capability_id = chio_tool_receipts.capability_id)
3117 )
3118 WHERE subject_key IS NULL;
3119
3120 UPDATE chio_tool_receipts
3121 SET issuer_key = COALESCE(
3122 issuer_key,
3123 CAST(json_extract(raw_json, '$.metadata.attribution.issuer_key') AS TEXT),
3124 (SELECT cl.issuer_key FROM capability_lineage cl WHERE cl.capability_id = chio_tool_receipts.capability_id)
3125 )
3126 WHERE issuer_key IS NULL;
3127
3128 -- Phase 1.5 multi-tenant receipt isolation: hydrate tenant_id
3129 -- from the canonical receipt body. Legacy receipts (pre-1.5)
3130 -- that were stored before the field existed stay NULL, which
3131 -- means "public / visible to any tenant under the default
3132 -- compat query mode". Operators who want to purge those
3133 -- legacy rows can enable strict tenant isolation on queries.
3134 --
3135 -- The receipt body uses snake_case field names (no rename_all),
3136 -- so the JSON key is `tenant_id`, not `tenantId`.
3137 UPDATE chio_tool_receipts
3138 SET tenant_id = CAST(json_extract(raw_json, '$.tenant_id') AS TEXT)
3139 WHERE tenant_id IS NULL
3140 AND json_extract(raw_json, '$.tenant_id') IS NOT NULL;
3141 "#,
3142 )?;
3143 Ok(())
3144}
3145
3146const SESSION_ANCHOR_SOURCE_KIND: &str = "session_anchor";
3147const REQUEST_LINEAGE_SOURCE_KIND: &str = "request_lineage_record";
3148const RECEIPT_LINEAGE_SOURCE_KIND: &str = "receipt_lineage_statement";
3149const CHILD_RECEIPT_BACKFILL_SOURCE_KIND: &str = "child_receipt_backfill";
3150const GOVERNED_RECEIPT_BACKFILL_SOURCE_KIND: &str = "governed_receipt_backfill";
3151
3152fn provenance_json_sha256(value: &serde_json::Value) -> Result<String, ReceiptStoreError> {
3153 let canonical = canonical_json_bytes(value)
3154 .map_err(|error| ReceiptStoreError::Canonical(error.to_string()))?;
3155 Ok(sha256_hex(&canonical))
3156}
3157
3158fn sanitize_required_identifier(
3159 record_kind: &str,
3160 record_id: &str,
3161 field: &str,
3162 value: &str,
3163) -> Result<String, ReceiptStoreError> {
3164 let trimmed = value.trim();
3165 if trimmed.is_empty() {
3166 return Err(ReceiptStoreError::Conflict(format!(
3167 "{record_kind} `{record_id}` requires non-empty {field}"
3168 )));
3169 }
3170 Ok(trimmed.to_string())
3171}
3172
3173fn sanitize_optional_identifier(
3174 record_kind: &str,
3175 record_id: &str,
3176 field: &str,
3177 value: Option<&str>,
3178) -> Result<Option<String>, ReceiptStoreError> {
3179 value
3180 .map(|value| sanitize_required_identifier(record_kind, record_id, field, value))
3181 .transpose()
3182}
3183
3184fn merge_optional_identifier(
3185 record_kind: &str,
3186 record_id: &str,
3187 field: &str,
3188 existing: Option<String>,
3189 incoming: Option<&str>,
3190) -> Result<Option<String>, ReceiptStoreError> {
3191 let incoming = sanitize_optional_identifier(record_kind, record_id, field, incoming)?;
3192 match (existing, incoming) {
3193 (Some(existing), Some(incoming)) if existing != incoming => {
3194 Err(ReceiptStoreError::Conflict(format!(
3195 "{record_kind} `{record_id}` reuses {field} with conflicting value `{incoming}` (existing `{existing}`)"
3196 )))
3197 }
3198 (Some(existing), _) => Ok(Some(existing)),
3199 (None, incoming) => Ok(incoming),
3200 }
3201}
3202
3203fn request_lineage_exists_tx(
3204 tx: &rusqlite::Transaction<'_>,
3205 session_id: &str,
3206 request_id: &str,
3207) -> Result<bool, ReceiptStoreError> {
3208 Ok(tx
3209 .query_row(
3210 r#"
3211 SELECT 1
3212 FROM request_lineage
3213 WHERE session_id = ?1 AND request_id = ?2
3214 LIMIT 1
3215 "#,
3216 params![session_id, request_id],
3217 |_| Ok(()),
3218 )
3219 .optional()?
3220 .is_some())
3221}
3222
3223fn anchored_request_lineage_exists_tx(
3224 tx: &rusqlite::Transaction<'_>,
3225 session_id: &str,
3226 request_id: &str,
3227 session_anchor_id: &str,
3228) -> Result<bool, ReceiptStoreError> {
3229 Ok(tx
3230 .query_row(
3231 r#"
3232 SELECT 1
3233 FROM request_lineage
3234 WHERE session_id = ?1
3235 AND request_id = ?2
3236 AND session_anchor_id = ?3
3237 LIMIT 1
3238 "#,
3239 params![session_id, request_id, session_anchor_id],
3240 |_| Ok(()),
3241 )
3242 .optional()?
3243 .is_some())
3244}
3245
3246fn session_anchor_exists_tx(
3247 tx: &rusqlite::Transaction<'_>,
3248 session_id: &str,
3249 session_anchor_id: &str,
3250) -> Result<bool, ReceiptStoreError> {
3251 Ok(tx
3252 .query_row(
3253 r#"
3254 SELECT 1
3255 FROM session_anchors
3256 WHERE anchor_id = ?1
3257 AND session_id = ?2
3258 LIMIT 1
3259 "#,
3260 params![session_anchor_id, session_id],
3261 |_| Ok(()),
3262 )
3263 .optional()?
3264 .is_some())
3265}
3266
3267fn receipt_id_exists_tx(
3268 tx: &rusqlite::Transaction<'_>,
3269 receipt_id: &str,
3270) -> Result<bool, ReceiptStoreError> {
3271 Ok(tx
3272 .query_row(
3273 r#"
3274 SELECT 1
3275 FROM (
3276 SELECT receipt_id FROM chio_tool_receipts
3277 UNION ALL
3278 SELECT receipt_id FROM chio_child_receipts
3279 )
3280 WHERE receipt_id = ?1
3281 LIMIT 1
3282 "#,
3283 params![receipt_id],
3284 |_| Ok(()),
3285 )
3286 .optional()?
3287 .is_some())
3288}
3289
3290fn extract_lineage_evidence_class(statement_json: &serde_json::Value) -> Option<String> {
3291 let nested = statement_json
3292 .get("callChain")
3293 .or_else(|| statement_json.get("call_chain"));
3294 [Some(statement_json), nested]
3295 .into_iter()
3296 .flatten()
3297 .find_map(|value| {
3298 value
3299 .get("evidenceClass")
3300 .or_else(|| value.get("evidence_class"))
3301 .and_then(serde_json::Value::as_str)
3302 .map(str::to_string)
3303 })
3304}
3305
3306fn extract_lineage_evidence_sources_json(
3307 statement_json: &serde_json::Value,
3308) -> Result<Option<String>, ReceiptStoreError> {
3309 let nested = statement_json
3310 .get("callChain")
3311 .or_else(|| statement_json.get("call_chain"));
3312 for value in [Some(statement_json), nested].into_iter().flatten() {
3313 if let Some(sources) = value
3314 .get("evidenceSources")
3315 .or_else(|| value.get("evidence_sources"))
3316 {
3317 return Ok(Some(serde_json::to_string(sources)?));
3318 }
3319 }
3320 Ok(None)
3321}
3322
3323#[derive(Debug, Clone, Default)]
3324struct ReceiptLineageStatementIdentifiers {
3325 statement_id: Option<String>,
3326 child_receipt_id: Option<String>,
3327 child_request_id: Option<String>,
3328 child_session_anchor_id: Option<String>,
3329 parent_request_id: Option<String>,
3330 parent_receipt_id: Option<String>,
3331}
3332
3333fn extract_receipt_lineage_statement_identifiers(
3334 statement_json: &serde_json::Value,
3335) -> ReceiptLineageStatementIdentifiers {
3336 let schema = statement_json
3337 .get("schema")
3338 .and_then(serde_json::Value::as_str);
3339 if schema != Some(chio_core::receipt::CHIO_RECEIPT_LINEAGE_STATEMENT_SCHEMA) {
3340 return ReceiptLineageStatementIdentifiers::default();
3341 }
3342
3343 if let Ok(statement) = serde_json::from_value::<chio_core::receipt::ReceiptLineageStatement>(
3344 statement_json.clone(),
3345 ) {
3346 return ReceiptLineageStatementIdentifiers {
3347 statement_id: Some(statement.id),
3348 child_receipt_id: Some(statement.child_receipt_id),
3349 child_request_id: Some(statement.child_request_id.to_string()),
3350 child_session_anchor_id: Some(statement.child_session_anchor.session_anchor_id),
3351 parent_request_id: Some(statement.parent_request_id.to_string()),
3352 parent_receipt_id: Some(statement.parent_receipt_id),
3353 };
3354 }
3355
3356 if let Ok(statement) = serde_json::from_value::<chio_core::receipt::ReceiptLineageStatementBody>(
3357 statement_json.clone(),
3358 ) {
3359 return ReceiptLineageStatementIdentifiers {
3360 statement_id: Some(statement.id),
3361 child_receipt_id: Some(statement.child_receipt_id),
3362 child_request_id: Some(statement.child_request_id.to_string()),
3363 child_session_anchor_id: Some(statement.child_session_anchor.session_anchor_id),
3364 parent_request_id: Some(statement.parent_request_id.to_string()),
3365 parent_receipt_id: Some(statement.parent_receipt_id),
3366 };
3367 }
3368
3369 ReceiptLineageStatementIdentifiers::default()
3370}
3371
3372fn build_receipt_lineage_verification_tx(
3373 tx: &rusqlite::Transaction<'_>,
3374 receipt_id: &str,
3375 request_id: Option<&str>,
3376 session_id: Option<&str>,
3377 session_anchor_id: Option<&str>,
3378 parent_request_id: Option<&str>,
3379 parent_receipt_id: Option<&str>,
3380) -> Result<ReceiptLineageVerification, ReceiptStoreError> {
3381 let session_anchor_verified = match (session_id, session_anchor_id) {
3382 (Some(session_id), Some(session_anchor_id)) => {
3383 session_anchor_exists_tx(tx, session_id, session_anchor_id)?
3384 }
3385 _ => false,
3386 };
3387 let parent_request_verified = match (session_id, parent_request_id) {
3388 (Some(session_id), Some(parent_request_id)) => {
3389 request_lineage_exists_tx(tx, session_id, parent_request_id)?
3390 }
3391 _ => false,
3392 };
3393 let parent_receipt_verified = match parent_receipt_id {
3394 Some(parent_receipt_id) => receipt_id_exists_tx(tx, parent_receipt_id)?,
3395 None => false,
3396 };
3397 let replay_protected = match (session_id, request_id, session_anchor_id) {
3398 (Some(session_id), Some(request_id), Some(session_anchor_id))
3399 if session_anchor_verified =>
3400 {
3401 anchored_request_lineage_exists_tx(tx, session_id, request_id, session_anchor_id)?
3402 }
3403 _ => false,
3404 };
3405
3406 Ok(ReceiptLineageVerification {
3407 receipt_id: receipt_id.to_string(),
3408 request_id: request_id.map(str::to_string),
3409 session_id: session_id.map(str::to_string),
3410 session_anchor_id: session_anchor_id.map(str::to_string),
3411 session_anchor_verified,
3412 parent_request_verified,
3413 parent_receipt_verified,
3414 replay_protected,
3415 })
3416}
3417
3418fn refresh_receipt_lineage_verification_state_tx(
3419 tx: &rusqlite::Transaction<'_>,
3420 receipt_id: &str,
3421) -> Result<(), ReceiptStoreError> {
3422 let row = tx
3423 .query_row(
3424 r#"
3425 SELECT request_id, session_id, session_anchor_id, parent_request_id, parent_receipt_id
3426 FROM receipt_lineage_statements
3427 WHERE receipt_id = ?1
3428 "#,
3429 params![receipt_id],
3430 |row| {
3431 Ok((
3432 row.get::<_, Option<String>>(0)?,
3433 row.get::<_, Option<String>>(1)?,
3434 row.get::<_, Option<String>>(2)?,
3435 row.get::<_, Option<String>>(3)?,
3436 row.get::<_, Option<String>>(4)?,
3437 ))
3438 },
3439 )
3440 .optional()?;
3441 let Some((request_id, session_id, session_anchor_id, parent_request_id, parent_receipt_id)) =
3442 row
3443 else {
3444 return Ok(());
3445 };
3446
3447 let verification = build_receipt_lineage_verification_tx(
3448 tx,
3449 receipt_id,
3450 request_id.as_deref(),
3451 session_id.as_deref(),
3452 session_anchor_id.as_deref(),
3453 parent_request_id.as_deref(),
3454 parent_receipt_id.as_deref(),
3455 )?;
3456 tx.execute(
3457 r#"
3458 UPDATE receipt_lineage_statements
3459 SET verified_session_anchor = ?2,
3460 verified_parent_request = ?3,
3461 verified_parent_receipt = ?4,
3462 replay_protected = ?5
3463 WHERE receipt_id = ?1
3464 "#,
3465 params![
3466 receipt_id,
3467 sqlite_bool(verification.session_anchor_verified),
3468 sqlite_bool(verification.parent_request_verified),
3469 sqlite_bool(verification.parent_receipt_verified),
3470 sqlite_bool(verification.replay_protected),
3471 ],
3472 )?;
3473 Ok(())
3474}
3475
3476fn refresh_receipt_lineage_rows_for_request_tx(
3477 tx: &rusqlite::Transaction<'_>,
3478 session_id: &str,
3479 request_id: &str,
3480) -> Result<(), ReceiptStoreError> {
3481 let mut statement = tx.prepare(
3482 r#"
3483 SELECT receipt_id
3484 FROM receipt_lineage_statements
3485 WHERE session_id = ?1
3486 AND (request_id = ?2 OR parent_request_id = ?2)
3487 "#,
3488 )?;
3489 let receipt_ids = statement
3490 .query_map(params![session_id, request_id], |row| {
3491 row.get::<_, String>(0)
3492 })?
3493 .collect::<Result<Vec<_>, _>>()?;
3494 drop(statement);
3495
3496 for receipt_id in receipt_ids {
3497 refresh_receipt_lineage_verification_state_tx(tx, &receipt_id)?;
3498 }
3499 Ok(())
3500}
3501
3502fn refresh_receipt_lineage_rows_for_anchor_tx(
3503 tx: &rusqlite::Transaction<'_>,
3504 session_id: &str,
3505 session_anchor_id: &str,
3506) -> Result<(), ReceiptStoreError> {
3507 let mut statement = tx.prepare(
3508 r#"
3509 SELECT receipt_id
3510 FROM receipt_lineage_statements
3511 WHERE session_id = ?1
3512 AND session_anchor_id = ?2
3513 "#,
3514 )?;
3515 let receipt_ids = statement
3516 .query_map(params![session_id, session_anchor_id], |row| {
3517 row.get::<_, String>(0)
3518 })?
3519 .collect::<Result<Vec<_>, _>>()?;
3520 drop(statement);
3521
3522 for receipt_id in receipt_ids {
3523 refresh_receipt_lineage_verification_state_tx(tx, &receipt_id)?;
3524 }
3525 Ok(())
3526}
3527
3528fn refresh_receipt_lineage_rows_for_parent_receipt_tx(
3529 tx: &rusqlite::Transaction<'_>,
3530 parent_receipt_id: &str,
3531) -> Result<(), ReceiptStoreError> {
3532 let mut statement = tx.prepare(
3533 r#"
3534 SELECT receipt_id
3535 FROM receipt_lineage_statements
3536 WHERE parent_receipt_id = ?1
3537 "#,
3538 )?;
3539 let receipt_ids = statement
3540 .query_map(params![parent_receipt_id], |row| row.get::<_, String>(0))?
3541 .collect::<Result<Vec<_>, _>>()?;
3542 drop(statement);
3543
3544 for receipt_id in receipt_ids {
3545 refresh_receipt_lineage_verification_state_tx(tx, &receipt_id)?;
3546 }
3547 Ok(())
3548}
3549
3550#[allow(clippy::too_many_arguments)]
3551fn persist_session_anchor_tx(
3552 tx: &rusqlite::Transaction<'_>,
3553 session_id: &str,
3554 anchor_id: &str,
3555 auth_context_fingerprint: &str,
3556 issued_at: u64,
3557 supersedes_anchor_id: Option<&str>,
3558 source_kind: &str,
3559 anchor_json: &serde_json::Value,
3560) -> Result<(), ReceiptStoreError> {
3561 let session_id =
3562 sanitize_required_identifier("session anchor", anchor_id, "session_id", session_id)?;
3563 let anchor_id =
3564 sanitize_required_identifier("session anchor", anchor_id, "anchor_id", anchor_id)?;
3565 let auth_context_fingerprint = sanitize_required_identifier(
3566 "session anchor",
3567 &anchor_id,
3568 "auth_context_fingerprint",
3569 auth_context_fingerprint,
3570 )?;
3571 let supersedes_anchor_id = sanitize_optional_identifier(
3572 "session anchor",
3573 &anchor_id,
3574 "supersedes_anchor_id",
3575 supersedes_anchor_id,
3576 )?;
3577 if supersedes_anchor_id.as_deref() == Some(anchor_id.as_str()) {
3578 return Err(ReceiptStoreError::Conflict(format!(
3579 "session anchor `{anchor_id}` cannot supersede itself"
3580 )));
3581 }
3582
3583 if tx
3584 .query_row(
3585 r#"
3586 SELECT anchor_id
3587 FROM session_anchors
3588 WHERE session_id = ?1
3589 AND auth_context_fingerprint = ?2
3590 AND anchor_id <> ?3
3591 LIMIT 1
3592 "#,
3593 params![&session_id, &auth_context_fingerprint, &anchor_id],
3594 |row| row.get::<_, String>(0),
3595 )
3596 .optional()?
3597 .is_some()
3598 {
3599 return Err(ReceiptStoreError::Conflict(format!(
3600 "session anchor replay detected for session `{session_id}` auth_context_fingerprint `{auth_context_fingerprint}`"
3601 )));
3602 }
3603
3604 if let Some(existing_session_id) = tx
3605 .query_row(
3606 "SELECT session_id FROM session_anchors WHERE anchor_id = ?1",
3607 params![&anchor_id],
3608 |row| row.get::<_, String>(0),
3609 )
3610 .optional()?
3611 {
3612 if existing_session_id != session_id {
3613 return Err(ReceiptStoreError::Conflict(format!(
3614 "session anchor `{anchor_id}` is already bound to session `{existing_session_id}`"
3615 )));
3616 }
3617 }
3618
3619 let raw_json = serde_json::to_string(anchor_json)?;
3620 let json_sha256 = provenance_json_sha256(anchor_json)?;
3621
3622 tx.execute(
3623 "UPDATE session_anchors SET is_current = 0 WHERE session_id = ?1 AND anchor_id <> ?2",
3624 params![&session_id, &anchor_id],
3625 )?;
3626 tx.execute(
3627 r#"
3628 INSERT INTO session_anchors (
3629 anchor_id,
3630 session_id,
3631 auth_context_fingerprint,
3632 issued_at,
3633 supersedes_anchor_id,
3634 is_current,
3635 source_kind,
3636 json_sha256,
3637 raw_json
3638 ) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?8)
3639 ON CONFLICT(anchor_id) DO UPDATE SET
3640 auth_context_fingerprint = excluded.auth_context_fingerprint,
3641 issued_at = excluded.issued_at,
3642 supersedes_anchor_id = COALESCE(excluded.supersedes_anchor_id, session_anchors.supersedes_anchor_id),
3643 is_current = 1,
3644 source_kind = excluded.source_kind,
3645 json_sha256 = excluded.json_sha256,
3646 raw_json = excluded.raw_json
3647 "#,
3648 params![
3649 &anchor_id,
3650 &session_id,
3651 &auth_context_fingerprint,
3652 sqlite_i64(issued_at, "session anchor issued_at")?,
3653 supersedes_anchor_id.as_deref(),
3654 source_kind,
3655 &json_sha256,
3656 &raw_json,
3657 ],
3658 )?;
3659 refresh_receipt_lineage_rows_for_anchor_tx(tx, &session_id, &anchor_id)?;
3660 Ok(())
3661}
3662
3663#[allow(clippy::too_many_arguments)]
3664fn persist_request_lineage_tx(
3665 tx: &rusqlite::Transaction<'_>,
3666 session_id: &str,
3667 request_id: &str,
3668 parent_request_id: Option<&str>,
3669 session_anchor_id: Option<&str>,
3670 recorded_at: u64,
3671 request_fingerprint: Option<&str>,
3672 source_kind: &str,
3673 lineage_json: &serde_json::Value,
3674) -> Result<(), ReceiptStoreError> {
3675 let session_id =
3676 sanitize_required_identifier("request lineage", request_id, "session_id", session_id)?;
3677 let request_id =
3678 sanitize_required_identifier("request lineage", request_id, "request_id", request_id)?;
3679 let parent_request_id = sanitize_optional_identifier(
3680 "request lineage",
3681 &request_id,
3682 "parent_request_id",
3683 parent_request_id,
3684 )?;
3685 if parent_request_id.as_deref() == Some(request_id.as_str()) {
3686 return Err(ReceiptStoreError::Conflict(format!(
3687 "request lineage `{request_id}` cannot point at itself as parent_request_id"
3688 )));
3689 }
3690
3691 let existing = tx
3692 .query_row(
3693 r#"
3694 SELECT parent_request_id, session_anchor_id, request_fingerprint
3695 FROM request_lineage
3696 WHERE session_id = ?1 AND request_id = ?2
3697 "#,
3698 params![&session_id, &request_id],
3699 |row| {
3700 Ok((
3701 row.get::<_, Option<String>>(0)?,
3702 row.get::<_, Option<String>>(1)?,
3703 row.get::<_, Option<String>>(2)?,
3704 ))
3705 },
3706 )
3707 .optional()?;
3708 let (existing_parent_request_id, existing_session_anchor_id, existing_request_fingerprint) =
3709 existing.unwrap_or((None, None, None));
3710
3711 let session_anchor_id = merge_optional_identifier(
3712 "request lineage",
3713 &request_id,
3714 "session_anchor_id",
3715 existing_session_anchor_id,
3716 session_anchor_id,
3717 )?;
3718 let parent_request_id = merge_optional_identifier(
3719 "request lineage",
3720 &request_id,
3721 "parent_request_id",
3722 existing_parent_request_id,
3723 parent_request_id.as_deref(),
3724 )?;
3725 let request_fingerprint = merge_optional_identifier(
3726 "request lineage",
3727 &request_id,
3728 "request_fingerprint",
3729 existing_request_fingerprint,
3730 request_fingerprint,
3731 )?;
3732
3733 let raw_json = serde_json::to_string(lineage_json)?;
3734 let json_sha256 = provenance_json_sha256(lineage_json)?;
3735 tx.execute(
3736 r#"
3737 INSERT INTO request_lineage (
3738 session_id,
3739 request_id,
3740 parent_request_id,
3741 session_anchor_id,
3742 recorded_at,
3743 request_fingerprint,
3744 source_kind,
3745 json_sha256,
3746 raw_json
3747 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
3748 ON CONFLICT(session_id, request_id) DO UPDATE SET
3749 parent_request_id = excluded.parent_request_id,
3750 session_anchor_id = excluded.session_anchor_id,
3751 recorded_at = excluded.recorded_at,
3752 request_fingerprint = excluded.request_fingerprint,
3753 source_kind = excluded.source_kind,
3754 json_sha256 = excluded.json_sha256,
3755 raw_json = excluded.raw_json
3756 "#,
3757 params![
3758 &session_id,
3759 &request_id,
3760 parent_request_id.as_deref(),
3761 session_anchor_id.as_deref(),
3762 sqlite_i64(recorded_at, "request lineage recorded_at")?,
3763 request_fingerprint.as_deref(),
3764 source_kind,
3765 &json_sha256,
3766 &raw_json,
3767 ],
3768 )?;
3769 refresh_receipt_lineage_rows_for_request_tx(tx, &session_id, &request_id)?;
3770 Ok(())
3771}
3772
3773#[allow(clippy::too_many_arguments)]
3774fn persist_receipt_lineage_statement_tx(
3775 tx: &rusqlite::Transaction<'_>,
3776 child_receipt_id: &str,
3777 request_id: Option<&str>,
3778 session_id: Option<&str>,
3779 session_anchor_id: Option<&str>,
3780 parent_request_id: Option<&str>,
3781 parent_receipt_id: Option<&str>,
3782 chain_id: Option<&str>,
3783 recorded_at: u64,
3784 source_kind: &str,
3785 statement_json: &serde_json::Value,
3786) -> Result<(), ReceiptStoreError> {
3787 let child_receipt_id = sanitize_required_identifier(
3788 "receipt lineage statement",
3789 child_receipt_id,
3790 "child_receipt_id",
3791 child_receipt_id,
3792 )?;
3793 let extracted = extract_receipt_lineage_statement_identifiers(statement_json);
3794 if let Some(extracted_child_receipt_id) = extracted.child_receipt_id.as_deref() {
3795 let extracted_child_receipt_id = sanitize_required_identifier(
3796 "receipt lineage statement",
3797 &child_receipt_id,
3798 "statement.child_receipt_id",
3799 extracted_child_receipt_id,
3800 )?;
3801 if extracted_child_receipt_id != child_receipt_id {
3802 return Err(ReceiptStoreError::Conflict(format!(
3803 "receipt lineage statement `{child_receipt_id}` conflicts with signed child_receipt_id `{extracted_child_receipt_id}`"
3804 )));
3805 }
3806 }
3807
3808 let existing = tx
3809 .query_row(
3810 r#"
3811 SELECT statement_id, request_id, session_id, session_anchor_id, parent_request_id, parent_receipt_id, chain_id
3812 FROM receipt_lineage_statements
3813 WHERE receipt_id = ?1
3814 "#,
3815 params![&child_receipt_id],
3816 |row| {
3817 Ok((
3818 row.get::<_, Option<String>>(0)?,
3819 row.get::<_, Option<String>>(1)?,
3820 row.get::<_, Option<String>>(2)?,
3821 row.get::<_, Option<String>>(3)?,
3822 row.get::<_, Option<String>>(4)?,
3823 row.get::<_, Option<String>>(5)?,
3824 row.get::<_, Option<String>>(6)?,
3825 ))
3826 },
3827 )
3828 .optional()?;
3829 let (
3830 existing_statement_id,
3831 existing_request_id,
3832 existing_session_id,
3833 existing_session_anchor_id,
3834 existing_parent_request_id,
3835 existing_parent_receipt_id,
3836 existing_chain_id,
3837 ) = existing.unwrap_or((None, None, None, None, None, None, None));
3838
3839 let statement_id = merge_optional_identifier(
3840 "receipt lineage statement",
3841 &child_receipt_id,
3842 "statement_id",
3843 existing_statement_id,
3844 extracted.statement_id.as_deref(),
3845 )?;
3846
3847 let request_id = merge_optional_identifier(
3848 "receipt lineage statement",
3849 &child_receipt_id,
3850 "request_id",
3851 existing_request_id,
3852 extracted.child_request_id.as_deref().or(request_id),
3853 )?;
3854 let session_id = merge_optional_identifier(
3855 "receipt lineage statement",
3856 &child_receipt_id,
3857 "session_id",
3858 existing_session_id,
3859 session_id,
3860 )?;
3861 let session_anchor_id = merge_optional_identifier(
3862 "receipt lineage statement",
3863 &child_receipt_id,
3864 "session_anchor_id",
3865 existing_session_anchor_id,
3866 extracted
3867 .child_session_anchor_id
3868 .as_deref()
3869 .or(session_anchor_id),
3870 )?;
3871 let parent_request_id = merge_optional_identifier(
3872 "receipt lineage statement",
3873 &child_receipt_id,
3874 "parent_request_id",
3875 existing_parent_request_id,
3876 extracted.parent_request_id.as_deref().or(parent_request_id),
3877 )?;
3878 let parent_receipt_id = merge_optional_identifier(
3879 "receipt lineage statement",
3880 &child_receipt_id,
3881 "parent_receipt_id",
3882 existing_parent_receipt_id,
3883 extracted.parent_receipt_id.as_deref().or(parent_receipt_id),
3884 )?;
3885 let chain_id = merge_optional_identifier(
3886 "receipt lineage statement",
3887 &child_receipt_id,
3888 "chain_id",
3889 existing_chain_id,
3890 chain_id,
3891 )?;
3892
3893 if session_anchor_id.is_some() && session_id.is_none() {
3894 return Err(ReceiptStoreError::Conflict(format!(
3895 "receipt lineage statement `{child_receipt_id}` requires session_id when session_anchor_id is present"
3896 )));
3897 }
3898 if request_id.is_some() && session_id.is_none() {
3899 return Err(ReceiptStoreError::Conflict(format!(
3900 "receipt lineage statement `{child_receipt_id}` requires session_id when request_id is present"
3901 )));
3902 }
3903 if request_id.is_some() && parent_request_id.is_some() && request_id == parent_request_id {
3904 return Err(ReceiptStoreError::Conflict(format!(
3905 "receipt lineage statement `{child_receipt_id}` cannot reuse request_id as parent_request_id"
3906 )));
3907 }
3908 if parent_receipt_id.as_deref() == Some(child_receipt_id.as_str()) {
3909 return Err(ReceiptStoreError::Conflict(format!(
3910 "receipt lineage statement `{child_receipt_id}` cannot point at itself as parent_receipt_id"
3911 )));
3912 }
3913
3914 let verification = build_receipt_lineage_verification_tx(
3915 tx,
3916 &child_receipt_id,
3917 request_id.as_deref(),
3918 session_id.as_deref(),
3919 session_anchor_id.as_deref(),
3920 parent_request_id.as_deref(),
3921 parent_receipt_id.as_deref(),
3922 )?;
3923 let evidence_class = extract_lineage_evidence_class(statement_json);
3924 let evidence_sources_json = extract_lineage_evidence_sources_json(statement_json)?;
3925 let raw_json = serde_json::to_string(statement_json)?;
3926 let json_sha256 = provenance_json_sha256(statement_json)?;
3927
3928 tx.execute(
3929 r#"
3930 INSERT INTO receipt_lineage_statements (
3931 receipt_id,
3932 statement_id,
3933 request_id,
3934 session_id,
3935 session_anchor_id,
3936 chain_id,
3937 parent_request_id,
3938 parent_receipt_id,
3939 evidence_class,
3940 evidence_sources_json,
3941 verified_session_anchor,
3942 verified_parent_request,
3943 verified_parent_receipt,
3944 replay_protected,
3945 recorded_at,
3946 source_kind,
3947 json_sha256,
3948 raw_json
3949 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
3950 ON CONFLICT(receipt_id) DO UPDATE SET
3951 statement_id = excluded.statement_id,
3952 request_id = excluded.request_id,
3953 session_id = excluded.session_id,
3954 session_anchor_id = excluded.session_anchor_id,
3955 chain_id = excluded.chain_id,
3956 parent_request_id = excluded.parent_request_id,
3957 parent_receipt_id = excluded.parent_receipt_id,
3958 evidence_class = excluded.evidence_class,
3959 evidence_sources_json = excluded.evidence_sources_json,
3960 verified_session_anchor = excluded.verified_session_anchor,
3961 verified_parent_request = excluded.verified_parent_request,
3962 verified_parent_receipt = excluded.verified_parent_receipt,
3963 replay_protected = excluded.replay_protected,
3964 recorded_at = excluded.recorded_at,
3965 source_kind = excluded.source_kind,
3966 json_sha256 = excluded.json_sha256,
3967 raw_json = excluded.raw_json
3968 "#,
3969 params![
3970 &child_receipt_id,
3971 statement_id.as_deref(),
3972 request_id.as_deref(),
3973 session_id.as_deref(),
3974 session_anchor_id.as_deref(),
3975 chain_id.as_deref(),
3976 parent_request_id.as_deref(),
3977 parent_receipt_id.as_deref(),
3978 evidence_class.as_deref(),
3979 evidence_sources_json.as_deref(),
3980 sqlite_bool(verification.session_anchor_verified),
3981 sqlite_bool(verification.parent_request_verified),
3982 sqlite_bool(verification.parent_receipt_verified),
3983 sqlite_bool(verification.replay_protected),
3984 sqlite_i64(recorded_at, "receipt lineage statement recorded_at")?,
3985 source_kind,
3986 &json_sha256,
3987 &raw_json,
3988 ],
3989 )?;
3990 refresh_receipt_lineage_rows_for_parent_receipt_tx(tx, &child_receipt_id)?;
3991 Ok(())
3992}
3993
3994fn ensure_receipt_lineage_statement_for_receipt_id_tx(
3995 tx: &rusqlite::Transaction<'_>,
3996 receipt_id: &str,
3997) -> Result<(), ReceiptStoreError> {
3998 if tx
3999 .query_row(
4000 "SELECT 1 FROM receipt_lineage_statements WHERE receipt_id = ?1 LIMIT 1",
4001 params![receipt_id],
4002 |_| Ok(()),
4003 )
4004 .optional()?
4005 .is_some()
4006 {
4007 refresh_receipt_lineage_verification_state_tx(tx, receipt_id)?;
4008 refresh_receipt_lineage_rows_for_parent_receipt_tx(tx, receipt_id)?;
4009 return Ok(());
4010 }
4011
4012 let row = tx
4013 .query_row(
4014 "SELECT seq, raw_json FROM chio_tool_receipts WHERE receipt_id = ?1",
4015 params![receipt_id],
4016 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
4017 )
4018 .optional()?;
4019 let Some((seq, raw_json)) = row else {
4020 return Ok(());
4021 };
4022 let receipt =
4023 decode_verified_chio_receipt(&raw_json, "persisted tool receipt", Some(seq.max(0) as u64))?;
4024 let Some(governed) = extract_governed_transaction_metadata(&receipt) else {
4025 refresh_receipt_lineage_rows_for_parent_receipt_tx(tx, receipt_id)?;
4026 return Ok(());
4027 };
4028 let Some(call_chain) = governed.call_chain.as_ref() else {
4029 refresh_receipt_lineage_rows_for_parent_receipt_tx(tx, receipt_id)?;
4030 return Ok(());
4031 };
4032
4033 persist_receipt_lineage_statement_tx(
4034 tx,
4035 &receipt.id,
4036 None,
4037 None,
4038 None,
4039 Some(call_chain.parent_request_id.as_str()),
4040 call_chain.parent_receipt_id.as_deref(),
4041 Some(call_chain.chain_id.as_str()),
4042 receipt.timestamp,
4043 GOVERNED_RECEIPT_BACKFILL_SOURCE_KIND,
4044 &serde_json::to_value(call_chain)?,
4045 )?;
4046 Ok(())
4047}
4048
4049fn load_receipt_lineage_verification(
4050 connection: &Connection,
4051 receipt_id: &str,
4052) -> Result<Option<ReceiptLineageVerification>, ReceiptStoreError> {
4053 connection
4054 .query_row(
4055 r#"
4056 SELECT receipt_id, request_id, session_id, session_anchor_id,
4057 verified_session_anchor, verified_parent_request,
4058 verified_parent_receipt, replay_protected
4059 FROM receipt_lineage_statements
4060 WHERE receipt_id = ?1
4061 "#,
4062 params![receipt_id],
4063 |row| {
4064 Ok(ReceiptLineageVerification {
4065 receipt_id: row.get::<_, String>(0)?,
4066 request_id: row.get::<_, Option<String>>(1)?,
4067 session_id: row.get::<_, Option<String>>(2)?,
4068 session_anchor_id: row.get::<_, Option<String>>(3)?,
4069 session_anchor_verified: row.get::<_, i64>(4)? != 0,
4070 parent_request_verified: row.get::<_, i64>(5)? != 0,
4071 parent_receipt_verified: row.get::<_, i64>(6)? != 0,
4072 replay_protected: row.get::<_, i64>(7)? != 0,
4073 })
4074 },
4075 )
4076 .optional()
4077 .map_err(ReceiptStoreError::from)
4078}
4079
4080fn load_receipt_lineage_statement_links(
4081 connection: &Connection,
4082 receipt_id: &str,
4083) -> Result<Vec<ReceiptLineageStatementLink>, ReceiptStoreError> {
4084 let mut statement = connection.prepare(
4085 r#"
4086 SELECT statement_id,
4087 receipt_id,
4088 request_id,
4089 parent_receipt_id,
4090 parent_request_id,
4091 session_id,
4092 session_anchor_id,
4093 chain_id,
4094 recorded_at
4095 FROM receipt_lineage_statements
4096 WHERE receipt_id = ?1
4097 OR parent_receipt_id = ?1
4098 ORDER BY recorded_at ASC, receipt_id ASC
4099 "#,
4100 )?;
4101 let rows = statement
4102 .query_map(params![receipt_id], |row| {
4103 let recorded_at = row.get::<_, i64>(8)?;
4104 let recorded_at = u64::try_from(recorded_at)
4105 .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(8, recorded_at))?;
4106 Ok(ReceiptLineageStatementLink {
4107 statement_id: row.get::<_, Option<String>>(0)?,
4108 child_receipt_id: row.get::<_, String>(1)?,
4109 child_request_id: row.get::<_, Option<String>>(2)?,
4110 parent_receipt_id: row.get::<_, Option<String>>(3)?,
4111 parent_request_id: row.get::<_, Option<String>>(4)?,
4112 session_id: row.get::<_, Option<String>>(5)?,
4113 session_anchor_id: row.get::<_, Option<String>>(6)?,
4114 chain_id: row.get::<_, Option<String>>(7)?,
4115 recorded_at,
4116 })
4117 })?
4118 .collect::<Result<Vec<_>, _>>()?;
4119 Ok(rows)
4120}
4121
4122pub(crate) fn backfill_provenance_lineage_tables(
4123 connection: &mut Connection,
4124) -> Result<(), ReceiptStoreError> {
4125 let tx = connection.transaction()?;
4126
4127 let child_rows = {
4128 let mut statement = tx.prepare(
4129 "SELECT seq, raw_json FROM chio_child_receipts ORDER BY timestamp ASC, seq ASC",
4130 )?;
4131 let rows = statement
4132 .query_map([], |row| {
4133 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
4134 })?
4135 .collect::<Result<Vec<_>, _>>()?;
4136 rows
4137 };
4138 for (seq, raw_json) in child_rows {
4139 let receipt = decode_verified_child_receipt(
4140 &raw_json,
4141 "persisted child receipt",
4142 Some(seq.max(0) as u64),
4143 )?;
4144 persist_request_lineage_tx(
4145 &tx,
4146 receipt.session_id.as_str(),
4147 receipt.request_id.as_str(),
4148 Some(receipt.parent_request_id.as_str()),
4149 None,
4150 receipt.timestamp,
4151 None,
4152 CHILD_RECEIPT_BACKFILL_SOURCE_KIND,
4153 &serde_json::from_str::<serde_json::Value>(&raw_json)?,
4154 )?;
4155 }
4156
4157 let tool_receipt_ids = {
4158 let mut statement = tx
4159 .prepare("SELECT receipt_id FROM chio_tool_receipts ORDER BY timestamp ASC, seq ASC")?;
4160 let rows = statement
4161 .query_map([], |row| row.get::<_, String>(0))?
4162 .collect::<Result<Vec<_>, _>>()?;
4163 rows
4164 };
4165 for receipt_id in tool_receipt_ids {
4166 ensure_receipt_lineage_statement_for_receipt_id_tx(&tx, &receipt_id)?;
4167 }
4168
4169 tx.commit()?;
4170 Ok(())
4171}
4172
4173impl SqliteReceiptStore {
4174 #[allow(dead_code)]
4175 pub(crate) fn claim_tree_canonical_bytes_range(
4176 &self,
4177 start_entry_seq: u64,
4178 end_entry_seq: u64,
4179 ) -> Result<Vec<(u64, Vec<u8>)>, ReceiptStoreError> {
4180 let connection = self.connection()?;
4181 load_claim_tree_canonical_bytes_range(&connection, start_entry_seq, end_entry_seq)
4182 }
4183
4184 pub fn record_session_anchor_record(
4185 &self,
4186 session_id: &str,
4187 anchor_id: &str,
4188 auth_context_fingerprint: &str,
4189 issued_at: u64,
4190 supersedes_anchor_id: Option<&str>,
4191 anchor_json: &serde_json::Value,
4192 ) -> Result<(), ReceiptStoreError> {
4193 let mut connection = self.connection()?;
4194 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4195 persist_session_anchor_tx(
4196 &tx,
4197 session_id,
4198 anchor_id,
4199 auth_context_fingerprint,
4200 issued_at,
4201 supersedes_anchor_id,
4202 SESSION_ANCHOR_SOURCE_KIND,
4203 anchor_json,
4204 )?;
4205 tx.commit()?;
4206 Ok(())
4207 }
4208
4209 #[allow(clippy::too_many_arguments)]
4210 pub fn record_request_lineage_record(
4211 &self,
4212 session_id: &str,
4213 request_id: &str,
4214 parent_request_id: Option<&str>,
4215 session_anchor_id: Option<&str>,
4216 recorded_at: u64,
4217 request_fingerprint: Option<&str>,
4218 lineage_json: &serde_json::Value,
4219 ) -> Result<(), ReceiptStoreError> {
4220 let mut connection = self.connection()?;
4221 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4222 persist_request_lineage_tx(
4223 &tx,
4224 session_id,
4225 request_id,
4226 parent_request_id,
4227 session_anchor_id,
4228 recorded_at,
4229 request_fingerprint,
4230 REQUEST_LINEAGE_SOURCE_KIND,
4231 lineage_json,
4232 )?;
4233 tx.commit()?;
4234 Ok(())
4235 }
4236
4237 #[allow(clippy::too_many_arguments)]
4238 pub fn record_receipt_lineage_statement_record(
4239 &self,
4240 child_receipt_id: &str,
4241 request_id: Option<&str>,
4242 session_id: Option<&str>,
4243 session_anchor_id: Option<&str>,
4244 parent_request_id: Option<&str>,
4245 parent_receipt_id: Option<&str>,
4246 chain_id: Option<&str>,
4247 recorded_at: u64,
4248 statement_json: &serde_json::Value,
4249 ) -> Result<(), ReceiptStoreError> {
4250 let mut connection = self.connection()?;
4251 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4252 persist_receipt_lineage_statement_tx(
4253 &tx,
4254 child_receipt_id,
4255 request_id,
4256 session_id,
4257 session_anchor_id,
4258 parent_request_id,
4259 parent_receipt_id,
4260 chain_id,
4261 recorded_at,
4262 RECEIPT_LINEAGE_SOURCE_KIND,
4263 statement_json,
4264 )?;
4265 tx.commit()?;
4266 Ok(())
4267 }
4268
4269 pub fn list_receipt_lineage_statement_links(
4270 &self,
4271 receipt_id: &str,
4272 ) -> Result<Vec<ReceiptLineageStatementLink>, ReceiptStoreError> {
4273 let mut connection = self.connection()?;
4274 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4275 ensure_receipt_lineage_statement_for_receipt_id_tx(&tx, receipt_id)?;
4276 refresh_receipt_lineage_rows_for_parent_receipt_tx(&tx, receipt_id)?;
4277 let links = load_receipt_lineage_statement_links(&tx, receipt_id)?;
4278 tx.commit()?;
4279 Ok(links)
4280 }
4281
4282 pub fn receipt_lineage_verification(
4283 &self,
4284 receipt_id: &str,
4285 ) -> Result<Option<ReceiptLineageVerification>, ReceiptStoreError> {
4286 let mut connection = self.connection()?;
4287 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4288 ensure_receipt_lineage_statement_for_receipt_id_tx(&tx, receipt_id)?;
4289 let verification = load_receipt_lineage_verification(&tx, receipt_id)?;
4290 tx.commit()?;
4291 Ok(verification)
4292 }
4293
4294 pub fn append_child_receipt_record(
4295 &self,
4296 receipt: &ChildRequestReceipt,
4297 ) -> Result<(), ReceiptStoreError> {
4298 ensure_child_receipt_verified(receipt)?;
4299 let raw_json = serde_json::to_string(receipt)?;
4300 let mut connection = self.connection()?;
4301 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4302 tx.execute(
4303 r#"
4304 INSERT INTO chio_child_receipts (
4305 receipt_id,
4306 timestamp,
4307 session_id,
4308 parent_request_id,
4309 request_id,
4310 operation_kind,
4311 terminal_state,
4312 policy_hash,
4313 outcome_hash,
4314 raw_json
4315 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4316 ON CONFLICT(receipt_id) DO NOTHING
4317 "#,
4318 params![
4319 receipt.id,
4320 sqlite_i64(receipt.timestamp, "child receipt timestamp")?,
4321 receipt.session_id.as_str(),
4322 receipt.parent_request_id.as_str(),
4323 receipt.request_id.as_str(),
4324 receipt.operation_kind.as_str(),
4325 terminal_state_kind(&receipt.terminal_state),
4326 receipt.policy_hash,
4327 receipt.outcome_hash,
4328 &raw_json,
4329 ],
4330 )?;
4331 persist_request_lineage_tx(
4332 &tx,
4333 receipt.session_id.as_str(),
4334 receipt.request_id.as_str(),
4335 Some(receipt.parent_request_id.as_str()),
4336 None,
4337 receipt.timestamp,
4338 None,
4339 CHILD_RECEIPT_BACKFILL_SOURCE_KIND,
4340 &serde_json::from_str::<serde_json::Value>(&raw_json)?,
4341 )?;
4342 tx.commit()?;
4343 Ok(())
4344 }
4345}
4346
4347impl ReceiptStore for SqliteReceiptStore {
4348 fn append_chio_receipt(&mut self, receipt: &ChioReceipt) -> Result<(), ReceiptStoreError> {
4349 self.append_chio_receipt_returning_seq(receipt).map(|_| ())
4350 }
4351
4352 fn append_chio_receipt_returning_seq(
4353 &mut self,
4354 receipt: &ChioReceipt,
4355 ) -> Result<Option<u64>, ReceiptStoreError> {
4356 let connection = self.connection()?;
4357 ensure_checkpoint_transparency_guards(&connection)?;
4358 verify_latest_checkpoint_integrity(&connection)?;
4359 let seq = SqliteReceiptStore::append_chio_receipt_returning_seq(self, receipt)?;
4360 let mut connection = self.connection()?;
4361 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
4362 ensure_receipt_lineage_statement_for_receipt_id_tx(&tx, &receipt.id)?;
4363 tx.commit()?;
4364 Ok(Some(seq))
4365 }
4366
4367 fn receipts_canonical_bytes_range(
4368 &self,
4369 start_seq: u64,
4370 end_seq: u64,
4371 ) -> Result<Vec<(u64, Vec<u8>)>, ReceiptStoreError> {
4372 SqliteReceiptStore::receipts_canonical_bytes_range(self, start_seq, end_seq)
4373 }
4374
4375 fn store_checkpoint(&mut self, checkpoint: &KernelCheckpoint) -> Result<(), ReceiptStoreError> {
4376 let connection = self.connection()?;
4377 ensure_checkpoint_transparency_guards(&connection)?;
4378
4379 chio_kernel::checkpoint::validate_checkpoint(checkpoint)
4380 .map_err(checkpoint_error_to_receipt_store)?;
4381 if let Some(existing) =
4382 load_persisted_checkpoint_row(&connection, checkpoint.body.checkpoint_seq)?
4383 {
4384 let existing = parse_persisted_checkpoint_row(existing)?;
4385 if existing == *checkpoint {
4386 return Ok(());
4387 }
4388 return Err(ReceiptStoreError::Conflict(format!(
4389 "checkpoint {} already exists with different content",
4390 checkpoint.body.checkpoint_seq
4391 )));
4392 }
4393
4394 match verify_checkpoint_chain_integrity(&connection)? {
4395 Some(predecessor) => {
4396 if checkpoint.body.checkpoint_seq <= predecessor.body.checkpoint_seq {
4397 return Err(ReceiptStoreError::Conflict(format!(
4398 "checkpoint {} must be appended after existing checkpoint {}",
4399 checkpoint.body.checkpoint_seq, predecessor.body.checkpoint_seq
4400 )));
4401 }
4402 chio_kernel::checkpoint::validate_checkpoint_predecessor(&predecessor, checkpoint)
4403 .map_err(|error| {
4404 ReceiptStoreError::Conflict(format!(
4405 "checkpoint predecessor continuity violation: {error}"
4406 ))
4407 })?;
4408 }
4409 None if checkpoint.body.checkpoint_seq != 1 => {
4410 return Err(ReceiptStoreError::Conflict(format!(
4411 "checkpoint {} cannot initialize an empty checkpoint log",
4412 checkpoint.body.checkpoint_seq
4413 )));
4414 }
4415 None => {}
4416 }
4417
4418 let statement_json = serde_json::to_string(&checkpoint.body)?;
4419 connection.execute(
4420 r#"
4421 INSERT INTO kernel_checkpoints (
4422 checkpoint_seq, batch_start_seq, batch_end_seq, tree_size,
4423 merkle_root, issued_at, statement_json, signature, kernel_key
4424 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
4425 "#,
4426 params![
4427 sqlite_i64(checkpoint.body.checkpoint_seq, "checkpoint_seq")?,
4428 sqlite_i64(checkpoint.body.batch_start_seq, "batch_start_seq")?,
4429 sqlite_i64(checkpoint.body.batch_end_seq, "batch_end_seq")?,
4430 sqlite_i64(checkpoint.body.tree_size as u64, "tree_size")?,
4431 checkpoint.body.merkle_root.to_hex(),
4432 sqlite_i64(checkpoint.body.issued_at, "issued_at")?,
4433 statement_json,
4434 checkpoint.signature.to_hex(),
4435 checkpoint.body.kernel_key.to_hex(),
4436 ],
4437 )?;
4438
4439 let stored = load_persisted_checkpoint_row(&connection, checkpoint.body.checkpoint_seq)?
4440 .ok_or_else(|| {
4441 ReceiptStoreError::Conflict(format!(
4442 "checkpoint {} was not visible after persistence",
4443 checkpoint.body.checkpoint_seq
4444 ))
4445 })?;
4446 let stored = parse_persisted_checkpoint_row(stored)?;
4447 if stored != *checkpoint {
4448 return Err(ReceiptStoreError::Conflict(format!(
4449 "checkpoint {} persisted with conflicting contents",
4450 checkpoint.body.checkpoint_seq
4451 )));
4452 }
4453
4454 Ok(())
4455 }
4456
4457 fn load_checkpoint_by_seq(
4458 &self,
4459 checkpoint_seq: u64,
4460 ) -> Result<Option<KernelCheckpoint>, ReceiptStoreError> {
4461 SqliteReceiptStore::load_checkpoint_by_seq(self, checkpoint_seq)
4462 }
4463
4464 fn supports_kernel_signed_checkpoints(&self) -> bool {
4465 true
4466 }
4467
4468 fn record_capability_snapshot(
4469 &mut self,
4470 token: &CapabilityToken,
4471 parent_capability_id: Option<&str>,
4472 ) -> Result<(), ReceiptStoreError> {
4473 SqliteReceiptStore::record_capability_snapshot(self, token, parent_capability_id).map_err(
4474 |error| match error {
4475 chio_kernel::CapabilityLineageError::ReceiptStore(error) => error,
4476 chio_kernel::CapabilityLineageError::Sqlite(error) => {
4477 ReceiptStoreError::Sqlite(error)
4478 }
4479 chio_kernel::CapabilityLineageError::Json(error) => ReceiptStoreError::Json(error),
4480 },
4481 )
4482 }
4483
4484 fn get_capability_snapshot(
4485 &self,
4486 capability_id: &str,
4487 ) -> Result<Option<chio_kernel::CapabilitySnapshot>, ReceiptStoreError> {
4488 SqliteReceiptStore::get_lineage(self, capability_id).map_err(|error| match error {
4489 chio_kernel::CapabilityLineageError::ReceiptStore(error) => error,
4490 chio_kernel::CapabilityLineageError::Sqlite(error) => ReceiptStoreError::Sqlite(error),
4491 chio_kernel::CapabilityLineageError::Json(error) => ReceiptStoreError::Json(error),
4492 })
4493 }
4494
4495 fn get_capability_delegation_chain(
4496 &self,
4497 capability_id: &str,
4498 ) -> Result<Vec<chio_kernel::CapabilitySnapshot>, ReceiptStoreError> {
4499 SqliteReceiptStore::get_delegation_chain(self, capability_id).map_err(|error| match error {
4500 chio_kernel::CapabilityLineageError::ReceiptStore(error) => error,
4501 chio_kernel::CapabilityLineageError::Sqlite(error) => ReceiptStoreError::Sqlite(error),
4502 chio_kernel::CapabilityLineageError::Json(error) => ReceiptStoreError::Json(error),
4503 })
4504 }
4505
4506 fn record_session_anchor(
4507 &mut self,
4508 session_id: &str,
4509 anchor_id: &str,
4510 auth_context_fingerprint: &str,
4511 issued_at: u64,
4512 supersedes_anchor_id: Option<&str>,
4513 anchor_json: &serde_json::Value,
4514 ) -> Result<(), ReceiptStoreError> {
4515 self.record_session_anchor_record(
4516 session_id,
4517 anchor_id,
4518 auth_context_fingerprint,
4519 issued_at,
4520 supersedes_anchor_id,
4521 anchor_json,
4522 )
4523 }
4524
4525 fn record_request_lineage(
4526 &mut self,
4527 session_id: &str,
4528 request_id: &str,
4529 parent_request_id: Option<&str>,
4530 session_anchor_id: Option<&str>,
4531 recorded_at: u64,
4532 request_fingerprint: Option<&str>,
4533 lineage_json: &serde_json::Value,
4534 ) -> Result<(), ReceiptStoreError> {
4535 self.record_request_lineage_record(
4536 session_id,
4537 request_id,
4538 parent_request_id,
4539 session_anchor_id,
4540 recorded_at,
4541 request_fingerprint,
4542 lineage_json,
4543 )
4544 }
4545
4546 fn record_receipt_lineage_statement(
4547 &mut self,
4548 child_receipt_id: &str,
4549 request_id: Option<&str>,
4550 session_id: Option<&str>,
4551 session_anchor_id: Option<&str>,
4552 parent_request_id: Option<&str>,
4553 parent_receipt_id: Option<&str>,
4554 chain_id: Option<&str>,
4555 recorded_at: u64,
4556 statement_json: &serde_json::Value,
4557 ) -> Result<(), ReceiptStoreError> {
4558 self.record_receipt_lineage_statement_record(
4559 child_receipt_id,
4560 request_id,
4561 session_id,
4562 session_anchor_id,
4563 parent_request_id,
4564 parent_receipt_id,
4565 chain_id,
4566 recorded_at,
4567 statement_json,
4568 )
4569 }
4570
4571 fn get_receipt_lineage_verification(
4572 &self,
4573 receipt_id: &str,
4574 ) -> Result<Option<ReceiptLineageVerification>, ReceiptStoreError> {
4575 self.receipt_lineage_verification(receipt_id)
4576 }
4577
4578 fn list_receipt_lineage_statement_links(
4579 &self,
4580 receipt_id: &str,
4581 ) -> Result<Vec<ReceiptLineageStatementLink>, ReceiptStoreError> {
4582 SqliteReceiptStore::list_receipt_lineage_statement_links(self, receipt_id)
4583 }
4584
4585 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
4586 Some(self)
4587 }
4588
4589 fn resolve_credit_bond(
4590 &self,
4591 bond_id: &str,
4592 ) -> Result<Option<CreditBondRow>, ReceiptStoreError> {
4593 self.query_credit_bonds(&CreditBondListQuery {
4594 bond_id: Some(bond_id.to_string()),
4595 facility_id: None,
4596 capability_id: None,
4597 agent_subject: None,
4598 tool_server: None,
4599 tool_name: None,
4600 disposition: None,
4601 lifecycle_state: None,
4602 limit: Some(1),
4603 })
4604 .map(|report| report.bonds.into_iter().next())
4605 }
4606
4607 fn append_child_receipt(
4608 &mut self,
4609 receipt: &ChildRequestReceipt,
4610 ) -> Result<(), ReceiptStoreError> {
4611 let connection = self.connection()?;
4612 ensure_checkpoint_transparency_guards(&connection)?;
4613 verify_latest_checkpoint_integrity(&connection)?;
4614 SqliteReceiptStore::append_child_receipt_record(self, receipt)
4615 }
4616}
4617
4618impl SqliteReceiptStore {
4619 pub fn record_checkpoint_publication_trust_anchor_binding(
4620 &mut self,
4621 checkpoint_seq: u64,
4622 binding: &chio_core::receipt::CheckpointPublicationTrustAnchorBinding,
4623 ) -> Result<(), ReceiptStoreError> {
4624 binding
4625 .validate()
4626 .map_err(|error| ReceiptStoreError::Conflict(error.to_string()))?;
4627 let checkpoint = self
4628 .load_checkpoint_by_seq(checkpoint_seq)?
4629 .ok_or_else(|| {
4630 ReceiptStoreError::NotFound(format!(
4631 "checkpoint {} does not exist for publication binding",
4632 checkpoint_seq
4633 ))
4634 })?;
4635 let publication = chio_kernel::checkpoint::build_trust_anchored_checkpoint_publication(
4636 &checkpoint,
4637 binding.clone(),
4638 )
4639 .map_err(checkpoint_error_to_receipt_store)?;
4640 let normalized_binding = publication.trust_anchor_binding.ok_or_else(|| {
4641 ReceiptStoreError::Conflict(format!(
4642 "checkpoint {} trust-anchor binding was not preserved during validation",
4643 checkpoint_seq
4644 ))
4645 })?;
4646
4647 let connection = self.connection()?;
4648 ensure_checkpoint_transparency_guards(&connection)?;
4649 ensure_transparency_projection_guards(&connection)?;
4650
4651 let existing = connection
4652 .query_row(
4653 r#"
4654 SELECT binding_json
4655 FROM checkpoint_publication_trust_anchor_bindings
4656 WHERE checkpoint_seq = ?1
4657 "#,
4658 params![sqlite_i64(checkpoint_seq, "checkpoint_seq")?],
4659 |row| row.get::<_, String>(0),
4660 )
4661 .optional()?;
4662 match existing {
4663 Some(binding_json) => {
4664 let existing_binding: chio_core::receipt::CheckpointPublicationTrustAnchorBinding =
4665 serde_json::from_str(&binding_json)?;
4666 if existing_binding == normalized_binding {
4667 return Ok(());
4668 }
4669 Err(ReceiptStoreError::Conflict(format!(
4670 "checkpoint {} already has a different trust-anchor publication binding",
4671 checkpoint_seq
4672 )))
4673 }
4674 None => {
4675 connection.execute(
4676 r#"
4677 INSERT INTO checkpoint_publication_trust_anchor_bindings (
4678 checkpoint_seq,
4679 binding_json
4680 ) VALUES (?1, ?2)
4681 "#,
4682 params![
4683 sqlite_i64(checkpoint_seq, "checkpoint_seq")?,
4684 serde_json::to_string(&normalized_binding)?,
4685 ],
4686 )?;
4687 Ok(())
4688 }
4689 }
4690 }
4691}
4692
4693pub(crate) fn decision_kind(decision: &Decision) -> &'static str {
4694 match decision {
4695 Decision::Allow => "allow",
4696 Decision::Deny { .. } => "deny",
4697 Decision::Cancelled { .. } => "cancelled",
4698 Decision::Incomplete { .. } => "incomplete",
4699 }
4700}
4701
4702pub(crate) fn terminal_state_kind(state: &OperationTerminalState) -> &'static str {
4703 match state {
4704 OperationTerminalState::Completed => "completed",
4705 OperationTerminalState::Cancelled { .. } => "cancelled",
4706 OperationTerminalState::Incomplete { .. } => "incomplete",
4707 }
4708}