1use std::collections::BTreeMap;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use super::types::SupportState;
23use crate::{error::ClaimLedgerError, ids::sha256_bytes};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LedgerEntry {
28 pub sequence: u64,
30 pub previous_entry_digest: Option<String>,
32 pub event: LedgerEvent,
34 pub entry_digest: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum LedgerEvent {
42 ClaimAdded {
43 claim_id: String,
44 source_id: String,
45 span_id: String,
46 normalized_claim: String,
47 },
48 SupportJudgment {
49 support_judgment_id: String,
50 claim_id: String,
51 evidence_bundle_ref: String,
52 support_state: SupportState,
53 method: String,
54 },
55 SupportAdmission {
56 support_admission_receipt_id: String,
57 claim_id: String,
58 previous_support_judgment_ref: String,
59 new_support_judgment_ref: String,
60 admitted_support_state: SupportState,
61 },
62 ContradictionCandidate {
63 contradiction_id: String,
64 claim_refs: Vec<String>,
65 pattern: String,
66 rationale: String,
67 },
68 ContradictionResolved {
69 contradiction_resolution_receipt_id: String,
70 contradiction_id: String,
71 resolution: String,
72 affected_claim_refs: Vec<String>,
73 },
74 EvidenceAttached {
75 evidence_bundle_id: String,
76 claim_id: String,
77 evidence_link_count: usize,
78 },
79 BundleExported {
80 bundle_id: String,
81 export_receipt_id: String,
82 output_ref: String,
83 output_digest: String,
84 },
85 ProofDebtConsumed {
86 budget_id: String,
87 debit_id: String,
88 amount_micros: u64,
89 source: String,
90 overdrawn: bool,
91 },
92 ProofDebtReplenished {
93 budget_id: String,
94 credit_id: String,
95 amount_micros: u64,
96 source: String,
97 },
98}
99
100impl LedgerEvent {
101 pub fn type_name(&self) -> &'static str {
103 match self {
104 Self::ClaimAdded { .. } => "claim_added",
105 Self::SupportJudgment { .. } => "support_judgment",
106 Self::SupportAdmission { .. } => "support_admission",
107 Self::ContradictionCandidate { .. } => "contradiction_candidate",
108 Self::ContradictionResolved { .. } => "contradiction_resolved",
109 Self::EvidenceAttached { .. } => "evidence_attached",
110 Self::BundleExported { .. } => "bundle_exported",
111 Self::ProofDebtConsumed { .. } => "proof_debt_consumed",
112 Self::ProofDebtReplenished { .. } => "proof_debt_replenished",
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum ExpectedLedgerHead {
120 Empty,
122 Entry { sequence: u64, entry_digest: String },
124}
125
126impl ExpectedLedgerHead {
127 pub const fn empty() -> Self {
129 Self::Empty
130 }
131
132 pub fn new(sequence: u64, entry_digest: impl Into<String>) -> Self {
134 Self::Entry {
135 sequence,
136 entry_digest: entry_digest.into(),
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct LedgerVerification {
144 pub last_sequence: u64,
146 pub last_entry_digest: Option<String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct SnapshotClaim {
153 pub claim_id: String,
154 pub source_id: String,
155 pub span_id: String,
156 pub normalized_claim: String,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct SnapshotFactClaimLink {
162 pub fact_id: String,
163 pub claim_id: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct SnapshotContentClaimLink {
169 pub normalized_claim: String,
170 pub claim_id: String,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct SnapshotSupportJudgment {
176 pub support_judgment_id: String,
177 pub claim_id: String,
178 pub evidence_bundle_ref: String,
179 pub support_state: SupportState,
180 pub method: String,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct SnapshotClaimSupport {
186 pub claim_id: String,
187 pub support_state: SupportState,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct SnapshotContradictionState {
193 pub contradiction_id: String,
194 pub claim_refs: Vec<String>,
195 pub pattern: String,
196 pub rationale: String,
197 pub resolution: Option<String>,
198 pub affected_claim_refs: Vec<String>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct LedgerSnapshot {
209 pub format_version: String,
210 pub claims: Vec<SnapshotClaim>,
211 pub fact_to_claim_links: Vec<SnapshotFactClaimLink>,
212 pub content_to_claim_links: Vec<SnapshotContentClaimLink>,
213 pub support_judgments: Vec<SnapshotSupportJudgment>,
214 pub claim_support: Vec<SnapshotClaimSupport>,
215 pub contradiction_states: Vec<SnapshotContradictionState>,
216 pub last_compacted_sequence: u64,
217 pub last_compacted_entry_digest: Option<String>,
218 pub created_at: DateTime<Utc>,
219 pub snapshot_digest: String,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
224pub struct CompactionReceipt {
225 pub format_version: String,
226 pub snapshot_digest: String,
227 pub snapshot_sequence: u64,
228 pub snapshot_entry_digest: Option<String>,
229 pub pre_compaction_sequence: u64,
230 pub pre_compaction_entry_digest: Option<String>,
231 pub retained_tail_start_sequence: Option<u64>,
232 pub retained_tail_entries: u64,
233 pub previous_snapshot_digest: Option<String>,
234 pub created_at: DateTime<Utc>,
235 pub receipt_digest: String,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum UnprojectableEventPolicy {
242 Retain,
244 FailClosed,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct CompactionPolicy {
251 pub retain_tail_entries: usize,
253 pub unprojectable_events: UnprojectableEventPolicy,
255}
256
257impl Default for CompactionPolicy {
258 fn default() -> Self {
259 Self {
260 retain_tail_entries: 256,
261 unprojectable_events: UnprojectableEventPolicy::Retain,
262 }
263 }
264}
265
266#[derive(Debug, Clone)]
268pub struct CompactedLedger {
269 pub snapshot: LedgerSnapshot,
270 pub retained_tail: Vec<LedgerEntry>,
271 pub receipt: CompactionReceipt,
272}
273
274pub struct LedgerEntryBuilder {
276 sequence: u64,
277 previous_entry_digest: Option<String>,
278}
279
280impl LedgerEntryBuilder {
281 pub fn new(sequence: u64, previous_entry_digest: Option<String>) -> Self {
283 Self {
284 sequence,
285 previous_entry_digest,
286 }
287 }
288
289 pub fn add_claim(
291 self,
292 claim_id: &str,
293 source_id: &str,
294 span_id: &str,
295 normalized_claim: &str,
296 ) -> Result<LedgerEntry, ClaimLedgerError> {
297 self.build(LedgerEvent::ClaimAdded {
298 claim_id: claim_id.into(),
299 source_id: source_id.into(),
300 span_id: span_id.into(),
301 normalized_claim: normalized_claim.into(),
302 })
303 }
304
305 pub fn add_support_judgment(
307 self,
308 support_judgment_id: &str,
309 claim_id: &str,
310 evidence_bundle_ref: &str,
311 support_state: SupportState,
312 method: &str,
313 ) -> Result<LedgerEntry, ClaimLedgerError> {
314 self.build(LedgerEvent::SupportJudgment {
315 support_judgment_id: support_judgment_id.into(),
316 claim_id: claim_id.into(),
317 evidence_bundle_ref: evidence_bundle_ref.into(),
318 support_state,
319 method: method.into(),
320 })
321 }
322
323 pub fn add_support_admission(
325 self,
326 receipt_id: &str,
327 claim_id: &str,
328 previous_ref: &str,
329 new_ref: &str,
330 admitted_state: SupportState,
331 ) -> Result<LedgerEntry, ClaimLedgerError> {
332 self.build(LedgerEvent::SupportAdmission {
333 support_admission_receipt_id: receipt_id.into(),
334 claim_id: claim_id.into(),
335 previous_support_judgment_ref: previous_ref.into(),
336 new_support_judgment_ref: new_ref.into(),
337 admitted_support_state: admitted_state,
338 })
339 }
340
341 pub fn add_contradiction_candidate(
343 self,
344 contradiction_id: &str,
345 claim_refs: Vec<String>,
346 pattern: &str,
347 rationale: &str,
348 ) -> Result<LedgerEntry, ClaimLedgerError> {
349 self.build(LedgerEvent::ContradictionCandidate {
350 contradiction_id: contradiction_id.into(),
351 claim_refs,
352 pattern: pattern.into(),
353 rationale: rationale.into(),
354 })
355 }
356
357 pub fn add_contradiction_resolved(
359 self,
360 receipt_id: &str,
361 contradiction_id: &str,
362 resolution: &str,
363 affected_claim_refs: Vec<String>,
364 ) -> Result<LedgerEntry, ClaimLedgerError> {
365 self.build(LedgerEvent::ContradictionResolved {
366 contradiction_resolution_receipt_id: receipt_id.into(),
367 contradiction_id: contradiction_id.into(),
368 resolution: resolution.into(),
369 affected_claim_refs,
370 })
371 }
372
373 pub fn add_proof_debt_consumed(
375 self,
376 budget_id: &str,
377 debit_id: &str,
378 amount_micros: u64,
379 source: &str,
380 overdrawn: bool,
381 ) -> Result<LedgerEntry, ClaimLedgerError> {
382 self.build(LedgerEvent::ProofDebtConsumed {
383 budget_id: budget_id.into(),
384 debit_id: debit_id.into(),
385 amount_micros,
386 source: source.into(),
387 overdrawn,
388 })
389 }
390
391 pub fn add_proof_debt_replenished(
393 self,
394 budget_id: &str,
395 credit_id: &str,
396 amount_micros: u64,
397 source: &str,
398 ) -> Result<LedgerEntry, ClaimLedgerError> {
399 self.build(LedgerEvent::ProofDebtReplenished {
400 budget_id: budget_id.into(),
401 credit_id: credit_id.into(),
402 amount_micros,
403 source: source.into(),
404 })
405 }
406
407 fn build(self, event: LedgerEvent) -> Result<LedgerEntry, ClaimLedgerError> {
408 let entry_digest =
409 compute_entry_digest(self.sequence, self.previous_entry_digest.as_deref(), &event)?;
410 Ok(LedgerEntry {
411 sequence: self.sequence,
412 previous_entry_digest: self.previous_entry_digest,
413 event,
414 entry_digest,
415 })
416 }
417}
418
419fn put_u64(out: &mut Vec<u8>, value: u64) {
420 out.extend_from_slice(&value.to_be_bytes());
421}
422fn put_bool(out: &mut Vec<u8>, value: bool) {
423 out.push(u8::from(value));
424}
425fn put_str(out: &mut Vec<u8>, value: &str) -> Result<(), ClaimLedgerError> {
426 let len = u64::try_from(value.len())
427 .map_err(|_| ClaimLedgerError::SerializationError("string length exceeds u64".into()))?;
428 put_u64(out, len);
429 out.extend_from_slice(value.as_bytes());
430 Ok(())
431}
432fn put_vec(out: &mut Vec<u8>, values: &[String]) -> Result<(), ClaimLedgerError> {
433 put_u64(
434 out,
435 u64::try_from(values.len()).map_err(|_| {
436 ClaimLedgerError::SerializationError("vector length exceeds u64".into())
437 })?,
438 );
439 for value in values {
440 put_str(out, value)?;
441 }
442 Ok(())
443}
444fn support_state_name(state: SupportState) -> &'static str {
445 match state {
446 SupportState::Supported => "supported",
447 SupportState::PartiallySupported => "partially_supported",
448 SupportState::Unsupported => "unsupported",
449 SupportState::Contradicted => "contradicted",
450 SupportState::HeuristicOnly => "heuristic_only",
451 SupportState::Unknown => "unknown",
452 }
453}
454
455pub fn entry_digest_preimage(
457 sequence: u64,
458 previous_digest: Option<&str>,
459 event: &LedgerEvent,
460) -> Result<Vec<u8>, ClaimLedgerError> {
461 let mut out = b"claim-ledger.entry-digest.v1".to_vec();
462 put_u64(&mut out, sequence);
463 match previous_digest {
464 None => out.push(0),
465 Some(value) => {
466 out.push(1);
467 put_str(&mut out, value)?;
468 }
469 }
470 put_str(&mut out, event.type_name())?;
471 match event {
472 LedgerEvent::ClaimAdded {
473 claim_id,
474 source_id,
475 span_id,
476 normalized_claim,
477 } => {
478 put_str(&mut out, claim_id)?;
479 put_str(&mut out, source_id)?;
480 put_str(&mut out, span_id)?;
481 put_str(&mut out, normalized_claim)?;
482 }
483 LedgerEvent::SupportJudgment {
484 support_judgment_id,
485 claim_id,
486 evidence_bundle_ref,
487 support_state,
488 method,
489 } => {
490 put_str(&mut out, support_judgment_id)?;
491 put_str(&mut out, claim_id)?;
492 put_str(&mut out, evidence_bundle_ref)?;
493 put_str(&mut out, support_state_name(*support_state))?;
494 put_str(&mut out, method)?;
495 }
496 LedgerEvent::SupportAdmission {
497 support_admission_receipt_id,
498 claim_id,
499 previous_support_judgment_ref,
500 new_support_judgment_ref,
501 admitted_support_state,
502 } => {
503 put_str(&mut out, support_admission_receipt_id)?;
504 put_str(&mut out, claim_id)?;
505 put_str(&mut out, previous_support_judgment_ref)?;
506 put_str(&mut out, new_support_judgment_ref)?;
507 put_str(&mut out, support_state_name(*admitted_support_state))?;
508 }
509 LedgerEvent::ContradictionCandidate {
510 contradiction_id,
511 claim_refs,
512 pattern,
513 rationale,
514 } => {
515 put_str(&mut out, contradiction_id)?;
516 put_vec(&mut out, claim_refs)?;
517 put_str(&mut out, pattern)?;
518 put_str(&mut out, rationale)?;
519 }
520 LedgerEvent::ContradictionResolved {
521 contradiction_resolution_receipt_id,
522 contradiction_id,
523 resolution,
524 affected_claim_refs,
525 } => {
526 put_str(&mut out, contradiction_resolution_receipt_id)?;
527 put_str(&mut out, contradiction_id)?;
528 put_str(&mut out, resolution)?;
529 put_vec(&mut out, affected_claim_refs)?;
530 }
531 LedgerEvent::EvidenceAttached {
532 evidence_bundle_id,
533 claim_id,
534 evidence_link_count,
535 } => {
536 put_str(&mut out, evidence_bundle_id)?;
537 put_str(&mut out, claim_id)?;
538 put_u64(
539 &mut out,
540 u64::try_from(*evidence_link_count).map_err(|_| {
541 ClaimLedgerError::SerializationError("evidence link count exceeds u64".into())
542 })?,
543 );
544 }
545 LedgerEvent::BundleExported {
546 bundle_id,
547 export_receipt_id,
548 output_ref,
549 output_digest,
550 } => {
551 put_str(&mut out, bundle_id)?;
552 put_str(&mut out, export_receipt_id)?;
553 put_str(&mut out, output_ref)?;
554 put_str(&mut out, output_digest)?;
555 }
556 LedgerEvent::ProofDebtConsumed {
557 budget_id,
558 debit_id,
559 amount_micros,
560 source,
561 overdrawn,
562 } => {
563 put_str(&mut out, budget_id)?;
564 put_str(&mut out, debit_id)?;
565 put_u64(&mut out, *amount_micros);
566 put_str(&mut out, source)?;
567 put_bool(&mut out, *overdrawn);
568 }
569 LedgerEvent::ProofDebtReplenished {
570 budget_id,
571 credit_id,
572 amount_micros,
573 source,
574 } => {
575 put_str(&mut out, budget_id)?;
576 put_str(&mut out, credit_id)?;
577 put_u64(&mut out, *amount_micros);
578 put_str(&mut out, source)?;
579 }
580 }
581 Ok(out)
582}
583
584pub fn compute_entry_digest(
586 sequence: u64,
587 previous_digest: Option<&str>,
588 event: &LedgerEvent,
589) -> Result<String, ClaimLedgerError> {
590 Ok(sha256_bytes(&entry_digest_preimage(
591 sequence,
592 previous_digest,
593 event,
594 )?))
595}
596
597pub fn verify_ledger(
599 entries: &[LedgerEntry],
600 expected_head: &ExpectedLedgerHead,
601) -> Result<LedgerVerification, ClaimLedgerError> {
602 if entries.is_empty() {
603 return match expected_head {
604 ExpectedLedgerHead::Empty => Ok(LedgerVerification {
605 last_sequence: 0,
606 last_entry_digest: None,
607 }),
608 ExpectedLedgerHead::Entry { .. } => Err(ClaimLedgerError::LedgerCorrupt(
609 "ledger is empty but a non-empty head was expected".into(),
610 )),
611 };
612 }
613 if matches!(expected_head, ExpectedLedgerHead::Empty) {
614 return Err(ClaimLedgerError::LedgerCorrupt(
615 "ledger contains entries but an empty head was expected".into(),
616 ));
617 }
618 for (index, entry) in entries.iter().enumerate() {
619 let expected_sequence = u64::try_from(index + 1)
620 .map_err(|_| ClaimLedgerError::LedgerCorrupt("ledger index exceeds u64".into()))?;
621 if entry.sequence != expected_sequence {
622 return Err(ClaimLedgerError::LedgerCorrupt(format!(
623 "sequence mismatch at entry {}: expected {}, got {}",
624 index + 1,
625 expected_sequence,
626 entry.sequence
627 )));
628 }
629 let expected_previous = if index == 0 {
630 None
631 } else {
632 Some(entries[index - 1].entry_digest.as_str())
633 };
634 if entry.previous_entry_digest.as_deref() != expected_previous {
635 return Err(ClaimLedgerError::LedgerCorrupt(format!(
636 "previous digest mismatch at sequence {}",
637 entry.sequence
638 )));
639 }
640 let computed = compute_entry_digest(
641 entry.sequence,
642 entry.previous_entry_digest.as_deref(),
643 &entry.event,
644 )?;
645 if computed != entry.entry_digest {
646 return Err(ClaimLedgerError::LedgerCorrupt(format!(
647 "entry digest mismatch at sequence {}",
648 entry.sequence
649 )));
650 }
651 }
652 let last = entries.last().ok_or_else(|| {
653 ClaimLedgerError::LedgerCorrupt("ledger unexpectedly lost its final entry".into())
654 })?;
655 match expected_head {
656 ExpectedLedgerHead::Entry {
657 sequence,
658 entry_digest,
659 } if *sequence == last.sequence && entry_digest == &last.entry_digest => {
660 Ok(LedgerVerification {
661 last_sequence: last.sequence,
662 last_entry_digest: Some(last.entry_digest.clone()),
663 })
664 }
665 ExpectedLedgerHead::Entry {
666 sequence,
667 entry_digest,
668 } => Err(ClaimLedgerError::LedgerCorrupt(format!(
669 "ledger head mismatch: expected sequence {} digest {}, got sequence {} digest {}",
670 sequence, entry_digest, last.sequence, last.entry_digest
671 ))),
672 ExpectedLedgerHead::Empty => Err(ClaimLedgerError::LedgerCorrupt(
673 "an empty head cannot authenticate a non-empty ledger".into(),
674 )),
675 }
676}
677
678#[derive(Default)]
679struct SnapshotProjection {
680 claims: BTreeMap<String, SnapshotClaim>,
681 fact_links: BTreeMap<String, SnapshotFactClaimLink>,
682 content_links: BTreeMap<String, SnapshotContentClaimLink>,
683 judgments: BTreeMap<String, SnapshotSupportJudgment>,
684 claim_support: BTreeMap<String, SnapshotClaimSupport>,
685 contradictions: BTreeMap<String, SnapshotContradictionState>,
686}
687
688impl SnapshotProjection {
689 fn from_snapshot(snapshot: &LedgerSnapshot) -> Self {
690 Self {
691 claims: snapshot
692 .claims
693 .iter()
694 .cloned()
695 .map(|item| (item.claim_id.clone(), item))
696 .collect(),
697 fact_links: snapshot
698 .fact_to_claim_links
699 .iter()
700 .cloned()
701 .map(|item| (item.fact_id.clone(), item))
702 .collect(),
703 content_links: snapshot
704 .content_to_claim_links
705 .iter()
706 .cloned()
707 .map(|item| (item.normalized_claim.clone(), item))
708 .collect(),
709 judgments: snapshot
710 .support_judgments
711 .iter()
712 .cloned()
713 .map(|item| (item.claim_id.clone(), item))
714 .collect(),
715 claim_support: snapshot
716 .claim_support
717 .iter()
718 .cloned()
719 .map(|item| (item.claim_id.clone(), item))
720 .collect(),
721 contradictions: snapshot
722 .contradiction_states
723 .iter()
724 .cloned()
725 .map(|item| (item.contradiction_id.clone(), item))
726 .collect(),
727 }
728 }
729
730 fn event_is_projectable(event: &LedgerEvent) -> bool {
731 matches!(
732 event,
733 LedgerEvent::ClaimAdded { .. }
734 | LedgerEvent::SupportJudgment { .. }
735 | LedgerEvent::ContradictionCandidate { .. }
736 | LedgerEvent::ContradictionResolved { .. }
737 )
738 }
739
740 fn apply(&mut self, event: &LedgerEvent) {
741 match event {
742 LedgerEvent::ClaimAdded {
743 claim_id,
744 source_id,
745 span_id,
746 normalized_claim,
747 } => {
748 self.claims.insert(
749 claim_id.clone(),
750 SnapshotClaim {
751 claim_id: claim_id.clone(),
752 source_id: source_id.clone(),
753 span_id: span_id.clone(),
754 normalized_claim: normalized_claim.clone(),
755 },
756 );
757 if let Some(fact_id) = source_id.strip_prefix("semantic-memory:fact:") {
758 self.fact_links.insert(
759 fact_id.to_string(),
760 SnapshotFactClaimLink {
761 fact_id: fact_id.to_string(),
762 claim_id: claim_id.clone(),
763 },
764 );
765 }
766 self.content_links.insert(
767 normalized_claim.clone(),
768 SnapshotContentClaimLink {
769 normalized_claim: normalized_claim.clone(),
770 claim_id: claim_id.clone(),
771 },
772 );
773 }
774 LedgerEvent::SupportJudgment {
775 support_judgment_id,
776 claim_id,
777 evidence_bundle_ref,
778 support_state,
779 method,
780 } => {
781 self.judgments.insert(
782 claim_id.clone(),
783 SnapshotSupportJudgment {
784 support_judgment_id: support_judgment_id.clone(),
785 claim_id: claim_id.clone(),
786 evidence_bundle_ref: evidence_bundle_ref.clone(),
787 support_state: *support_state,
788 method: method.clone(),
789 },
790 );
791 self.claim_support.insert(
792 claim_id.clone(),
793 SnapshotClaimSupport {
794 claim_id: claim_id.clone(),
795 support_state: *support_state,
796 },
797 );
798 }
799 LedgerEvent::ContradictionCandidate {
800 contradiction_id,
801 claim_refs,
802 pattern,
803 rationale,
804 } => {
805 self.contradictions.insert(
806 contradiction_id.clone(),
807 SnapshotContradictionState {
808 contradiction_id: contradiction_id.clone(),
809 claim_refs: claim_refs.clone(),
810 pattern: pattern.clone(),
811 rationale: rationale.clone(),
812 resolution: None,
813 affected_claim_refs: Vec::new(),
814 },
815 );
816 for claim_id in claim_refs {
817 self.claim_support.insert(
818 claim_id.clone(),
819 SnapshotClaimSupport {
820 claim_id: claim_id.clone(),
821 support_state: SupportState::Contradicted,
822 },
823 );
824 }
825 }
826 LedgerEvent::ContradictionResolved {
827 contradiction_id,
828 resolution,
829 affected_claim_refs,
830 ..
831 } => {
832 let state = self
833 .contradictions
834 .entry(contradiction_id.clone())
835 .or_insert_with(|| SnapshotContradictionState {
836 contradiction_id: contradiction_id.clone(),
837 claim_refs: Vec::new(),
838 pattern: String::new(),
839 rationale: String::new(),
840 resolution: None,
841 affected_claim_refs: Vec::new(),
842 });
843 state.resolution = Some(resolution.clone());
844 state.affected_claim_refs = affected_claim_refs.clone();
845 }
846 LedgerEvent::SupportAdmission { .. }
847 | LedgerEvent::EvidenceAttached { .. }
848 | LedgerEvent::BundleExported { .. }
849 | LedgerEvent::ProofDebtConsumed { .. }
850 | LedgerEvent::ProofDebtReplenished { .. } => {}
851 }
852 }
853
854 fn into_snapshot(
855 self,
856 sequence: u64,
857 entry_digest: Option<String>,
858 created_at: DateTime<Utc>,
859 ) -> Result<LedgerSnapshot, ClaimLedgerError> {
860 let mut snapshot = LedgerSnapshot {
861 format_version: "claim-ledger.snapshot.v1".to_string(),
862 claims: self.claims.into_values().collect(),
863 fact_to_claim_links: self.fact_links.into_values().collect(),
864 content_to_claim_links: self.content_links.into_values().collect(),
865 support_judgments: self.judgments.into_values().collect(),
866 claim_support: self.claim_support.into_values().collect(),
867 contradiction_states: self.contradictions.into_values().collect(),
868 last_compacted_sequence: sequence,
869 last_compacted_entry_digest: entry_digest,
870 created_at,
871 snapshot_digest: String::new(),
872 };
873 snapshot.snapshot_digest = compute_snapshot_digest(&snapshot)?;
874 Ok(snapshot)
875 }
876}
877
878fn digest_json<T: Serialize>(domain: &[u8], value: &T) -> Result<String, ClaimLedgerError> {
879 let encoded = serde_json::to_vec(value)
880 .map_err(|error| ClaimLedgerError::SerializationError(error.to_string()))?;
881 let mut preimage = domain.to_vec();
882 put_u64(
883 &mut preimage,
884 u64::try_from(encoded.len()).map_err(|_| {
885 ClaimLedgerError::SerializationError("canonical JSON exceeds u64".into())
886 })?,
887 );
888 preimage.extend_from_slice(&encoded);
889 Ok(sha256_bytes(&preimage))
890}
891
892pub fn compute_snapshot_digest(snapshot: &LedgerSnapshot) -> Result<String, ClaimLedgerError> {
894 let mut unsigned = snapshot.clone();
895 unsigned.snapshot_digest.clear();
896 digest_json(b"claim-ledger.snapshot-digest.v1", &unsigned)
897}
898
899fn compute_compaction_receipt_digest(
900 receipt: &CompactionReceipt,
901) -> Result<String, ClaimLedgerError> {
902 let mut unsigned = receipt.clone();
903 unsigned.receipt_digest.clear();
904 digest_json(b"claim-ledger.compaction-receipt-digest.v1", &unsigned)
905}
906
907fn strictly_sorted_by<T, F>(items: &[T], key: F) -> bool
908where
909 F: Fn(&T) -> &str,
910{
911 items.windows(2).all(|pair| key(&pair[0]) < key(&pair[1]))
912}
913
914pub fn verify_snapshot(snapshot: &LedgerSnapshot) -> Result<(), ClaimLedgerError> {
916 if snapshot.format_version != "claim-ledger.snapshot.v1" {
917 return Err(ClaimLedgerError::LedgerCorrupt(format!(
918 "unsupported snapshot format {}",
919 snapshot.format_version
920 )));
921 }
922 if snapshot.last_compacted_sequence == 0 && snapshot.last_compacted_entry_digest.is_some()
923 || snapshot.last_compacted_sequence > 0 && snapshot.last_compacted_entry_digest.is_none()
924 {
925 return Err(ClaimLedgerError::LedgerCorrupt(
926 "snapshot sequence/digest anchor is inconsistent".into(),
927 ));
928 }
929 let canonical = strictly_sorted_by(&snapshot.claims, |item| &item.claim_id)
930 && strictly_sorted_by(&snapshot.fact_to_claim_links, |item| &item.fact_id)
931 && strictly_sorted_by(&snapshot.content_to_claim_links, |item| {
932 &item.normalized_claim
933 })
934 && strictly_sorted_by(&snapshot.support_judgments, |item| &item.claim_id)
935 && strictly_sorted_by(&snapshot.claim_support, |item| &item.claim_id)
936 && strictly_sorted_by(&snapshot.contradiction_states, |item| {
937 &item.contradiction_id
938 });
939 if !canonical {
940 return Err(ClaimLedgerError::LedgerCorrupt(
941 "snapshot collections are not in canonical unique order".into(),
942 ));
943 }
944 let computed = compute_snapshot_digest(snapshot)?;
945 if computed != snapshot.snapshot_digest {
946 return Err(ClaimLedgerError::LedgerCorrupt(
947 "snapshot digest mismatch".into(),
948 ));
949 }
950 Ok(())
951}
952
953pub fn verify_ledger_tail(
955 entries: &[LedgerEntry],
956 anchor_sequence: u64,
957 anchor_digest: Option<&str>,
958) -> Result<LedgerVerification, ClaimLedgerError> {
959 if (anchor_sequence == 0) != anchor_digest.is_none() {
960 return Err(ClaimLedgerError::LedgerCorrupt(
961 "tail anchor sequence/digest is inconsistent".into(),
962 ));
963 }
964 let mut previous_sequence = anchor_sequence;
965 let mut previous_digest = anchor_digest.map(str::to_owned);
966 for entry in entries {
967 let expected_sequence = previous_sequence
968 .checked_add(1)
969 .ok_or_else(|| ClaimLedgerError::LedgerCorrupt("ledger sequence overflow".into()))?;
970 if entry.sequence != expected_sequence {
971 return Err(ClaimLedgerError::LedgerCorrupt(format!(
972 "tail sequence mismatch: expected {}, got {}",
973 expected_sequence, entry.sequence
974 )));
975 }
976 if entry.previous_entry_digest.as_deref() != previous_digest.as_deref() {
977 return Err(ClaimLedgerError::LedgerCorrupt(format!(
978 "tail previous digest mismatch at sequence {}",
979 entry.sequence
980 )));
981 }
982 let computed = compute_entry_digest(
983 entry.sequence,
984 entry.previous_entry_digest.as_deref(),
985 &entry.event,
986 )?;
987 if computed != entry.entry_digest {
988 return Err(ClaimLedgerError::LedgerCorrupt(format!(
989 "tail entry digest mismatch at sequence {}",
990 entry.sequence
991 )));
992 }
993 previous_sequence = entry.sequence;
994 previous_digest = Some(entry.entry_digest.clone());
995 }
996 Ok(LedgerVerification {
997 last_sequence: previous_sequence,
998 last_entry_digest: previous_digest,
999 })
1000}
1001
1002pub fn verify_compaction(
1005 snapshot: &LedgerSnapshot,
1006 retained_tail: &[LedgerEntry],
1007 receipt: &CompactionReceipt,
1008) -> Result<LedgerVerification, ClaimLedgerError> {
1009 verify_snapshot(snapshot)?;
1010 if receipt.format_version != "claim-ledger.compaction-receipt.v1" {
1011 return Err(ClaimLedgerError::LedgerCorrupt(format!(
1012 "unsupported compaction receipt format {}",
1013 receipt.format_version
1014 )));
1015 }
1016 if compute_compaction_receipt_digest(receipt)? != receipt.receipt_digest {
1017 return Err(ClaimLedgerError::LedgerCorrupt(
1018 "compaction receipt digest mismatch".into(),
1019 ));
1020 }
1021 if receipt.snapshot_digest != snapshot.snapshot_digest
1022 || receipt.snapshot_sequence != snapshot.last_compacted_sequence
1023 || receipt.snapshot_entry_digest != snapshot.last_compacted_entry_digest
1024 {
1025 return Err(ClaimLedgerError::LedgerCorrupt(
1026 "compaction receipt does not bind the supplied snapshot".into(),
1027 ));
1028 }
1029 let retained_count = u64::try_from(retained_tail.len())
1030 .map_err(|_| ClaimLedgerError::LedgerCorrupt("retained tail exceeds u64".into()))?;
1031 let retained_start = retained_tail.first().map(|entry| entry.sequence);
1032 if receipt.retained_tail_entries != retained_count
1033 || receipt.retained_tail_start_sequence != retained_start
1034 {
1035 return Err(ClaimLedgerError::LedgerCorrupt(
1036 "compaction receipt retained-tail metadata mismatch".into(),
1037 ));
1038 }
1039 let verification = verify_ledger_tail(
1040 retained_tail,
1041 snapshot.last_compacted_sequence,
1042 snapshot.last_compacted_entry_digest.as_deref(),
1043 )?;
1044 if verification.last_sequence != receipt.pre_compaction_sequence
1045 || verification.last_entry_digest != receipt.pre_compaction_entry_digest
1046 {
1047 return Err(ClaimLedgerError::LedgerCorrupt(
1048 "compacted ledger does not reach the receipt's pre-compaction head".into(),
1049 ));
1050 }
1051 Ok(verification)
1052}
1053
1054pub fn compact_ledger(
1056 entries: &[LedgerEntry],
1057 policy: &CompactionPolicy,
1058) -> Result<CompactedLedger, ClaimLedgerError> {
1059 compact_ledger_from_snapshot(None, entries, policy)
1060}
1061
1062pub fn compact_ledger_from_snapshot(
1064 prior_snapshot: Option<&LedgerSnapshot>,
1065 entries: &[LedgerEntry],
1066 policy: &CompactionPolicy,
1067) -> Result<CompactedLedger, ClaimLedgerError> {
1068 let (base_sequence, base_digest, mut projection, previous_snapshot_digest) =
1069 match prior_snapshot {
1070 Some(snapshot) => {
1071 verify_snapshot(snapshot)?;
1072 (
1073 snapshot.last_compacted_sequence,
1074 snapshot.last_compacted_entry_digest.clone(),
1075 SnapshotProjection::from_snapshot(snapshot),
1076 Some(snapshot.snapshot_digest.clone()),
1077 )
1078 }
1079 None => (0, None, SnapshotProjection::default(), None),
1080 };
1081 let pre_head = verify_ledger_tail(entries, base_sequence, base_digest.as_deref())?;
1082 let mut compact_count = entries.len().saturating_sub(policy.retain_tail_entries);
1083 if let Some(index) = entries[..compact_count]
1084 .iter()
1085 .position(|entry| !SnapshotProjection::event_is_projectable(&entry.event))
1086 {
1087 match policy.unprojectable_events {
1088 UnprojectableEventPolicy::Retain => compact_count = index,
1089 UnprojectableEventPolicy::FailClosed => {
1090 return Err(ClaimLedgerError::LedgerCorrupt(format!(
1091 "event type {} at sequence {} is not projectable by snapshot v1",
1092 entries[index].event.type_name(),
1093 entries[index].sequence
1094 )));
1095 }
1096 }
1097 }
1098 for entry in &entries[..compact_count] {
1099 projection.apply(&entry.event);
1100 }
1101 let checkpoint = compact_count
1102 .checked_sub(1)
1103 .and_then(|index| entries.get(index));
1104 let checkpoint_sequence = checkpoint.map_or(base_sequence, |entry| entry.sequence);
1105 let checkpoint_digest = checkpoint
1106 .map(|entry| entry.entry_digest.clone())
1107 .or(base_digest);
1108 let created_at = Utc::now();
1109 let snapshot =
1110 projection.into_snapshot(checkpoint_sequence, checkpoint_digest.clone(), created_at)?;
1111 let retained_tail = entries[compact_count..].to_vec();
1112 let mut receipt = CompactionReceipt {
1113 format_version: "claim-ledger.compaction-receipt.v1".to_string(),
1114 snapshot_digest: snapshot.snapshot_digest.clone(),
1115 snapshot_sequence: snapshot.last_compacted_sequence,
1116 snapshot_entry_digest: snapshot.last_compacted_entry_digest.clone(),
1117 pre_compaction_sequence: pre_head.last_sequence,
1118 pre_compaction_entry_digest: pre_head.last_entry_digest,
1119 retained_tail_start_sequence: retained_tail.first().map(|entry| entry.sequence),
1120 retained_tail_entries: u64::try_from(retained_tail.len()).map_err(|_| {
1121 ClaimLedgerError::SerializationError("retained tail exceeds u64".into())
1122 })?,
1123 previous_snapshot_digest,
1124 created_at,
1125 receipt_digest: String::new(),
1126 };
1127 receipt.receipt_digest = compute_compaction_receipt_digest(&receipt)?;
1128 verify_compaction(&snapshot, &retained_tail, &receipt)?;
1129 Ok(CompactedLedger {
1130 snapshot,
1131 retained_tail,
1132 receipt,
1133 })
1134}
1135
1136pub fn parse_ledger_entries(jsonl: &str) -> Result<Vec<LedgerEntry>, ClaimLedgerError> {
1138 jsonl
1139 .lines()
1140 .enumerate()
1141 .filter(|(_, line)| !line.trim().is_empty())
1142 .map(|(index, line)| {
1143 serde_json::from_str(line).map_err(|error| {
1144 ClaimLedgerError::SerializationError(format!(
1145 "invalid ledger JSONL at line {}: {}",
1146 index + 1,
1147 error
1148 ))
1149 })
1150 })
1151 .collect()
1152}
1153
1154pub fn serialize_entry(entry: &LedgerEntry) -> Result<String, ClaimLedgerError> {
1156 serde_json::to_string(entry)
1157 .map_err(|error| ClaimLedgerError::SerializationError(error.to_string()))
1158}