Skip to main content

claim_ledger/
ledger.rs

1//! Append-only hash-chained ledger for claim events.
2//!
3//! The ledger is a newline-delimited JSON (JSONL) file where each entry contains:
4//! - A sequence number
5//! - A hash of the previous entry (for chain integrity)
6//! - The event payload
7//! - A hash of the entry itself
8//!
9//! This structure allows the ledger to be verified by checking the hash chain
10//! and makes it possible to detect tampering or corruption.
11
12use serde::{Deserialize, Serialize};
13
14use super::types::SupportState;
15use crate::ids::sha256_text;
16
17/// An entry in the claim ledger.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct LedgerEntry {
20    /// Sequence number (1-indexed).
21    pub sequence: u64,
22    /// Digest of the previous entry (None for first entry).
23    pub previous_entry_digest: Option<String>,
24    /// The event payload.
25    pub event: LedgerEvent,
26    /// Digest of this entry (computed from sequence + previous + event).
27    pub entry_digest: String,
28}
29
30/// Events that can be appended to the ledger.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum LedgerEvent {
34    /// A claim was added to the bundle.
35    ClaimAdded {
36        claim_id: String,
37        source_id: String,
38        span_id: String,
39        normalized_claim: String,
40    },
41    /// A support judgment was assigned to a claim.
42    SupportJudgment {
43        support_judgment_id: String,
44        claim_id: String,
45        evidence_bundle_ref: String,
46        support_state: SupportState,
47        method: String,
48    },
49    /// A support admission was recorded.
50    SupportAdmission {
51        support_admission_receipt_id: String,
52        claim_id: String,
53        previous_support_judgment_ref: String,
54        new_support_judgment_ref: String,
55        admitted_support_state: SupportState,
56    },
57    /// A contradiction candidate was recorded.
58    ContradictionCandidate {
59        contradiction_id: String,
60        claim_refs: Vec<String>,
61        pattern: String,
62        rationale: String,
63    },
64    /// A contradiction was resolved.
65    ContradictionResolved {
66        contradiction_resolution_receipt_id: String,
67        contradiction_id: String,
68        resolution: String,
69        affected_claim_refs: Vec<String>,
70    },
71    /// An evidence bundle was attached to a claim.
72    EvidenceAttached {
73        evidence_bundle_id: String,
74        claim_id: String,
75        evidence_link_count: usize,
76    },
77    /// A bundle was exported.
78    BundleExported {
79        bundle_id: String,
80        export_receipt_id: String,
81        output_ref: String,
82        output_digest: String,
83    },
84    /// Proof-debt budget was consumed.
85    ProofDebtConsumed {
86        budget_id: String,
87        debit_id: String,
88        amount_micros: u64,
89        source: String,
90        overdrawn: bool,
91    },
92    /// Proof-debt budget was replenished (evidence, admission, or waiver).
93    ProofDebtReplenished {
94        budget_id: String,
95        credit_id: String,
96        amount_micros: u64,
97        source: String,
98    },
99}
100
101impl LedgerEvent {
102    /// Get the event type name as a string.
103    pub fn type_name(&self) -> &'static str {
104        match self {
105            Self::ClaimAdded { .. } => "claim_added",
106            Self::SupportJudgment { .. } => "support_judgment",
107            Self::SupportAdmission { .. } => "support_admission",
108            Self::ContradictionCandidate { .. } => "contradiction_candidate",
109            Self::ContradictionResolved { .. } => "contradiction_resolved",
110            Self::EvidenceAttached { .. } => "evidence_attached",
111            Self::BundleExported { .. } => "bundle_exported",
112            Self::ProofDebtConsumed { .. } => "proof_debt_consumed",
113            Self::ProofDebtReplenished { .. } => "proof_debt_replenished",
114        }
115    }
116}
117
118/// Result of verifying a ledger.
119#[derive(Debug, Clone)]
120pub struct LedgerVerification {
121    /// Whether the ledger is valid.
122    pub valid: bool,
123    /// Sequence of the last valid entry.
124    pub last_sequence: u64,
125    /// Last entry digest in the chain.
126    pub last_entry_digest: Option<String>,
127    /// Errors encountered during verification.
128    pub errors: Vec<String>,
129}
130
131impl Default for LedgerVerification {
132    fn default() -> Self {
133        Self {
134            valid: true,
135            last_sequence: 0,
136            last_entry_digest: None,
137            errors: Vec::new(),
138        }
139    }
140}
141
142/// A builder for ledger entries with hash chain support.
143pub struct LedgerEntryBuilder {
144    sequence: u64,
145    previous_entry_digest: Option<String>,
146}
147
148impl LedgerEntryBuilder {
149    /// Create a new builder for the next entry.
150    pub fn new(sequence: u64, previous_entry_digest: Option<String>) -> Self {
151        Self {
152            sequence,
153            previous_entry_digest,
154        }
155    }
156
157    /// Append a claim to the ledger.
158    pub fn add_claim(
159        self,
160        claim_id: &str,
161        source_id: &str,
162        span_id: &str,
163        normalized_claim: &str,
164    ) -> LedgerEntry {
165        let event = LedgerEvent::ClaimAdded {
166            claim_id: claim_id.to_string(),
167            source_id: source_id.to_string(),
168            span_id: span_id.to_string(),
169            normalized_claim: normalized_claim.to_string(),
170        };
171        self._build_entry(event)
172    }
173
174    /// Add a support judgment event.
175    pub fn add_support_judgment(
176        self,
177        support_judgment_id: &str,
178        claim_id: &str,
179        evidence_bundle_ref: &str,
180        support_state: SupportState,
181        method: &str,
182    ) -> LedgerEntry {
183        let event = LedgerEvent::SupportJudgment {
184            support_judgment_id: support_judgment_id.to_string(),
185            claim_id: claim_id.to_string(),
186            evidence_bundle_ref: evidence_bundle_ref.to_string(),
187            support_state,
188            method: method.to_string(),
189        };
190        self._build_entry(event)
191    }
192
193    /// Add a support admission event.
194    pub fn add_support_admission(
195        self,
196        support_admission_receipt_id: &str,
197        claim_id: &str,
198        previous_ref: &str,
199        new_ref: &str,
200        admitted_state: SupportState,
201    ) -> LedgerEntry {
202        let event = LedgerEvent::SupportAdmission {
203            support_admission_receipt_id: support_admission_receipt_id.to_string(),
204            claim_id: claim_id.to_string(),
205            previous_support_judgment_ref: previous_ref.to_string(),
206            new_support_judgment_ref: new_ref.to_string(),
207            admitted_support_state: admitted_state,
208        };
209        self._build_entry(event)
210    }
211
212    /// Add a contradiction candidate event.
213    pub fn add_contradiction_candidate(
214        self,
215        contradiction_id: &str,
216        claim_refs: Vec<String>,
217        pattern: &str,
218        rationale: &str,
219    ) -> LedgerEntry {
220        let event = LedgerEvent::ContradictionCandidate {
221            contradiction_id: contradiction_id.to_string(),
222            claim_refs,
223            pattern: pattern.to_string(),
224            rationale: rationale.to_string(),
225        };
226        self._build_entry(event)
227    }
228
229    /// Add a contradiction resolution event.
230    pub fn add_contradiction_resolved(
231        self,
232        receipt_id: &str,
233        contradiction_id: &str,
234        resolution: &str,
235        affected_claim_refs: Vec<String>,
236    ) -> LedgerEntry {
237        let event = LedgerEvent::ContradictionResolved {
238            contradiction_resolution_receipt_id: receipt_id.to_string(),
239            contradiction_id: contradiction_id.to_string(),
240            resolution: resolution.to_string(),
241            affected_claim_refs,
242        };
243        self._build_entry(event)
244    }
245
246    /// Add a proof-debt consumption event.
247    pub fn add_proof_debt_consumed(
248        self,
249        budget_id: &str,
250        debit_id: &str,
251        amount_micros: u64,
252        source: &str,
253        overdrawn: bool,
254    ) -> LedgerEntry {
255        let event = LedgerEvent::ProofDebtConsumed {
256            budget_id: budget_id.to_string(),
257            debit_id: debit_id.to_string(),
258            amount_micros,
259            source: source.to_string(),
260            overdrawn,
261        };
262        self._build_entry(event)
263    }
264
265    /// Add a proof-debt replenishment event.
266    pub fn add_proof_debt_replenished(
267        self,
268        budget_id: &str,
269        credit_id: &str,
270        amount_micros: u64,
271        source: &str,
272    ) -> LedgerEntry {
273        let event = LedgerEvent::ProofDebtReplenished {
274            budget_id: budget_id.to_string(),
275            credit_id: credit_id.to_string(),
276            amount_micros,
277            source: source.to_string(),
278        };
279        self._build_entry(event)
280    }
281
282    fn _build_entry(self, event: LedgerEvent) -> LedgerEntry {
283        let entry_content = serde_json::json!({
284            "sequence": self.sequence,
285            "previous_entry_digest": self.previous_entry_digest,
286            "event": event,
287        });
288
289        let entry_json = serde_json::to_string(&entry_content).unwrap_or_default();
290        let digest = sha256_text(&entry_json);
291
292        LedgerEntry {
293            sequence: self.sequence,
294            previous_entry_digest: self.previous_entry_digest,
295            event,
296            entry_digest: digest,
297        }
298    }
299}
300
301/// Compute the digest for an entry.
302pub fn compute_entry_digest(
303    sequence: u64,
304    previous_digest: Option<&str>,
305    event: &LedgerEvent,
306) -> String {
307    let content = serde_json::json!({
308        "sequence": sequence,
309        "previous_entry_digest": previous_digest,
310        "event": event,
311    });
312    let full_json = serde_json::to_string(&content).unwrap_or_default();
313    sha256_text(&full_json)
314}
315
316/// Verify the integrity of a ledger from a list of entries.
317pub fn verify_ledger(entries: &[LedgerEntry]) -> LedgerVerification {
318    let mut verification = LedgerVerification::default();
319
320    for (i, entry) in entries.iter().enumerate() {
321        let expected_seq = (i + 1) as u64;
322        if entry.sequence != expected_seq {
323            verification.valid = false;
324            verification.errors.push(format!(
325                "sequence mismatch at index {}: expected {}, got {}",
326                i, expected_seq, entry.sequence
327            ));
328        }
329
330        if i == 0 {
331            if entry.previous_entry_digest.is_some() {
332                verification.valid = false;
333                verification
334                    .errors
335                    .push("first entry should have no previous_entry_digest".to_string());
336            }
337        } else {
338            let prev = &entries[i - 1];
339            if entry.previous_entry_digest.as_ref() != Some(&prev.entry_digest) {
340                verification.valid = false;
341                verification.errors.push(format!(
342                    "previous_entry_digest mismatch at sequence {}: expected {}, got {:?}",
343                    entry.sequence, prev.entry_digest, entry.previous_entry_digest
344                ));
345            }
346        }
347
348        let computed = compute_entry_digest(
349            entry.sequence,
350            entry.previous_entry_digest.as_deref(),
351            &entry.event,
352        );
353        if computed != entry.entry_digest {
354            verification.valid = false;
355            verification.errors.push(format!(
356                "entry_digest mismatch at sequence {}: expected {}, got {}",
357                entry.sequence, computed, entry.entry_digest
358            ));
359        }
360
361        verification.last_sequence = entry.sequence;
362        verification.last_entry_digest = Some(entry.entry_digest.clone());
363    }
364
365    verification
366}
367
368/// Parse ledger entries from JSONL text.
369pub fn parse_ledger_entries(jsonl: &str) -> Vec<LedgerEntry> {
370    jsonl
371        .lines()
372        .filter(|line| !line.trim().is_empty())
373        .filter_map(|line| serde_json::from_str(line).ok())
374        .collect()
375}
376
377/// Serialize a ledger entry to a JSON line.
378pub fn serialize_entry(entry: &LedgerEntry) -> String {
379    serde_json::to_string(entry).unwrap_or_default()
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn ledger_entry_builder_add_claim() {
388        let entry = LedgerEntryBuilder::new(1, None).add_claim(
389            "clm_abc123",
390            "src1",
391            "sp1",
392            "the sky is blue",
393        );
394
395        assert_eq!(entry.sequence, 1);
396        assert!(entry.previous_entry_digest.is_none());
397        assert!(!entry.entry_digest.is_empty());
398        if let LedgerEvent::ClaimAdded { claim_id, .. } = entry.event {
399            assert_eq!(claim_id, "clm_abc123");
400        } else {
401            panic!("expected ClaimAdded event");
402        }
403    }
404
405    #[test]
406    fn ledger_chain_integrity() {
407        let entry1 = LedgerEntryBuilder::new(1, None).add_claim("clm_a", "src1", "sp1", "claim a");
408        let entry2 = LedgerEntryBuilder::new(2, Some(entry1.entry_digest.clone()))
409            .add_claim("clm_b", "src1", "sp2", "claim b");
410
411        assert_eq!(entry2.sequence, 2);
412        assert_eq!(
413            entry2.previous_entry_digest,
414            Some(entry1.entry_digest.clone())
415        );
416    }
417
418    #[test]
419    fn verify_ledger_accepts_valid_chain() {
420        let entry1 = LedgerEntryBuilder::new(1, None).add_claim("clm_a", "src1", "sp1", "claim a");
421        let entry2 = LedgerEntryBuilder::new(2, Some(entry1.entry_digest.clone()))
422            .add_claim("clm_b", "src1", "sp2", "claim b");
423
424        let verification = verify_ledger(&[entry1, entry2]);
425        assert!(verification.valid);
426        assert_eq!(verification.last_sequence, 2);
427    }
428
429    #[test]
430    fn verify_ledger_detects_broken_chain() {
431        let entry1 = LedgerEntryBuilder::new(1, None).add_claim("clm_a", "src1", "sp1", "claim a");
432        // entry2 with wrong previous digest
433        let entry2 = LedgerEntryBuilder::new(2, Some("wrong_digest".to_string()))
434            .add_claim("clm_b", "src1", "sp2", "claim b");
435
436        let verification = verify_ledger(&[entry1, entry2]);
437        assert!(!verification.valid);
438        assert!(!verification.errors.is_empty());
439    }
440
441    #[test]
442    fn parse_ledger_entries_from_jsonl() {
443        let jsonl = r#"{"sequence":1,"previous_entry_digest":null,"event":{"type":"claim_added","claim_id":"clm_1","source_id":"s1","span_id":"sp1","normalized_claim":"test"},"entry_digest":"abc"}
444{"sequence":2,"previous_entry_digest":"abc","event":{"type":"claim_added","claim_id":"clm_2","source_id":"s1","span_id":"sp2","normalized_claim":"test2"},"entry_digest":"def"}"#;
445
446        let entries = parse_ledger_entries(jsonl);
447        assert_eq!(entries.len(), 2);
448        assert_eq!(entries[0].sequence, 1);
449        assert_eq!(entries[1].sequence, 2);
450    }
451
452    #[test]
453    fn serialize_entry_round_trips() {
454        let entry =
455            LedgerEntryBuilder::new(1, None).add_claim("clm_test", "src1", "sp1", "hello world");
456
457        let json = serialize_entry(&entry);
458        let parsed: LedgerEntry = serde_json::from_str(&json).unwrap();
459        assert_eq!(parsed.sequence, entry.sequence);
460        assert_eq!(parsed.entry_digest, entry.entry_digest);
461    }
462
463    #[test]
464    fn ledger_event_type_name() {
465        let event = LedgerEvent::ClaimAdded {
466            claim_id: "clm_1".to_string(),
467            source_id: "s1".to_string(),
468            span_id: "sp1".to_string(),
469            normalized_claim: "test".to_string(),
470        };
471        assert_eq!(event.type_name(), "claim_added");
472    }
473}