Skip to main content

claim_ledger/
ledger.rs

1//! Append-only, hash-chained ledger for claim events.
2//!
3//! ## Canonical digest preimage (v1)
4//!
5//! Entry digests are SHA-256 over a binary, language-independent preimage. It
6//! is deliberately not JSON, so it does not depend on map ordering or a JSON
7//! serializer. A verifier writes the UTF-8 bytes of
8//! `claim-ledger.entry-digest.v1`, then the sequence as an unsigned 64-bit
9//! big-endian integer, then the previous digest as `0x00` for absent or `0x01`
10//! followed by its byte-length-prefixed UTF-8 value. It then writes the event
11//! tag as a byte-length-prefixed UTF-8 value and each event field in declaration
12//! order. Strings are UTF-8 prefixed with their unsigned 64-bit big-endian byte
13//! length; integer fields are unsigned 64-bit big-endian; booleans are one byte
14//! (`0x00` or `0x01`); vectors are an unsigned 64-bit count followed by encoded
15//! strings. Recorded timestamps and `entry_digest` are excluded.
16
17use 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/// An entry in the claim ledger.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LedgerEntry {
28    /// Sequence number (1-indexed).
29    pub sequence: u64,
30    /// Digest of the previous entry (None for first entry).
31    pub previous_entry_digest: Option<String>,
32    /// The event payload.
33    pub event: LedgerEvent,
34    /// SHA-256 digest of the canonical entry preimage.
35    pub entry_digest: String,
36}
37
38/// Events that can be appended to the ledger.
39#[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    /// Get the stable event type name used in the digest preimage.
102    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/// The head a verifier expects to authenticate.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum ExpectedLedgerHead {
120    /// An intentionally empty ledger; no entries may be present.
121    Empty,
122    /// A non-empty ledger whose final sequence and digest must match exactly.
123    Entry { sequence: u64, entry_digest: String },
124}
125
126impl ExpectedLedgerHead {
127    /// Construct the expected head for an empty ledger.
128    pub const fn empty() -> Self {
129        Self::Empty
130    }
131
132    /// Construct the expected head for a non-empty ledger.
133    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/// Successful verification details.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct LedgerVerification {
144    /// Sequence of the authenticated final entry, or zero when empty.
145    pub last_sequence: u64,
146    /// Digest of the authenticated final entry, if any.
147    pub last_entry_digest: Option<String>,
148}
149
150/// Current claim content captured by a ledger checkpoint.
151#[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/// Durable fact-to-claim link derived from a claim's semantic-memory source id.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct SnapshotFactClaimLink {
162    pub fact_id: String,
163    pub claim_id: String,
164}
165
166/// Last-writer-wins reverse mapping used for content-based claim linking.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct SnapshotContentClaimLink {
169    pub normalized_claim: String,
170    pub claim_id: String,
171}
172
173/// Latest support-judgment payload for a claim at the checkpoint.
174#[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/// Authoritative projected support state after all checkpoint events are folded.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct SnapshotClaimSupport {
186    pub claim_id: String,
187    pub support_state: SupportState,
188}
189
190/// Projected lifecycle state of a contradiction at the checkpoint.
191#[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/// Verified checkpoint of the state projected from a prefix of the claim ledger.
202///
203/// Vector fields are sorted by their stable identifiers before hashing. The
204/// `snapshot_digest` is SHA-256 over a domain-separated JSON encoding of this
205/// structure with `snapshot_digest` cleared. Because this format contains no
206/// JSON maps or floating-point values, its v1 encoding is deterministic.
207#[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/// Receipt binding a verified snapshot to the original pre-compaction head.
223#[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/// Handling for a ledger event that a snapshot projector does not understand.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum UnprojectableEventPolicy {
242    /// Keep the event and every later entry in the retained, verifiable tail.
243    Retain,
244    /// Refuse to compact rather than discard an event.
245    FailClosed,
246}
247
248/// Explicit policy controlling which prefix may be checkpointed.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct CompactionPolicy {
251    /// Minimum number of most-recent events retained verbatim.
252    pub retain_tail_entries: usize,
253    /// Behavior for events not covered by the v1 projector.
254    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/// Result of a successful in-memory compaction plan.
267#[derive(Debug, Clone)]
268pub struct CompactedLedger {
269    pub snapshot: LedgerSnapshot,
270    pub retained_tail: Vec<LedgerEntry>,
271    pub receipt: CompactionReceipt,
272}
273
274/// A builder for ledger entries with hash-chain support.
275pub struct LedgerEntryBuilder {
276    sequence: u64,
277    previous_entry_digest: Option<String>,
278}
279
280impl LedgerEntryBuilder {
281    /// Create a builder for the next entry.
282    pub fn new(sequence: u64, previous_entry_digest: Option<String>) -> Self {
283        Self {
284            sequence,
285            previous_entry_digest,
286        }
287    }
288
289    /// Append a claim to the ledger.
290    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    /// Add a support judgment event.
306    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    /// Add a support admission event.
324    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    /// Add a contradiction candidate event.
342    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    /// Add a contradiction resolution event.
358    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    /// Add a proof-debt consumption event.
374    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    /// Add a proof-debt replenishment event.
392    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
455/// Produce the documented canonical preimage for an entry digest.
456pub 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
584/// Compute the SHA-256 digest for an entry's canonical preimage.
585pub 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
597/// Verify a ledger's chain and bind it to the supplied expected head.
598pub 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
892/// Compute the canonical digest of a snapshot (excluding its digest field).
893pub 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
914/// Verify a snapshot's format, canonical ordering, anchor, and digest.
915pub 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
953/// Verify a tail whose first entry continues from a snapshot checkpoint.
954pub 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
1002/// Verify the snapshot digest, anchored retained tail, receipt digest, and
1003/// binding to the original pre-compaction ledger head.
1004pub 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
1054/// Compact a complete sequence-1 ledger into a verified checkpoint and tail.
1055pub fn compact_ledger(
1056    entries: &[LedgerEntry],
1057    policy: &CompactionPolicy,
1058) -> Result<CompactedLedger, ClaimLedgerError> {
1059    compact_ledger_from_snapshot(None, entries, policy)
1060}
1061
1062/// Advance an existing verified checkpoint using its anchored retained tail.
1063pub 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
1136/// Parse JSONL strictly. Blank lines are ignored; malformed nonblank lines fail with a one-based line number.
1137pub 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
1154/// Serialize a ledger entry as exactly one JSON line.
1155pub fn serialize_entry(entry: &LedgerEntry) -> Result<String, ClaimLedgerError> {
1156    serde_json::to_string(entry)
1157        .map_err(|error| ClaimLedgerError::SerializationError(error.to_string()))
1158}