1use std::fmt;
20use std::fs::File;
21use std::io::{BufRead, BufReader, Read};
22use std::path::{Path, PathBuf};
23use std::str::FromStr;
24
25use chrono::{DateTime, SecondsFormat, Utc};
26use thiserror::Error;
27
28use crate::hash::{event_hash, payload_hash, HEX_HASH_LEN};
29use crate::jsonl::JsonlError;
30use crate::signed_row::SignedRow;
31
32pub const ANCHOR_FORMAT_HEADER_V1: &str = "# cortex-ledger-anchor-format: 1";
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct LedgerAnchor {
38 pub timestamp: DateTime<Utc>,
40 pub event_count: u64,
44 pub chain_head_hash: String,
46}
47
48impl LedgerAnchor {
49 pub fn new(
51 timestamp: DateTime<Utc>,
52 event_count: u64,
53 chain_head_hash: impl Into<String>,
54 ) -> Result<Self, AnchorParseError> {
55 let chain_head_hash = chain_head_hash.into();
56 validate_chain_head_hash(&chain_head_hash)?;
57 Ok(Self {
58 timestamp,
59 event_count,
60 chain_head_hash,
61 })
62 }
63
64 #[must_use]
66 pub fn to_anchor_text(&self) -> String {
67 format!(
68 "{ANCHOR_FORMAT_HEADER_V1}\n{} {} {}\n",
69 self.timestamp.to_rfc3339_opts(SecondsFormat::Secs, true),
70 self.event_count,
71 self.chain_head_hash
72 )
73 }
74}
75
76impl fmt::Display for LedgerAnchor {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 f.write_str(&self.to_anchor_text())
79 }
80}
81
82impl FromStr for LedgerAnchor {
83 type Err = AnchorParseError;
84
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
86 parse_anchor(s)
87 }
88}
89
90pub fn parse_anchor(input: &str) -> Result<LedgerAnchor, AnchorParseError> {
92 let mut lines = input.lines();
93 let Some(header) = lines.next() else {
94 return Err(AnchorParseError::MissingHeader);
95 };
96 if header != ANCHOR_FORMAT_HEADER_V1 {
97 return Err(AnchorParseError::UnknownFormatHeader {
98 observed: header.to_string(),
99 });
100 }
101
102 let Some(body) = lines.next() else {
103 return Err(AnchorParseError::MissingBody);
104 };
105 if body.trim() != body {
106 return Err(AnchorParseError::MalformedBody {
107 reason: "body line must not have leading or trailing whitespace".to_string(),
108 });
109 }
110 if lines.next().is_some() {
111 return Err(AnchorParseError::TrailingContent);
112 }
113
114 let parts: Vec<&str> = body.split(' ').collect();
115 if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
116 return Err(AnchorParseError::MalformedBody {
117 reason: "expected exactly: <timestamp> <event_count> <chain_head_hash>".to_string(),
118 });
119 }
120
121 let timestamp = DateTime::parse_from_rfc3339(parts[0])
122 .map_err(|source| AnchorParseError::InvalidTimestamp {
123 value: parts[0].to_string(),
124 message: source.to_string(),
125 })?
126 .with_timezone(&Utc);
127 let event_count =
128 parts[1]
129 .parse::<u64>()
130 .map_err(|source| AnchorParseError::InvalidEventCount {
131 value: parts[1].to_string(),
132 message: source.to_string(),
133 })?;
134 let chain_head_hash = parts[2].to_string();
135 validate_chain_head_hash(&chain_head_hash)?;
136
137 Ok(LedgerAnchor {
138 timestamp,
139 event_count,
140 chain_head_hash,
141 })
142}
143
144pub fn parse_anchor_history(input: &str) -> Result<Vec<LedgerAnchor>, AnchorParseError> {
159 let mut lines = input.lines();
160 let mut anchors = Vec::new();
161
162 loop {
163 let Some(header) = lines.next() else {
164 break;
165 };
166 let Some(body) = lines.next() else {
167 return Err(AnchorParseError::MissingBody);
168 };
169 anchors.push(parse_anchor(&format!("{header}\n{body}\n"))?);
170 }
171
172 if anchors.is_empty() {
173 return Err(AnchorParseError::MissingHeader);
174 }
175 Ok(anchors)
176}
177
178pub fn verify_anchor(
184 path: impl AsRef<Path>,
185 anchor: &LedgerAnchor,
186) -> Result<AnchorVerification, AnchorVerifyError> {
187 let path = path.as_ref().to_path_buf();
188 let file = File::open(&path).map_err(|source| JsonlError::Io {
189 path: path.clone(),
190 source,
191 })?;
192 let mut prev_event_hash: Option<String> = None;
193 let mut db_count = 0u64;
194 let mut hash_at_anchor_position: Option<String> = None;
195
196 for (i, line_result) in BufReader::new(file).lines().enumerate() {
197 let line = i + 1;
198 let line_text = line_result.map_err(|source| JsonlError::Io {
199 path: path.clone(),
200 source,
201 })?;
202 let trimmed = line_text.trim();
203 if trimmed.is_empty() {
204 continue;
205 }
206 let row: SignedRow =
207 serde_json::from_str(trimmed).map_err(|source| JsonlError::Decode {
208 path: path.clone(),
209 line,
210 source,
211 })?;
212 let event = row.event;
213 db_count += 1;
214
215 let expected_payload_hash = payload_hash(&event.payload);
216 if event.payload_hash != expected_payload_hash {
217 return Err(AnchorVerifyError::ChainBroken {
218 path,
219 line,
220 reason: format!(
221 "payload_hash mismatch: observed {}, expected {expected_payload_hash}",
222 event.payload_hash
223 ),
224 });
225 }
226
227 let expected_event_hash = event_hash(event.prev_event_hash.as_deref(), &event.payload_hash);
228 if event.event_hash != expected_event_hash {
229 return Err(AnchorVerifyError::ChainBroken {
230 path,
231 line,
232 reason: format!(
233 "event_hash mismatch: observed {}, expected {expected_event_hash}",
234 event.event_hash
235 ),
236 });
237 }
238
239 if event.prev_event_hash != prev_event_hash {
240 return Err(AnchorVerifyError::ChainBroken {
241 path,
242 line,
243 reason: format!(
244 "prev_event_hash mismatch: observed {:?}, expected {:?}",
245 event.prev_event_hash, prev_event_hash
246 ),
247 });
248 }
249
250 if db_count == anchor.event_count {
251 hash_at_anchor_position = Some(event.event_hash.clone());
252 }
253 prev_event_hash = Some(event.event_hash);
254 }
255
256 if db_count < anchor.event_count {
257 return Err(AnchorVerifyError::Truncated {
258 path,
259 db_count,
260 anchor_event_count: anchor.event_count,
261 });
262 }
263
264 let observed = hash_at_anchor_position.ok_or_else(|| AnchorVerifyError::MissingPosition {
265 path: path.clone(),
266 anchor_event_count: anchor.event_count,
267 })?;
268 if observed != anchor.chain_head_hash {
269 return Err(AnchorVerifyError::PositionHashMismatch {
270 path,
271 event_count: anchor.event_count,
272 observed,
273 expected: anchor.chain_head_hash.clone(),
274 });
275 }
276
277 Ok(AnchorVerification {
278 path,
279 db_count,
280 db_head_hash: prev_event_hash,
281 anchor: anchor.clone(),
282 })
283}
284
285pub fn current_anchor(
290 path: impl AsRef<Path>,
291 timestamp: DateTime<Utc>,
292) -> Result<LedgerAnchor, AnchorVerifyError> {
293 let path = path.as_ref().to_path_buf();
294 let file = File::open(&path).map_err(|source| JsonlError::Io {
295 path: path.clone(),
296 source,
297 })?;
298 let mut prev_event_hash: Option<String> = None;
299 let mut db_count = 0u64;
300
301 for (i, line_result) in BufReader::new(file).lines().enumerate() {
302 let line = i + 1;
303 let line_text = line_result.map_err(|source| JsonlError::Io {
304 path: path.clone(),
305 source,
306 })?;
307 let trimmed = line_text.trim();
308 if trimmed.is_empty() {
309 continue;
310 }
311 let row: SignedRow =
312 serde_json::from_str(trimmed).map_err(|source| JsonlError::Decode {
313 path: path.clone(),
314 line,
315 source,
316 })?;
317 let event = row.event;
318 db_count += 1;
319
320 let expected_payload_hash = payload_hash(&event.payload);
321 if event.payload_hash != expected_payload_hash {
322 return Err(AnchorVerifyError::ChainBroken {
323 path,
324 line,
325 reason: format!(
326 "payload_hash mismatch: observed {}, expected {expected_payload_hash}",
327 event.payload_hash
328 ),
329 });
330 }
331
332 let expected_event_hash = event_hash(event.prev_event_hash.as_deref(), &event.payload_hash);
333 if event.event_hash != expected_event_hash {
334 return Err(AnchorVerifyError::ChainBroken {
335 path,
336 line,
337 reason: format!(
338 "event_hash mismatch: observed {}, expected {expected_event_hash}",
339 event.event_hash
340 ),
341 });
342 }
343
344 if event.prev_event_hash != prev_event_hash {
345 return Err(AnchorVerifyError::ChainBroken {
346 path,
347 line,
348 reason: format!(
349 "prev_event_hash mismatch: observed {:?}, expected {:?}",
350 event.prev_event_hash, prev_event_hash
351 ),
352 });
353 }
354
355 prev_event_hash = Some(event.event_hash);
356 }
357
358 let Some(chain_head_hash) = prev_event_hash else {
359 return Err(AnchorVerifyError::EmptyLedger { path });
360 };
361 LedgerAnchor::new(timestamp, db_count, chain_head_hash)
362 .map_err(|source| AnchorVerifyError::InternalAnchorBuild { path, source })
363}
364
365pub fn verify_anchor_history(
371 ledger_path: impl AsRef<Path>,
372 history_path: impl AsRef<Path>,
373) -> Result<AnchorHistoryVerification, AnchorHistoryVerifyError> {
374 let history_path = history_path.as_ref().to_path_buf();
375 let mut text = String::new();
376 File::open(&history_path)
377 .map_err(|source| AnchorHistoryVerifyError::ReadHistory {
378 path: history_path.clone(),
379 source,
380 })?
381 .read_to_string(&mut text)
382 .map_err(|source| AnchorHistoryVerifyError::ReadHistory {
383 path: history_path.clone(),
384 source,
385 })?;
386
387 let anchors =
388 parse_anchor_history(&text).map_err(|source| AnchorHistoryVerifyError::Parse {
389 path: history_path.clone(),
390 source,
391 })?;
392
393 let mut previous_event_count = None;
394 for (index, anchor) in anchors.iter().enumerate() {
395 if let Some(previous_event_count) = previous_event_count {
396 if anchor.event_count < previous_event_count {
397 return Err(AnchorHistoryVerifyError::NonMonotonic {
398 path: history_path,
399 anchor_index: index + 1,
400 previous_event_count,
401 event_count: anchor.event_count,
402 });
403 }
404 }
405 previous_event_count = Some(anchor.event_count);
406 }
407
408 let mut latest_verification = None;
409 for (index, anchor) in anchors.iter().enumerate() {
410 let verification = verify_anchor(&ledger_path, anchor).map_err(|source| {
411 AnchorHistoryVerifyError::Anchor {
412 path: history_path.clone(),
413 anchor_index: index + 1,
414 source: Box::new(source),
415 }
416 })?;
417 latest_verification = Some(verification);
418 }
419
420 let latest_verification = latest_verification.expect("non-empty anchor history was parsed");
421 Ok(AnchorHistoryVerification {
422 path: latest_verification.path,
423 history_path,
424 db_count: latest_verification.db_count,
425 db_head_hash: latest_verification.db_head_hash,
426 anchors_verified: anchors.len(),
427 latest_anchor: latest_verification.anchor,
428 })
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct AnchorVerification {
434 pub path: PathBuf,
436 pub db_count: u64,
438 pub db_head_hash: Option<String>,
441 pub anchor: LedgerAnchor,
443}
444
445#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct AnchorHistoryVerification {
448 pub path: PathBuf,
450 pub history_path: PathBuf,
452 pub db_count: u64,
454 pub db_head_hash: Option<String>,
457 pub anchors_verified: usize,
459 pub latest_anchor: LedgerAnchor,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Error)]
465pub enum AnchorParseError {
466 #[error("missing ledger anchor format header")]
468 MissingHeader,
469 #[error("unknown ledger anchor format header: {observed}")]
471 UnknownFormatHeader {
472 observed: String,
474 },
475 #[error("missing ledger anchor body")]
477 MissingBody,
478 #[error("malformed ledger anchor body: {reason}")]
480 MalformedBody {
481 reason: String,
483 },
484 #[error("ledger anchor has trailing content")]
486 TrailingContent,
487 #[error("invalid ledger anchor timestamp {value}: {message}")]
489 InvalidTimestamp {
490 value: String,
492 message: String,
494 },
495 #[error("invalid ledger anchor event_count {value}: {message}")]
497 InvalidEventCount {
498 value: String,
500 message: String,
502 },
503 #[error("invalid ledger anchor chain_head_hash: {value}")]
505 InvalidChainHeadHash {
506 value: String,
508 },
509}
510
511#[derive(Debug, Error)]
513pub enum AnchorHistoryVerifyError {
514 #[error("failed to read anchor history {path:?}: {source}")]
516 ReadHistory {
517 path: PathBuf,
519 source: std::io::Error,
521 },
522 #[error("invalid anchor history {path:?}: {source}")]
524 Parse {
525 path: PathBuf,
527 source: AnchorParseError,
529 },
530 #[error(
532 "anchor history is non-monotonic at record {anchor_index}: event_count {event_count} follows {previous_event_count}"
533 )]
534 NonMonotonic {
535 path: PathBuf,
537 anchor_index: usize,
539 previous_event_count: u64,
541 event_count: u64,
543 },
544 #[error("anchor history record {anchor_index} failed verification in {path:?}: {source}")]
546 Anchor {
547 path: PathBuf,
549 anchor_index: usize,
551 source: Box<AnchorVerifyError>,
553 },
554}
555
556#[derive(Debug, Error)]
558pub enum AnchorVerifyError {
559 #[error(transparent)]
561 Jsonl(#[from] JsonlError),
562 #[error("cannot anchor empty ledger {path:?}")]
564 EmptyLedger {
565 path: PathBuf,
567 },
568 #[error("failed to build current ledger anchor for {path:?}: {source}")]
570 InternalAnchorBuild {
571 path: PathBuf,
573 source: AnchorParseError,
575 },
576 #[error("ledger chain broken at line {line} in {path:?}: {reason}")]
578 ChainBroken {
579 path: PathBuf,
581 line: usize,
583 reason: String,
585 },
586 #[error(
588 "ledger is shorter than anchor: db_count {db_count}, anchor_event_count {anchor_event_count}"
589 )]
590 Truncated {
591 path: PathBuf,
593 db_count: u64,
595 anchor_event_count: u64,
597 },
598 #[error("ledger anchor position {anchor_event_count} has no event hash")]
600 MissingPosition {
601 path: PathBuf,
603 anchor_event_count: u64,
605 },
606 #[error(
608 "anchor hash mismatch at event_count {event_count}: observed {observed}, expected {expected}"
609 )]
610 PositionHashMismatch {
611 path: PathBuf,
613 event_count: u64,
615 observed: String,
617 expected: String,
619 },
620}
621
622fn validate_chain_head_hash(value: &str) -> Result<(), AnchorParseError> {
623 if value.len() != HEX_HASH_LEN
624 || !value
625 .bytes()
626 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
627 {
628 return Err(AnchorParseError::InvalidChainHeadHash {
629 value: value.to_string(),
630 });
631 }
632 Ok(())
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use chrono::TimeZone;
639 use cortex_core::{Event, EventId, EventSource, EventType, SCHEMA_VERSION};
640 use tempfile::tempdir;
641
642 use crate::{JsonlLog, SignedRow};
643
644 fn fixture_event(seq: u64) -> Event {
645 Event {
646 id: EventId::new(),
647 schema_version: SCHEMA_VERSION,
648 observed_at: Utc.with_ymd_and_hms(2026, 5, 5, 12, 0, 0).unwrap(),
649 recorded_at: Utc.with_ymd_and_hms(2026, 5, 5, 12, 0, 1).unwrap(),
650 source: EventSource::User,
651 event_type: EventType::UserMessage,
652 trace_id: None,
653 session_id: Some("s-anchor".into()),
654 domain_tags: vec![],
655 payload: serde_json::json!({"seq": seq}),
656 payload_hash: String::new(),
657 prev_event_hash: None,
658 event_hash: String::new(),
659 }
660 }
661
662 fn write_fixture_log(count: u64) -> (tempfile::TempDir, std::path::PathBuf, Vec<String>) {
663 let dir = tempdir().unwrap();
664 let path = dir.path().join("anchor.jsonl");
665 let mut log = JsonlLog::open(&path).unwrap();
666 let policy = crate::jsonl::append_policy_decision_test_allow();
667 let mut heads = Vec::new();
668 for seq in 0..count {
669 heads.push(log.append(fixture_event(seq), &policy).unwrap());
670 }
671 (dir, path, heads)
672 }
673
674 fn rewrite_rows(path: &std::path::Path, rows: &[SignedRow]) {
675 let text = rows
676 .iter()
677 .map(|row| serde_json::to_string(row).unwrap())
678 .collect::<Vec<_>>()
679 .join("\n");
680 std::fs::write(path, format!("{text}\n")).unwrap();
681 }
682
683 fn write_history(path: &std::path::Path, anchors: &[LedgerAnchor]) {
684 let mut text = String::new();
685 for anchor in anchors {
686 text.push_str(&anchor.to_anchor_text());
687 }
688 std::fs::write(path, text).unwrap();
689 }
690
691 fn read_rows(path: &std::path::Path) -> Vec<SignedRow> {
692 std::fs::read_to_string(path)
693 .unwrap()
694 .lines()
695 .map(|line| serde_json::from_str(line).unwrap())
696 .collect()
697 }
698
699 #[test]
700 fn anchor_format_round_trips() {
701 let anchor = LedgerAnchor::new(
702 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
703 7,
704 "a".repeat(HEX_HASH_LEN),
705 )
706 .unwrap();
707
708 let text = anchor.to_anchor_text();
709 assert_eq!(
710 text,
711 format!(
712 "{ANCHOR_FORMAT_HEADER_V1}\n2026-05-05T12:30:00Z 7 {}\n",
713 "a".repeat(HEX_HASH_LEN)
714 )
715 );
716 assert_eq!(parse_anchor(&text).unwrap(), anchor);
717 }
718
719 #[test]
720 fn anchor_unknown_format_header_fails_closed() {
721 let text = format!(
722 "# cortex-ledger-anchor-format: 2\n2026-05-05T12:30:00Z 7 {}\n",
723 "a".repeat(HEX_HASH_LEN)
724 );
725 let err = parse_anchor(&text).unwrap_err();
726 assert!(matches!(err, AnchorParseError::UnknownFormatHeader { .. }));
727 }
728
729 #[test]
730 fn anchor_trailing_content_fails_closed() {
731 let text = format!(
732 "{ANCHOR_FORMAT_HEADER_V1}\n2026-05-05T12:30:00Z 7 {}\nextra\n",
733 "a".repeat(HEX_HASH_LEN)
734 );
735 let err = parse_anchor(&text).unwrap_err();
736 assert_eq!(err, AnchorParseError::TrailingContent);
737 }
738
739 #[test]
740 fn anchor_history_format_round_trips() {
741 let anchors = vec![
742 LedgerAnchor::new(
743 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
744 1,
745 "a".repeat(HEX_HASH_LEN),
746 )
747 .unwrap(),
748 LedgerAnchor::new(
749 Utc.with_ymd_and_hms(2026, 5, 5, 12, 31, 0).unwrap(),
750 2,
751 "b".repeat(HEX_HASH_LEN),
752 )
753 .unwrap(),
754 ];
755 let text = anchors
756 .iter()
757 .map(LedgerAnchor::to_anchor_text)
758 .collect::<String>();
759
760 assert_eq!(parse_anchor_history(&text).unwrap(), anchors);
761 }
762
763 #[test]
764 fn anchor_history_unknown_format_header_fails_closed() {
765 let text = format!(
766 "# cortex-ledger-anchor-format: 2\n2026-05-05T12:30:00Z 7 {}\n",
767 "a".repeat(HEX_HASH_LEN)
768 );
769 let err = parse_anchor_history(&text).unwrap_err();
770 assert!(matches!(err, AnchorParseError::UnknownFormatHeader { .. }));
771 }
772
773 #[test]
774 fn anchor_history_truncated_record_fails_closed() {
775 let err = parse_anchor_history(ANCHOR_FORMAT_HEADER_V1).unwrap_err();
776 assert_eq!(err, AnchorParseError::MissingBody);
777 }
778
779 #[test]
780 fn anchor_correct_head_passes() {
781 let (_dir, path, heads) = write_fixture_log(3);
782 let anchor = LedgerAnchor::new(
783 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
784 3,
785 heads[2].clone(),
786 )
787 .unwrap();
788
789 let verified = verify_anchor(&path, &anchor).unwrap();
790 assert_eq!(verified.db_count, 3);
791 assert_eq!(verified.db_head_hash.as_deref(), Some(heads[2].as_str()));
792 }
793
794 #[test]
795 fn current_anchor_scans_clean_chain_head() {
796 let (_dir, path, heads) = write_fixture_log(3);
797 let timestamp = Utc.with_ymd_and_hms(2026, 5, 5, 12, 45, 0).unwrap();
798
799 let anchor = current_anchor(&path, timestamp).unwrap();
800
801 assert_eq!(anchor.timestamp, timestamp);
802 assert_eq!(anchor.event_count, 3);
803 assert_eq!(anchor.chain_head_hash, heads[2]);
804 verify_anchor(&path, &anchor).unwrap();
805 }
806
807 #[test]
808 fn current_anchor_rejects_empty_ledger() {
809 let dir = tempdir().unwrap();
810 let path = dir.path().join("empty.jsonl");
811 std::fs::write(&path, "").unwrap();
812
813 let err = current_anchor(&path, Utc.with_ymd_and_hms(2026, 5, 5, 12, 45, 0).unwrap())
814 .unwrap_err();
815
816 assert!(matches!(err, AnchorVerifyError::EmptyLedger { .. }));
817 }
818
819 #[test]
820 fn anchor_detects_tail_truncation() {
821 let (_dir, path, heads) = write_fixture_log(3);
822 let anchor = LedgerAnchor::new(
823 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
824 3,
825 heads[2].clone(),
826 )
827 .unwrap();
828 let rows = read_rows(&path);
829 rewrite_rows(&path, &rows[..2]);
830
831 let err = verify_anchor(&path, &anchor).unwrap_err();
832 assert!(matches!(
833 err,
834 AnchorVerifyError::Truncated {
835 db_count: 2,
836 anchor_event_count: 3,
837 ..
838 }
839 ));
840 }
841
842 #[test]
843 fn anchor_wrong_event_count_fails() {
844 let (_dir, path, heads) = write_fixture_log(3);
845 let anchor = LedgerAnchor::new(
846 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
847 2,
848 heads[2].clone(),
849 )
850 .unwrap();
851
852 let err = verify_anchor(&path, &anchor).unwrap_err();
853 assert!(matches!(
854 err,
855 AnchorVerifyError::PositionHashMismatch { event_count: 2, .. }
856 ));
857 }
858
859 #[test]
860 fn anchor_tampered_line_fails() {
861 let (_dir, path, heads) = write_fixture_log(3);
862 let anchor = LedgerAnchor::new(
863 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
864 3,
865 heads[2].clone(),
866 )
867 .unwrap();
868 let mut rows = read_rows(&path);
869 rows[1].event.payload = serde_json::json!({"seq": 99});
870 rewrite_rows(&path, &rows);
871
872 let err = verify_anchor(&path, &anchor).unwrap_err();
873 assert!(matches!(
874 err,
875 AnchorVerifyError::ChainBroken { line: 2, .. }
876 ));
877 }
878
879 #[test]
880 fn anchor_history_correct_passes() {
881 let (dir, ledger_path, heads) = write_fixture_log(3);
882 let history_path = dir.path().join("ANCHOR_HISTORY");
883 let anchors = vec![
884 LedgerAnchor::new(
885 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
886 1,
887 heads[0].clone(),
888 )
889 .unwrap(),
890 LedgerAnchor::new(
891 Utc.with_ymd_and_hms(2026, 5, 5, 12, 31, 0).unwrap(),
892 3,
893 heads[2].clone(),
894 )
895 .unwrap(),
896 ];
897 write_history(&history_path, &anchors);
898
899 let verified = verify_anchor_history(&ledger_path, &history_path).unwrap();
900 assert_eq!(verified.anchors_verified, 2);
901 assert_eq!(verified.latest_anchor.event_count, 3);
902 assert_eq!(verified.db_count, 3);
903 }
904
905 #[test]
906 fn anchor_history_non_monotonic_event_count_fails_closed() {
907 let (dir, ledger_path, heads) = write_fixture_log(3);
908 let history_path = dir.path().join("ANCHOR_HISTORY");
909 let anchors = vec![
910 LedgerAnchor::new(
911 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
912 3,
913 heads[2].clone(),
914 )
915 .unwrap(),
916 LedgerAnchor::new(
917 Utc.with_ymd_and_hms(2026, 5, 5, 12, 31, 0).unwrap(),
918 2,
919 heads[1].clone(),
920 )
921 .unwrap(),
922 ];
923 write_history(&history_path, &anchors);
924
925 let err = verify_anchor_history(&ledger_path, &history_path).unwrap_err();
926 assert!(matches!(
927 err,
928 AnchorHistoryVerifyError::NonMonotonic {
929 anchor_index: 2,
930 previous_event_count: 3,
931 event_count: 2,
932 ..
933 }
934 ));
935 }
936
937 #[test]
938 fn anchor_history_detects_tail_truncation() {
939 let (dir, ledger_path, heads) = write_fixture_log(3);
940 let history_path = dir.path().join("ANCHOR_HISTORY");
941 let anchor = LedgerAnchor::new(
942 Utc.with_ymd_and_hms(2026, 5, 5, 12, 30, 0).unwrap(),
943 3,
944 heads[2].clone(),
945 )
946 .unwrap();
947 write_history(&history_path, &[anchor]);
948 let rows = read_rows(&ledger_path);
949 rewrite_rows(&ledger_path, &rows[..2]);
950
951 let err = verify_anchor_history(&ledger_path, &history_path).unwrap_err();
952 match err {
953 AnchorHistoryVerifyError::Anchor { source, .. } => assert!(matches!(
954 source.as_ref(),
955 AnchorVerifyError::Truncated {
956 db_count: 2,
957 anchor_event_count: 3,
958 ..
959 }
960 )),
961 other => panic!("expected truncated anchor history verification error, got {other:?}"),
962 }
963 }
964}