1use serde::{Deserialize, Serialize};
13
14use super::types::SupportState;
15use crate::ids::sha256_text;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct LedgerEntry {
20 pub sequence: u64,
22 pub previous_entry_digest: Option<String>,
24 pub event: LedgerEvent,
26 pub entry_digest: String,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum LedgerEvent {
34 ClaimAdded {
36 claim_id: String,
37 source_id: String,
38 span_id: String,
39 normalized_claim: String,
40 },
41 SupportJudgment {
43 support_judgment_id: String,
44 claim_id: String,
45 evidence_bundle_ref: String,
46 support_state: SupportState,
47 method: String,
48 },
49 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 ContradictionCandidate {
59 contradiction_id: String,
60 claim_refs: Vec<String>,
61 pattern: String,
62 rationale: String,
63 },
64 ContradictionResolved {
66 contradiction_resolution_receipt_id: String,
67 contradiction_id: String,
68 resolution: String,
69 affected_claim_refs: Vec<String>,
70 },
71 EvidenceAttached {
73 evidence_bundle_id: String,
74 claim_id: String,
75 evidence_link_count: usize,
76 },
77 BundleExported {
79 bundle_id: String,
80 export_receipt_id: String,
81 output_ref: String,
82 output_digest: String,
83 },
84 ProofDebtConsumed {
86 budget_id: String,
87 debit_id: String,
88 amount_micros: u64,
89 source: String,
90 overdrawn: bool,
91 },
92 ProofDebtReplenished {
94 budget_id: String,
95 credit_id: String,
96 amount_micros: u64,
97 source: String,
98 },
99}
100
101impl LedgerEvent {
102 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#[derive(Debug, Clone)]
120pub struct LedgerVerification {
121 pub valid: bool,
123 pub last_sequence: u64,
125 pub last_entry_digest: Option<String>,
127 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
142pub struct LedgerEntryBuilder {
144 sequence: u64,
145 previous_entry_digest: Option<String>,
146}
147
148impl LedgerEntryBuilder {
149 pub fn new(sequence: u64, previous_entry_digest: Option<String>) -> Self {
151 Self {
152 sequence,
153 previous_entry_digest,
154 }
155 }
156
157 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 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 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 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 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 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 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
301pub 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
316pub 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
368pub 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
377pub 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 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}