claim-ledger 0.2.1

Deterministic, local-first claim/evidence/provenance ledger. Creates receipts for all material operations.
Documentation
use claim_ledger::{
    compact_ledger, compact_ledger_from_snapshot, compute_entry_digest, verify_compaction,
    verify_snapshot, CompactionPolicy, LedgerEntry, LedgerEvent, SupportState,
    UnprojectableEventPolicy,
};

fn append(entries: &mut Vec<LedgerEntry>, event: LedgerEvent) {
    let sequence = entries.len() as u64 + 1;
    let previous_entry_digest = entries.last().map(|entry| entry.entry_digest.clone());
    let entry_digest = compute_entry_digest(sequence, previous_entry_digest.as_deref(), &event)
        .expect("entry digest");
    entries.push(LedgerEntry {
        sequence,
        previous_entry_digest,
        event,
        entry_digest,
    });
}

fn projected_entries() -> Vec<LedgerEntry> {
    let mut entries = Vec::new();
    append(
        &mut entries,
        LedgerEvent::ClaimAdded {
            claim_id: "claim-1".into(),
            source_id: "semantic-memory:fact:fact-1".into(),
            span_id: "span-1".into(),
            normalized_claim: "the claim".into(),
        },
    );
    append(
        &mut entries,
        LedgerEvent::SupportJudgment {
            support_judgment_id: "judgment-1".into(),
            claim_id: "claim-1".into(),
            evidence_bundle_ref: "bundle-1".into(),
            support_state: SupportState::Supported,
            method: "operator".into(),
        },
    );
    append(
        &mut entries,
        LedgerEvent::ContradictionCandidate {
            contradiction_id: "contradiction-1".into(),
            claim_refs: vec!["claim-1".into()],
            pattern: "test".into(),
            rationale: "conflict".into(),
        },
    );
    append(
        &mut entries,
        LedgerEvent::ContradictionResolved {
            contradiction_resolution_receipt_id: "resolution-1".into(),
            contradiction_id: "contradiction-1".into(),
            resolution: "confirmed".into(),
            affected_claim_refs: vec!["claim-1".into()],
        },
    );
    entries
}

#[test]
fn snapshot_round_trip_and_receipt_verify() {
    let entries = projected_entries();
    let compacted = compact_ledger(
        &entries,
        &CompactionPolicy {
            retain_tail_entries: 1,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .unwrap();
    let json = serde_json::to_string(&compacted.snapshot).unwrap();
    let decoded = serde_json::from_str(&json).unwrap();
    assert_eq!(compacted.snapshot, decoded);
    verify_snapshot(&decoded).unwrap();
    verify_compaction(&decoded, &compacted.retained_tail, &compacted.receipt).unwrap();
}

#[test]
fn tampered_snapshot_is_rejected() {
    let mut snapshot = compact_ledger(
        &projected_entries(),
        &CompactionPolicy {
            retain_tail_entries: 0,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .unwrap()
    .snapshot;
    snapshot.claims[0].normalized_claim = "tampered".into();
    assert!(verify_snapshot(&snapshot).is_err());
}

#[test]
fn compacted_replay_equals_full_replay() {
    let entries = projected_entries();
    let full = compact_ledger(
        &entries,
        &CompactionPolicy {
            retain_tail_entries: 0,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .unwrap();
    let first = compact_ledger(
        &entries,
        &CompactionPolicy {
            retain_tail_entries: 2,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .unwrap();
    let replayed = compact_ledger_from_snapshot(
        Some(&first.snapshot),
        &first.retained_tail,
        &CompactionPolicy {
            retain_tail_entries: 0,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .unwrap();
    assert_eq!(full.snapshot.claims, replayed.snapshot.claims);
    assert_eq!(
        full.snapshot.fact_to_claim_links,
        replayed.snapshot.fact_to_claim_links
    );
    assert_eq!(
        full.snapshot.content_to_claim_links,
        replayed.snapshot.content_to_claim_links
    );
    assert_eq!(
        full.snapshot.support_judgments,
        replayed.snapshot.support_judgments
    );
    assert_eq!(full.snapshot.claim_support, replayed.snapshot.claim_support);
    assert_eq!(
        full.snapshot.contradiction_states,
        replayed.snapshot.contradiction_states
    );
    assert_eq!(
        full.snapshot.last_compacted_sequence,
        replayed.snapshot.last_compacted_sequence
    );
    assert_eq!(
        full.snapshot.last_compacted_entry_digest,
        replayed.snapshot.last_compacted_entry_digest
    );
}

#[test]
fn unprojectable_events_are_retained_or_fail_closed() {
    let mut entries = projected_entries();
    entries.insert(
        1,
        LedgerEntry {
            sequence: 0,
            previous_entry_digest: None,
            event: LedgerEvent::BundleExported {
                bundle_id: "bundle".into(),
                export_receipt_id: "receipt".into(),
                output_ref: "output".into(),
                output_digest: "digest".into(),
            },
            entry_digest: String::new(),
        },
    );
    let events: Vec<_> = entries.into_iter().map(|entry| entry.event).collect();
    let mut entries = Vec::new();
    for event in events {
        append(&mut entries, event);
    }

    let retained = compact_ledger(
        &entries,
        &CompactionPolicy {
            retain_tail_entries: 0,
            unprojectable_events: UnprojectableEventPolicy::Retain,
        },
    )
    .unwrap();
    assert_eq!(retained.snapshot.last_compacted_sequence, 1);
    assert!(matches!(
        retained.retained_tail[0].event,
        LedgerEvent::BundleExported { .. }
    ));
    verify_compaction(
        &retained.snapshot,
        &retained.retained_tail,
        &retained.receipt,
    )
    .unwrap();

    assert!(compact_ledger(
        &entries,
        &CompactionPolicy {
            retain_tail_entries: 0,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .is_err());
}

#[test]
fn unknown_event_types_fail_closed_before_compaction() {
    let entry = &projected_entries()[0];
    let mut value = serde_json::to_value(entry).unwrap();
    value["event"]["type"] = serde_json::json!("future_unknown_event");
    let jsonl = format!("{}\n", serde_json::to_string(&value).unwrap());
    assert!(claim_ledger::parse_ledger_entries(&jsonl).is_err());
}

#[test]
fn snapshot_preserves_last_writer_content_link() {
    let mut entries = Vec::new();
    for claim_id in ["claim-z", "claim-a"] {
        append(
            &mut entries,
            LedgerEvent::ClaimAdded {
                claim_id: claim_id.into(),
                source_id: format!("source:{claim_id}"),
                span_id: "span".into(),
                normalized_claim: "duplicate content".into(),
            },
        );
    }
    let compacted = compact_ledger(
        &entries,
        &CompactionPolicy {
            retain_tail_entries: 0,
            unprojectable_events: UnprojectableEventPolicy::FailClosed,
        },
    )
    .unwrap();
    assert_eq!(compacted.snapshot.content_to_claim_links.len(), 1);
    assert_eq!(
        compacted.snapshot.content_to_claim_links[0].claim_id,
        "claim-a"
    );
}