Skip to main content

cortex_ledger/
anchor.rs

1//! Position-bound external anchor primitive (ADR 0013).
2//!
3//! A ledger anchor binds a human-correlation timestamp to both the logical
4//! chain position and the event hash at that exact position:
5//! `(timestamp, event_count, chain_head_hash)`.
6//!
7//! The text format is deliberately small and fail-closed:
8//!
9//! ```text
10//! # cortex-ledger-anchor-format: 1
11//! <timestamp_rfc3339> <event_count> <chain_head_hash>
12//! ```
13//!
14//! Unknown format headers, malformed fields, or extra structure are parse
15//! errors. Verification recomputes the hash chain from the JSONL rows and
16//! compares the stored hash at `event_count`; it does not trust only the
17//! current tip hash.
18
19use 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
32/// Header required at the top of every v1 ledger anchor text.
33pub const ANCHOR_FORMAT_HEADER_V1: &str = "# cortex-ledger-anchor-format: 1";
34
35/// Position-bound ledger anchor payload.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct LedgerAnchor {
38    /// Operator-local or wall-clock timestamp for human correlation.
39    pub timestamp: DateTime<Utc>,
40    /// Total event count at anchor time. Position `n` means row `n` in
41    /// append order, so `chain_head_hash` must equal the event hash on
42    /// that row.
43    pub event_count: u64,
44    /// Lowercase hex event hash at exactly [`Self::event_count`].
45    pub chain_head_hash: String,
46}
47
48impl LedgerAnchor {
49    /// Build an anchor, validating fields exactly as the text parser does.
50    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    /// Render this anchor in the canonical ADR 0013 v1 text format.
65    #[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
90/// Parse a v1 ledger anchor from text.
91pub 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
144/// Parse a v1 ledger anchor history from repeated canonical anchor records.
145///
146/// The history format is deliberately just the existing fail-closed v1 anchor
147/// record repeated back-to-back:
148///
149/// ```text
150/// # cortex-ledger-anchor-format: 1
151/// <timestamp_rfc3339> <event_count> <chain_head_hash>
152/// # cortex-ledger-anchor-format: 1
153/// <timestamp_rfc3339> <event_count> <chain_head_hash>
154/// ```
155///
156/// Blank separators, unknown headers, incomplete records, or extra structure
157/// are parse errors. Monotonicity is checked by [`verify_anchor_history`].
158pub 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
178/// Verify one anchor against a JSONL ledger file.
179///
180/// This is ADR 0013 weak-mode primitive verification: the current chain must
181/// contain at least `anchor.event_count` rows and the recomputed event hash at
182/// that exact position must match `anchor.chain_head_hash`.
183pub 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
285/// Build a current position-bound anchor after verifying the JSONL ledger chain.
286///
287/// Empty ledgers cannot be anchored because ADR 0013 anchors bind to an event
288/// position and event hash, not to the absence of rows.
289pub 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
365/// Verify a monotonic multi-anchor history against a JSONL ledger file.
366///
367/// This is the local strong-mode primitive for ADR 0013: every anchor in the
368/// supplied history must individually verify against the ledger, and the
369/// published anchor stream must never move backwards in `event_count`.
370pub 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/// Successful anchor verification summary.
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct AnchorVerification {
434    /// Ledger path that was verified.
435    pub path: PathBuf,
436    /// Number of rows observed in the current ledger.
437    pub db_count: u64,
438    /// Current tip hash after scanning the ledger, or `None` for an empty
439    /// ledger.
440    pub db_head_hash: Option<String>,
441    /// Anchor that was verified.
442    pub anchor: LedgerAnchor,
443}
444
445/// Successful multi-anchor history verification summary.
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct AnchorHistoryVerification {
448    /// Ledger path that was verified.
449    pub path: PathBuf,
450    /// Anchor history path that was verified.
451    pub history_path: PathBuf,
452    /// Number of rows observed in the current ledger.
453    pub db_count: u64,
454    /// Current tip hash after scanning the ledger, or `None` for an empty
455    /// ledger.
456    pub db_head_hash: Option<String>,
457    /// Number of anchor records verified.
458    pub anchors_verified: usize,
459    /// Latest anchor in the monotonic history.
460    pub latest_anchor: LedgerAnchor,
461}
462
463/// Parse errors for the v1 ledger anchor text format.
464#[derive(Debug, Clone, PartialEq, Eq, Error)]
465pub enum AnchorParseError {
466    /// No first line was present.
467    #[error("missing ledger anchor format header")]
468    MissingHeader,
469    /// Header was present but was not the supported v1 header.
470    #[error("unknown ledger anchor format header: {observed}")]
471    UnknownFormatHeader {
472        /// Header line found in the input.
473        observed: String,
474    },
475    /// Header was present but the payload line was absent.
476    #[error("missing ledger anchor body")]
477    MissingBody,
478    /// Body did not have the exact three-field structure.
479    #[error("malformed ledger anchor body: {reason}")]
480    MalformedBody {
481        /// Human-readable parse failure.
482        reason: String,
483    },
484    /// More non-format structure followed the body line.
485    #[error("ledger anchor has trailing content")]
486    TrailingContent,
487    /// Timestamp was not valid RFC 3339.
488    #[error("invalid ledger anchor timestamp {value}: {message}")]
489    InvalidTimestamp {
490        /// Timestamp field as parsed from the input.
491        value: String,
492        /// Parser error message.
493        message: String,
494    },
495    /// Event count was not a valid `u64`.
496    #[error("invalid ledger anchor event_count {value}: {message}")]
497    InvalidEventCount {
498        /// Event-count field as parsed from the input.
499        value: String,
500        /// Parser error message.
501        message: String,
502    },
503    /// Chain head hash was not lowercase 64-character hex.
504    #[error("invalid ledger anchor chain_head_hash: {value}")]
505    InvalidChainHeadHash {
506        /// Hash field as parsed from the input.
507        value: String,
508    },
509}
510
511/// Verification errors for an anchor history checked against a JSONL ledger.
512#[derive(Debug, Error)]
513pub enum AnchorHistoryVerifyError {
514    /// The history file could not be opened or read.
515    #[error("failed to read anchor history {path:?}: {source}")]
516    ReadHistory {
517        /// Anchor history path that was being read.
518        path: PathBuf,
519        /// I/O failure.
520        source: std::io::Error,
521    },
522    /// The anchor history text did not parse as repeated v1 anchor records.
523    #[error("invalid anchor history {path:?}: {source}")]
524    Parse {
525        /// Anchor history path that was being parsed.
526        path: PathBuf,
527        /// Parse failure.
528        source: AnchorParseError,
529    },
530    /// A later anchor moved backwards in logical event position.
531    #[error(
532        "anchor history is non-monotonic at record {anchor_index}: event_count {event_count} follows {previous_event_count}"
533    )]
534    NonMonotonic {
535        /// Anchor history path that was being verified.
536        path: PathBuf,
537        /// 1-based anchor record index.
538        anchor_index: usize,
539        /// Previous anchor event count.
540        previous_event_count: u64,
541        /// Current anchor event count.
542        event_count: u64,
543    },
544    /// One record in the history failed single-anchor verification.
545    #[error("anchor history record {anchor_index} failed verification in {path:?}: {source}")]
546    Anchor {
547        /// Anchor history path that was being verified.
548        path: PathBuf,
549        /// 1-based anchor record index.
550        anchor_index: usize,
551        /// Single-anchor verification failure.
552        source: Box<AnchorVerifyError>,
553    },
554}
555
556/// Verification errors for an anchor checked against a JSONL ledger.
557#[derive(Debug, Error)]
558pub enum AnchorVerifyError {
559    /// The ledger could not be opened, decoded, or scanned.
560    #[error(transparent)]
561    Jsonl(#[from] JsonlError),
562    /// The ledger had no event rows to bind an anchor to.
563    #[error("cannot anchor empty ledger {path:?}")]
564    EmptyLedger {
565        /// Ledger path that was being scanned.
566        path: PathBuf,
567    },
568    /// The verifier produced fields that did not satisfy the anchor format.
569    #[error("failed to build current ledger anchor for {path:?}: {source}")]
570    InternalAnchorBuild {
571        /// Ledger path that was being scanned.
572        path: PathBuf,
573        /// Anchor validation failure.
574        source: AnchorParseError,
575    },
576    /// The ledger hash chain itself is invalid.
577    #[error("ledger chain broken at line {line} in {path:?}: {reason}")]
578    ChainBroken {
579        /// Ledger path that was being verified.
580        path: PathBuf,
581        /// 1-based JSONL line number.
582        line: usize,
583        /// Human-readable chain failure.
584        reason: String,
585    },
586    /// Current ledger is shorter than the anchored position.
587    #[error(
588        "ledger is shorter than anchor: db_count {db_count}, anchor_event_count {anchor_event_count}"
589    )]
590    Truncated {
591        /// Ledger path that was being verified.
592        path: PathBuf,
593        /// Current number of rows in the ledger.
594        db_count: u64,
595        /// Event count required by the anchor.
596        anchor_event_count: u64,
597    },
598    /// The anchor names a position that has no event hash, such as zero.
599    #[error("ledger anchor position {anchor_event_count} has no event hash")]
600    MissingPosition {
601        /// Ledger path that was being verified.
602        path: PathBuf,
603        /// Event count required by the anchor.
604        anchor_event_count: u64,
605    },
606    /// Recomputed hash at the anchored position did not match the anchor.
607    #[error(
608        "anchor hash mismatch at event_count {event_count}: observed {observed}, expected {expected}"
609    )]
610    PositionHashMismatch {
611        /// Ledger path that was being verified.
612        path: PathBuf,
613        /// Event count required by the anchor.
614        event_count: u64,
615        /// Recomputed hash at `event_count`.
616        observed: String,
617        /// Hash declared by the anchor.
618        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}